enhance(#3638): render bracket phase IDs on progress, stats, manager and statusline surfaces (epic #612 PR-5) (#4111)
* 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) <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
5
.changeset/3638-bracket-display.md
Normal file
5
.changeset/3638-bracket-display.md
Normal file
@@ -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)
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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 <filename> [--dry-run]
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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',
|
||||
|
||||
28
gsd-core/references/phase-id-convention.md
Normal file
28
gsd-core/references/phase-id-convention.md
Normal file
@@ -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.
|
||||
|
||||
<!-- PHASE-ID-CARD:START -->
|
||||
```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
|
||||
```
|
||||
<!-- PHASE-ID-CARD:END -->
|
||||
|
||||
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.
|
||||
@@ -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,8 +417,15 @@ 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})`
|
||||
? 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 });
|
||||
}
|
||||
|
||||
176
src/commands.cts
176
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<string, string[]>;
|
||||
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) {
|
||||
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+)*)-?(.*)/);
|
||||
const phaseNum = dm ? dm[1] : dir;
|
||||
const phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : '';
|
||||
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<string, {
|
||||
number: string;
|
||||
display_id?: string;
|
||||
name: string;
|
||||
plans: number;
|
||||
summaries: number;
|
||||
@@ -3735,19 +3823,47 @@ function cmdStats(cwd: string, format: string | undefined, raw: boolean): void {
|
||||
// prose mentioning `### Phase N:` inside an inline code span produced a phantom
|
||||
// Not-Started row and made phases_total disagree with roadmap analyze.
|
||||
// phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
|
||||
const headingPattern = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+([A-Za-z]?\d+[A-Z]?(?:[.-]\d+)*)(?:\s*\([^)\n]{0,200}\))?\s*:\s*([^\n]+)/gi;
|
||||
const capturesBracketId = phaseIdConvention === 'bracket';
|
||||
const headingPrefix = phaseHeadingPrefixSrcFor(
|
||||
PHASE_HEADING_BASELINE.ANY_BRACKET,
|
||||
phaseIdConvention,
|
||||
capturesBracketId,
|
||||
);
|
||||
// phase-id-owner: this preserves cmdStats's shipped dot-or-dash heading
|
||||
// token variant; PHASE_NUMBER_TOKEN_SOURCE is dot-only and would drop M-NN.
|
||||
const headingPattern = new RegExp(
|
||||
// phase-id-owner: cmdStats's shipped [.-] token variant; the canonical token is dot-only.
|
||||
`#{2,4}\\s*${headingPrefix}([A-Za-z]?\\d+[A-Z]?(?:[.-]\\d+)*)(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`,
|
||||
'gi',
|
||||
);
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = headingPattern.exec(roadmapContent)) !== null) {
|
||||
const bracketId = capturesBracketId ? match[1] : undefined;
|
||||
const phaseToken = capturesBracketId ? match[2] : match[1];
|
||||
const phaseName = capturesBracketId ? match[3] : match[2];
|
||||
// #3185: the heading seed carried no sentinel filter, so a
|
||||
// `### Phase 999.1:` backlog heading produced a stats row even with no
|
||||
// directory on disk. Uses the canonical predicate (phase-id.cts), not a
|
||||
// local literal — the rule had five copies and three regex variants
|
||||
// before this phase, disagreeing about Phase 0.
|
||||
if (isSentinelPhaseId(match[1])) continue;
|
||||
const key = normalizePhaseName(match[1]);
|
||||
const sentinelId = bracketId ? `${bracketId}-${phaseToken}` : phaseToken;
|
||||
if (capturesBracketId
|
||||
? isSentinelPhaseId(sentinelId, phaseIdConvention)
|
||||
: isSentinelPhaseId(sentinelId)) continue;
|
||||
const key = normalizePhaseName(phaseToken);
|
||||
let displayId: string | undefined;
|
||||
if (bracketId) {
|
||||
try {
|
||||
displayId = renderPhaseId(parsePhaseId(`${bracketId}-${phaseToken}`));
|
||||
} catch {
|
||||
// Read tolerance can admit a non-canonical heading spelling; keep
|
||||
// the stats row but do not invent a canonical identity for it.
|
||||
}
|
||||
}
|
||||
phasesByNumber.set(key, {
|
||||
number: key,
|
||||
name: match[2].replace(/\(INSERTED\)/i, '').trim(),
|
||||
...(displayId ? { display_id: displayId } : {}),
|
||||
name: phaseName.replace(/\(INSERTED\)/i, '').trim(),
|
||||
plans: 0,
|
||||
summaries: 0,
|
||||
status: 'Not Started',
|
||||
@@ -3761,16 +3877,35 @@ function cmdStats(cwd: string, format: string | undefined, raw: boolean): void {
|
||||
// sentinel filter — and getMilestonePhaseFilter degrades to a pass-all
|
||||
// predicate when its heading set is empty, at which point every directory
|
||||
// on disk passed, backlog included (#3167).
|
||||
const { value: dirs, scope } = listMilestonePhaseDirs(phasesDir, { cwd });
|
||||
const { value: dirs, scope } = listMilestonePhaseDirs(phasesDir, {
|
||||
cwd,
|
||||
phaseIdConvention,
|
||||
});
|
||||
phaseScope = scope;
|
||||
|
||||
for (const dir of dirs) {
|
||||
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 {
|
||||
const phaseToken = extractPhaseToken(dir, phaseIdConvention);
|
||||
phaseNum = phaseToken || dir;
|
||||
phaseName = recoverBracketPhaseName(dir, phaseToken);
|
||||
}
|
||||
} else {
|
||||
// Use extractPhaseToken to correctly parse M-NN-style and code-prefixed dir names.
|
||||
const phaseToken = extractPhaseToken(dir) as string | null;
|
||||
const phaseNum = phaseToken || dir;
|
||||
phaseNum = phaseToken || dir;
|
||||
// phaseName is everything after the token (strip leading '-')
|
||||
const afterToken = dir.slice(phaseToken ? phaseToken.length : 0).replace(/^-/, '');
|
||||
const phaseName = afterToken ? afterToken.replace(/-/g, ' ') : '';
|
||||
phaseName = afterToken ? afterToken.replace(/-/g, ' ') : '';
|
||||
}
|
||||
// #3183: canonical plan/summary counts (root+nested, superseded-excluded,
|
||||
// canonical pairing) from the single owner.
|
||||
const phaseScan = scanPhasePlans(path.join(phasesDir, dir));
|
||||
@@ -3786,6 +3921,9 @@ function cmdStats(cwd: string, format: string | undefined, raw: boolean): void {
|
||||
const existing = phasesByNumber.get(normalizedNum);
|
||||
phasesByNumber.set(normalizedNum, {
|
||||
number: normalizedNum,
|
||||
...(existing?.display_id || displayId
|
||||
? { display_id: existing?.display_id || displayId }
|
||||
: {}),
|
||||
name: existing?.name || phaseName,
|
||||
plans: (existing?.plans || 0) + plans,
|
||||
summaries: (existing?.summaries || 0) + summaries,
|
||||
@@ -3847,7 +3985,7 @@ function cmdStats(cwd: string, format: string | undefined, raw: boolean): void {
|
||||
}
|
||||
|
||||
const result = {
|
||||
milestone_version: milestone?.version ?? null,
|
||||
milestone_version: milestoneVersion,
|
||||
milestone_name: milestone?.name ?? null,
|
||||
phases,
|
||||
phases_completed: completedPhases,
|
||||
@@ -3868,7 +4006,7 @@ function cmdStats(cwd: string, format: string | undefined, raw: boolean): void {
|
||||
|
||||
if (format === 'table') {
|
||||
const bar = renderProgressBar(percent, 10);
|
||||
let out = `# ${milestone?.version ?? ''} ${milestone?.name ?? ''} — Statistics\n\n`;
|
||||
let out = `# ${milestoneVersion ?? ''} ${milestone?.name ?? ''} — Statistics\n\n`;
|
||||
const percentSuffix = percent === null ? '' : ` (${percent}%)`;
|
||||
out += `**Progress:** [${bar}] ${completedPhases}/${phases.length} phases${percentSuffix}\n`;
|
||||
if (totalPlans > 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`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
80
src/init.cts
80
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<string, boolean>();
|
||||
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<string, unknown>[] = [];
|
||||
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]);
|
||||
|
||||
35
src/phase-id-card.cts
Normal file
35
src/phase-id-card.cts
Normal file
@@ -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,
|
||||
};
|
||||
85
src/phase-id-display.cts
Normal file
85
src/phase-id-display.cts
Normal file
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
342
tests/adr-612-bracket-display.property.test.cjs
Normal file
342
tests/adr-612-bracket-display.property.test.cjs
Normal file
@@ -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);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
1
tests/fixtures/install-tree/antigravity.json
vendored
1
tests/fixtures/install-tree/antigravity.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/augment.json
vendored
1
tests/fixtures/install-tree/augment.json
vendored
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/claude.json
vendored
1
tests/fixtures/install-tree/claude.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/cline.json
vendored
1
tests/fixtures/install-tree/cline.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/codebuddy.json
vendored
1
tests/fixtures/install-tree/codebuddy.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/codex.json
vendored
1
tests/fixtures/install-tree/codex.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/copilot.json
vendored
1
tests/fixtures/install-tree/copilot.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/cursor.json
vendored
1
tests/fixtures/install-tree/cursor.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/hermes.json
vendored
1
tests/fixtures/install-tree/hermes.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/kilo.json
vendored
1
tests/fixtures/install-tree/kilo.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/kimi-code.json
vendored
1
tests/fixtures/install-tree/kimi-code.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/kimi.json
vendored
1
tests/fixtures/install-tree/kimi.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/opencode.json
vendored
1
tests/fixtures/install-tree/opencode.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/pi.json
vendored
1
tests/fixtures/install-tree/pi.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/qwen.json
vendored
1
tests/fixtures/install-tree/qwen.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/trae.json
vendored
1
tests/fixtures/install-tree/trae.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/windsurf.json
vendored
1
tests/fixtures/install-tree/windsurf.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/zcode.json
vendored
1
tests/fixtures/install-tree/zcode.json
vendored
@@ -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",
|
||||
|
||||
58
tests/phase-id-card.test.cjs
Normal file
58
tests/phase-id-card.test.cjs
Normal file
@@ -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 = '<!-- PHASE-ID-CARD:START -->\n```text\n';
|
||||
const closer = '\n```\n<!-- PHASE-ID-CARD:END -->';
|
||||
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());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user