Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ Custom integrations implement `Dev::Deps::Integration` (with `install_all(pins,
- **`dev cred get <namespace> <key>`** — 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-<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>]` (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

Expand Down
35 changes: 27 additions & 8 deletions lib/dev/plan/accessor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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?("<<<<<<<")
Expand Down Expand Up @@ -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|
Expand Down
21 changes: 17 additions & 4 deletions lib/dev/plan/content.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion share/cursor-skills/ai-flow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
55 changes: 55 additions & 0 deletions test/dev/plan/accessor_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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-")
Expand Down
19 changes: 19 additions & 0 deletions test/dev/plan/content_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading