@opengsd/gsd-core 1.19.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-code-fixer.compact.md +5 -5
  4. package/agents/gsd-code-fixer.md +9 -4
  5. package/agents/gsd-code-reviewer.compact.md +49 -8
  6. package/agents/gsd-code-reviewer.md +53 -14
  7. package/agents/gsd-executor.md +8 -4
  8. package/commands/gsd/update.md +5 -2
  9. package/gsd-core/bin/gsd-tools.cjs +73 -23
  10. package/gsd-core/bin/lib/capability-lifecycle.cjs +1 -12
  11. package/gsd-core/bin/lib/capability-registry.cjs +284 -93
  12. package/gsd-core/bin/lib/capability-validator.cjs +15 -0
  13. package/gsd-core/bin/lib/commands.cjs +27 -17
  14. package/gsd-core/bin/lib/config-loader.cjs +3 -3
  15. package/gsd-core/bin/lib/core-utils.cjs +11 -0
  16. package/gsd-core/bin/lib/frontmatter.cjs +21 -3
  17. package/gsd-core/bin/lib/gate-api-coverage-verify-pre.cjs +2 -2
  18. package/gsd-core/bin/lib/gate-context-drift.cjs +1 -1
  19. package/gsd-core/bin/lib/gate-phase-context.cjs +7 -8
  20. package/gsd-core/bin/lib/gate-schema-drift.cjs +1 -1
  21. package/gsd-core/bin/lib/git-info-exclude.cjs +1 -6
  22. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +13 -9
  23. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +9 -7
  24. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +3 -3
  25. package/gsd-core/bin/lib/init.cjs +180 -67
  26. package/gsd-core/bin/lib/io.cjs +6 -0
  27. package/gsd-core/bin/lib/markdown-sectionizer.cjs +34 -1
  28. package/gsd-core/bin/lib/markdown-table.cjs +23 -0
  29. package/gsd-core/bin/lib/mcp-server.cjs +8 -5
  30. package/gsd-core/bin/lib/milestone.cjs +18 -50
  31. package/gsd-core/bin/lib/phase-entry-grammar.cjs +119 -0
  32. package/gsd-core/bin/lib/phase-id-display.cjs +2 -0
  33. package/gsd-core/bin/lib/phase-id.cjs +609 -35
  34. package/gsd-core/bin/lib/phase-lifecycle.cjs +9 -74
  35. package/gsd-core/bin/lib/phase-locator.cjs +328 -19
  36. package/gsd-core/bin/lib/phase.cjs +2990 -666
  37. package/gsd-core/bin/lib/planning-document.cjs +26 -5
  38. package/gsd-core/bin/lib/planning-inspect.cjs +27 -35
  39. package/gsd-core/bin/lib/planning-snapshot.cjs +12 -7
  40. package/gsd-core/bin/lib/planning-workspace.cjs +3 -3
  41. package/gsd-core/bin/lib/read-only-git-env.cjs +23 -2
  42. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +2 -2
  43. package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -0
  44. package/gsd-core/bin/lib/review-lane-invocation.cjs +125 -18
  45. package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +11 -9
  46. package/gsd-core/bin/lib/roadmap-parser.cjs +730 -144
  47. package/gsd-core/bin/lib/roadmap-progress.cjs +136 -0
  48. package/gsd-core/bin/lib/roadmap-upgrade.cjs +8 -5
  49. package/gsd-core/bin/lib/roadmap.cjs +358 -106
  50. package/gsd-core/bin/lib/shell-command-projection.cjs +36 -9
  51. package/gsd-core/bin/lib/smart-entry.cjs +22 -11
  52. package/gsd-core/bin/lib/state-contract.cjs +11 -7
  53. package/gsd-core/bin/lib/state-document.cjs +332 -23
  54. package/gsd-core/bin/lib/state-transition.cjs +39 -10
  55. package/gsd-core/bin/lib/state.cjs +498 -179
  56. package/gsd-core/bin/lib/template.cjs +2 -2
  57. package/gsd-core/bin/lib/validate.cjs +3 -2
  58. package/gsd-core/bin/lib/vendor/re2js.cjs +863 -577
  59. package/gsd-core/bin/lib/vendor/re2js.d.cts +284 -219
  60. package/gsd-core/bin/lib/verify.cjs +2 -2
  61. package/gsd-core/bin/lib/workstream-inventory.cjs +26 -20
  62. package/gsd-core/bin/verify-reapply-patches.cjs +411 -10
  63. package/gsd-core/references/cicd-review-patterns.md +45 -0
  64. package/gsd-core/references/necessity-review.md +48 -0
  65. package/gsd-core/workflows/code-review-fix.md +46 -5
  66. package/gsd-core/workflows/code-review.md +3 -0
  67. package/gsd-core/workflows/execute-phase.md +1 -1
  68. package/gsd-core/workflows/execute-plan.md +1 -1
  69. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +13 -9
  70. package/gsd-core/workflows/plan-phase.md +1 -1
  71. package/gsd-core/workflows/pr-branch.md +2 -0
  72. package/gsd-core/workflows/reapply-patches.md +104 -0
  73. package/gsd-core/workflows/review.md +54 -141
  74. package/gsd-core/workflows/secure-phase.md +1 -1
  75. package/gsd-core/workflows/settings-advanced.md +57 -9
  76. package/gsd-core/workflows/ship.md +12 -0
  77. package/hooks/dist/gsd-graphify-update.sh +6 -0
  78. package/hooks/dist/gsd-statusline.js +3 -2
  79. package/hooks/dist/gsd-windsurf-pre-write.js +4 -4
  80. package/hooks/dist/gsd-workflow-guard.js +2 -1
  81. package/hooks/dist/gsd-worktree-path-guard.js +4 -4
  82. package/hooks/dist/lib/git-probe.js +16 -0
  83. package/hooks/gsd-graphify-update.sh +6 -0
  84. package/hooks/gsd-statusline.js +3 -2
  85. package/hooks/gsd-windsurf-pre-write.js +4 -4
  86. package/hooks/gsd-workflow-guard.js +2 -1
  87. package/hooks/gsd-worktree-path-guard.js +4 -4
  88. package/hooks/lib/git-probe.js +16 -0
  89. package/package.json +3 -3
  90. package/pi/package-agents/gsd-code-fixer.md +9 -4
  91. package/pi/package-agents/gsd-code-reviewer.md +53 -14
  92. package/pi/package-agents/gsd-executor.md +8 -4
  93. package/pi/package-agents/manifest.json +3 -3
  94. package/pi/package-skills/gsd-update/SKILL.md +4 -1
  95. package/scripts/ci-next-health.cjs +246 -23
  96. package/scripts/docs-guard-registry.cjs +2 -0
  97. package/scripts/gen-features.cjs +14 -2
  98. package/scripts/lib/drift-scan.cjs +27 -0
  99. package/scripts/lib/macos-conformance-tier.generated.cjs +6 -0
  100. package/scripts/lib/platform-conformance-tier.generated.cjs +11 -0
  101. package/scripts/lint-phase-enumeration-drift.cjs +8 -4
  102. package/scripts/lint-roadmap-progress-drift.cjs +421 -0
  103. package/scripts/mutation-matrix.cjs +23 -1
  104. package/scripts/sync-next-version.cjs +38 -6
  105. package/skills/gsd-update/SKILL.md +5 -2
  106. package/vscode/package.json +1 -1
@@ -21,84 +21,25 @@
21
21
  * - Issue #4 (open-gsd/gsd-core)
22
22
  */
23
23
  Object.defineProperty(exports, "__esModule", { value: true });
24
- exports.locateProgressTable = locateProgressTable;
25
- exports.normalizeMilestoneToken = normalizeMilestoneToken;
26
- exports.milestoneVersionOf = milestoneVersionOf;
27
24
  exports.deriveProgressFromRoadmap = deriveProgressFromRoadmap;
28
25
  exports.clampPercentFromFraction = clampPercentFromFraction;
29
26
  exports.clampPercent = clampPercent;
30
27
  exports.progressBarFilledCells = progressBarFilledCells;
31
28
  exports.renderProgressBar = renderProgressBar;
32
- const markdown_table_cjs_1 = require("./markdown-table.cjs");
29
+ // #5761: the Progress table and its milestone slice have one owner.
30
+ const roadmap_progress_cjs_1 = require("./roadmap-progress.cjs");
33
31
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
34
32
  const phaseIdMod = require("./phase-id.cjs");
35
33
  const { isSentinelPhaseId } = phaseIdMod;
36
34
  // #5060: `phase-status.cjs` is a load-time leaf (no top-level requires), safe
37
35
  // to import here for the ROADMAP Status-cell reader.
38
36
  const phase_status_cjs_1 = require("./phase-status.cjs");
39
- /**
40
- * #3227: the single owner of "where is this ROADMAP's Progress table".
41
- * Lifted verbatim out of deriveProgressFromRoadmap so `state-contract.cts`
42
- * enumerates phases from THE SAME table this module derives its counts from.
43
- * A second copy of this locator is the DEFECT.GENERATIVE-FIX shape.
44
- *
45
- * ADR-2143 §3 ("addressed by NAME, never ordinal"): the Progress table is
46
- * located via the markdown-table seam's `findTableWithColumns`, which is
47
- * column-NAME/order/count-invariant — it matches the first table whose header
48
- * is a SUPERSET of the canonical `Phase` / `Plans Complete` / `Status` /
49
- * `Completed` names, in any order, tolerating extra/injected unrelated
50
- * columns (#2137's fast-check property test shuffles headers and injects
51
- * columns and asserts the derived counts never change). This supersedes the
52
- * earlier `findTableBySchema` exact-schema lookup, which required an exact
53
- * canonical column SET+ORDER and returned all-null on any reordering or
54
- * injection.
55
- *
56
- * Scoped to the `## Progress` section when the document has one (#2012 decoy
57
- * avoidance — a differently-headed table sharing the same column names must
58
- * not be picked up instead); a headingless milestone slice (#1445) falls back
59
- * to scanning the whole input, preserving the "Progress table not under a
60
- * `## Progress` heading, or not the first table in the document, still
61
- * resolves" behaviour.
62
- */
63
- function locateProgressTable(roadmapContent) {
64
- const progressMatch = roadmapContent.match(/^##[ \t]+Progress\b/im);
65
- let scoped = roadmapContent;
66
- if (progressMatch && progressMatch.index !== undefined) {
67
- const afterHeading = roadmapContent.slice(progressMatch.index);
68
- const nextHeading = afterHeading.search(/\n#{1,2}[ \t]/);
69
- scoped = nextHeading >= 0 ? afterHeading.slice(0, nextHeading) : afterHeading;
70
- }
71
- return (0, markdown_table_cjs_1.findTableWithColumns)(scoped, ['Phase', 'Plans Complete', 'Status', 'Completed']);
72
- }
73
- /**
74
- * #5165: a milestone token compared by identity, not spelling — `v1.1`, `V1.1`
75
- * and ` 1.1 ` name one milestone. A hand-decorated Progress cell
76
- * (`**v1.1**`, `` `v1.1` ``, `v1.1 (current)`) still names its first
77
- * whitespace-separated token. Absent/blank tokens normalize to `''`.
78
- */
79
- function normalizeMilestoneToken(value) {
80
- // `[v1.1](ROADMAP.md#v11)` names its link text.
81
- const unlinked = (milestoneVersionOf(value) ?? '').replace(/\[([^\]]*)\]\([^)]*\)/g, '$1');
82
- const first = unlinked.split(/\s+/)[0] ?? '';
83
- return first.replace(/^[*_`]+|[*_`:,;]+$/g, '').replace(/^v/i, '').toLowerCase();
84
- }
85
- /**
86
- * #5165: a STATE.md `milestone:` frontmatter value as a trimmed version string,
87
- * or null when it is absent, blank or not scalar text.
88
- */
89
- function milestoneVersionOf(value) {
90
- if (typeof value !== 'string' && typeof value !== 'number')
91
- return null;
92
- const text = String(value).trim();
93
- return text === '' ? null : text;
94
- }
95
37
  /**
96
38
  * Derive completed_phases, total_phases, and total_plans from ROADMAP content.
97
39
  * Root cause fix for issue #4 — see gen-phase-lifecycle.mjs for full documentation.
98
40
  *
99
- * The Progress table itself is located by `locateProgressTable` (ADR-2143 §3,
100
- * lifted out as #3227's single-owner extraction) — this function consumes
101
- * that table.
41
+ * The Progress table itself is located by `locateRoadmapProgress` (the Roadmap
42
+ * Progress Module, ADR-2143 §3) — this function consumes that table's slice.
102
43
  *
103
44
  * Cells are read by column NAME (`r['Status']`, `r['Plans Complete']`,
104
45
  * `r['Phase']`), fixing #2137 (the old position-based regex assumed "Status"
@@ -117,18 +58,12 @@ function deriveProgressFromRoadmap(roadmapContent, milestoneFilter) {
117
58
  // `{ ok: false, reason }`, not an exception — so the catch was masking
118
59
  // nothing but dead code paths. Removed per ADR-2143 §5; the public
119
60
  // `RoadmapProgress` contract (nulls = absent) is unchanged.
120
- const table = locateProgressTable(roadmapContent);
61
+ // #5165: the milestone-grouped Progress table keeps every milestone's rows,
62
+ // shipped ones included; the owner returns only the named milestone's slice.
63
+ // A named milestone that matches no row yields nulls (nothing countable)
64
+ // rather than a foreign count.
65
+ const { table, rows: scopedRows } = (0, roadmap_progress_cjs_1.locateRoadmapProgress)(roadmapContent, { milestone: milestoneFilter });
121
66
  if (table) {
122
- // #5165: the milestone-grouped Progress table (templates/roadmap.md) keeps
123
- // every milestone's rows, shipped ones included. When the caller names the
124
- // milestone and the table carries a `Milestone` column, count only that
125
- // milestone's rows. A table without the column (or a caller without a
126
- // milestone) keeps the whole-table behavior; a named milestone that matches
127
- // no row yields nulls (nothing countable) rather than a foreign count.
128
- const wantMilestone = normalizeMilestoneToken(milestoneFilter);
129
- const scopedRows = wantMilestone !== '' && table.columns.includes('Milestone')
130
- ? table.rows.filter((r) => normalizeMilestoneToken(r['Milestone']) === wantMilestone)
131
- : table.rows;
132
67
  // Data rows only (exclude sentinel phases 0 and 999.x).
133
68
  // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
134
69
  const dataRows = scopedRows.filter((r) => {
@@ -6,15 +6,25 @@
6
6
  * Owns active-phase discovery against the `.planning/phases/` tree
7
7
  * (`searchPhaseInDir`, `findPhaseInternal`) and archived-phase-dir
8
8
  * enumeration (`getArchivedPhaseDirs`), matching phase ids/tokens against
9
- * the filesystem. Behaviour is preserved byte-for-behaviour from the prior
10
- * location; only the module boundary moved. The core.cjs re-export spine
9
+ * the filesystem. Also owns the silent naming projection and shared writer
10
+ * tail (preflight, containment, directory creation, identity markers).
11
+ * The core.cjs re-export spine
11
12
  * was retired in epic #1267; callers import phase-locator helpers directly.
12
13
  *
13
- * Dependencies (leaf modules only — no loadConfig):
14
+ * Dependencies:
14
15
  * - node:fs / node:path (stdlib)
15
- * - ./phase-id.cjs (normalizePhaseName, matchPhaseDirs, phaseNumberForMatch)
16
+ * - ./phase-id.cjs (phaseDirectoryContext, customPhaseQuery, customPhaseDirectoryParts,
17
+ * normalizePhaseName, matchPhaseDirs, phaseNumberForMatch)
18
+ * - ./phase-id-display.cjs (milestone numeric canonicalization)
16
19
  * - ./core-utils.cjs (readSubdirectories, getPhaseFileStats, extractCanonicalPlanId, toPosixPath)
17
- * - ./planning-workspace.cjs (planningDir)
20
+ * - ./planning-workspace.cjs (planningDir, resolvePhaseIdConvention)
21
+ * - ./config-loader.cjs (CONFIG_DEFAULTS, _deepMergeConfig, _getConfigValue;
22
+ * silent file-selection projection; loadConfig for the
23
+ * active bracket project code)
24
+ * - ./runtime-homes.cjs (resolveGsdHome)
25
+ * - ./frontmatter.cjs / ./plan-dependency-graph.cjs (plan dependency evidence)
26
+ * - ./roadmap-parser.cjs / ./planning-scope.cjs (milestone membership/scope)
27
+ * - ./io.cjs / ./shell-command-projection.cjs (typed write errors and files)
18
28
  */
19
29
  var __importDefault = (this && this.__importDefault) || function (mod) {
20
30
  return (mod && mod.__esModule) ? mod : { "default": mod };
@@ -22,14 +32,23 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
22
32
  const node_fs_1 = __importDefault(require("node:fs"));
23
33
  const node_path_1 = __importDefault(require("node:path"));
24
34
  // eslint-disable-next-line @typescript-eslint/no-require-imports
35
+ const ioMod = require("./io.cjs");
36
+ const { error, ERROR_REASON } = ioMod;
37
+ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
38
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
25
39
  const phaseIdModule = require("./phase-id.cjs");
26
- const { normalizePhaseName, matchPhaseDirs, phaseNumberForMatch, isSentinelPhaseId, comparePhaseNum } = phaseIdModule;
40
+ const { BRACKET_DIR_PREFIX_SRC, CUSTOM_PHASE_ID_FILE, comparePhaseNum, customPhaseDirectoryParts, customPhaseQuery, foldBracketId, isSentinelPhaseId, matchPhaseDirs, normalizePhaseName, parsePhaseId, phaseDirectoryContext, phaseNumberForMatch, } = phaseIdModule;
27
41
  // eslint-disable-next-line @typescript-eslint/no-require-imports
28
42
  const coreUtilsModule = require("./core-utils.cjs");
29
43
  const { readSubdirectories, getPhaseFileStats, extractCanonicalPlanId, toPosixPath, findUnsummarizedPlans } = coreUtilsModule;
30
44
  // eslint-disable-next-line @typescript-eslint/no-require-imports
31
45
  const planningWorkspace = require("./planning-workspace.cjs");
32
- const { planningDir, planningRoot } = planningWorkspace;
46
+ const { planningDir, planningRoot, resolveEnvWorkstream, resolvePhaseIdConvention } = planningWorkspace;
47
+ // Naming readers and writers share the loader's scoped-key precedence and defaults.
48
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
49
+ const configLoaderModule = require("./config-loader.cjs");
50
+ const { CONFIG_DEFAULTS, _deepMergeConfig, _getConfigValue, loadConfig } = configLoaderModule;
51
+ const runtime_homes_cjs_1 = require("./runtime-homes.cjs");
33
52
  // eslint-disable-next-line @typescript-eslint/no-require-imports
34
53
  const frontmatterModule = require("./frontmatter.cjs");
35
54
  const { extractFrontmatter } = frontmatterModule;
@@ -38,10 +57,17 @@ const planDependencyGraphModule = require("./plan-dependency-graph.cjs");
38
57
  const { computeHaltPropagation, buildSummaryFileIndex, isSummaryFileHalted, isSummaryFileBlocked } = planDependencyGraphModule;
39
58
  // eslint-disable-next-line @typescript-eslint/no-require-imports
40
59
  const roadmapParserModule = require("./roadmap-parser.cjs");
41
- const { getMilestonePhaseFilter } = roadmapParserModule;
60
+ const { getMilestoneInfo, getMilestonePhaseFilter } = roadmapParserModule;
61
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
62
+ const phaseIdDisplayModule = require("./phase-id-display.cjs");
63
+ const { milestoneToken, phaseToken } = phaseIdDisplayModule;
42
64
  // eslint-disable-next-line @typescript-eslint/no-require-imports
43
65
  const planningScopeMod = require("./planning-scope.cjs");
44
66
  const { SCOPE } = planningScopeMod;
67
+ // #4304: result shaping must distinguish a canonical bracket directory from a
68
+ // legacy directory that matchPhaseDirs deliberately still admits during a
69
+ // bracket migration. Derive that distinction from the exported owner grammar.
70
+ const BRACKET_DIRECTORY_PREFIX_RE = new RegExp(`^${BRACKET_DIR_PREFIX_SRC}`);
45
71
  /**
46
72
  * #2830: parse a plan file's `depends_on` frontmatter. Returns [] — never
47
73
  * throws — on a missing/unreadable/malformed plan or absent field, matching
@@ -64,6 +90,75 @@ function parsePlanDependsOn(phaseDir, planFile) {
64
90
  return [];
65
91
  }
66
92
  }
93
+ /**
94
+ * Resolve the convention and active bracket identity once for every current-
95
+ * checkout phase-directory lookup. A caller-provided convention takes priority
96
+ * over config resolution. Legacy callers receive the exact historical
97
+ * normalization and no context. A partially configured bracket repo degrades
98
+ * to the ambiguity-safe unqualified matcher rather than inventing an identity.
99
+ */
100
+ function resolvePhaseDirectoryLookup(cwd, phase, conventionOverride, directoryContext) {
101
+ const legacyNormalized = normalizePhaseName(phase);
102
+ const resolvedConvention = conventionOverride ?? resolvePhaseIdConvention(cwd);
103
+ // Custom ids have no padding form: the id is looked up as written.
104
+ if (directoryContext?.phaseNaming === 'custom' && resolvedConvention !== 'bracket') {
105
+ return {
106
+ normalized: typeof phase === 'string' ? phase : legacyNormalized,
107
+ legacyNormalized: undefined,
108
+ convention: undefined,
109
+ bracketContext: undefined,
110
+ };
111
+ }
112
+ if (resolvedConvention !== 'bracket') {
113
+ return {
114
+ normalized: legacyNormalized,
115
+ legacyNormalized: undefined,
116
+ convention: undefined,
117
+ bracketContext: undefined,
118
+ };
119
+ }
120
+ // The bracket writers canonicalize both numeric segments through phaseToken
121
+ // (`1.1` -> `01.01`). Finish the convention-only query normalization at
122
+ // this shared locator boundary while retaining normalizePhaseName's legacy
123
+ // decimal spelling (`01.1`) as the second-pass migration fallback. Every
124
+ // other convention and already-canonical bracket form remains single-pass.
125
+ const normalized = phaseToken(legacyNormalized) ?? legacyNormalized;
126
+ let bracketContext;
127
+ try {
128
+ const config = loadConfig(cwd);
129
+ const project = typeof config['project_code'] === 'string' ? config['project_code'].trim() : '';
130
+ const version = getMilestoneInfo(cwd).value?.version ?? '';
131
+ const milestone = milestoneToken(version);
132
+ if (project && milestone !== null)
133
+ bracketContext = { project, milestone };
134
+ }
135
+ catch {
136
+ // Read-side lookup remains non-throwing when config/milestone state is
137
+ // incomplete; matchPhaseDirs will surface ambiguity rather than guess.
138
+ }
139
+ return {
140
+ normalized,
141
+ legacyNormalized: normalized === legacyNormalized ? undefined : legacyNormalized,
142
+ convention: 'bracket',
143
+ bracketContext,
144
+ };
145
+ }
146
+ /**
147
+ * Apply one resolved lookup to a directory set. Bracket queries try the writer-
148
+ * canonical spelling first, then the pre-bracket `normalizePhaseName` spelling
149
+ * only when the canonical pass finds nothing. `matchPhaseDirs` remains the
150
+ * single selection owner for both passes, including its migration-window legacy
151
+ * fallback and ambiguity behavior. Non-bracket lookups have no second spelling.
152
+ */
153
+ function matchPhaseDirsForLookup(dirs, lookup, bracketContextOverride, directoryContext) {
154
+ const bracketContext = bracketContextOverride === undefined
155
+ ? lookup.bracketContext
156
+ : bracketContextOverride;
157
+ const primary = matchPhaseDirs(dirs, lookup.normalized, lookup.convention, bracketContext, directoryContext);
158
+ if (primary.matches.length > 0 || lookup.legacyNormalized === undefined)
159
+ return primary;
160
+ return matchPhaseDirs(dirs, lookup.legacyNormalized, lookup.convention, bracketContext, directoryContext);
161
+ }
67
162
  // ─── Phase search helpers ─────────────────────────────────────────────────────
68
163
  /**
69
164
  * #2855: single source of truth for resolving and enumerating a project's
@@ -146,7 +241,190 @@ function listArchiveVersionDirs(cwd, wsOverride) {
146
241
  }
147
242
  return out;
148
243
  }
149
- function searchPhaseInDir(baseDir, relBase, normalized, convention) {
244
+ /** Effective naming is a silent projection; roadmap and marker reads are bounded. */
245
+ function phaseDirectoryOptions(cwd, config, roadmap, ws) {
246
+ // These two flat keys have no legacy aliases and are not inherited from
247
+ // global defaults by loadConfig. Mirror only its file-selection branches,
248
+ // reusing its merge, value and default owners without warnings or write-back.
249
+ // Replace this projection if the loader gains a quiet option; parity tests
250
+ // guard workstream, project, corrupt-file and global-default precedence.
251
+ let naming = config;
252
+ let configError;
253
+ if (naming === undefined) {
254
+ const readConfig = (file) => {
255
+ try {
256
+ const value = JSON.parse(node_fs_1.default.readFileSync(file, 'utf8'));
257
+ if (value && typeof value === 'object' && !Array.isArray(value))
258
+ return value;
259
+ configError = { path: file, message: 'configuration must be a JSON object' };
260
+ return null;
261
+ }
262
+ catch (cause) {
263
+ if (cause.code !== 'ENOENT')
264
+ configError = { path: file, message: cause.message };
265
+ return null;
266
+ }
267
+ };
268
+ const activeWs = ws === undefined ? resolveEnvWorkstream() : ws?.trim() || null;
269
+ const root = activeWs ? readConfig(node_path_1.default.join(planningRoot(cwd), 'config.json')) : null;
270
+ const scoped = readConfig(node_path_1.default.join(planningDir(cwd, activeWs), 'config.json'));
271
+ // A missing/broken workstream with a usable root recurses with ws:null in
272
+ // loadConfig, including GSD_PROJECT's own config rather than the flat root.
273
+ const parsed = scoped ? (root ? _deepMergeConfig(root, scoped) : scoped)
274
+ : root ? readConfig(node_path_1.default.join(planningDir(cwd, null), 'config.json')) : null;
275
+ naming = {
276
+ phase_naming: _getConfigValue(parsed ?? {}, 'phase_naming') ?? CONFIG_DEFAULTS.phase_naming,
277
+ project_code: _getConfigValue(parsed ?? {}, 'project_code') ?? CONFIG_DEFAULTS.project_code,
278
+ };
279
+ }
280
+ const context = phaseDirectoryContext(naming, roadmap ?? '');
281
+ if (configError)
282
+ context.configError = configError;
283
+ if (context.phaseNaming !== 'custom')
284
+ return context;
285
+ // Load directory-owned IDs once for this scope, including archive fallback.
286
+ // Old directories retain known heading hints. Mutations accept a unique
287
+ // owner and refuse ambiguous candidates before any deletion or rename.
288
+ context.directoryIdsByRoot = Object.create(null);
289
+ const unmarkedDirectories = [];
290
+ context.unmarkedDirectories = unmarkedDirectories;
291
+ const roots = [node_path_1.default.join(planningDir(cwd, ws), 'phases'), ...listArchiveVersionDirs(cwd, ws).map(entry => entry.archivePath)];
292
+ for (const root of roots) {
293
+ const ids = Object.create(null);
294
+ context.directoryIdsByRoot[root] = ids;
295
+ const directories = readSubdirectories(root, true);
296
+ for (const dir of directories) {
297
+ let identityFd;
298
+ try {
299
+ const markerPath = node_path_1.default.join(root, dir, CUSTOM_PHASE_ID_FILE);
300
+ const marker = node_fs_1.default.lstatSync(markerPath);
301
+ if (!marker.isFile() || marker.size > 1024)
302
+ continue;
303
+ // Nonblocking/no-follow open also bounds a replacement between lstat and
304
+ // open; fstat validates the actual descriptor before reading at most 1 KiB.
305
+ // Windows may omit these flags: lstat/fstat still require a regular file,
306
+ // but the open is not atomically no-follow there. Adopt Windows no-follow
307
+ // flags here if Node exposes them; no caller should weaken these checks.
308
+ identityFd = node_fs_1.default.openSync(markerPath, node_fs_1.default.constants.O_RDONLY | node_fs_1.default.constants.O_NONBLOCK | node_fs_1.default.constants.O_NOFOLLOW);
309
+ const opened = node_fs_1.default.fstatSync(identityFd);
310
+ if (!opened.isFile() || opened.size > 1024)
311
+ continue;
312
+ const buffer = Buffer.alloc(opened.size);
313
+ const count = node_fs_1.default.readSync(identityFd, buffer, 0, buffer.length, 0);
314
+ const id = buffer.toString('utf8', 0, count).trim();
315
+ if (id) {
316
+ phaseIdModule.phaseDirectoryName(id, '');
317
+ ids[dir] = id;
318
+ }
319
+ }
320
+ catch { /* An absent or unusable marker supplies no ownership proof. */ }
321
+ finally {
322
+ if (identityFd !== undefined)
323
+ node_fs_1.default.closeSync(identityFd);
324
+ }
325
+ }
326
+ unmarkedDirectories.push(...directories.filter(dir => ids[dir] === undefined));
327
+ }
328
+ // Basenames can repeat across milestones with different ID/slug boundaries.
329
+ // Identity hints span the scope; authoritative records belong to one pool.
330
+ context.directoryIds = context.directoryIdsByRoot[roots[0]];
331
+ context.knownIds = [...new Set(Object.values(context.directoryIdsByRoot).flatMap(ids => Object.values(ids)))];
332
+ if (roadmap !== undefined)
333
+ return context;
334
+ let fd;
335
+ try {
336
+ fd = node_fs_1.default.openSync(node_path_1.default.join(planningDir(cwd, ws), 'ROADMAP.md'), 'r');
337
+ const buffer = Buffer.alloc(Math.min(node_fs_1.default.fstatSync(fd).size, 1024 * 1024));
338
+ const count = node_fs_1.default.readSync(fd, buffer, 0, buffer.length, 0);
339
+ return { ...context, roadmap: buffer.toString('utf8', 0, count) };
340
+ }
341
+ catch {
342
+ return context;
343
+ }
344
+ finally {
345
+ if (fd !== undefined)
346
+ node_fs_1.default.closeSync(fd);
347
+ }
348
+ }
349
+ const directoryPoolContexts = new WeakMap();
350
+ /** Select records for this physical pool, reusing its context across queries. */
351
+ function phaseDirectoryContextForRoot(context, root) {
352
+ const ids = context.directoryIdsByRoot?.[root];
353
+ if (!ids || ids === context.directoryIds)
354
+ return context;
355
+ let pools = directoryPoolContexts.get(context);
356
+ if (!pools) {
357
+ pools = new Map();
358
+ directoryPoolContexts.set(context, pools);
359
+ }
360
+ let selected = pools.get(root);
361
+ if (!selected) {
362
+ selected = { ...context, directoryIds: ids };
363
+ pools.set(root, selected);
364
+ }
365
+ return selected;
366
+ }
367
+ /** Usage guidance for every custom-ID writer, using the resolved naming policy. */
368
+ function customPhaseNamingUsage(cwd, context) {
369
+ if (context.phaseNaming === 'custom')
370
+ return null;
371
+ if (context.configError)
372
+ return `Cannot read phase naming from ${context.configError.path}: ${context.configError.message}. Repair the project config before using a custom ID`;
373
+ let message = 'phase_naming is not custom for this project; configure phase_naming as "custom" before using a custom ID';
374
+ try {
375
+ const defaultsPath = node_path_1.default.join((0, runtime_homes_cjs_1.resolveGsdHome)(), '.gsd', 'defaults.json');
376
+ const defaults = JSON.parse(node_fs_1.default.readFileSync(defaultsPath, 'utf8'));
377
+ if (typeof defaults.phase_naming === 'string' && defaults.phase_naming.toLowerCase() === 'custom' && node_fs_1.default.existsSync(node_path_1.default.join(planningDir(cwd), 'config.json'))) {
378
+ message += `; project config shadows phase_naming from ${defaultsPath}`;
379
+ }
380
+ }
381
+ catch { /* Missing/unusable defaults do not change effective naming. */ }
382
+ return message;
383
+ }
384
+ /** Preflight the complete write set, then create directories and identity records. */
385
+ function createPhaseDirectories(cwd, entries, context, gitkeep = true) {
386
+ const phasesRoot = node_path_1.default.resolve(planningDir(cwd), 'phases');
387
+ let prepared;
388
+ try {
389
+ const recorded = entries.filter(entry => entry.recordIdentity ?? context.phaseNaming === 'custom');
390
+ if (recorded.length > 0)
391
+ phaseIdModule.validateCustomPhaseIds(recorded.map(entry => entry.id), context);
392
+ const pendingPaths = new Set();
393
+ prepared = entries.map(entry => {
394
+ // Validate the raw ID before legacy scaffold normalization can discard it.
395
+ phaseIdModule.phaseDirectoryName(entry.id, '', context.projectCode);
396
+ const dirName = phaseIdModule.phaseDirectoryName(entry.directoryId ?? entry.id, entry.slug, context.projectCode);
397
+ const dirPath = node_path_1.default.resolve(phasesRoot, dirName);
398
+ if (node_path_1.default.dirname(dirPath) !== phasesRoot)
399
+ error('Phase directory escapes the phases directory', ERROR_REASON.USAGE);
400
+ const recordIdentity = entry.recordIdentity ?? context.phaseNaming === 'custom';
401
+ // An occupied target is a collision only where a custom writer would
402
+ // record an identity into it. Numeric writers keep the idempotent
403
+ // ensure-directory they always had.
404
+ if (recordIdentity && (pendingPaths.has(dirPath) || node_fs_1.default.lstatSync(dirPath, { throwIfNoEntry: false }) !== undefined)) {
405
+ throw new phaseIdModule.PhaseDirectoryNameError(`Phase directory ${dirName} already exists or is claimed by another pending entry; choose a distinct ID or title`, 'collision');
406
+ }
407
+ pendingPaths.add(dirPath);
408
+ return { ...entry, dirName, dirPath, recordIdentity };
409
+ });
410
+ }
411
+ catch (cause) {
412
+ if (cause instanceof phaseIdModule.PhaseDirectoryNameError) {
413
+ error(cause.message, cause.kind === 'collision' ? ERROR_REASON.PHASE_ID_COLLISION : ERROR_REASON.USAGE);
414
+ }
415
+ throw cause;
416
+ }
417
+ for (const entry of prepared) {
418
+ (0, shell_command_projection_cjs_1.platformEnsureDir)(entry.dirPath);
419
+ if (gitkeep)
420
+ (0, shell_command_projection_cjs_1.platformWriteSync)(node_path_1.default.join(entry.dirPath, '.gitkeep'), '');
421
+ if (entry.recordIdentity)
422
+ (0, shell_command_projection_cjs_1.platformWriteSync)(node_path_1.default.join(entry.dirPath, CUSTOM_PHASE_ID_FILE), entry.id);
423
+ }
424
+ return prepared;
425
+ }
426
+ function searchPhaseInDir(baseDir, relBase, normalized, convention, bracketContext, legacyNormalized, context = phaseDirectoryContext({})) {
427
+ context = phaseDirectoryContextForRoot(context, baseDir);
150
428
  try {
151
429
  const dirs = readSubdirectories(baseDir, true);
152
430
  // #2528: canonical two-pass selection (exact token match, then the
@@ -154,7 +432,12 @@ function searchPhaseInDir(baseDir, relBase, normalized, convention) {
154
432
  // phase-plan-index scans — see phase-id.cts::matchPhaseDirs.
155
433
  // #4801: the convention is threaded (optional) so bracket-convention
156
434
  // consumers get exact token matching here too.
157
- const { matches, usedBareFallback } = matchPhaseDirs(dirs, normalized, convention);
435
+ const { matches, usedBareFallback } = matchPhaseDirsForLookup(dirs, {
436
+ normalized,
437
+ legacyNormalized,
438
+ convention: convention === 'bracket' ? 'bracket' : undefined,
439
+ bracketContext: bracketContext ?? undefined,
440
+ }, bracketContext, context);
158
441
  if (matches.length === 0)
159
442
  return null;
160
443
  // #2237: fail loud when multiple directories match the same bare phase
@@ -182,10 +465,26 @@ function searchPhaseInDir(baseDir, relBase, normalized, convention) {
182
465
  };
183
466
  }
184
467
  const match = matches[0];
185
- const phaseToken = phaseNumberForMatch(match, usedBareFallback);
468
+ const isBracketDirectory = convention === 'bracket'
469
+ && BRACKET_DIRECTORY_PREFIX_RE.test(foldBracketId(match));
470
+ const resultConvention = isBracketDirectory ? 'bracket' : undefined;
471
+ const custom = convention === 'bracket' ? null : customPhaseQuery(normalized, context);
472
+ const parts = custom ? customPhaseDirectoryParts(match, custom) : null;
473
+ const phaseToken = parts?.id || phaseNumberForMatch(match, usedBareFallback, resultConvention);
186
474
  const phaseNumber = phaseToken || normalized;
187
- const afterToken = match.slice(phaseToken ? phaseToken.length : 0).replace(/^-/, '');
188
- const phaseName = afterToken || null;
475
+ let afterToken;
476
+ if (isBracketDirectory) {
477
+ const id = parsePhaseId(match);
478
+ const idToken = `${id.phase}${id.subphase ? `.${id.subphase}` : ''}`;
479
+ const bracketPrefix = `${id.project}.${id.milestone}-${idToken}`;
480
+ afterToken = match.slice(bracketPrefix.length).replace(/^-/, '');
481
+ }
482
+ else {
483
+ // Exact pre-#4304 legacy shaping, including project-prefixed and bare
484
+ // fallback matches. Bracket mode changes selection, not these bytes.
485
+ afterToken = match.slice(phaseToken ? phaseToken.length : 0).replace(/^-/, '');
486
+ }
487
+ const phaseName = parts?.slug || afterToken || null;
189
488
  const phaseDir = node_path_1.default.join(baseDir, match);
190
489
  const { plans: unsortedPlans, summaries: unsortedSummaries, hasResearch, hasContext, hasVerification, hasReviews } = getPhaseFileStats(phaseDir);
191
490
  const plans = unsortedPlans.sort();
@@ -283,14 +582,16 @@ function searchPhaseInDir(baseDir, relBase, normalized, convention) {
283
582
  return null;
284
583
  }
285
584
  }
286
- function findPhaseInternal(cwd, phase, convention) {
585
+ function findPhaseInternal(cwd, phase, convention, context) {
287
586
  if (!phase)
288
587
  return null;
588
+ // Existing callers may omit context; multi-phase readers supply one reusable context.
589
+ context ??= phaseDirectoryOptions(cwd);
289
590
  const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
290
- const normalized = normalizePhaseName(phase);
591
+ const { normalized, legacyNormalized, convention: lookupConvention, bracketContext, } = resolvePhaseDirectoryLookup(cwd, phase, convention, context);
291
592
  const relPhasesDir = toPosixPath(node_path_1.default.relative(cwd, phasesDir));
292
593
  // #4801: convention threaded through to the matcher (see searchPhaseInDir).
293
- const current = searchPhaseInDir(phasesDir, relPhasesDir, normalized, convention);
594
+ const current = searchPhaseInDir(phasesDir, relPhasesDir, normalized, lookupConvention, bracketContext, legacyNormalized, context);
294
595
  if (current)
295
596
  return current;
296
597
  // #2855: scope the archived-milestone fallback to the SAME workstream as the
@@ -304,7 +605,7 @@ function findPhaseInternal(cwd, phase, convention) {
304
605
  // getArchivedPhaseDirs via listArchiveVersionDirs (see its doc comment).
305
606
  for (const { version, archivePath } of listArchiveVersionDirs(cwd)) {
306
607
  const relBase = toPosixPath(node_path_1.default.relative(cwd, archivePath));
307
- const result = searchPhaseInDir(archivePath, relBase, normalized, convention);
608
+ const result = searchPhaseInDir(archivePath, relBase, normalized, lookupConvention, null, legacyNormalized, context);
308
609
  if (result) {
309
610
  result.archived = version;
310
611
  return result;
@@ -366,10 +667,12 @@ function listMilestonePhaseDirs(phasesDir, opts = {}) {
366
667
  // Without a cwd there is nothing to scope AGAINST — the caller asked for an
367
668
  // unscoped read, which is a real answer (mirrors extractCurrentMilestoneScoped's
368
669
  // row 1). Sentinels are still refused: they are never milestone phases.
670
+ let directoryContext;
369
671
  let inWindow = () => true;
370
672
  let scope = SCOPE.COMPLETE;
371
673
  if (cwd) {
372
- const filter = getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention, ws);
674
+ directoryContext = phaseDirectoryOptions(cwd, undefined, undefined, ws);
675
+ const filter = getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention, ws, directoryContext);
373
676
  inWindow = filter;
374
677
  scope = filter.scope;
375
678
  }
@@ -392,7 +695,7 @@ function listMilestonePhaseDirs(phasesDir, opts = {}) {
392
695
  }
393
696
  const value = names
394
697
  .filter((name) => inWindow(name) && !isSentinelPhaseId(name, phaseIdConvention ?? undefined))
395
- .sort((a, b) => comparePhaseNum(a, b));
698
+ .sort((a, b) => comparePhaseNum(a, b, directoryContext));
396
699
  return { value, scope };
397
700
  }
398
701
  /**
@@ -497,7 +800,13 @@ function getAllArchivedPhaseDirs(cwd) {
497
800
  return out;
498
801
  }
499
802
  module.exports = {
803
+ createPhaseDirectories,
804
+ phaseDirectoryOptions,
805
+ phaseDirectoryContextForRoot,
806
+ customPhaseNamingUsage,
500
807
  searchPhaseInDir,
808
+ resolvePhaseDirectoryLookup,
809
+ matchPhaseDirsForLookup,
501
810
  findPhaseInternal,
502
811
  getArchivedPhaseDirs,
503
812
  getAllArchivedPhaseDirs,