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"