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:
- 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.
- 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_ACTIONSmakes it fire in every project that runsaidlc 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-runis 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 ownactions.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 tomain— 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.