Skip to content

lifecycle: status — date sharding + openspec migrate - #2

Closed
ixxie wants to merge 6 commits into
lifecycle-statusfrom
lifecycle-sharding
Closed

lifecycle: status — date sharding + openspec migrate#2
ixxie wants to merge 6 commits into
lifecycle-statusfrom
lifecycle-sharding

Conversation

@ixxie

@ixxie ixxie commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Layer 2 of 2, stacked on #1 — review only this layer's diff.

Creation-date sharding (changes/YYYY/MM/DD-<name>/, assigned at birth, immutable — location never encodes lifecycle state) via a shared discovery module powering list/sync/ship/creation; and openspec migrate, the one-way legacy conversion: archived changes become sharded shipped (the folder date's meaning shifts from archival to creation), active changes shard as proposed, config flips, nothing is deleted. --dry-run prints the plan; metadata stamping is tolerant raw-YAML so unknown legacy fields survive.

Demonstrated end-to-end in the demo repo's migration commit: five months of real archive-mode history converted by one command, gate green immediately — https://github.com/ixxie/openspec-status-demo

🤖 Generated with Claude Code

@ixxie ixxie changed the title lifecycle: status — date sharding + openspec migrate (layer 2/2) lifecycle: status — date sharding + openspec migrate Aug 13, 2026

@ixxie ixxie left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review of layer 2 (typecheck clean, 225 targeted tests green; all findings below reproduced empirically in a scratch repo).

🔴 1. Sharded layout breaks every change-enumerating surface outside list/sync/ship

The shared discovery covers list/sync/ship/creation only. Still enumerating flat:

  • src/utils/item-discovery.tsgetActiveChangeIds feeds validate, show, status, instructions, and shell completions
  • src/core/view.ts (readdirSync of changes/)
  • src/commands/workflow/shared.ts

On a migrated tree this is actively wrong, not just blind: getActiveChangeIds returns ['2026'] — the year shard reported as a change. First thing an upstream maintainer will hit after running the demo migration. Fix is the same retarget through discoverChanges already done for list.

🔴 2. Legacy name reuse migrates into permanent id ambiguity, silently

archive/2026-05-12-add-auth/ + active add-auth/ is idiomatic under archive mode — the date prefix exists precisely to allow reuse. migrate shards both to id add-auth with no warning (even in --dry-run); resolveChangeDir then throws ambiguous forever, so sync add-auth / ship add-auth are permanently broken and the name is unusable for new changes. Needs pre-flight duplicate-id detection in the plan — refuse (or require rename) naming the colliding paths, in dry-run too. Underlying design question for the proposal text: a change's identity in sharded mode is really (date, name) but every command takes a bare name — either forbid reuse or specify disambiguation.

🟠 3. Interrupted migration wedges the tree permanently

The config flip is last, so a mid-run crash (e.g. finding 2's sibling: two proposed changes colliding on a flat target) leaves archive-mode config + partial shards. Re-run: toStatus's flat scan treats 2026/ as a change named 2026 and dies on EINVAL: rename changes/2026 → changes/2026/08/13-2026 — every time, unrecoverable without git restore. Fix: skip YEAR_DIR-matching entries in the flat scan (reuse the shard rule); with finding 2's pre-flight the whole plan validates before the first rename.

Recommendation

Hold this layer until 1–3 are fixed (1 and 3 are small; 2 needs the refuse-vs-rename design call). Layer 1 peels upstream independently as-is.

});
}

for (const entry of await this.dirs(changesDir)) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding 3 anchor: this flat scan sees a partially-migrated tree's 2026/ year shard as a change named 2026 and later attempts rename changes/2026 → changes/2026/08/13-2026EINVAL, wedging every re-run. Skip entries matching YEAR_DIR here, mirroring discoverChanges' shard rule.

Comment thread src/core/lifecycle-migrate.ts Outdated
// shipped-but-unfolded change must be folded (or unshipped) first. Reuse
// the gate itself rather than a parallel reimplementation of its verdict.
const exitBefore = process.exitCode;
const silencedLog = console.log;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Monkey-patching console.log and sniffing process.exitCode to read the gate's verdict will draw upstream review fire, and it's blind if exitCode was already 1. SyncCommand.execute already builds a SyncReport — have it return the report and keep the exit-code side effect at the CLI edge; this call site then just reads report.clean.

Comment thread src/core/lifecycle-migrate.ts Outdated
// would be a second, contradicting record.
delete stamped.status;
}
await fs.writeFile(path.join(move.to, '.openspec.yaml'), stringifyYaml(stamped), 'utf-8');

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Tolerant raw-YAML" is half-true: unknown keys survive, but stringifyYaml destroys comments and key order in .openspec.yaml. Probably fine for a machine-written file — but either say so in the PR body or round-trip via yaml's Document API.

// fall through to sharded lookup
}

const matches = (await discoverChanges(changesDir)).filter((c) => c.id === id);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding 2 anchor: this ambiguity throw is correct, but migrate can create the ambiguous state from an idiomatic legacy repo (archived 2026-05-12-add-auth + active add-auth) with no warning — after which this throws forever for that id. The migration plan needs a duplicate-id pre-flight.

@ixxie
ixxie force-pushed the lifecycle-sharding branch from 034f545 to de2efb2 Compare August 13, 2026 13:18
@ixxie

ixxie commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Review findings addressed in de2efb2 (branch rebased on the updated lifecycle-status):

  1. Enumeration/resolution sweep: getActiveChangeIds, getAvailableChanges, and the view dashboard now enumerate via discoverChanges; show/validate/status/instructions resolve sharded dirs via resolveChangeDir (flat join kept as fallback behind the traversal guard). A migrated tree no longer reports a change named '2026'.
  2. Duplicate-id pre-flight: migrate refuses (dry-run included) when legacy name reuse would shard into an ambiguous bare id, naming the collisions before the first rename. Design call: refuse-with-rename-hint over auto-qualified ids — collisions are rare and a one-time rename beats a second id syntax. Duplicate targets refuse likewise.
  3. Interrupted migrate resumes: the flat scan skips year shards left by a partial run; combined with the pre-flight, the plan validates before the first rename.
  4. Gate reuse: reads the returned SyncReport via silent — no console monkey-patching, no exitCode sniffing.
  5. Stamping: yaml Document API edits in place; comments and key order survive (covered by a new test).

New regression tests for all five; full suite green minus the 4 known environmental failures.

@ixxie ixxie left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second pass over de2efb2/c4f0d7e/fd9e069: all first-pass findings verified fixed — re-ran the repros empirically (ambiguity refused with collisions named incl. --dry-run; half-migrated tree resumes to completion with the config flipped; getActiveChangeIds returns real ids on a sharded tree; a hand-written YAML comment survives stamping). tsc clean, 244 tests green. The traversal guard on ids is a nice unprompted addition.

Three new findings, all minor:

🟡 1. resolveChangeDir hands out shard dirs as changes

The flat-first stat bypasses the shard rule: resolveChangeDir(cd, '2026') returns changes/2026 (verified). On a sharded tree, show 2026 / validate 2026 then operate on the year directory. Discovery already makes such names invisible — the resolver should refuse YEAR_DIR/archive names before the flat stat so the two agree on what is addressable.

🟡 2. openspec new change reports the wrong path under status mode

src/commands/workflow/new-change.ts:81 prints formatChangeLocation — a flat join — while createChange actually sharded the dir. The happy-path success message points at a directory that doesn't exist. formatChangeLocation needs a shard-aware variant (or take the created dir from createChange's return).

⚪ 3. extractNameFromPath shard logic duplicated

json-converter.ts and validator.ts now carry identical shard-recognition blocks (the commit message itself says "both copies"). Extract a shared helper — upstream review will ask for it otherwise.

Also still open from pass 1: the proposal pitches sync --check --changed as the scoped pre-commit tier, but only the change-name positional exists.

Comment thread src/core/change-discovery.ts Outdated
* a hostile id cannot address anything outside changes/.
*/
export async function resolveChangeDir(changesDir: string, id: string): Promise<string | null> {
if (!id || id.startsWith('.') || id.includes('/') || id.includes('\\') || id.includes('\0')) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New finding 1 anchor: this guard rejects hostile ids, but the flat stat below still resolves '2026' to the year shard dir on a sharded tree (verified). Add YEAR_DIR.test(id) || id === 'archive' to the refusal so the resolver and discovery agree on the addressable namespace.

Comment thread src/core/converters/json-converter.ts Outdated
for (let i = parts.length - 1; i >= 0; i--) {
if (parts[i] === 'specs' || parts[i] === 'changes') {
if (i < parts.length - 1) {
// creation-date sharded layout: changes/YYYY/MM/DD-<name>/...

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New finding 3 anchor: this block and its twin in validator.ts are byte-identical — extract a shared changeNameFromPath helper (change-discovery.ts is the natural home) before upstream asks.

Comment thread src/core/planning-home.ts
* (flat or creation-date sharded), falling back to the flat join so callers
* on a not-yet-created change still get a path to report.
*/
export async function resolvePlanningChangeDir(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New finding 2 anchor: formatChangeLocation below is still a flat join, and its one caller (new-change.ts:81) prints it as the location of a change createChange just sharded — the success message points at a nonexistent path under status mode. Needs the same shard-aware treatment as this resolver.

@ixxie

ixxie commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Second-pass findings addressed in bf2debd + e50da8a:

  1. resolveChangeDir refuses 'archive' and year-shaped ids — resolver and discovery now agree on the addressable namespace; show 2026 errors as unknown-item instead of operating on the shard dir (verified live on the demo).
  2. new change prints the path it actually created — derived from createChange's returned dir rather than a flat join. Live QA of this fix surfaced a bonus bug: the root-completion scaffold in createChange was resurrecting changes/archive/ into status-mode trees; it's mode-aware now (e50da8a).
  3. Shared itemNameFromPath in change-discovery.ts — both byte-identical copies now delegate.

On the pass-1 leftover: the proposal doc was already softened in the first round — --changed is marked a planned refinement with the O(shipped history) cost stated. The flag stays unimplemented by design for the initial slice.

Note for posterity: test/commands/spec.test.ts flaked with 10s timeouts during verification — reproduced identically on the pre-change commit, so it's load flake in the CLI-spawning tests, not a regression.

@ixxie

ixxie commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Verified bf2debd + e50da8a against the pass-2 findings: resolver refuses archive/YYYY ids (repro now returns null; show 2026 correctly unknown), created-change message derives from the dir createChange actually made, itemNameFromPath extracted with both copies delegating, and the changes/archive/ scaffold is mode-gated — good catch on that resurrection, it was pass-2-blind here. tsc clean, targeted suites green. Layer 2 findings are all closed from my side; stack looks peel-ready pending the demo's Actions run.

@ixxie
ixxie force-pushed the lifecycle-sharding branch from e50da8a to 60075e4 Compare August 17, 2026 11:08
ixxie and others added 6 commits August 17, 2026 14:34
Changes under `lifecycle: status` shard as changes/YYYY/MM/DD-<name>/ —
assigned at birth, immutable, so location encodes only the creation date
and nothing ever moves. A shared discovery module enumerates both layouts
(YYYY/MM dirs are shards to walk into; anything else is a change; the DD-
prefix strips from the id), list/sync/ship resolve ids through it, and
createChange shards new changes. Duplicate ids across shard dates are
rejected at creation and on lookup.

`openspec migrate` converts a legacy project one way: archived changes
become changes/YYYY/MM/DD-<name>/ with status: shipped (the folder date's
meaning shifts from archival to creation — the closest surviving record),
active changes shard by their created date as proposed, config gains
lifecycle: status. Metadata edits are tolerant raw-YAML key writes, never
strict-schema round-trips — a migration that drops fields it does not
understand destroys history. --dry-run prints the plan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reversal moves only bookkeeping, like the forward direction: shipped
changes return to changes/archive/YYYY-MM-DD-<name>/ (dates from the
shard path), proposed changes return flat, the status key is stripped
(under archive mode, location is the state), empty shard dirs prune, and
the config line disappears. No spec text changes in either direction —
archive-mode specs/ is folded shipped reality, which is exactly what
status-mode maintains, so the round-trip is a pure relayout (covered by
a round-trip test).

Refuses while any shipped change has unfolded deltas: the archive layout
asserts folds that must actually exist. One honest asymmetry, printed on
completion: changes shipped under status mode carry their creation date
into the archive folder name, where convention reads an archival date.

This is the exit ramp the experimental flag's exit criteria require —
if the mode is ever removed rather than graduated, --to archive is how
projects return to supported ground.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mbiguity and survives interruption

Review fixes on the sharded layout:

- getActiveChangeIds, getAvailableChanges and the view dashboard
  enumerate through discoverChanges, so a migrated tree no longer
  reports the year shard as a change named '2026'; show, validate,
  status and instructions resolve sharded dirs via resolveChangeDir
  (with the flat join kept as fallback behind the traversal guard).
- resolveChangeDir returns null for ids discovery could never produce
  (separators, dot segments), so hostile ids cannot address anything
  outside changes/; discovery skips hidden dirs like the flat scan did.
- migrate pre-flights id ambiguity: a legacy name reused across archive
  eras — idiomatic under archive mode — would shard into two dirs no
  bare id can address, so the plan is refused with the collisions named
  before the first rename (dry-run included). Duplicate targets refuse
  likewise instead of clobbering.
- an interrupted migration now resumes: the flat scan skips year shards
  left by a partial run instead of renaming changes/YYYY into itself.
- the reverse-migration gate reads SyncCommand's returned report via the
  new silent option instead of monkey-patching console.log and sniffing
  process.exitCode.
- metadata stamping edits the YAML document in place, preserving
  comments and key order legacy files may carry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live QA on the demo caught show --json reporting a sharded change as
'2026': both extractNameFromPath copies took the segment after
'changes/', which in the sharded layout is the year. They now recognize
changes/YYYY/MM/DD-<name>/ and return the de-prefixed name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… name derivation share one source of truth

Second-pass review fixes:

- resolveChangeDir refuses 'archive' and year-shaped ids, so the
  resolver and discovery agree on the addressable namespace — the
  flat-first stat no longer hands out changes/2026 as a change.
- openspec new change derives its success message from the dir
  createChange actually made instead of a flat join, so under
  lifecycle: status it prints the sharded path that exists.
- the two byte-identical extractNameFromPath copies delegate to a
  shared itemNameFromPath in change-discovery, where the shard rule
  lives.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…/ under status mode

Live QA caught openspec new change scaffolding changes/archive/ into a
status-mode tree — the one directory the mode abolishes. The
root-completion scaffold is now mode-aware; root health never required
the dir (a missing archive/ raises no diagnostic).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ixxie
ixxie force-pushed the lifecycle-sharding branch from 60075e4 to ae3107a Compare August 17, 2026 11:34
@ixxie

ixxie commented Aug 17, 2026

Copy link
Copy Markdown
Owner Author

Superseded: layer 2 is now part of the single upstream PR (Fission-AI#1684), which carries the whole design per the vision issue Fission-AI#1683. The staging stack is no longer split.

@ixxie ixxie closed this Aug 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant