From ddbb48d074573e4ff68bb7b12435393a8be89438 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Sat, 18 Jul 2026 21:15:57 -0400 Subject: [PATCH] dev plan push by issue number and blank-line-tolerant header parsing `dev plan push ` now mirrors `pull ` (workspace lookup, `--org` for the org plans repo), so pushing no longer requires the file path. Also strips leading blank lines before header detection in Content.parse, so Cursor's rewrite layout (frontmatter, blank line, ai-flow header) parses instead of failing with "no ai-flow header". Co-authored-by: Cursor --- README.md | 2 +- lib/dev/plan/accessor.rb | 35 ++++++++++++++---- lib/dev/plan/content.rb | 21 +++++++++-- share/cursor-skills/ai-flow/SKILL.md | 2 +- test/dev/plan/accessor_test.rb | 55 ++++++++++++++++++++++++++++ test/dev/plan/content_test.rb | 19 ++++++++++ 6 files changed, 120 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index f60df7b..bdf30c3 100644 --- a/README.md +++ b/README.md @@ -291,7 +291,7 @@ Custom integrations implement `Dev::Deps::Integration` (with `install_all(pins, - **`dev cred get `** — resolve a credential through the provider chain (ENV → keychain → file → prompt) and print it. A non-interactive miss errors with `gh secret set` guidance. Mirrors `dev deps path` for shell consumers (e.g. a staging sync). - **`dev cache gc [--keep N]`** — reclaim host caches dev owns (see below). - **`dev reset-container`** — remove the persistent build container (clears its incremental cache); registered only when `build.container.persist` is set. -- **`dev plan …`** — sync Cursor plans with GitHub issues (ai-flow): the issue is the canonical plan, the local `.cursor/plans/gh--.plan.md` is a transient working copy carrying an `` header. Subcommands: `new "" [--org]` (create issue + linked plan; `--org` scaffolds a `Target repos:` line), `link <n> [<file>]` / `link <file>` (attach a draft to an existing issue / create one from it), `pull <n> [--merge]` (fetch, 3-way merging when both sides changed — the merge base lives at `~/.local/state/ai-flow/`), `push [<file>]` (guarded body PATCH — refuses to clobber newer remote edits), and `status` (clean / ahead / behind / diverged, per linked plan). `--org` targets the org plans repo (`plans_repo:` in `~/.config/dev/config.yml`, or `DEV_PLANS_REPO`) instead of the current repo's origin. Every invocation also ensures `~/.cursor/skills/ai-flow` symlinks to the skill shipped in `share/cursor-skills/`, so the Cursor agent knows these verbs. For auto-push, a participating repo adds a Cursor `afterFileEdit` hook to `.cursor/hooks.json` running `dev plan hook-after-edit` — it reads the hook payload from stdin and no-ops unless the edited file is a linked plan. What happens to a plan after it's canonical — `/ask`, `/edit`, `/split` (two-phase dry/apply), `/build` — is ai-flow's remote half: see [plan-lifecycle.md](https://github.com/d3mlabs/ai-flow/blob/HEAD/docs/plan-lifecycle.md) and [commands.md](https://github.com/d3mlabs/ai-flow/blob/HEAD/docs/commands.md). +- **`dev plan …`** — sync Cursor plans with GitHub issues (ai-flow): the issue is the canonical plan, the local `.cursor/plans/gh-<n>-<slug>.plan.md` is a transient working copy carrying an `<!-- ai-flow … -->` header. Subcommands: `new "<title>" [--org]` (create issue + linked plan; `--org` scaffolds a `Target repos:` line), `link <n> [<file>]` / `link <file>` (attach a draft to an existing issue / create one from it), `pull <n> [--merge]` (fetch, 3-way merging when both sides changed — the merge base lives at `~/.local/state/ai-flow/`), `push [<file>|<n>]` (guarded body PATCH — refuses to clobber newer remote edits; a number resolves the linked plan like `pull`), and `status` (clean / ahead / behind / diverged, per linked plan). `--org` targets the org plans repo (`plans_repo:` in `~/.config/dev/config.yml`, or `DEV_PLANS_REPO`) instead of the current repo's origin. Every invocation also ensures `~/.cursor/skills/ai-flow` symlinks to the skill shipped in `share/cursor-skills/`, so the Cursor agent knows these verbs. For auto-push, a participating repo adds a Cursor `afterFileEdit` hook to `.cursor/hooks.json` running `dev plan hook-after-edit` — it reads the hook payload from stdin and no-ops unless the edited file is a linked plan. What happens to a plan after it's canonical — `/ask`, `/edit`, `/split` (two-phase dry/apply), `/build` — is ai-flow's remote half: see [plan-lifecycle.md](https://github.com/d3mlabs/ai-flow/blob/HEAD/docs/plan-lifecycle.md) and [commands.md](https://github.com/d3mlabs/ai-flow/blob/HEAD/docs/commands.md). ## Build container & caching model diff --git a/lib/dev/plan/accessor.rb b/lib/dev/plan/accessor.rb index 1280cc1..8fdaa78 100644 --- a/lib/dev/plan/accessor.rb +++ b/lib/dev/plan/accessor.rb @@ -22,7 +22,7 @@ class UsageError < RuntimeError; end dev plan link <n> [<file>] [--org] attach a plan file to issue #n dev plan link <file> [--org] create an issue from a plan file dev plan pull <n> [--merge] [--org] fetch the issue into the local plan - dev plan push [<file>] update the issue body (guarded) + dev plan push [<file>|<n>] [--org] update the issue body (guarded) dev plan status sync state of all linked plans USAGE @@ -187,15 +187,18 @@ def merge_pull(path, issue, owner_repo, number, plan, base, remote_body, out:) end end - # `dev plan push [<file>]` — PATCH the issue body iff the remote hasn't - # changed since the recorded base; otherwise fail with instructions. The - # target repo comes from the file's header, so org-wide plans push - # transparently. + # `dev plan push [<file>|<n>] [--org]` — PATCH the issue body iff the + # remote hasn't changed since the recorded base; otherwise fail with + # instructions. A number resolves through the same workspace lookup + # `pull` uses (`--org` picks the org plans repo, symmetric with pull); + # a file path never needs `--org` — the target repo comes from the + # file's header, so org-wide plans push transparently. def push(args, out:) - file = args.shift - raise UsageError, "usage: dev plan push [<file>]" unless args.empty? + org = args.delete("--org") ? true : false + target = args.shift + raise UsageError, "usage: dev plan push [<file>|<n>] [--org]" unless args.empty? - path = file ? Pathname.new(file) : sole_linked_plan + path = push_path(target, org:) plan = Content.parse(path.read) raise UsageError, "#{path} has no ai-flow header — link it first with `dev plan link`." unless plan.header raise "#{path} contains unresolved merge conflict markers — resolve them before pushing." if plan.body.include?("<<<<<<<") @@ -332,6 +335,22 @@ def extract_title(body) body[/^# (.+)$/, 1]&.strip end + # Resolve push's optional argument: nothing (the sole linked plan), an + # issue number (workspace lookup, like pull), or a file path. + # + # @param target [String, nil] + # @param org [Boolean] + # @return [Pathname] + def push_path(target, org:) + return sole_linked_plan if target.nil? + return Pathname.new(target) unless target.match?(/\A\d+\z/) + + owner_repo = target_repo(org:) + number = Integer(target) + find_linked_plan(owner_repo, number) || + raise(UsageError, "no linked plan for #{owner_repo}##{number} — run `dev plan pull #{number}` first.") + end + # @return [Pathname] def find_linked_plan(owner_repo, number) @workspace.linked_plan_files.find do |path| diff --git a/lib/dev/plan/content.rb b/lib/dev/plan/content.rb index 61624d2..506f0ad 100644 --- a/lib/dev/plan/content.rb +++ b/lib/dev/plan/content.rb @@ -20,13 +20,14 @@ class Content # Parse a plan file into its layers. Canonical on-disk order is header, # then optional frontmatter, then body. When frontmatter sits above the - # ai-flow header (hand edit), both are still recognized; {#render} + # ai-flow header (Cursor's plan tool writes that layout, with a blank + # line after the closing fence), both are still recognized; {#render} # rewrites canonical order. # # @param content [String] # @return [Content] def self.parse(content) - header, remainder = Header.split(content) + header, remainder = Header.split(without_leading_blank_lines(content)) if header frontmatter, body = Frontmatter.split(remainder) return new(header: header, frontmatter: frontmatter, body: body) @@ -35,13 +36,25 @@ def self.parse(content) # Frontmatter may sit above a misplaced ai-flow header. frontmatter, after_frontmatter = Frontmatter.split(content) if frontmatter - header, body = Header.split(after_frontmatter) - return new(header: header, frontmatter: frontmatter, body: body) + header, body = Header.split(without_leading_blank_lines(after_frontmatter)) + # No header: keep the body byte-exact (the stripped copy was only + # for detection). + return new(header: header, frontmatter: frontmatter, body: header ? body : after_frontmatter) end new(header: nil, frontmatter: nil, body: content) end + # The Header pattern is anchored at the start of its input, so blank + # lines ahead of the comment (Cursor writes one after its frontmatter + # fence) are skipped before detection — and only for detection. + # + # @param content [String] + # @return [String] + def self.without_leading_blank_lines(content) + content.sub(/\A(?:[ \t]*\n)+/, "") + end + # @param header [Dev::Plan::Header, nil] # @param frontmatter [String, nil] # @param body [String] diff --git a/share/cursor-skills/ai-flow/SKILL.md b/share/cursor-skills/ai-flow/SKILL.md index eebe84f..8f036ad 100644 --- a/share/cursor-skills/ai-flow/SKILL.md +++ b/share/cursor-skills/ai-flow/SKILL.md @@ -20,7 +20,7 @@ conflict guard so local content can never clobber newer remote edits. | User intent | Action | |---|---| | "load issue 123 as a plan" / "open issue 123 as a plan" | `dev plan pull 123`, then open the file it reports (under `.cursor/plans/`) | -| "push this plan" / "sync this plan to GitHub" | `dev plan push` (add the file path if several plans are linked) | +| "push this plan" / "sync this plan to GitHub" | `dev plan push` (add the issue number or file path if several plans are linked, e.g. `dev plan push 123`; `--org` for org plans pushed by number) | | "canonize this plan" / "link this plan to an issue" | `dev plan link <file>` to create a new issue from it, or `dev plan link <n> <file>` to attach it to existing issue #n | | "create a plan for X" (canonical from the start) | `dev plan new "X"`, then edit the created file | | "is this plan in sync?" | `dev plan status` | diff --git a/test/dev/plan/accessor_test.rb b/test/dev/plan/accessor_test.rb index 41a1a39..89c3bdc 100644 --- a/test/dev/plan/accessor_test.rb +++ b/test/dev/plan/accessor_test.rb @@ -179,6 +179,61 @@ def read_plan(root, name) FileUtils.rm_rf(dir) end + test "push by issue number resolves the linked plan like pull does" do + Given "two linked plans with local edits on the first" + dir = Dir.mktmpdir("ai-flow-acc-test-") + accessor, root, issues = build_env(dir) + accessor.run(["new", "Carve system"], out: StringIO.new) + accessor.run(["new", "Second plan"], out: StringIO.new) + path = root / ".cursor" / "plans" / "gh-1-carve-system.plan.md" + header, _body = Dev::Plan::Header.split(path.read) + path.write(header.render + "# Carve system\n\nNew section.\n") + + When "pushing by number" + accessor.run(["push", "1"], out: StringIO.new) + + Then "the right issue is updated even though the workspace holds several plans" + issues.get(REPO, 1).body == "# Carve system\n\nNew section.\n" + issues.get(REPO, 2).body == "# Second plan\n" + + Cleanup + FileUtils.rm_rf(dir) + end + + test "push by number with --org resolves against the org plans repo" do + Given "a linked org plan with local edits" + dir = Dir.mktmpdir("ai-flow-acc-test-") + accessor, root, issues = build_env(dir) + accessor.run(["new", "Org roadmap", "--org"], out: StringIO.new) + path = root / ".cursor" / "plans" / "gh-plans-1-org-roadmap.plan.md" + header, _body = Dev::Plan::Header.split(path.read) + path.write(header.render + "# Org roadmap\n\nScoped.\n") + + When "pushing by number with --org" + accessor.run(["push", "1", "--org"], out: StringIO.new) + + Then + issues.get("d3mlabs/plans", 1).body == "# Org roadmap\n\nScoped.\n" + + Cleanup + FileUtils.rm_rf(dir) + end + + test "push by a number with no linked plan errors with pull-first guidance" do + Given "a workspace with no linked plan for issue 7" + dir = Dir.mktmpdir("ai-flow-acc-test-") + accessor, _root, _issues = build_env(dir) + + When "pushing by that number" + accessor.run(["push", "7"], out: StringIO.new) + + Then + raises Dev::Plan::Accessor::UsageError + + Cleanup + FileUtils.rm_rf(dir) + end + test "push refuses when the remote body changed since the last sync" do Given "a linked plan whose issue was edited remotely" dir = Dir.mktmpdir("ai-flow-acc-test-") diff --git a/test/dev/plan/content_test.rb b/test/dev/plan/content_test.rb index dad4572..8e84918 100644 --- a/test/dev/plan/content_test.rb +++ b/test/dev/plan/content_test.rb @@ -50,6 +50,25 @@ class Dev::Plan::ContentTest < Minitest::Test nil end + test "parse recognizes Cursor's rewrite layout: frontmatter, blank line, header" do + Given "the exact layout Cursor's plan tool writes for a linked plan" + header = Dev::Plan::Header.new(owner_repo: "d3mlabs/demo", number: 3, synced_at: "2026-01-01T00:00:00Z") + body = "# Title\n\nprose\n" + raw = "#{FRONTMATTER}\n#{header.render}#{body}" + + When "parsing and re-rendering" + plan = Dev::Plan::Content.parse(raw) + + Then "the header is found past the blank line and render restores canonical order" + plan.header.issue_ref == "d3mlabs/demo#3" + plan.frontmatter == FRONTMATTER + plan.body == body + plan.render == "#{header.render}#{FRONTMATTER}#{body}" + + Cleanup + nil + end + test "parse tolerates a draft with frontmatter and no ai-flow header" do Given "an unlinked Cursor draft" body = "# Draft\n"