From 0967358b8b287b69f8fc9390b2b0301b65602035 Mon Sep 17 00:00:00 2001 From: BeeHiggs Date: Tue, 15 Sep 2026 02:47:47 -0600 Subject: [PATCH] enhance(#3638): render bracket phase IDs on progress, stats, manager and statusline surfaces (epic #612 PR-5) (#4111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * enhance(#3638): render bracket IDs on display surfaces Gate progress, stats, manager, and statusline projections on the bracket convention; validate phase_id_convention and single-source the convention card. Forward note: the uat.cts bracket co-change remains deliberately deferred to its owning slice. * chore(#3638): point the changeset at PR #4111 Co-Authored-By: Claude Opus 5 (1M context) * fix(#3638): close bracket display review gaps * docs(#3638): register phase display modules * chore(#3638): re-trigger CI after macOS shard SIGTERM `full test (macos-latest, 24, shard 3/3)` failed on 20ce98cd1 in `tests/lint-compiled-artifact-sync.test.cjs` — the spawned `scripts/lint-compiled-artifact-sync.cjs` was killed at 60024ms (`exited null (signal SIGTERM)`, stdout and stderr both empty), 24ms past the test's own `TSC_COMPILE_TIMEOUT_MS`. That is the failure mode the constant's comment already documents ("under CI shard load that compile can exceed the budget, dying to a SIGTERM with empty piped stdout"). No content change; this empty commit exists only to re-run the matrix, since re-running a job needs write access on the upstream repository. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Tom Boucher --- .changeset/3638-bracket-display.md | 5 + .gitignore | 2 + CONTEXT.md | 8 +- docs/CLI-TOOLS.md | 7 + docs/CONFIGURATION.md | 2 +- docs/INVENTORY-MANIFEST.json | 3 + docs/INVENTORY.md | 3 + docs/reference/state-md.md | 7 + eslint.config.mjs | 2 + gsd-core/references/phase-id-convention.md | 28 ++ hooks/gsd-statusline.js | 83 ++++- src/commands.cts | 186 ++++++++-- src/config.cts | 14 + src/init.cts | 80 +++- src/phase-id-card.cts | 35 ++ src/phase-id-display.cts | 85 +++++ src/phase-id.cts | 7 +- .../adr-612-bracket-display.property.test.cjs | 342 ++++++++++++++++++ ...adr-612-bracket-heading-selection.test.cjs | 8 + tests/config.test.cjs | 59 +++ tests/fixtures/install-tree/antigravity.json | 1 + tests/fixtures/install-tree/augment.json | 1 + tests/fixtures/install-tree/claude-local.json | 1 + tests/fixtures/install-tree/claude.json | 1 + tests/fixtures/install-tree/cline.json | 1 + tests/fixtures/install-tree/codebuddy.json | 1 + tests/fixtures/install-tree/codex.json | 1 + tests/fixtures/install-tree/copilot.json | 1 + tests/fixtures/install-tree/cursor.json | 1 + tests/fixtures/install-tree/hermes.json | 1 + tests/fixtures/install-tree/kilo.json | 1 + tests/fixtures/install-tree/kimi-code.json | 1 + tests/fixtures/install-tree/kimi.json | 1 + tests/fixtures/install-tree/opencode.json | 1 + tests/fixtures/install-tree/pi.json | 1 + tests/fixtures/install-tree/qwen.json | 1 + tests/fixtures/install-tree/trae.json | 1 + tests/fixtures/install-tree/windsurf.json | 1 + tests/fixtures/install-tree/zcode.json | 1 + tests/phase-id-card.test.cjs | 58 +++ 40 files changed, 993 insertions(+), 50 deletions(-) create mode 100644 .changeset/3638-bracket-display.md create mode 100644 gsd-core/references/phase-id-convention.md create mode 100644 src/phase-id-card.cts create mode 100644 src/phase-id-display.cts create mode 100644 tests/adr-612-bracket-display.property.test.cjs create mode 100644 tests/phase-id-card.test.cjs diff --git a/.changeset/3638-bracket-display.md b/.changeset/3638-bracket-display.md new file mode 100644 index 000000000..46821f802 --- /dev/null +++ b/.changeset/3638-bracket-display.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 4111 +--- +**Opted-in bracket phase IDs now render consistently on progress surfaces** — `progress`, `stats`, manager init, and both statusline formats display canonical `[CODE.MM] NN` identities only when `phase_id_convention` is exactly `"bracket"`; other conventions retain their existing patterns and output shape. `config-set` now validates the convention's three supported values. (#3638) diff --git a/.gitignore b/.gitignore index 07d9cf3d9..1503d3824 100644 --- a/.gitignore +++ b/.gitignore @@ -246,6 +246,8 @@ build/ /gsd-core/bin/lib/core-utils.cjs /gsd-core/bin/lib/io.cjs /gsd-core/bin/lib/phase-id.cjs +/gsd-core/bin/lib/phase-id-card.cjs +/gsd-core/bin/lib/phase-id-display.cjs /gsd-core/bin/lib/normalize-test-command.cjs /gsd-core/bin/lib/config-loader.cjs /gsd-core/bin/lib/unusable-input.cjs diff --git a/CONTEXT.md b/CONTEXT.md index 3f94e850e..14bca6b5c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -15,7 +15,13 @@ Module owning `milestone complete` (archive roadmap/requirements/phases, build M Module that composes Dispatch Policy Module, Query Execution Policy Module, and per-stage handlers (input-validation, plan, execution, result-builder, formatting, error-mapping, observability) into the end-to-end pipeline that produces a `QueryDispatchResult`. The SDK-era pipeline collapsed onto the Command Routing Hub per ADR-0174; current dispatch seam: `gsd-core/bin/lib/command-routing-hub.cjs` (see Command Routing Hub below). ### Phase Id Module -Module owning the pure phase-id parsing and matching helpers: phase-name normalization, phase-token extraction/matching, canonical phase-directory selection, milestone- and phase-dir id parsing, phase-markdown regex builders, and the ADR-612 bracket phase-id round-trip grammar (`escapeRegex`, `normalizePhaseName`, `comparePhaseNum`, `extractPhaseToken`, `phaseTokenMatches`, `matchPhaseDirs`, `phaseNumberForMatch`, `phaseMarkdownRegexSource`/`phaseMarkdownRegexSourceExact`, `getMilestoneFromPhaseId`, `getPhaseDirFromPhaseId`, `parsePhaseId`/`renderPhaseId`/`toDir` over the `PhaseId` type, `isSentinelPhaseId`/`SENTINEL_RANGES`, and the `BRACKET_PHASE_TOKEN_SOURCE`/`PHASE_HEADING_PREFIX_SRC` grammar sources, plus the #612 read-path surface: the one bracket identity grammar `BRACKET_ID_SRC`/`BRACKET_MILESTONE_NUMERIC_SRC`/`BRACKET_DIR_PREFIX_SRC`, the case-folding `foldBracketId`, `bracketQualifiedKey`, and the convention-gated heading-intro selector `phaseHeadingPrefixSrcFor` over `PHASE_HEADING_BASELINE` / `BASE_ANY_BRACKET_HEADING_PREFIX_SRC` / `BASE_PHASE_LABEL_PREFIX_SRC`). `matchPhaseDirs` is the single owner of "which directories does this query name" (#2528) and takes the same optional `convention` its `phaseTokenMatches` primitive does, so bracket directories resolve through the shared selector rather than around it; the #3511 membership seam (`isPhaseArtifact`/`scopeToPhase`) takes the same optional `convention`, so call sites that hold the resolved convention scope a bracket directory's listing by its real phase token instead of the include-everything ambiguity fail-safe. Also owns the canonical phase KEY surface (#2562) — `phaseKeyFromToken`/`phaseKeyFromDir`/`phaseKeyFromProse`/`parentPhaseKey` — the padding-, case- and project-code-insensitive identity used whenever two independently-derived phase references (a ROADMAP table cell and a phase directory, say) are compared; deriving one side of such a comparison with a bespoke regex is what silently zeroed a rollup in #2562. Pure string/regex — no I/O, no config, no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2a (#865) as the cycle-free leaf that unblocks the roadmap-parser and phase-locator extractions; the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/phase-id.cjs` (generated from `src/phase-id.cts`). +Module owning the pure phase-id parsing and matching helpers: phase-name normalization, phase-token extraction/matching, canonical phase-directory selection, milestone- and phase-dir id parsing, phase-markdown regex builders, and the ADR-612 bracket phase-id round-trip grammar (`escapeRegex`, `normalizePhaseName`, `comparePhaseNum`, `extractPhaseToken`, `phaseTokenMatches`, `matchPhaseDirs`, `phaseNumberForMatch`, `phaseMarkdownRegexSource`/`phaseMarkdownRegexSourceExact`, `getMilestoneFromPhaseId`, `getPhaseDirFromPhaseId`, `parsePhaseId`/`renderMilestoneId`/`renderPhaseId`/`toDir` over the `PhaseId` type, `isSentinelPhaseId`/`SENTINEL_RANGES`, and the `BRACKET_PHASE_TOKEN_SOURCE`/`PHASE_HEADING_PREFIX_SRC` grammar sources, plus the #612 read-path surface: the one bracket identity grammar `BRACKET_ID_SRC`/`BRACKET_MILESTONE_NUMERIC_SRC`/`BRACKET_DIR_PREFIX_SRC`, the case-folding `foldBracketId`, `bracketQualifiedKey`, and the convention-gated heading-intro selector `phaseHeadingPrefixSrcFor` over `PHASE_HEADING_BASELINE` / `BASE_ANY_BRACKET_HEADING_PREFIX_SRC` / `BASE_PHASE_LABEL_PREFIX_SRC`). `matchPhaseDirs` is the single owner of "which directories does this query name" (#2528) and takes the same optional `convention` its `phaseTokenMatches` primitive does, so bracket directories resolve through the shared selector rather than around it; the #3511 membership seam (`isPhaseArtifact`/`scopeToPhase`) takes the same optional `convention`, so call sites that hold the resolved convention scope a bracket directory's listing by its real phase token instead of the include-everything ambiguity fail-safe. Also owns the canonical phase KEY surface (#2562) — `phaseKeyFromToken`/`phaseKeyFromDir`/`phaseKeyFromProse`/`parentPhaseKey` — the padding-, case- and project-code-insensitive identity used whenever two independently-derived phase references (a ROADMAP table cell and a phase directory, say) are compared; deriving one side of such a comparison with a bespoke regex is what silently zeroed a rollup in #2562. Pure string/regex — no I/O, no config, no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2a (#865) as the cycle-free leaf that unblocks the roadmap-parser and phase-locator extractions; the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/phase-id.cjs` (generated from `src/phase-id.cts`). + +### Phase Id Card Module +Module owning the sole editable source of the compact ADR-612 bracket-grammar convention card. Interface: `phaseIdCard(options)`, `PHASE_ID_CARD`, and `PHASE_ID_LEGEND`; render sites import this card rather than authoring another diagram, and `tests/phase-id-card.test.cjs` holds generated documentation to the same bytes so later injection can widen the card's reach without creating a second copy. Source of truth: `src/phase-id-card.cts` (generated as `gsd-core/bin/lib/phase-id-card.cjs`). + +### Phase Id Display Module +Pure Adapter Module translating legacy `vN.0` milestone metadata into bracket display identities for cosmetic progress, stats, manager-init, and statusline callers. Interface: `renderBracketPhaseDisplay` and `renderBracketMilestoneDisplay`; both normalize only their boundary inputs, then delegate validation and identity rendering to the Phase Id Module's canonical `parsePhaseId`, `renderMilestoneId`, and `renderPhaseId` interfaces, so this module owns no second bracket grammar. Both return `null` for incomplete or invalid metadata, allowing a caller to degrade its render without failing the command. Source of truth: `src/phase-id-display.cts` (generated as `gsd-core/bin/lib/phase-id-display.cjs`). ### Phase Lifecycle Module Module owning phase create, rename, complete, remove, list, and plan-index operations, plus phase-dir prefix validation, STATE.md staleness detection, and auto-prune behaviour. Entry point: `gsd-core/bin/lib/phase.cjs` (CJS surface). Typed phase events: `GSDPhaseStartEvent`, `GSDPhaseStepStartEvent`, `GSDPhaseStepCompleteEvent`, `GSDPhaseCompleteEvent`. (The SDK native-query surface, the `types.ts` event definitions, `phase-runner.ts`, and `phase-prompt.ts` were retired with the SDK package per ADR-0174.) diff --git a/docs/CLI-TOOLS.md b/docs/CLI-TOOLS.md index 4fbb7b622..a37f07e5c 100644 --- a/docs/CLI-TOOLS.md +++ b/docs/CLI-TOOLS.md @@ -1165,6 +1165,13 @@ pre-milestone directories are not counted as current-milestone phases, and the aggregate completion percentage no longer reads `100` while phases from the active window are still outstanding. +On a project explicitly configured with `phase_id_convention: "bracket"`, both +JSON surfaces keep the phase's bare join key in `phases[].number` and add its +canonical human label in `phases[].display_id` (for example, +`{"number":"05.03","display_id":"[GSD.02] 05.03"}`). Their +`milestone_version` and table headings use `[GSD.02]` rather than the legacy +`v2.0` marker. Other conventions retain their prior object and table shapes. + ```bash # Complete a todo node gsd-tools.cjs todo complete [--dry-run] diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index e0eb80189..8d5a240fd 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -243,7 +243,7 @@ derived from the shipped agent declaration. | `dynamic_routing.max_escalations` | integer | `0`, `1`, `2`, … | `1` | Hard cap on retries per agent invocation. Beyond the cap the resolver returns the cap-tier model. Also caps `provider_escalation`. Added in v1.40 | | `dynamic_routing.provider_escalation` | string[] | ordered model IDs | (none) | Opt-in fallback providers tried when a run dies on a quota / rate limit — see [provider escalation](#provider-escalation-on-quota-exceeded--added-in-v143). Added in v1.43 ([#2296](https://github.com/open-gsd/gsd-core/issues/2296)) | | `project_code` | string | any short string | (none) | Prefix for phase directory names (e.g., `"ABC"` produces `ABC-01-setup/`). Added in v1.31 | -| `phase_id_convention` | enum | `"milestone-prefixed"`, `"bracket"`, `null` | `null` | Phase ID naming convention. `null` = legacy numeric IDs (`Phase 1`, `Phase 2`). `"milestone-prefixed"` = globally unique IDs that encode the enclosing milestone (`Phase 1-01`, `Phase 1-02`). Run `gsd-tools roadmap upgrade --convention milestone-prefixed` to migrate an existing ROADMAP.md. `"bracket"` = IDs that carry the milestone in a bracket ahead of the phase number — heading `### [GSD.02] 05: Name`, directory `GSD.02-05-name` — per [ADR-612](adr/612-bracket-phase-id-convention.md). **`"bracket"` currently affects the READ path only:** `roadmap analyze` / `roadmap get-phase`, the W005/W006/W007 phase checks, `validate health` (including an advisory W021 — a bracket phase's milestone disagreeing with its enclosing section, or a phase heading still spelled in legacy form that has not yet been migrated to bracket form), and both `total_phases` derivations recognise the bracket spelling once it is set. There is no bracket migrator and no bracket emit yet, so set it only on a project whose ROADMAP.md already uses that spelling; a project on any other value compiles the same patterns it did before and is unaffected. **What opting in costs:** on a bracket repo a heading whose bracket is followed directly by a digit is read as a phase heading, so shapes that are legal prose headings on any other convention — `### [RFC.2119] 5:`, `### [v1.0] 2024:`, `### [ADR.612] 3:` — are claimed as phases and will move `phase_count`, `total_phases` and W006. A bracket repo cedes that heading shape; that is the trade the opt-in buys, and it is why the widened read is selected at construction time from this value rather than applied everywhere ([#2761](https://github.com/open-gsd/gsd-core/issues/2761)). **Phase-directory membership** on a bracket repo scopes by the directory's real bracket token, so an artifact misfiled from another phase (`01-VERIFICATION.md` sitting in a phase `03` directory) no longer supplies `phase complete`'s pass/fail verdict for the phase it was misfiled into — the same protection legacy directories already have. Call sites that do not yet resolve a convention keep the wider include-everything fail-safe on bracket directories until they thread one: the aggregate scans (`uat`, `audit`, `init` projections, `gap-checker`, `phase-locator`); `phase complete`'s advisory UAT/VERIFICATION warning pre-scan, which can still surface a spurious warning but cannot decide completion; and the workstream inventory's per-phase completion projection, which can still report a bracket phase complete or incomplete from a cross-phase stray. | +| `phase_id_convention` | enum | `"sequential"`, `"milestone-prefixed"`, `"bracket"`, `null` | `null` | Phase ID naming convention. `null` and `"sequential"` use legacy numeric IDs (`Phase 1`, `Phase 2`). `"milestone-prefixed"` uses globally unique IDs that encode the enclosing milestone (`Phase 1-01`, `Phase 1-02`); it remains the only `roadmap upgrade` target on this release line. Run `gsd-tools roadmap upgrade --convention milestone-prefixed` to migrate an existing ROADMAP.md. `"bracket"` carries the milestone ahead of the phase number — heading `### [GSD.02] 05: Name`, directory `GSD.02-05-name` — per [ADR-612](adr/612-bracket-phase-id-convention.md) and the [compact convention card](../gsd-core/references/phase-id-convention.md). `config-set` accepts only these three exact strings (or `null` to unset the key). **`"bracket"` currently affects read and display paths only:** read-path surfaces — `roadmap analyze` / `roadmap get-phase`, the W005/W006/W007 phase checks, `validate health` (including an advisory W021 covering a bracket phase's milestone disagreeing with its enclosing section, or a phase heading still spelled in legacy form and not yet migrated to bracket form), both `total_phases` derivations, state, and phase-directory membership — recognize the bracket spelling once it is set; display-path surfaces — progress, stats, manager-init, and statusline — recognize and display canonical IDs, while `progress` / `stats` expose per-phase `display_id` and keep the bare `number`. Phase-directory membership on a bracket repo scopes by the directory's real bracket token, so an artifact misfiled from another phase (`01-VERIFICATION.md` sitting in a phase `03` directory) no longer supplies `phase complete`'s pass/fail verdict for the phase it was misfiled into — the same protection legacy directories already have. Call sites that do not yet resolve a convention keep the wider include-everything fail-safe on bracket directories until they thread one: the aggregate scans (`uat`, `audit`, `init` projections, `gap-checker`, `phase-locator`); `phase complete`'s advisory UAT/VERIFICATION warning pre-scan, which can still surface a spurious warning but cannot decide completion; and the workstream inventory's per-phase completion projection, which can still report a bracket phase complete or incomplete from a cross-phase stray. There is no bracket migrator and no bracket emit yet, so set it only on a project whose ROADMAP.md and phase directories already use that spelling. A project on any other value compiles the same heading patterns and retains the same output shape it did before. **What opting in costs:** on a bracket repo a heading whose bracket is followed directly by a digit is read as a phase heading, so shapes that are legal prose headings on any other convention — `### [RFC.2119] 5:`, `### [v1.0] 2024:`, `### [ADR.612] 3:` — are claimed as phases and will move `phase_count`, `total_phases` and W006. A bracket repo cedes that heading shape; that is the trade the opt-in buys, and it is why the widened read is selected at construction time from this value rather than applied everywhere. ([#2761](https://github.com/open-gsd/gsd-core/issues/2761), [#3638](https://github.com/open-gsd/gsd-core/issues/3638)) | | `response_language` | string | language code | (none) | Language for agent responses (e.g., `"pt"`, `"ko"`, `"ja"`). Propagates to all spawned agents for cross-phase language consistency. Added in v1.32. UAT checkpoint frames (`/gsd-verify-work`) render a localized banner/instruction for English, Spanish, French, German, Portuguese, Japanese, Chinese, Korean, Italian, Dutch, Polish, Russian, Ukrainian, Turkish, Hindi, Arabic, Vietnamese, and Indonesian (endonyms and ISO codes also accepted); any other value falls back to the English frame. One deliberate exception: the `spec-phase` edge-completeness probe is fed an English translation of each requirement's text, because its shape cues are English-only — the SPEC itself stays in this language. See [Spec-Phase Edge-Completeness Probe](FEATURES.md#144-spec-phase-edge-completeness-probe). Every workflow is required to carry a directive honouring this setting, including for inter-tool narration; authors add or fix one per [response-language coverage](contributing/response-language-coverage.md), and `npm run lint:response-language` enforces it. | | `context_window` | number | any integer | `200000` | Context window size in tokens. Set `1000000` for 1M-context models (e.g., `claude-fable-5`). Values `>= 500000` enable adaptive context enrichment (full-body reads of prior SUMMARY.md, deeper anti-pattern reads). Configured via `/gsd-config --advanced`. | | `context_profile` | string | `dev`, `research`, `review` | (none) | Execution context preset that applies a pre-configured bundle of mode, model, and workflow settings for the current type of work. Added in v1.34 | diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 94687f751..996488a9f 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -283,6 +283,7 @@ "nyquist-compliance.md", "offer-next.md", "phase-argument-parsing.md", + "phase-id-convention.md", "plan-checker-examples.md", "planner-antipatterns.md", "planner-chunked.md", @@ -491,6 +492,8 @@ "pattern.cjs", "phase-command-router.cjs", "phase-estimation.cjs", + "phase-id-card.cjs", + "phase-id-display.cjs", "phase-id.cjs", "phase-lifecycle.cjs", "phase-locator.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 0ffdd7ee5..fa5da992e 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -348,6 +348,7 @@ Full roster at `gsd-core/references/*.md`. References are shared knowledge docum | `verifier-phase-gates.md` | Verifier-time gates eagerly imported by `gsd-verifier` (migrated from the retired `verify-phase` workflow, #1892): decision-coverage validation (#2492), test-quality audit, and infrastructure-phase human-verification scoping (#2504). | | `verifier-evidence-gate.md` | Re-verification convergence gate loaded by `gsd-verifier` (#3304): a Step 7 anti-pattern blocker that is neither a carried-forward gap nor a regression needs deterministic evidence to stay blocking, else it downgrades to advisory. | | `planning-config.md` | Full config schema and behavior. | +| `phase-id-convention.md` | Canonical bracket phase-ID grammar card and display/disk translation reference (ADR-612). | | `security-asvs-levels.md` | OWASP ASVS level definitions for GSD threat modeling — per-level planner disposition rigor and auditor verification depth (L1 opportunistic, L2 standard, L3 comprehensive). | | `git-integration.md` | Git commit, branching, and history patterns. | | `git-planning-commit.md` | Planning directory commit conventions. | @@ -627,6 +628,8 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `pattern.cjs` | The pattern-construction seam — `escapeRegex` (delegates to the built-in `RegExp.escape`) and `literalPattern`; sole owner of building a `RegExp` from a runtime value (ADR-3212 §1, epic #3212 Phase 1, #3412) | | `phase-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools phase` | | `phase-estimation.cjs` | Pure phase-effort estimation — `estimate`/`actuals` schema parse+render, smart-zone budget classification, and estimate-vs-actual calibration (median ratio, clamped, sample-gated). Confidence is derived from calibration sample count, never self-rated (ADR-2629) | +| `phase-id-card.cjs` | Single runtime source for the compact annotated bracket phase-ID convention card and legend (ADR-612 PR-5) | +| `phase-id-display.cjs` | Pure display adapter that routes legacy milestone metadata through the canonical bracket phase-ID parse/render pair (ADR-612 PR-5) | | `phase-id.cjs` | Pure phase-id parsing/matching helpers — normalize, token match, milestone/phase-dir id parsing, phase-markdown regex builders (extracted from `core.cjs`, ADR-857) | | `phase-lifecycle.cjs` | Pure-computation phase lifecycle helpers extracted from the phase-lifecycle SDK handler | | `phase-locator.cjs` | Phase-directory search/location — active + archived phase-dir discovery, phase-id matching against the filesystem (extracted from `core.cjs`, ADR-857) | diff --git a/docs/reference/state-md.md b/docs/reference/state-md.md index 1c2d96b18..3b1ab5ae0 100644 --- a/docs/reference/state-md.md +++ b/docs/reference/state-md.md @@ -165,6 +165,13 @@ Phase-completion verbs never write `Milestone complete` (the overloaded bare val **Scene priority:** when both `active_phase` and `next_action` are populated, Scene 1 wins — an orchestrator is in flight, so a "next recommendation" would be misleading. This priority is enforced by check order in `formatGsdState()` and covered by the `"scene priority"` suite in `tests/gsd-statusline.test.cjs`. +When `phase_id_convention` is exactly `"bracket"` and `project_code` is set, +the full and compact renderers replace the legacy milestone/phase labels with +the canonical identity: `[GSD.02] · [GSD.02] 05.03 executing`. They do not emit +the `vX.Y`, `Phase`, or compact `P` labels on that gated path. Any other +convention, or a bracket config missing the metadata needed to form an ID, +falls back to the strings shown in the table above. + The progress bar (`[██░░░░░░░░] 20%`) is appended to the milestone segment only when `progress.percent` is present in frontmatter; absent means no bar. --- diff --git a/eslint.config.mjs b/eslint.config.mjs index 371aa678c..83bf8a269 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -271,6 +271,8 @@ export default tseslint.config( 'gsd-core/bin/lib/core-utils.cjs', 'gsd-core/bin/lib/io.cjs', 'gsd-core/bin/lib/phase-id.cjs', + 'gsd-core/bin/lib/phase-id-card.cjs', + 'gsd-core/bin/lib/phase-id-display.cjs', 'gsd-core/bin/lib/phase-estimation.cjs', 'gsd-core/bin/lib/estimate-cli.cjs', 'gsd-core/bin/lib/normalize-test-command.cjs', diff --git a/gsd-core/references/phase-id-convention.md b/gsd-core/references/phase-id-convention.md new file mode 100644 index 000000000..d8631752f --- /dev/null +++ b/gsd-core/references/phase-id-convention.md @@ -0,0 +1,28 @@ +# Bracket phase-ID convention + +The bracket convention is opt-in through `phase_id_convention: "bracket"`. +Its compact grammar card below is generated from `src/phase-id-card.cts`; edit +that module, not this block. + + +```text +[GSD.02] 05.03-01 + │ │ │ │ │ + │ │ │ │ └── plan 01 + │ │ │ └────── subphase 03 + │ │ └───────── phase 05 + │ └───────────── milestone 02 + └───────────────── project GSD + +milestone = bracket integer; dots = phase-levels; one hyphen = plan; no 'Phase' word, no vX.Y +``` + + +The display form is `[PROJECT.MM] PP[.SS][-LL]`: the bracket carries the +project and milestone, dots join phase levels, and the single hyphen introduces +the optional plan. A phase directory encodes the same identity without brackets: +`PROJECT.MM-PP[.SS]-slug/`. + +Human-facing bracket surfaces omit both the literal `Phase` label and the +legacy `vX.Y` milestone marker. Repositories using the unset, `sequential`, or +`milestone-prefixed` conventions retain their existing display forms. diff --git a/hooks/gsd-statusline.js b/hooks/gsd-statusline.js index 37e110e9e..57dbb8731 100755 --- a/hooks/gsd-statusline.js +++ b/hooks/gsd-statusline.js @@ -42,6 +42,10 @@ if (require.main === module) { const { isSemverNewer } = require('../gsd-core/bin/lib/semver-compare.cjs'); const { PACKAGE_NAME, updateCacheFileName } = require('../gsd-core/bin/lib/package-identity.cjs'); const { normalizeStateStatus } = require('../gsd-core/bin/lib/state-document.cjs'); +const { + renderBracketPhaseDisplay, + renderBracketMilestoneDisplay, +} = require('../gsd-core/bin/lib/phase-id-display.cjs'); // #2850: reuse the existing workstream resolution seams rather than // re-implementing CLI>env>store precedence or path construction inline. // peekActiveWorkstream is the read-only sibling of the store-tier lookup @@ -359,16 +363,26 @@ function renderProgressBar(percent) { * Progress bar is opt-in: appended to the milestone segment only when * progress.percent is present in frontmatter; absent → empty string. */ -function formatGsdState(s) { +function formatGsdState(s, opts = {}) { // #2850: workstream mode with nothing resolvable — an observable signal, // never silent emptiness (distinguishes from "GSD isn't installed here"). if (s.noActiveWorkstream) return NO_ACTIVE_WORKSTREAM_LABEL; const parts = []; + const bracket = opts.convention === 'bracket'; + const bracketMilestone = bracket + ? renderBracketMilestoneDisplay(s.milestone, opts.projectCode) + : null; + const phaseLabel = (phase) => { + const display = bracket + ? renderBracketPhaseDisplay(s.milestone, phase, opts.projectCode) + : null; + return display ?? `Phase ${phase}`; + }; // Milestone segment: version + name + (opt-in) progress bar if (s.milestone || s.milestoneName) { - const ver = s.milestone || ''; + const ver = bracketMilestone ?? s.milestone ?? ''; const name = (s.milestoneName && s.milestoneName !== 'milestone') ? s.milestoneName : ''; const bar = renderProgressBar(s.percent); const pieces = [ver, name, bar].filter(Boolean); @@ -384,7 +398,8 @@ function formatGsdState(s) { // stage = whichever lifecycle status was written by the orchestrator // (discussing / planning / executing / verifying) const stage = s.status || ''; - parts.push(stage ? `Phase ${s.activePhase} ${stage}` : `Phase ${s.activePhase}`); + const phase = phaseLabel(s.activePhase); + parts.push(stage ? `${phase} ${stage}` : phase); } else if (s.nextAction && phasesStr) { // Scene 2: idle + a recommended next command is visible to the user. // Surfaces "what to run next" without the user opening STATE.md. @@ -402,9 +417,16 @@ function formatGsdState(s) { // earlier so no existing project's status-line changes shape. if (s.status) parts.push(s.status); if (s.phaseNum && s.phaseTotal) { + const bracketPhase = bracket + ? renderBracketPhaseDisplay(s.milestone, s.phaseNum, opts.projectCode) + : null; const phase = s.phaseName - ? `${s.phaseName} (${s.phaseNum}/${s.phaseTotal})` - : `ph ${s.phaseNum}/${s.phaseTotal}`; + ? bracketPhase + ? `${bracketPhase} ${s.phaseName} (${s.phaseNum}/${s.phaseTotal})` + : `${s.phaseName} (${s.phaseNum}/${s.phaseTotal})` + : bracketPhase + ? `${bracketPhase} (${s.phaseNum}/${s.phaseTotal})` + : `ph ${s.phaseNum}/${s.phaseTotal}`; parts.push(phase); } } @@ -490,17 +512,24 @@ function shortGsdStatus(status) { * default format — and collapses narrative statuses via shortGsdStatus(). * The default "full" format is untouched. */ -function formatGsdStateCompact(s) { +function formatGsdStateCompact(s, opts = {}) { // #2850: mirrors formatGsdState's observable "nothing resolvable" signal. if (s.noActiveWorkstream) return NO_ACTIVE_WORKSTREAM_LABEL; const parts = []; + const bracket = opts.convention === 'bracket'; + const bracketMilestone = bracket + ? renderBracketMilestoneDisplay(s.milestone, opts.projectCode) + : null; - if (s.milestone) parts.push(s.milestone); + if (s.milestone) parts.push(bracketMilestone ?? s.milestone); const phaseId = s.activePhase || s.phaseNum; if (phaseId) { - parts.push(s.phaseTotal ? `P${phaseId}/${s.phaseTotal}` : `P${phaseId}`); + const bracketPhase = bracket + ? renderBracketPhaseDisplay(s.milestone, phaseId, opts.projectCode) + : null; + parts.push(bracketPhase ?? (s.phaseTotal ? `P${phaseId}/${s.phaseTotal}` : `P${phaseId}`)); } // Scene exclusivity mirrors formatGsdState's if/else chain: an in-flight @@ -747,7 +776,7 @@ function formatStateFreshness(fresh) { * entry points — this collapses both onto one resolver. * * @param {object} cfg — parsed .planning/config.json (readGsdConfig()) - * @returns {{ showLastCommand: boolean, position: 'end'|'front', stateFormat: 'full'|'compact', showGit: boolean, showStateFreshness: boolean }} + * @returns {{ showLastCommand: boolean, position: 'end'|'front', stateFormat: 'full'|'compact', showGit: boolean, showStateFreshness: boolean, convention: string|null, projectCode: string|null }} */ function resolveStatuslineOptions(cfg) { const showLastCommand = getConfigValue(cfg, 'statusline.show_last_command') === true; @@ -759,7 +788,22 @@ function resolveStatuslineOptions(cfg) { const stateFormat = getConfigValue(cfg, 'statusline.state_format') === 'compact' ? 'compact' : 'full'; const showGit = getConfigValue(cfg, 'statusline.show_git') === true; const showStateFreshness = getConfigValue(cfg, 'statusline.show_state_freshness') === true; - return { showLastCommand, position, stateFormat, showGit, showStateFreshness }; + const convention = getConfigValue(cfg, 'phase_id_convention') === 'bracket' + ? 'bracket' + : null; + const projectCodeValue = getConfigValue(cfg, 'project_code'); + const projectCode = typeof projectCodeValue === 'string' && projectCodeValue !== '' + ? projectCodeValue + : null; + return { + showLastCommand, + position, + stateFormat, + showGit, + showStateFreshness, + convention, + projectCode, + }; } // --- stdin ------------------------------------------------------------------ @@ -934,7 +978,9 @@ function runStatusline() { // freshness git spawn here would spend a subprocess on discarded output. if (!task) { const state = readGsdState(dir, { stateFreshness: options.showStateFreshness }) || {}; - gsdStateStr = options.stateFormat === 'compact' ? formatGsdStateCompact(state) : formatGsdState(state); + gsdStateStr = options.stateFormat === 'compact' + ? formatGsdStateCompact(state, options) + : formatGsdState(state, options); } // Output @@ -1042,6 +1088,7 @@ module.exports = { STATE_HEAD_ADVISORY_COMMITS, isValidStateHeadStamp, readStateHeadCommits, parseRevListCounts, deriveStateFreshness, formatStateFreshness, resolveStatuslineOptions, + renderBracketPhaseDisplay, renderBracketMilestoneDisplay, }; /** @@ -1059,7 +1106,15 @@ function renderStatusline(data) { // key from reaching only one of them. let lastCmdSuffix = ''; let gitSuffix = ''; - let options = { showLastCommand: false, position: 'end', stateFormat: 'full', showGit: false, showStateFreshness: false }; + let options = { + showLastCommand: false, + position: 'end', + stateFormat: 'full', + showGit: false, + showStateFreshness: false, + convention: null, + projectCode: null, + }; try { const cfg = readGsdConfig(dir); options = resolveStatuslineOptions(cfg); @@ -1075,7 +1130,9 @@ function renderStatusline(data) { } catch (e) { /* swallow */ } const state = readGsdState(dir, { stateFreshness: options.showStateFreshness }) || {}; - const gsdStateStr = options.stateFormat === 'compact' ? formatGsdStateCompact(state) : formatGsdState(state); + const gsdStateStr = options.stateFormat === 'compact' + ? formatGsdStateCompact(state, options) + : formatGsdState(state, options); const middle = gsdStateStr ? `\x1b[2m${gsdStateStr}\x1b[0m` : null; return composeStatusline({ model, ctx: '', middle, dirname, lastCmdSuffix, gitSuffix, position: options.position }); } diff --git a/src/commands.cts b/src/commands.cts index 1e5005720..756e095ae 100644 --- a/src/commands.cts +++ b/src/commands.cts @@ -27,7 +27,21 @@ import coreUtilsMod = require('./core-utils.cjs'); const { toPosixPath, generateSlugInternal, extractOneLinerFromBody } = coreUtilsMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseIdMod = require('./phase-id.cjs'); -const { normalizePhaseName, comparePhaseNum, extractPhaseToken, PHASE_NUMBER_TOKEN_SOURCE, isSentinelPhaseId, renderPhaseBranchName } = phaseIdMod; +const { + normalizePhaseName, + comparePhaseNum, + extractPhaseToken, + PHASE_NUMBER_TOKEN_SOURCE, + isSentinelPhaseId, + renderPhaseBranchName, + parsePhaseId, + renderPhaseId, + phaseHeadingPrefixSrcFor, + PHASE_HEADING_BASELINE, +} = phaseIdMod; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import phaseIdDisplayMod = require('./phase-id-display.cjs'); +const { renderBracketMilestoneDisplay } = phaseIdDisplayMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseLocatorMod = require('./phase-locator.cjs'); const { getArchivedPhaseDirs, findPhaseInternal, listMilestonePhaseDirs } = phaseLocatorMod; @@ -53,7 +67,7 @@ import { parseCodexAgentToml, renderCodexAgentToml, stripModel, stripReasoningEf import hostIntegrationMod = require('./host-integration.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports import planningWorkspace = require('./planning-workspace.cjs'); -const { planningDir, planningPaths, todosDir } = planningWorkspace; +const { planningDir, planningPaths, todosDir, resolvePhaseIdConvention } = planningWorkspace; // eslint-disable-next-line @typescript-eslint/no-require-imports import frontmatter = require('./frontmatter.cjs'); const { extractFrontmatter, agentScalarNeedsDoubleQuoting, escapeDoubleQuotedScalar } = frontmatter; @@ -80,12 +94,46 @@ interface ArchivedPhaseDir { interface PhaseProgress { number: string; + display_id?: string; name: string; plans: number; summaries: number; status: string; } +interface BracketPhaseDirProjection { + number: string; + display_id: string; + name: string; +} + +/** + * Project one canonical bracket directory onto its display identity and slug. + * + * The identity is deliberately obtained only through parsePhaseId/renderPhaseId; + * this helper owns no second bracket grammar. Callers invoke it only after the + * project's resolved convention is exactly `bracket`. + */ +function bracketPhaseDirProjection(dir: string): BracketPhaseDirProjection { + const id = parsePhaseId(dir); + const number = id.subphase ? `${id.phase}.${id.subphase}` : id.phase; + const identityPrefix = `${id.project}.${id.milestone}-${number}`; + const slug = dir.slice(identityPrefix.length).replace(/^-/, ''); + return { + number, + display_id: renderPhaseId(id), + name: slug ? slug.replace(/-/g, ' ') : '', + }; +} + +function recoverBracketPhaseName(dir: string, phaseToken: string): string { + const tokenBoundary = `-${phaseToken}`; + const tokenOffset = dir.indexOf(tokenBoundary); + if (tokenOffset === -1) return ''; + const afterToken = dir.slice(tokenOffset + tokenBoundary.length).replace(/^-/, ''); + return afterToken ? afterToken.replace(/-/g, ' ') : ''; +} + interface GroupFilesBySubrepoResult { grouped: Record; unmatched: string[]; @@ -3292,6 +3340,11 @@ async function cmdWebsearch(query: string | undefined, options: WebsearchOptions function cmdProgressRender(cwd: string, format: string | undefined, raw: boolean): void { const phasesDir = planningPaths(cwd).phases; const milestone = getMilestoneInfo(cwd).value; + const phaseIdConvention = resolvePhaseIdConvention(cwd); + const milestoneDisplay = phaseIdConvention === 'bracket' + ? renderBracketMilestoneDisplay(milestone?.version, loadConfig(cwd).project_code) + : null; + const milestoneVersion = milestoneDisplay ?? milestone?.version ?? null; const phases: PhaseProgress[] = []; let totalPlans = 0; @@ -3304,13 +3357,35 @@ function cmdProgressRender(cwd: string, format: string | undefined, raw: boolean // comparePhaseNum. This command previously read the phases directory // directly with neither, which is why `query progress` listed 999.* // backlog directories as current-milestone phases (#3167). - const { value: dirs, scope } = listMilestonePhaseDirs(phasesDir, { cwd }); + const { value: dirs, scope } = listMilestonePhaseDirs(phasesDir, { + cwd, + phaseIdConvention, + }); phaseScope = scope; for (const dir of dirs) { - const dm = dir.match(/^(\d+(?:\.\d+)*)-?(.*)/); - const phaseNum = dm ? dm[1] : dir; - const phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : ''; + let phaseNum: string; + let phaseName: string; + let displayId: string | undefined; + if (phaseIdConvention === 'bracket') { + try { + const projection = bracketPhaseDirProjection(dir); + phaseNum = projection.number; + phaseName = projection.name; + displayId = projection.display_id; + } catch { + // A malformed/tolerated directory must not suppress every later row. + // It cannot receive a canonical display_id because parsePhaseId + // rejected it, but the convention-aware token keeps it observable. + const phaseToken = extractPhaseToken(dir, phaseIdConvention); + phaseNum = phaseToken || dir; + phaseName = recoverBracketPhaseName(dir, phaseToken); + } + } else { + const dm = dir.match(/^(\d+(?:\.\d+)*)-?(.*)/); + phaseNum = dm ? dm[1] : dir; + phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : ''; + } // #3183: canonical plan/summary counts (root+nested, superseded-excluded, // canonical pairing) from the single owner. const phaseScan = scanPhasePlans(path.join(phasesDir, dir)); @@ -3322,7 +3397,14 @@ function cmdProgressRender(cwd: string, format: string | undefined, raw: boolean const status = determinePhaseStatus(plans, summaries, path.join(phasesDir, dir), 'Pending'); - phases.push({ number: phaseNum, name: phaseName, plans, summaries, status }); + phases.push({ + number: phaseNum, + ...(displayId ? { display_id: displayId } : {}), + name: phaseName, + plans, + summaries, + status, + }); } } catch { /* intentionally empty */ } @@ -3341,12 +3423,12 @@ function cmdProgressRender(cwd: string, format: string | undefined, raw: boolean // Render markdown table const bar = renderProgressBar(percent, 10); const percentSuffix = percent === null ? '' : ` (${percent}%)`; - let out = `# ${milestone?.version ?? ''} ${milestone?.name ?? ''}\n\n`; + let out = `# ${milestoneVersion ?? ''} ${milestone?.name ?? ''}\n\n`; out += `**Progress:** [${bar}] ${totalSummaries}/${totalPlans} plans${percentSuffix}\n\n`; out += `| Phase | Name | Plans | Status |\n`; out += `|-------|------|-------|--------|\n`; for (const p of phases) { - out += `| ${p.number} | ${p.name} | ${p.summaries}/${p.plans} | ${p.status} |\n`; + out += `| ${p.display_id ?? p.number} | ${p.name} | ${p.summaries}/${p.plans} | ${p.status} |\n`; } output({ rendered: out }, raw, out); } else if (format === 'bar') { @@ -3357,7 +3439,7 @@ function cmdProgressRender(cwd: string, format: string | undefined, raw: boolean } else { // JSON format output({ - milestone_version: milestone?.version ?? null, + milestone_version: milestoneVersion, milestone_name: milestone?.name ?? null, phases, total_plans: totalPlans, @@ -3709,10 +3791,16 @@ function cmdStats(cwd: string, format: string | undefined, raw: boolean): void { const reqPath = planningPaths(cwd).requirements; const statePath = planningPaths(cwd).state; const milestone = getMilestoneInfo(cwd).value; + const phaseIdConvention = resolvePhaseIdConvention(cwd); + const milestoneDisplay = phaseIdConvention === 'bracket' + ? renderBracketMilestoneDisplay(milestone?.version, loadConfig(cwd).project_code) + : null; + const milestoneVersion = milestoneDisplay ?? milestone?.version ?? null; // Phase & plan stats (reuse progress pattern) const phasesByNumber = new Map 0 && planPercent !== null) { @@ -3882,7 +4020,7 @@ function cmdStats(cwd: string, format: string | undefined, raw: boolean): void { out += `| Phase | Name | Plans | Completed | Status |\n`; out += `|-------|------|-------|-----------|--------|\n`; for (const p of phases) { - out += `| ${p.number} | ${p.name} | ${p.plans} | ${p.summaries} | ${p.status} |\n`; + out += `| ${p.display_id ?? p.number} | ${p.name} | ${p.plans} | ${p.summaries} | ${p.status} |\n`; } if (gitCommits > 0) { out += `\n**Git:** ${gitCommits} commits`; diff --git a/src/config.cts b/src/config.cts index bf349ac76..5974fe94e 100644 --- a/src/config.cts +++ b/src/config.cts @@ -89,6 +89,15 @@ const SHIP_PR_BODY_TEMPLATE_TOKENS = new Set([ ]); const SHIP_PR_BODY_SOURCE_RE = /^(ROADMAP|PLAN|SUMMARY|VERIFICATION|STATE|REQUIREMENTS|CONTEXT)\.md\s+##\s+[^\r\n#][^\r\n]*$/; +// ADR-612 PR-5: configuration accepts every convention the runtime can read. +// Keep this distinct from roadmap-upgrade's supported target set: sequential +// is valid project configuration but is not a migration destination. +const VALID_PHASE_ID_CONVENTIONS: readonly string[] = Object.freeze([ + 'sequential', + 'milestone-prefixed', + 'bracket', +]); + /** * Schema-level defaults for well-known config keys. * When a key is absent from config.json and no --default flag was supplied, @@ -890,6 +899,10 @@ function cmdConfigSet(cwd: string, keyPath: string | undefined, value: string | const VALID_CONTEXT_VALUES = ['dev', 'research', 'review']; if (kp === 'context') assertEnumValue(parsedValue, val, VALID_CONTEXT_VALUES, 'context value'); + if (kp === 'phase_id_convention') { + assertEnumValue(parsedValue, val, VALID_PHASE_ID_CONVENTIONS, 'phase_id_convention'); + } + // Codebase drift detector (#2003) const VALID_DRIFT_ACTIONS = ['warn', 'auto-remap']; if (kp === 'workflow.drift_action') assertEnumValue(parsedValue, val, VALID_DRIFT_ACTIONS, 'workflow.drift_action'); @@ -1438,6 +1451,7 @@ function cmdMigrateConfig(cwd: string, raw: boolean): void { export = { VALID_CONFIG_KEYS, + VALID_PHASE_ID_CONVENTIONS, cmdConfigEnsureSection, cmdConfigSet, cmdConfigGet, diff --git a/src/init.cts b/src/init.cts index 9a295dc98..890eed065 100644 --- a/src/init.cts +++ b/src/init.cts @@ -97,7 +97,22 @@ const { extractCurrentMilestone, } = roadmapParser; const { pathExistsInternal, generateSlugInternal, toPosixPath } = coreUtils; -const { comparePhaseNum, normalizePhaseName, matchPhaseDirs, stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE, isForeignPrefixedPhaseQuery, isSentinelPhaseId, extractPhaseToken, scopeToPhase, renderPhaseBranchName } = phaseId; +const { + comparePhaseNum, + normalizePhaseName, + matchPhaseDirs, + stripProjectCodePrefix, + PHASE_NUMBER_TOKEN_SOURCE, + isForeignPrefixedPhaseQuery, + isSentinelPhaseId, + extractPhaseToken, + scopeToPhase, + renderPhaseBranchName, + parsePhaseId, + renderPhaseId, + phaseHeadingPrefixSrcFor, + PHASE_HEADING_BASELINE, +} = phaseId; const { pruneOrphanedWorktrees } = worktreeSafety; const { @@ -111,6 +126,7 @@ const { diagnoseUnresolvedActiveWorkstream, describeUnresolvedWorkstreamReason, findContextMdIn, + resolvePhaseIdConvention, } = planningWorkspace; const { determinePhaseStatus } = commandsMod; @@ -2745,6 +2761,17 @@ function cmdInitManager(cwd: string, raw: boolean): void { const config = loadConfig(cwd); const milestone = milestoneRecord(cwd); const _slashRuntime = resolveRuntime(cwd); + const phaseIdConvention = resolvePhaseIdConvention(cwd); + const capturesBracketId = phaseIdConvention === 'bracket'; + const phaseHeadingPrefix = phaseHeadingPrefixSrcFor( + PHASE_HEADING_BASELINE.LABEL_ONLY, + phaseIdConvention, + capturesBracketId, + ); + const phaseHeadingPrefixNoCapture = phaseHeadingPrefixSrcFor( + PHASE_HEADING_BASELINE.LABEL_ONLY, + phaseIdConvention, + ); const paths = planningPaths(cwd); @@ -2763,27 +2790,52 @@ function cmdInitManager(cwd: string, raw: boolean): void { // routed through it instead of a hand-rolled readdirSync + a separate // getMilestonePhaseFilter window check (which also never excluded // sentinels, unlike the owner). - const _phaseDirEntries = listMilestonePhaseDirs(phasesDir, { cwd }).value; + const _phaseDirEntries = listMilestonePhaseDirs(phasesDir, { + cwd, + phaseIdConvention, + }).value; const _checkboxStates = new Map(); - const _cbPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})[:\\s]`, 'gi'); + const _cbPattern = new RegExp( + `-\\s*\\[(x| )\\]\\s*.*${phaseHeadingPrefix}(${PHASE_NUMBER_TOKEN_SOURCE})[:\\s]`, + 'gi', + ); let _cbMatch: RegExpExecArray | null; while ((_cbMatch = _cbPattern.exec(content)) !== null) { - _checkboxStates.set(_cbMatch[2], _cbMatch[1].toLowerCase() === 'x'); + const phaseGroup = capturesBracketId ? 3 : 2; + _checkboxStates.set(_cbMatch[phaseGroup], _cbMatch[1].toLowerCase() === 'x'); } // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi'); + const phasePattern = new RegExp( + `#{2,4}\\s*${phaseHeadingPrefix}(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, + 'gi', + ); const phases: Record[] = []; let match: RegExpExecArray | null; while ((match = phasePattern.exec(content)) !== null) { - const phaseNum = match[1]; - const phaseName = match[2].replace(/\(INSERTED\)/i, '').trim(); + const bracketId = capturesBracketId ? match[1] : undefined; + const phaseNum = capturesBracketId ? match[2] : match[1]; + const phaseName = (capturesBracketId ? match[3] : match[2]) + .replace(/\(INSERTED\)/i, '') + .trim(); + let displayId: string | undefined; + if (bracketId) { + try { + displayId = renderPhaseId(parsePhaseId(`${bracketId}-${phaseNum}`)); + } catch { + // The bracket selector is deliberately read-tolerant. If a heading is + // non-canonical, retain the manager row without fabricating display_id. + } + } const sectionStart = match.index; const restOfContent = content.slice(sectionStart); - const nextHeader = restOfContent.match(/\n#{2,4}\s+Phase\s+\d[\d.]*/i); + const nextHeader = restOfContent.match(new RegExp( + `\\n#{2,4}\\s+${phaseHeadingPrefixNoCapture}\\d[\\d.]*`, + 'i', + )); const sectionEnd = nextHeader ? sectionStart + (nextHeader.index as number) : content.length; @@ -2820,7 +2872,11 @@ function cmdInitManager(cwd: string, raw: boolean): void { // milestone-scoped set and onto the physical one; that scope choice is // kept. Only the matcher is this PR's: matchPhaseDirs resolves // digit-leading directory names the token predicate cannot (#2528). - const dirMatch = matchPhaseDirs(_phaseDirEntries, normalized).matches[0]; + const dirMatch = matchPhaseDirs( + _phaseDirEntries, + normalized, + phaseIdConvention, + ).matches[0]; if (dirMatch) { const fullDir = path.join(phasesDir, dirMatch); @@ -2901,6 +2957,7 @@ function cmdInitManager(cwd: string, raw: boolean): void { phases.push({ number: phaseNum, + ...(displayId ? { display_id: displayId } : {}), name: phaseName, goal, depends_on, @@ -2945,7 +3002,10 @@ function cmdInitManager(cwd: string, raw: boolean): void { ); const phaseMap = new Map(phases.map((p) => [normalizePhaseNumber(p['number'] as string), p])); - const _allCompletedPattern = new RegExp(`-\\s*\\[x\\]\\s*.*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})[:\\s]`, 'gi'); + const _allCompletedPattern = new RegExp( + `-\\s*\\[x\\]\\s*.*${phaseHeadingPrefixNoCapture}(${PHASE_NUMBER_TOKEN_SOURCE})[:\\s]`, + 'gi', + ); let _allMatch: RegExpExecArray | null; while ((_allMatch = _allCompletedPattern.exec(rawContent)) !== null) { const phaseNum = normalizePhaseNumber(_allMatch[1]); diff --git a/src/phase-id-card.cts b/src/phase-id-card.cts new file mode 100644 index 000000000..f23417c9e --- /dev/null +++ b/src/phase-id-card.cts @@ -0,0 +1,35 @@ +/** + * Canonical bracket convention card (#3638 / ADR-612 PR-5). + * + * This module is the sole editable source of the compact grammar diagram. + * Render sites import phaseIdCard(); generated documentation is held to the + * same bytes by tests/phase-id-card.test.cjs. PR-6 can therefore inject the + * card broadly without inventing another copy. + */ + +const PHASE_ID_CARD = [ + '[GSD.02] 05.03-01', + ' │ │ │ │ │', + ' │ │ │ │ └── plan 01', + ' │ │ │ └────── subphase 03', + ' │ │ └───────── phase 05', + ' │ └───────────── milestone 02', + ' └───────────────── project GSD', +].join('\n'); + +const PHASE_ID_LEGEND = + "milestone = bracket integer; dots = phase-levels; one hyphen = plan; " + + "no 'Phase' word, no vX.Y"; + +function phaseIdCard(options: { title?: string } = {}): string { + const parts: string[] = []; + if (options.title) parts.push(options.title, ''); + parts.push(PHASE_ID_CARD, '', PHASE_ID_LEGEND); + return parts.join('\n'); +} + +export = { + phaseIdCard, + PHASE_ID_CARD, + PHASE_ID_LEGEND, +}; diff --git a/src/phase-id-display.cts b/src/phase-id-display.cts new file mode 100644 index 000000000..e76a0ba5b --- /dev/null +++ b/src/phase-id-display.cts @@ -0,0 +1,85 @@ +/** + * Phase-ID display adapters (#3638 / ADR-612 PR-5). + * + * STATE.md and milestone metadata still expose the legacy `vN.0` marker on + * this stacked base. Display surfaces need to translate that metadata into a + * bracket identity without growing another renderer. These pure adapters do + * only the boundary normalization, then delegate identity parsing/rendering to + * phase-id.cts's canonical pair. + */ + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import phaseId = require('./phase-id.cjs'); +const { parsePhaseId, renderMilestoneId, renderPhaseId } = phaseId; + +function canonicalNumeric(value: string): string { + let offset = 0; + while (offset < value.length - 1 && value[offset] === '0') offset++; + const stripped = value.slice(offset); + return stripped.length < 2 ? stripped.padStart(2, '0') : stripped; +} + +function isDigits(value: string): boolean { + return value.length > 0 + && [...value].every(char => char >= '0' && char <= '9'); +} + +function milestoneToken(value: unknown): string | null { + if (typeof value !== 'string' && typeof value !== 'number') return null; + const raw = String(value).replace(/^v/i, ''); + const parts = raw.split('.'); + if (parts.length > 2 || parts.some(part => !isDigits(part))) return null; + return canonicalNumeric(parts[0]); +} + +function phaseToken(value: unknown): string | null { + if (typeof value !== 'string' && typeof value !== 'number') return null; + const raw = String(value); + const parts = raw.split('.'); + if (parts.length > 2 || parts.some(part => !isDigits(part))) return null; + return parts.map(canonicalNumeric).join('.'); +} + +/** + * Render a bracket phase display from legacy milestone metadata. + * Returns null for incomplete or invalid metadata so cosmetic callers can + * degrade without breaking a command/statusline render. + */ +function renderBracketPhaseDisplay( + milestone: unknown, + phase: unknown, + projectCode: unknown, +): string | null { + const project = typeof projectCode === 'string' ? projectCode : ''; + const mm = milestoneToken(milestone); + const pp = phaseToken(phase); + if (!project || mm === null || pp === null) return null; + + try { + return renderPhaseId(parsePhaseId(`${project}.${mm}-${pp}`)); + } catch { + return null; + } +} + +/** Render only the bracket milestone label, still through canonical parsing. */ +function renderBracketMilestoneDisplay( + milestone: unknown, + projectCode: unknown, +): string | null { + const project = typeof projectCode === 'string' ? projectCode : ''; + const mm = milestoneToken(milestone); + if (!project || mm === null) return null; + + try { + // The sentinel phase is a validation vehicle, not a rendering artifact. + return renderMilestoneId(parsePhaseId(`${project}.${mm}-00`)); + } catch { + return null; + } +} + +export = { + renderBracketPhaseDisplay, + renderBracketMilestoneDisplay, +}; diff --git a/src/phase-id.cts b/src/phase-id.cts index 434309076..8df56cd67 100644 --- a/src/phase-id.cts +++ b/src/phase-id.cts @@ -515,10 +515,14 @@ function parsePhaseId(input: string): PhaseId { throw new Error(`parsePhaseId: not a bracket phase id: ${JSON.stringify(input)}`); } +function renderMilestoneId(id: { project: string; milestone: string }): string { + return `[${id.project}.${id.milestone}]`; +} + function renderPhaseId(id: PhaseId): string { const sub = id.subphase ? `.${id.subphase}` : ''; const plan = id.plan ? `-${id.plan}` : ''; - return `[${id.project}.${id.milestone}] ${id.phase}${sub}${plan}`; + return `${renderMilestoneId(id)} ${id.phase}${sub}${plan}`; } // PhaseId is a structural type: nothing forces a caller through parsePhaseId, @@ -1651,6 +1655,7 @@ export = { getMilestoneFromPhaseId, getPhaseDirFromPhaseId, parsePhaseId, + renderMilestoneId, renderPhaseId, toDir, SENTINEL_RANGES, diff --git a/tests/adr-612-bracket-display.property.test.cjs b/tests/adr-612-bracket-display.property.test.cjs new file mode 100644 index 000000000..78ab43c8d --- /dev/null +++ b/tests/adr-612-bracket-display.property.test.cjs @@ -0,0 +1,342 @@ +'use strict'; + +/** + * ADR-612 PR-5 (#3638) — bracket display surfaces. + * + * Breaks caught: + * - progress/stats re-parse a bracket directory with the legacy numeric shape; + * - stats/init-manager fail to read label-less bracket headings; + * - a display surface hand-builds an ID that diverges from the canonical + * parse/render pair; + * - a non-bracket project gains the new display_id field or widened heading + * recognition without opting in. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const fc = require('./helpers/fast-check-setup.cjs'); +const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); +const { + parsePhaseId, + renderMilestoneId, + renderPhaseId, + toDir, +} = require('../gsd-core/bin/lib/phase-id.cjs'); +const { + renderBracketMilestoneDisplay, +} = require('../gsd-core/bin/lib/phase-id-display.cjs'); +const statusline = require('../hooks/gsd-statusline.js'); + +const UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; +const CODE_TAIL = `${UPPER}0123456789_`; +const codeArb = fc + .tuple( + fc.constantFrom(...UPPER), + fc.string({ unit: fc.constantFrom(...CODE_TAIL), maxLength: 5 }), + ) + .map(([head, tail]) => head + tail); +const canonicalNumberArb = fc.oneof( + fc.integer({ min: 0, max: 99 }).map((n) => String(n).padStart(2, '0')), + fc.integer({ min: 100, max: 9999 }).map(String), +); + +function writeState(planningDir) { + fs.writeFileSync( + path.join(planningDir, 'STATE.md'), + [ + '---', + 'milestone: v2.0', + 'milestone_name: Display', + 'status: planning', + 'active_phase: null', + '---', + '', + '# State', + '', + 'Phase: 1 of 1 (Display Slice)', + '', + ].join('\n'), + ); +} + +function bracketProject(t) { + const cwd = createTempProject('adr-612-display-'); + t.after(() => cleanup(cwd)); + const planning = path.join(cwd, '.planning'); + fs.writeFileSync( + path.join(planning, 'config.json'), + JSON.stringify({ phase_id_convention: 'bracket', project_code: 'GSD' }), + ); + fs.writeFileSync( + path.join(planning, 'ROADMAP.md'), + [ + '# Roadmap', + '', + '## [GSD.02] v2.0: Display', + '', + '- [ ] **[GSD.02] 05.03: Display Slice**', + '', + '### [GSD.02] 05.03: Display Slice', + '', + '**Goal:** Render the canonical ID', + '', + ].join('\n'), + ); + writeState(planning); + const phaseDir = path.join(planning, 'phases', 'GSD.02-05.03-display-slice'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '05.03-01-PLAN.md'), '# Plan\n'); + return cwd; +} + +function legacyProject(t) { + const cwd = createTempProject('adr-612-display-legacy-'); + t.after(() => cleanup(cwd)); + const planning = path.join(cwd, '.planning'); + fs.writeFileSync( + path.join(planning, 'config.json'), + JSON.stringify({ phase_id_convention: 'sequential', project_code: 'GSD' }), + ); + fs.writeFileSync( + path.join(planning, 'ROADMAP.md'), + [ + '# Roadmap', + '', + '## v2.0: Display', + '', + '- [ ] **Phase 05.03: Display Slice**', + '', + '### Phase 05.03: Display Slice', + '', + '**Goal:** Preserve legacy display', + '', + ].join('\n'), + ); + writeState(planning); + const phaseDir = path.join(planning, 'phases', '05.03-display-slice'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '05.03-01-PLAN.md'), '# Plan\n'); + return cwd; +} + +function malformedBracketProject(t) { + const cwd = createTempProject('adr-612-display-malformed-'); + t.after(() => cleanup(cwd)); + const planning = path.join(cwd, '.planning'); + fs.writeFileSync( + path.join(planning, 'config.json'), + JSON.stringify({ phase_id_convention: 'bracket', project_code: 'GSD' }), + ); + fs.writeFileSync( + path.join(planning, 'ROADMAP.md'), + [ + '# Roadmap', + '', + '## [GSD.02] v2.0: Display', + '', + ].join('\n'), + ); + writeState(planning); + const phaseDir = path.join(planning, 'phases', 'gsd.02-05.03-recovered-display-name'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '05.03-01-PLAN.md'), '# Plan\n'); + return cwd; +} + +function runJson(command, cwd) { + const result = runGsdTools(command, cwd); + assert.ok(result.success, `${command} failed: ${result.error}`); + return JSON.parse(result.output); +} + +describe('#3638: canonical bracket display examples', () => { + test('progress and stats expose canonical display_id while retaining the phase token', (t) => { + const cwd = bracketProject(t); + + for (const [command, expectedName] of [ + ['progress json', 'display slice'], + ['stats json', 'Display Slice'], + ]) { + const output = runJson(command, cwd); + assert.equal(output.phases.length, 1, `${command} must report the bracket phase`); + assert.equal(output.milestone_version, '[GSD.02]'); + assert.deepEqual( + { + number: output.phases[0].number, + display_id: output.phases[0].display_id, + name: output.phases[0].name, + }, + { number: '05.03', display_id: '[GSD.02] 05.03', name: expectedName }, + ); + } + + const progressTable = runJson('progress table', cwd).rendered; + const statsTable = runJson('stats table', cwd).rendered; + assert.match(progressTable, /\| \[GSD\.02\] 05\.03 \| display slice \|/); + assert.match(statsTable, /\| \[GSD\.02\] 05\.03 \| Display Slice \|/); + assert.doesNotMatch(progressTable, /v2\.0/); + assert.doesNotMatch(statsTable, /v2\.0/); + }); + + test('init manager reads the label-less bracket heading and emits its canonical display_id', (t) => { + const cwd = bracketProject(t); + const output = runJson('init manager', cwd); + + assert.equal(output.phases.length, 1); + assert.deepEqual( + { + number: output.phases[0].number, + display_id: output.phases[0].display_id, + name: output.phases[0].name, + disk_status: output.phases[0].disk_status, + }, + { + number: '05.03', + display_id: '[GSD.02] 05.03', + name: 'Display Slice', + disk_status: 'planned', + }, + ); + }); + + test('a non-bracket project preserves the legacy object and table shape', (t) => { + const cwd = legacyProject(t); + + for (const [command, expectedName] of [ + ['progress json', 'display slice'], + ['stats json', 'Display Slice'], + ['init manager', 'Display Slice'], + ]) { + const output = runJson(command, cwd); + assert.equal(output.phases.length, 1, `${command} must retain its legacy phase`); + assert.equal(output.phases[0].number, '05.03'); + assert.equal(output.phases[0].name, expectedName); + assert.equal(Object.hasOwn(output.phases[0], 'display_id'), false); + } + + assert.match(runJson('progress table', cwd).rendered, /\| 05\.03 \| display slice \|/); + assert.match(runJson('stats table', cwd).rendered, /\| 05\.03 \| Display Slice \|/); + }); + + test('progress recovers a malformed bracket directory name without a display_id', (t) => { + const output = runJson('progress json', malformedBracketProject(t)); + + assert.equal(output.phases.length, 1); + assert.deepEqual( + { + number: output.phases[0].number, + name: output.phases[0].name, + hasDisplayId: Object.hasOwn(output.phases[0], 'display_id'), + }, + { number: '05.03', name: 'recovered display name', hasDisplayId: false }, + ); + }); + + test('stats recovers a malformed bracket directory name without a display_id', (t) => { + const output = runJson('stats json', malformedBracketProject(t)); + + assert.equal(output.phases.length, 1); + assert.deepEqual( + { + number: output.phases[0].number, + name: output.phases[0].name, + hasDisplayId: Object.hasOwn(output.phases[0], 'display_id'), + }, + { number: '05.03', name: 'recovered display name', hasDisplayId: false }, + ); + }); +}); + +describe('#3638: bracket milestone renderer', () => { + test('pins the milestone label as the shared phase-display prefix', () => { + const id = parsePhaseId('[GSD.02] 05.03-01'); + + assert.equal(renderMilestoneId(id), '[GSD.02]'); + assert.equal(renderPhaseId(id), `${renderMilestoneId(id)} 05.03-01`); + assert.equal(renderBracketMilestoneDisplay('v2.0', 'GSD'), '[GSD.02]'); + }); +}); + +describe('#3638: statusline/progress-card convention gate', () => { + const state = { + milestone: 'v2.0', + milestoneName: 'Display', + activePhase: '05.03', + status: 'executing', + percent: '50', + }; + const bracket = { convention: 'bracket', projectCode: 'GSD' }; + + test('full and compact cards render only the canonical bracket identity', () => { + const full = statusline.formatGsdState(state, bracket); + const compact = statusline.formatGsdStateCompact(state, bracket); + + assert.equal( + full, + '[GSD.02] Display [█████░░░░░] 50% · [GSD.02] 05.03 executing', + ); + assert.equal(compact, '[GSD.02] · [GSD.02] 05.03 · executing'); + assert.doesNotMatch(`${full}\n${compact}`, /v2\.0|\bPhase\b|\bP05\.03\b/); + }); + + test('a non-bracket convention retains the exact legacy card strings', () => { + assert.equal( + statusline.formatGsdState(state, { convention: 'sequential', projectCode: 'GSD' }), + 'v2.0 Display [█████░░░░░] 50% · Phase 05.03 executing', + ); + assert.equal( + statusline.formatGsdStateCompact(state, { convention: 'sequential', projectCode: 'GSD' }), + 'v2.0 · P05.03 · executing', + ); + }); + + test('renderStatusline threads the bracket configuration through both entry points', (t) => { + const cwd = bracketProject(t); + const input = { + model: { display_name: 'Claude' }, + workspace: { current_dir: cwd }, + context_window: {}, + }; + + const full = statusline.renderStatusline(input); + assert.match(full, /\[GSD\.02\]/); + assert.doesNotMatch(full, /v2\.0/); + + fs.writeFileSync( + path.join(cwd, '.planning', 'config.json'), + JSON.stringify({ + phase_id_convention: 'bracket', + project_code: 'GSD', + statusline: { state_format: 'compact' }, + }), + ); + const compact = statusline.renderStatusline(input); + assert.match(compact, /\[GSD\.02\]/); + assert.doesNotMatch(compact, /v2\.0/); + }); +}); + +describe('#3638: pure bracket pair remains the display/disk oracle', () => { + test('render(parse(display)) is identity and toDir(parse(display)) reaches the hand-built dir', () => { + fc.assert( + fc.property( + codeArb, + canonicalNumberArb, + canonicalNumberArb, + fc.option(canonicalNumberArb, { nil: undefined }), + (project, milestone, phase, subphase) => { + const token = subphase === undefined ? phase : `${phase}.${subphase}`; + const display = `[${project}.${milestone}] ${token}`; + const dir = `${project}.${milestone}-${token}-display-slice`; + const parsed = parsePhaseId(display); + + assert.equal(renderPhaseId(parsed), display); + assert.equal(toDir(parsed, 'display slice'), dir); + }, + ), + ); + }); +}); diff --git a/tests/adr-612-bracket-heading-selection.test.cjs b/tests/adr-612-bracket-heading-selection.test.cjs index 26caf9e64..7fe7c7649 100644 --- a/tests/adr-612-bracket-heading-selection.test.cjs +++ b/tests/adr-612-bracket-heading-selection.test.cjs @@ -52,6 +52,8 @@ const BASE_SITES = [ baseline: B.ANY_BRACKET, src: '(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+' }, { file: 'roadmap-parser.cts', site: 'getMilestonePhaseFilter phaseHeadingPattern', baseline: B.ANY_BRACKET, src: '(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+' }, + { file: 'commands.cts', site: 'cmdStats headingPattern', + baseline: B.ANY_BRACKET, src: '(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+' }, // #2761 B2: BRACKET_PHASE_TAIL_RE (isBracketMilestoneBoundary's phase-tail // discriminator) always passes the literal 'bracket' convention — it is not // itself convention-gated (the CALLER, isBracketMilestoneBoundary, is only @@ -123,6 +125,10 @@ const BASE_SITES = [ baseline: B.LABEL_ONLY, src: 'Phase\\s+' }, { file: 'planning-snapshot.cts', site: 'buildRoadmapPhaseCheckboxesField (W011/W006 not-started)', baseline: B.LABEL_ONLY, src: 'Phase\\s+' }, + { file: 'init.cts', site: 'cmdInitManager phaseHeadingPrefix', + baseline: B.LABEL_ONLY, src: 'Phase\\s+' }, + { file: 'init.cts', site: 'cmdInitManager phaseHeadingPrefixNoCapture', + baseline: B.LABEL_ONLY, src: 'Phase\\s+' }, ]; // Every convention value that is NOT the bracket convention. A repo carrying any @@ -681,6 +687,8 @@ describe('#612 PR-2: every selector call site declares the right baseline (live // file -> [ANY_BRACKET count, LABEL_ONLY count] const EXPECTED = { + 'commands.cts': [1, 0], + 'init.cts': [0, 2], 'roadmap.cts': [3, 3], 'validate.cts': [1, 2], 'state.cts': [0, 3], diff --git a/tests/config.test.cjs b/tests/config.test.cjs index 04587ad30..3efe4ea0c 100644 --- a/tests/config.test.cjs +++ b/tests/config.test.cjs @@ -13,6 +13,65 @@ const fs = require('fs'); const path = require('path'); const { runGsdTools, createTempProject, cleanup, delay } = require('./helpers.cjs'); +// ─── ADR-612 PR-5: phase_id_convention enum validation ────────────────────── + +describe('#3638: phase_id_convention config enum', () => { + test('accepts sequential, milestone-prefixed, and bracket exactly', (t) => { + const tmpDir = createTempProject('gsd-phase-id-convention-'); + t.after(() => cleanup(tmpDir)); + + for (const convention of ['sequential', 'milestone-prefixed', 'bracket']) { + const result = runGsdTools(['config-set', 'phase_id_convention', convention], tmpDir); + assert.ok(result.success, `config-set phase_id_convention ${convention} failed: ${result.error}`); + assert.equal(readConfig(tmpDir).phase_id_convention, convention); + } + }); + + test('absent and null preserve the unset legacy behavior', (t) => { + const tmpDir = createTempProject('gsd-phase-id-convention-'); + t.after(() => cleanup(tmpDir)); + assert.equal( + fs.existsSync(path.join(tmpDir, '.planning', 'config.json')), + false, + 'an absent config leaves the convention unset', + ); + writeConfig(tmpDir, { phase_id_convention: 'bracket', model_profile: 'balanced' }); + + const result = runGsdTools(['config-set', 'phase_id_convention', 'null'], tmpDir); + assert.ok(result.success, `config-set phase_id_convention null failed: ${result.error}`); + const config = readConfig(tmpDir); + assert.equal(Object.hasOwn(config, 'phase_id_convention'), false); + assert.equal(config.model_profile, 'balanced', 'unsetting the convention must preserve sibling config'); + }); + + test('rejects unsupported and case-mismatched values with the supported set', (t) => { + const tmpDir = createTempProject('gsd-phase-id-convention-'); + t.after(() => cleanup(tmpDir)); + + for (const value of ['free-form', 'Bracket']) { + const result = runGsdTools(['config-set', 'phase_id_convention', value], tmpDir); + assert.equal(result.success, false, `${value} must be rejected`); + assert.match(result.error, /Invalid phase_id_convention/); + assert.match(result.error, /sequential, milestone-prefixed, bracket/); + } + }); + + test('sequential remains valid configuration but is not a roadmap-upgrade target', (t) => { + const tmpDir = createTempProject('gsd-phase-id-convention-'); + t.after(() => cleanup(tmpDir)); + + const configResult = runGsdTools(['config-set', 'phase_id_convention', 'sequential'], tmpDir); + assert.ok(configResult.success, `sequential config failed: ${configResult.error}`); + + const migrationResult = runGsdTools( + ['roadmap', 'upgrade', '--convention', 'sequential', '--dry-run'], + tmpDir, + ); + assert.equal(migrationResult.success, false, 'sequential must not become a migration target'); + assert.match(migrationResult.error, /Only --convention milestone-prefixed is supported/); + }); +}); + // ─── helpers ────────────────────────────────────────────────────────────────── function readConfig(tmpDir) { diff --git a/tests/fixtures/install-tree/antigravity.json b/tests/fixtures/install-tree/antigravity.json index 8fab0f6aa..958c36651 100644 --- a/tests/fixtures/install-tree/antigravity.json +++ b/tests/fixtures/install-tree/antigravity.json @@ -282,6 +282,7 @@ "gsd-core/references/nyquist-compliance.md", "gsd-core/references/offer-next.md", "gsd-core/references/phase-argument-parsing.md", + "gsd-core/references/phase-id-convention.md", "gsd-core/references/plan-checker-examples.md", "gsd-core/references/planner-antipatterns.md", "gsd-core/references/planner-chunked.md", diff --git a/tests/fixtures/install-tree/augment.json b/tests/fixtures/install-tree/augment.json index 9a7826e7c..3589a4110 100644 --- a/tests/fixtures/install-tree/augment.json +++ b/tests/fixtures/install-tree/augment.json @@ -354,6 +354,7 @@ "gsd-core/references/nyquist-compliance.md", "gsd-core/references/offer-next.md", "gsd-core/references/phase-argument-parsing.md", + "gsd-core/references/phase-id-convention.md", "gsd-core/references/plan-checker-examples.md", "gsd-core/references/planner-antipatterns.md", "gsd-core/references/planner-chunked.md", diff --git a/tests/fixtures/install-tree/claude-local.json b/tests/fixtures/install-tree/claude-local.json index 255318f7a..4f3046e8e 100644 --- a/tests/fixtures/install-tree/claude-local.json +++ b/tests/fixtures/install-tree/claude-local.json @@ -218,6 +218,7 @@ "gsd-core/references/nyquist-compliance.md", "gsd-core/references/offer-next.md", "gsd-core/references/phase-argument-parsing.md", + "gsd-core/references/phase-id-convention.md", "gsd-core/references/plan-checker-examples.md", "gsd-core/references/planner-antipatterns.md", "gsd-core/references/planner-chunked.md", diff --git a/tests/fixtures/install-tree/claude.json b/tests/fixtures/install-tree/claude.json index fdcdeea90..cd817b738 100644 --- a/tests/fixtures/install-tree/claude.json +++ b/tests/fixtures/install-tree/claude.json @@ -282,6 +282,7 @@ "gsd-core/references/nyquist-compliance.md", "gsd-core/references/offer-next.md", "gsd-core/references/phase-argument-parsing.md", + "gsd-core/references/phase-id-convention.md", "gsd-core/references/plan-checker-examples.md", "gsd-core/references/planner-antipatterns.md", "gsd-core/references/planner-chunked.md", diff --git a/tests/fixtures/install-tree/cline.json b/tests/fixtures/install-tree/cline.json index 5318bb567..54048213e 100644 --- a/tests/fixtures/install-tree/cline.json +++ b/tests/fixtures/install-tree/cline.json @@ -284,6 +284,7 @@ "gsd-core/references/nyquist-compliance.md", "gsd-core/references/offer-next.md", "gsd-core/references/phase-argument-parsing.md", + "gsd-core/references/phase-id-convention.md", "gsd-core/references/plan-checker-examples.md", "gsd-core/references/planner-antipatterns.md", "gsd-core/references/planner-chunked.md", diff --git a/tests/fixtures/install-tree/codebuddy.json b/tests/fixtures/install-tree/codebuddy.json index e2c3a5953..68ad4d3e8 100644 --- a/tests/fixtures/install-tree/codebuddy.json +++ b/tests/fixtures/install-tree/codebuddy.json @@ -354,6 +354,7 @@ "gsd-core/references/nyquist-compliance.md", "gsd-core/references/offer-next.md", "gsd-core/references/phase-argument-parsing.md", + "gsd-core/references/phase-id-convention.md", "gsd-core/references/plan-checker-examples.md", "gsd-core/references/planner-antipatterns.md", "gsd-core/references/planner-chunked.md", diff --git a/tests/fixtures/install-tree/codex.json b/tests/fixtures/install-tree/codex.json index d3ef6ca90..774249175 100644 --- a/tests/fixtures/install-tree/codex.json +++ b/tests/fixtures/install-tree/codex.json @@ -318,6 +318,7 @@ "gsd-core/references/nyquist-compliance.md", "gsd-core/references/offer-next.md", "gsd-core/references/phase-argument-parsing.md", + "gsd-core/references/phase-id-convention.md", "gsd-core/references/plan-checker-examples.md", "gsd-core/references/planner-antipatterns.md", "gsd-core/references/planner-chunked.md", diff --git a/tests/fixtures/install-tree/copilot.json b/tests/fixtures/install-tree/copilot.json index 2876dc69d..8d83a304c 100644 --- a/tests/fixtures/install-tree/copilot.json +++ b/tests/fixtures/install-tree/copilot.json @@ -283,6 +283,7 @@ "gsd-core/references/nyquist-compliance.md", "gsd-core/references/offer-next.md", "gsd-core/references/phase-argument-parsing.md", + "gsd-core/references/phase-id-convention.md", "gsd-core/references/plan-checker-examples.md", "gsd-core/references/planner-antipatterns.md", "gsd-core/references/planner-chunked.md", diff --git a/tests/fixtures/install-tree/cursor.json b/tests/fixtures/install-tree/cursor.json index ad852c0dc..a44e8885f 100644 --- a/tests/fixtures/install-tree/cursor.json +++ b/tests/fixtures/install-tree/cursor.json @@ -282,6 +282,7 @@ "gsd-core/references/nyquist-compliance.md", "gsd-core/references/offer-next.md", "gsd-core/references/phase-argument-parsing.md", + "gsd-core/references/phase-id-convention.md", "gsd-core/references/plan-checker-examples.md", "gsd-core/references/planner-antipatterns.md", "gsd-core/references/planner-chunked.md", diff --git a/tests/fixtures/install-tree/hermes.json b/tests/fixtures/install-tree/hermes.json index 7f1925fe6..d8f60328a 100644 --- a/tests/fixtures/install-tree/hermes.json +++ b/tests/fixtures/install-tree/hermes.json @@ -282,6 +282,7 @@ "gsd-core/references/nyquist-compliance.md", "gsd-core/references/offer-next.md", "gsd-core/references/phase-argument-parsing.md", + "gsd-core/references/phase-id-convention.md", "gsd-core/references/plan-checker-examples.md", "gsd-core/references/planner-antipatterns.md", "gsd-core/references/planner-chunked.md", diff --git a/tests/fixtures/install-tree/kilo.json b/tests/fixtures/install-tree/kilo.json index 1b74815f5..95896429f 100644 --- a/tests/fixtures/install-tree/kilo.json +++ b/tests/fixtures/install-tree/kilo.json @@ -354,6 +354,7 @@ "gsd-core/references/nyquist-compliance.md", "gsd-core/references/offer-next.md", "gsd-core/references/phase-argument-parsing.md", + "gsd-core/references/phase-id-convention.md", "gsd-core/references/plan-checker-examples.md", "gsd-core/references/planner-antipatterns.md", "gsd-core/references/planner-chunked.md", diff --git a/tests/fixtures/install-tree/kimi-code.json b/tests/fixtures/install-tree/kimi-code.json index a57222359..c60373cbd 100644 --- a/tests/fixtures/install-tree/kimi-code.json +++ b/tests/fixtures/install-tree/kimi-code.json @@ -283,6 +283,7 @@ "gsd-core/references/nyquist-compliance.md", "gsd-core/references/offer-next.md", "gsd-core/references/phase-argument-parsing.md", + "gsd-core/references/phase-id-convention.md", "gsd-core/references/plan-checker-examples.md", "gsd-core/references/planner-antipatterns.md", "gsd-core/references/planner-chunked.md", diff --git a/tests/fixtures/install-tree/kimi.json b/tests/fixtures/install-tree/kimi.json index 5c14148d7..2f7d5ab77 100644 --- a/tests/fixtures/install-tree/kimi.json +++ b/tests/fixtures/install-tree/kimi.json @@ -290,6 +290,7 @@ "gsd-core/references/nyquist-compliance.md", "gsd-core/references/offer-next.md", "gsd-core/references/phase-argument-parsing.md", + "gsd-core/references/phase-id-convention.md", "gsd-core/references/plan-checker-examples.md", "gsd-core/references/planner-antipatterns.md", "gsd-core/references/planner-chunked.md", diff --git a/tests/fixtures/install-tree/opencode.json b/tests/fixtures/install-tree/opencode.json index d19d9419b..61eecaa81 100644 --- a/tests/fixtures/install-tree/opencode.json +++ b/tests/fixtures/install-tree/opencode.json @@ -354,6 +354,7 @@ "gsd-core/references/nyquist-compliance.md", "gsd-core/references/offer-next.md", "gsd-core/references/phase-argument-parsing.md", + "gsd-core/references/phase-id-convention.md", "gsd-core/references/plan-checker-examples.md", "gsd-core/references/planner-antipatterns.md", "gsd-core/references/planner-chunked.md", diff --git a/tests/fixtures/install-tree/pi.json b/tests/fixtures/install-tree/pi.json index 1eaa3ff04..ab5b2b506 100644 --- a/tests/fixtures/install-tree/pi.json +++ b/tests/fixtures/install-tree/pi.json @@ -84,6 +84,7 @@ "gsd-core/references/nyquist-compliance.md", "gsd-core/references/offer-next.md", "gsd-core/references/phase-argument-parsing.md", + "gsd-core/references/phase-id-convention.md", "gsd-core/references/plan-checker-examples.md", "gsd-core/references/planner-antipatterns.md", "gsd-core/references/planner-chunked.md", diff --git a/tests/fixtures/install-tree/qwen.json b/tests/fixtures/install-tree/qwen.json index 5343f36b4..d460112cf 100644 --- a/tests/fixtures/install-tree/qwen.json +++ b/tests/fixtures/install-tree/qwen.json @@ -282,6 +282,7 @@ "gsd-core/references/nyquist-compliance.md", "gsd-core/references/offer-next.md", "gsd-core/references/phase-argument-parsing.md", + "gsd-core/references/phase-id-convention.md", "gsd-core/references/plan-checker-examples.md", "gsd-core/references/planner-antipatterns.md", "gsd-core/references/planner-chunked.md", diff --git a/tests/fixtures/install-tree/trae.json b/tests/fixtures/install-tree/trae.json index eca69ebfd..de7451f8e 100644 --- a/tests/fixtures/install-tree/trae.json +++ b/tests/fixtures/install-tree/trae.json @@ -282,6 +282,7 @@ "gsd-core/references/nyquist-compliance.md", "gsd-core/references/offer-next.md", "gsd-core/references/phase-argument-parsing.md", + "gsd-core/references/phase-id-convention.md", "gsd-core/references/plan-checker-examples.md", "gsd-core/references/planner-antipatterns.md", "gsd-core/references/planner-chunked.md", diff --git a/tests/fixtures/install-tree/windsurf.json b/tests/fixtures/install-tree/windsurf.json index 072362bcc..34ff197fc 100644 --- a/tests/fixtures/install-tree/windsurf.json +++ b/tests/fixtures/install-tree/windsurf.json @@ -210,6 +210,7 @@ "gsd-core/references/nyquist-compliance.md", "gsd-core/references/offer-next.md", "gsd-core/references/phase-argument-parsing.md", + "gsd-core/references/phase-id-convention.md", "gsd-core/references/plan-checker-examples.md", "gsd-core/references/planner-antipatterns.md", "gsd-core/references/planner-chunked.md", diff --git a/tests/fixtures/install-tree/zcode.json b/tests/fixtures/install-tree/zcode.json index 871afda8c..32ce4ceda 100644 --- a/tests/fixtures/install-tree/zcode.json +++ b/tests/fixtures/install-tree/zcode.json @@ -354,6 +354,7 @@ "gsd-core/references/nyquist-compliance.md", "gsd-core/references/offer-next.md", "gsd-core/references/phase-argument-parsing.md", + "gsd-core/references/phase-id-convention.md", "gsd-core/references/plan-checker-examples.md", "gsd-core/references/planner-antipatterns.md", "gsd-core/references/planner-chunked.md", diff --git a/tests/phase-id-card.test.cjs b/tests/phase-id-card.test.cjs new file mode 100644 index 000000000..067e9df4c --- /dev/null +++ b/tests/phase-id-card.test.cjs @@ -0,0 +1,58 @@ +'use strict'; + +/** ADR-612 PR-5: the bracket convention card has one runtime source. */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { normalizeEol } = require('../gsd-core/bin/lib/text-lines.cjs'); + +const { + phaseIdCard, + PHASE_ID_CARD, + PHASE_ID_LEGEND, +} = require('../gsd-core/bin/lib/phase-id-card.cjs'); + +const REFERENCE_PATH = path.join( + __dirname, + '..', + 'gsd-core', + 'references', + 'phase-id-convention.md', +); + +describe('#3638: phase ID convention card single source', () => { + test('the runtime card labels every identity dimension', () => { + const rendered = phaseIdCard(); + + assert.match(rendered, /^\[GSD\.02\] 05\.03-01/m); + assert.match(rendered, /plan\s+01/); + assert.match(rendered, /subphase\s+03/); + assert.match(rendered, /phase\s+05/); + assert.match(rendered, /milestone\s+02/); + assert.match(rendered, /project\s+GSD/); + assert.match(PHASE_ID_LEGEND, /no 'Phase' word, no vX\.Y/); + assert.equal(PHASE_ID_CARD.split('\n').length, 7); + }); + + test('an optional title composes without mutating the canonical card', () => { + assert.equal( + phaseIdCard({ title: 'Bracket convention:' }), + `Bracket convention:\n\n${PHASE_ID_CARD}\n\n${PHASE_ID_LEGEND}`, + ); + assert.equal(phaseIdCard(), `${PHASE_ID_CARD}\n\n${PHASE_ID_LEGEND}`); + }); + + test('the reference card block is generated byte-for-byte from the runtime source', () => { + const reference = normalizeEol(fs.readFileSync(REFERENCE_PATH, 'utf8')); + const opener = '\n```text\n'; + const closer = '\n```\n'; + const start = reference.indexOf(opener); + const end = reference.indexOf(closer, start + opener.length); + + assert.notEqual(start, -1, 'generated card opener must remain present'); + assert.notEqual(end, -1, 'generated card closer must remain present'); + assert.equal(reference.slice(start + opener.length, end), phaseIdCard()); + }); +});