Skip to content

lifecycle: status — state as data, sync --check gate - #1

Closed
ixxie wants to merge 13 commits into
mainfrom
lifecycle-status
Closed

lifecycle: status — state as data, sync --check gate#1
ixxie wants to merge 13 commits into
mainfrom
lifecycle-status

Conversation

@ixxie

@ixxie ixxie commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Layer 1 of 2 (stack: lifecycle-statuslifecycle-sharding). Fork-internal review stack for the upstream proposal drafted in QUA-1574; layers will peel serially to Fission-AI/OpenSpec.

Experimental opt-in lifecycle: status mode: a change's lifecycle state is a status: proposed | shipped field in its .openspec.yaml, never a directory move. openspec sync folds shipped changes' deltas idempotently; sync --check is the deterministic tree-level gate (shipped ⇒ folded) runnable at pre-commit, pre-push and CI; openspec ship flips and folds as one diff; archive refuses under the mode and points at the status workflow.

Working demo (installs this stack from the fork): https://github.com/ixxie/openspec-status-demo — see PR #2 there for the gate going red/green live.

🤖 Generated with Claude Code

@ixxie ixxie changed the title lifecycle: status — state as data, sync --check gate (layer 1/2) lifecycle: status — state as data, sync --check gate 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 1: upstream-ready. Typecheck clean, targeted suites green (225 tests incl. the full archive suite).

What holds up well: fail-closed gate semantics (unreadable metadata → conflict, never skipped), folded-ness decided by byte-identical regeneration rather than bookkeeping, symmetric mode-refusal messages in both directions, born-proposed at creation, and the deliberate lifecycle-vs-task-status naming in the list JSON payload. Tests cover idempotency and both refusal directions.

Minor notes (none blocking):

  • The proposal text pitches sync --check --changed as the scoped pre-commit tier, but only the optional change-name positional exists. Close the gap in one direction before peeling — implement --changed or soften the doc. Related: the gate regenerates every shipped change's folds on each run, O(all history) where archive's scan was O(active); fine now, but --changed/incremental is the eventual answer and worth a sentence in the proposal.
  • openspec list --status bogus silently prints "No changes with status 'bogus'" — validate against the closed set the completions registry already advertises.
  • Named sync <x> --check with unreadable metadata throws out of evaluate, while the no-arg gate reports the same state as a conflict entry; both exit non-zero, but the shapes differ.

Comment thread src/core/list.ts
const changePath = path.join(changesDir, changeDir);
const lastModified = await getLastModified(changePath);
const lifecycle = readLifecycleStatus(changePath);
if (options.status && lifecycle !== options.status) {

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.

--status bogus falls through to an empty list rather than an error. Validating against LIFECYCLE_STATES (the registry already advertises proposed/shipped) would catch the typo instead of reporting a plausible-looking "no changes".

Comment thread src/core/sync.ts Outdated

// An explicitly named change must be shipped before its deltas may touch
// specs/. In check mode a non-shipped change is simply not gated.
const metadata = readChangeMetadata(changeDir, projectRoot);

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.

In --check mode this readChangeMetadata throw escapes for a named change, while the no-arg sweep converts the same condition into a conflict report entry. Both exit non-zero, so low stakes — but wrapping here to emit the same conflict shape would make the gate's output uniform for CI parsers.

@ixxie

ixxie commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Review findings addressed in fd9e069:

  • list --status now rejects unknown values instead of printing an empty list that reads as success.
  • Named sync <x> --check with unreadable metadata returns the same conflict entry the no-arg sweep reports — one output shape for CI parsers.
  • SyncCommand.execute returns its SyncReport (with a silent option for programmatic reuse); exit codes now live at the CLI edge. This also unblocks the PR lifecycle: status — date sharding + openspec migrate #2 gate-reuse fix.

The --check --changed scoping / O(shipped history) cost note lands in the proposal text rather than here — flagged as future work.

ixxie and others added 4 commits August 17, 2026 14:04
… sync --check gate

Opt-in via `lifecycle: status` in openspec/config.yaml (default 'archive'
unchanged). Under the mode: a change's lifecycle state lives in its
.openspec.yaml `status` field (proposed | applied | shipped) and nothing
ever moves to changes/archive/. New `openspec sync` folds shipped changes'
deltas into specs/ as a standalone idempotent step through the existing
specs-apply engine; `sync --check` verifies by regeneration (rebuilt output
byte-identical to the current spec) and exits 1 on a shipped-but-unfolded
change, making the gate a pure function of the tree — runnable at
pre-commit, pre-push and CI. archive refuses under the mode and points at
the status workflow; list gains a status column and --status filter; new
changes are born `status: proposed`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Restores archive's declare+fold atomicity as a convenience instead of a
mandate: ship sets `status: shipped` in the change's .openspec.yaml and
runs the same idempotent sync fold, emitting one working-tree diff so the
commit that declares shipped is the commit whose tree satisfies the
shipped ⇒ folded predicate. Sugar over the field edit + sync, never the
only way. Also neutralizes the folded-spec skeleton wording ("created
from change X") — the old text assumed the archive workflow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…comment

Drops 'applied'. The closed set exists because tooling attaches
consequences to each state; applied carried none (sync gates shipped,
overlap reasoning keys on proposed), and implementation progress is
already recorded by tasks.md checkboxes — a duplicate that can drift.
The next state to earn a slot is whichever arrives with consequences
(abandoned — releasing the live claim — is the obvious candidate).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…edge

Programmatic callers read the returned SyncReport instead of sniffing
process.exitCode; a new silent option suppresses output for reuse as a
gate. Unreadable metadata on a named sync now yields the same conflict
entry the no-arg sweep reports, so CI parses one shape either way, and
list rejects an unknown --status value instead of printing an empty
list that reads as success.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ixxie
ixxie force-pushed the lifecycle-status branch from fd9e069 to 9315379 Compare August 17, 2026 11:08
ixxie and others added 9 commits August 17, 2026 14:34
The repo tracks its own features as OpenSpec changes; this adds the
proposal, design note, tasks and capability spec for lifecycle: status,
plus the release changeset. The design note records why the state set is
closed at two, why folded-ness is decided by regeneration rather than
bookkeeping, and why archive refuses under the mode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ailures stay JSON

Review findings from the PR:

- sync swallowed every readdir error and reported no shipped changes, so
  an unreadable changes/ dir passed a gate whose whole claim is failing
  closed. Only ENOENT means 'no changes' now; anything else propagates.
- the sync and ship CLI handlers dropped the --json flag on the error
  path, breaking the agent contract that a --json failure leaves exactly
  one JSON document on stdout.
- the capability spec claimed ship is a no-op under archive mode; it
  refuses, as the implementation and its test have always done. Added
  the fail-closed requirement the first fix creates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Layout and migration are part of the same vision, so the dogfooded
change describes all of it: two new capability specs for layout
discovery and bidirectional migration, the design note's reasoning for
sharding by an immutable creation date and for reversal being a pure
relayout, and an explicit note that Fission-AI#1367's domain discovery is the
better mechanism and should supersede the sharding if it lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ixxie

ixxie commented Aug 17, 2026

Copy link
Copy Markdown
Owner Author

Superseded by the upstream PR Fission-AI#1684.

@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