Skip to content

v1.21.0 ·

The second write is the one that fails

The problem

.aidlc/roadmap/ is the right place for a roadmap: versioned with the code, readable by agents, reviewable in a diff. It is the wrong place for a roadmap to be seen.

Two concrete gaps follow from that:

  1. Teams triage in a tracker. A team that already runs GitHub Issues will not maintain a second backlog in a directory. "We already have a backlog" is the most common reason to ignore the tool, and today there is no answer to it.
  2. Non-cloners cannot see the plan. Anybody who has not checked the repo out — a stakeholder, a designer, a manager — has no view of what is planned or in flight.

The failure mode to avoid is the obvious fix: two-way sync. Two writers over one fact means permanent reconciliation, maintained by one person. So the change is a one-way projection — disk is the source of truth, the tracker is a rendering of it — and everything below is shaped by making that projection convergent rather than stateful.

How it could be solved

Option A — Lifecycle action only (roadmap-sync on on-instance-complete)

Add a built-in handler beside roadmap-done in events/event-bus.ts and ship it in SHIPPED_ACTIONS. No new command.

  • For: smallest surface; reuses a seam that exists and is already the right one.
  • Against: nothing to invoke by hand, so no dry run, no first-run review, and no way to debug a failed projection except by completing another instance. Worse, shipping it in SHIPPED_ACTIONS makes it fire in every project that runs aidlc update — violating G5 outright.

Option B — aidlc roadmap sync command, with the lifecycle action as a thin wrapper (chosen)

A roadmap command group with sync and import leaves. The projection logic is a pure planner plus a thin executor; the lifecycle action, when a user adds it, calls the same planner.

  • For: --dry-run is available from day one and is the default-safe first run. The planner is testable with no network at all. The action stays optional, so G5 holds — nothing fires until somebody writes it into their own actions.yaml.
  • Against: a new top-level command costs the roster entry and the docs-drift regeneration noted above. That is a known, bounded cost.

Option C — GitHub Actions workflow that projects on every push to main

  • For: the roadmap stays current with no human in the loop.
  • Against: this is exactly the loop the roadmap item flagged. It also decides open question 1 by fiat, in the direction that needs remote: writebacks committed to main — the thing DD-1 removes. Deferrable at no cost: once the command exists and is idempotent, wrapping it in a workflow is a later, independent step a user can do themselves in ten lines.

Chosen: Option B. Option C remains available afterwards and is strictly easier once B exists.

How AIDLC solves it

aidlc roadmap sync renders the repository's roadmap as GitHub issues, one way, and aidlc roadmap import <issue-url> seeds a single inbox item from an existing issue. Both are inert until somebody writes a roadmap.sync section into their config, and neither installs a lifecycle action, so a project that has not opted in behaves exactly as it did before this release.

The feature exists because .aidlc/roadmap/ is the right place for a roadmap and the wrong place for one to be seen. A team that already triages in GitHub Issues will not maintain a second backlog in a directory, and anybody who has not cloned the repository has no view of what is planned. The obvious fix — two-way sync — was refused: two writers over one fact means permanent reconciliation maintained by one person. What shipped instead is a projection. Disk stays the source of truth, the tracker is a rendering of it, and the rendering can be thrown away and rebuilt.

The load-bearing decision is that sync writes nothing to disk. There is no remote: frontmatter field and no cache. Creating an issue and writing a local link back would be two writes to two systems, and every failure of the second leaves an issue with no link that the next run turns into a duplicate — which, on a projection meant to run often, produces duplicates within days. The durable link instead lives on the issue, as an HTML-comment marker carrying the item id, discovered through a single shared label. So there is no second write to fail, no commit loop for a CI-hosted run to break, and no question of who is allowed to run it: anyone, anywhere, any number of times.

Three defaults protect what gets published. inbox/ and hold/ are not projected, because an untriaged idea and a parked one are not decisions anybody has announced — and on this repository hold/ currently holds pricing thinking and a competitor teardown. done/ is never backfilled, so switching the feature on does not create forty-three closed issues nobody will read. And a closed issue is never reopened: closing an issue is the primary triage act of the person this feature is for, so the projection reports the divergence and leaves it alone rather than reopening it with a notification on every run.

Every write needs consent. --dry-run prints the plan and touches nothing; an interactive run prints the plan and asks; a non-interactive run without --yes prints the plan and exits non-zero rather than zero — a green run that wrote nothing is precisely how an automation comes to believe it is syncing when it never was. The plan header and the confirmation question both name the resolved repository, because a projection aimed at the wrong repo is only reversible before the first write.

Internally the shape is a hard split between planning and execution. plan.ts is pure and imports no filesystem, no subprocess, and not the gh seam — pinned by a test over the import graph, which is what makes "planning needs no network" a property of the code rather than a claim about the test suite. execute.ts is the only module that writes, and imports no node:fs at all, which is a stronger guarantee than snapshotting a directory around a run would be. Every gh invocation flows through one seam taking an argument array, which is also what lets the token-secrecy property be checked by value: set GH_TOKEN to a sentinel, run a full apply, and assert the sentinel appears in no recorded argv and no output.

The two adversarial reviews are most of why this shipped correctly. They returned 53 findings between them, eight critical, and at least six of those would have shipped as real defects. gh issue list defaults to thirty results, so "one call" silently truncated the issue map and turned every item beyond the thirtieth into a permanent duplicate. gh issue list --json labels returns objects rather than strings, so every status-label comparison would have failed and every item would have planned a label update on every run, forever. execFileSync defaults to a one-mebibyte buffer while discovery fetches every issue body, so the primary path would have failed in the low hundreds of issues. gh label create exits non-zero on a label that already exists, so creation plus abort-on-first-failure would have killed every run after the first — and --force alone is not the fix, because gh randomises the colour when none is given and would have repainted the label every time. Each of those was measured against gh 2.98.0 rather than reasoned about, and each now has a test written to fail against the original design.

The reviews also made the feature smaller. The first revision proposed a new aidlc doctor migration to report drift; it was withdrawn, because it duplicated the shipped roadmap-item-titles migration outright and because a check that reached the network would have made aidlc doctor network-dependent and hangable for every project on every run. --json was dropped from both commands rather than specified for the sake of it. And item prose was removed from issue bodies entirely, which closed marker injection, @mention and #reference side effects, and GitHub's body-length limit with one decision instead of three mechanisms.

Verified before release: 3558 tests passing across all three packages, twice, with 207 of them new; pnpm lint clean; the docs-drift gate green; and a read-only dry run against this repository's own seventy-four roadmap items planning exactly two creates and forty-three not-backfilled skips. Nothing has been applied to GitHub — that is an outward-facing write on a public repository and it is left for the maintainer to authorise.