@opengsd/gsd-core 1.5.0-rc.4 → 1.5.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 (89) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/LICENSE +1 -1
  3. package/agents/gsd-executor.md +22 -0
  4. package/agents/gsd-phase-researcher.md +1 -1
  5. package/agents/gsd-planner.md +4 -0
  6. package/agents/gsd-project-researcher.md +1 -1
  7. package/agents/gsd-verifier.md +45 -13
  8. package/bin/install.js +56 -100
  9. package/commands/gsd/autonomous.md +1 -1
  10. package/commands/gsd/execute-phase.md +1 -1
  11. package/commands/gsd/plan-phase.md +1 -1
  12. package/gemini-extension.json +1 -1
  13. package/gsd-core/bin/gsd-tools.cjs +85 -53
  14. package/gsd-core/bin/lib/agent-command-router.cjs +2 -2
  15. package/gsd-core/bin/lib/agent-install-check.cjs +143 -0
  16. package/gsd-core/bin/lib/audit-command-router.cjs +4 -4
  17. package/gsd-core/bin/lib/capability-activation.cjs +37 -10
  18. package/gsd-core/bin/lib/capability-registry.cjs +2 -0
  19. package/gsd-core/bin/lib/capability-state.cjs +80 -30
  20. package/gsd-core/bin/lib/capability-writer.cjs +5 -4
  21. package/gsd-core/bin/lib/check-command-router.cjs +50 -51
  22. package/gsd-core/bin/lib/commands.cjs +20 -2
  23. package/gsd-core/bin/lib/config-loader.cjs +3 -4
  24. package/gsd-core/bin/lib/config-schema.cjs +1 -1
  25. package/gsd-core/bin/lib/config-types.cjs +2 -1
  26. package/gsd-core/bin/lib/config.cjs +5 -2
  27. package/gsd-core/bin/lib/decisions.cjs +19 -1
  28. package/gsd-core/bin/lib/docs.cjs +14 -2
  29. package/gsd-core/bin/lib/frontmatter.cjs +2 -2
  30. package/gsd-core/bin/lib/gap-checker.cjs +5 -2
  31. package/gsd-core/bin/lib/git-base-branch.cjs +27 -1
  32. package/gsd-core/bin/lib/graphify-command-router.cjs +6 -8
  33. package/gsd-core/bin/lib/graphify.cjs +7 -31
  34. package/gsd-core/bin/lib/gsd2-import.cjs +2 -2
  35. package/gsd-core/bin/lib/init.cjs +30 -4
  36. package/gsd-core/bin/lib/intel-command-router.cjs +6 -3
  37. package/gsd-core/bin/lib/intel.cjs +28 -34
  38. package/gsd-core/bin/lib/io.cjs +2 -4
  39. package/gsd-core/bin/lib/learnings.cjs +2 -2
  40. package/gsd-core/bin/lib/loop-resolver.cjs +45 -167
  41. package/gsd-core/bin/lib/milestone.cjs +13 -5
  42. package/gsd-core/bin/lib/model-resolver.cjs +3 -4
  43. package/gsd-core/bin/lib/phase-id.cjs +3 -5
  44. package/gsd-core/bin/lib/phase-locator.cjs +3 -6
  45. package/gsd-core/bin/lib/phase.cjs +59 -13
  46. package/gsd-core/bin/lib/probe-core.cjs +40 -11
  47. package/gsd-core/bin/lib/profile-output.cjs +5 -2
  48. package/gsd-core/bin/lib/prohibition-enforcement.cjs +660 -0
  49. package/gsd-core/bin/lib/roadmap-command-router.cjs +2 -2
  50. package/gsd-core/bin/lib/roadmap-parser.cjs +28 -25
  51. package/gsd-core/bin/lib/roadmap.cjs +9 -4
  52. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +4 -1
  53. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +24 -3
  54. package/gsd-core/bin/lib/state.cjs +75 -17
  55. package/gsd-core/bin/lib/task-command-router.cjs +2 -2
  56. package/gsd-core/bin/lib/teams-status.cjs +74 -0
  57. package/gsd-core/bin/lib/template.cjs +11 -2
  58. package/gsd-core/bin/lib/uat.cjs +64 -2
  59. package/gsd-core/bin/lib/verification.cjs +8 -5
  60. package/gsd-core/bin/lib/verify.cjs +311 -4
  61. package/gsd-core/bin/lib/workstream-inventory.cjs +2 -2
  62. package/gsd-core/bin/lib/workstream.cjs +8 -2
  63. package/gsd-core/bin/lib/worktree-safety.cjs +44 -3
  64. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  65. package/gsd-core/references/planner-antipatterns.md +46 -0
  66. package/gsd-core/references/planning-config.md +5 -1
  67. package/gsd-core/references/prohibition-probe.md +80 -2
  68. package/gsd-core/references/worktree-branch-check.md +11 -5
  69. package/gsd-core/templates/verification-report.md +16 -3
  70. package/gsd-core/workflows/docs-update.md +23 -31
  71. package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +9 -0
  72. package/gsd-core/workflows/execute-phase.md +7 -2
  73. package/gsd-core/workflows/map-codebase.md +8 -10
  74. package/gsd-core/workflows/plan-phase.md +6 -0
  75. package/gsd-core/workflows/quick.md +26 -2
  76. package/gsd-core/workflows/settings-advanced.md +5 -5
  77. package/gsd-core/workflows/settings-integrations.md +5 -5
  78. package/gsd-core/workflows/spec-phase.md +30 -2
  79. package/gsd-core/workflows/verify-phase.md +22 -7
  80. package/hooks/dist/gsd-worktree-path-guard.js +34 -18
  81. package/hooks/gsd-worktree-path-guard.js +34 -18
  82. package/package.json +3 -3
  83. package/scripts/ci-prepare-test-scope.cjs +56 -14
  84. package/scripts/diff-touches-shipped-paths.cjs +5 -11
  85. package/scripts/gen-capability-registry.cjs +27 -1
  86. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -2
  87. package/scripts/research-profiles.cjs +2 -2
  88. package/scripts/run-tests.cjs +1 -0
  89. package/gsd-core/bin/lib/core.cjs +0 -345
@@ -20,31 +20,27 @@
20
20
  * Both pure functions (resolveLoopHooks, renderLoopHooks) take explicit
21
21
  * registry/config arguments so they are trivially testable without I/O.
22
22
  *
23
- * Dependencies (leaf modules only — no core.cjs circular risk):
24
- * - node:fs / node:path (raw config.json read for capability-key activation)
23
+ * Dependencies (leaf modules only — no circular risk):
25
24
  * - ./config-loader.cjs (loadConfig)
26
- * - ./planning-workspace.cjs (planningDir — to locate config.json)
27
- * - ./core.cjs (output, error)
25
+ * - ./io.cjs (output, error)
26
+ * - ./capability-activation.cjs (resolveConfigKey, _resolveActivationValue, _getNestedConfigValue, _readRawConfigKey)
28
27
  * - loop-host-contract.cjs (CANONICAL_POINTS via LOOP_HOST_CONTRACT)
29
28
  * - capability-registry.cjs (byLoopPoint, consumed at call time)
29
+ * - capability-state.cjs (resolveCapabilityRuntimeState — for capabilities list)
30
30
  */
31
- var __importDefault = (this && this.__importDefault) || function (mod) {
32
- return (mod && mod.__esModule) ? mod : { "default": mod };
33
- };
34
- const node_fs_1 = __importDefault(require("node:fs"));
35
- const node_path_1 = __importDefault(require("node:path"));
36
31
  // eslint-disable-next-line @typescript-eslint/no-require-imports
37
- const core = require("./core.cjs");
38
- const { output: coreOutput, error: coreError } = core;
32
+ const ioMod = require("./io.cjs");
33
+ const { output: coreOutput, error: coreError } = ioMod;
39
34
  // eslint-disable-next-line @typescript-eslint/no-require-imports
40
35
  const configLoaderModule = require("./config-loader.cjs");
41
36
  const { loadConfig } = configLoaderModule;
42
37
  // eslint-disable-next-line @typescript-eslint/no-require-imports
43
38
  const capabilityStateModule = require("./capability-state.cjs");
44
39
  const { resolveCapabilityRuntimeState } = capabilityStateModule;
40
+ // ─── Capability-activation engine (single owner for config-key precedence) ────
45
41
  // eslint-disable-next-line @typescript-eslint/no-require-imports
46
- const planningWorkspaceMod = require("./planning-workspace.cjs");
47
- const { planningDir, planningRoot } = planningWorkspaceMod;
42
+ const capabilityActivationModule = require("./capability-activation.cjs");
43
+ const { _getNestedConfigValue, _readRawConfigKey, _resolveActivationValue, resolveConfigKey } = capabilityActivationModule;
48
44
  // ─── Canonical points (derived from LOOP_HOST_CONTRACT — authoritative 12) ───
49
45
  // FIX 2: Derive the authoritative canonical set from LOOP_HOST_CONTRACT so it
50
46
  // cannot drift from the host contract. CANONICAL_POINTS_FALLBACK is kept as an
@@ -92,123 +88,12 @@ const CANONICAL_POINTS_FALLBACK = CANONICAL_POINTS;
92
88
  function _getCanonicalPoints(_registry) {
93
89
  return CANONICAL_POINTS;
94
90
  }
95
- // ─── Prototype-pollution guard (inline literal, CodeQL barrier) ───────────────
96
- /**
97
- * Traverse a dotted config key through a nested config object.
98
- * E.g. "workflow.ui_phase" in { workflow: { ui_phase: true } } → { found: true, value: true }
99
- * Returns { found: false } if any segment is a forbidden key or not an own property.
100
- */
101
- function _getNestedConfigValue(config, dotKey) {
102
- const segments = dotKey.split('.');
103
- let current = config;
104
- for (const seg of segments) {
105
- // Inline literal prototype-pollution guard (CodeQL barrier)
106
- if (seg === '__proto__' || seg === 'constructor' || seg === 'prototype') {
107
- return { found: false, value: undefined };
108
- }
109
- if (typeof current !== 'object' || current === null) {
110
- return { found: false, value: undefined };
111
- }
112
- const cur = current;
113
- if (!Object.prototype.hasOwnProperty.call(cur, seg)) {
114
- return { found: false, value: undefined };
115
- }
116
- current = cur[seg];
117
- }
118
- return { found: true, value: current };
119
- }
120
- // ─── Single-key activation resolver (FIX 1) ───────────────────────────────────
121
- /**
122
- * Warn-once set for raw config.json parse errors.
123
- * Avoids noisy per-call stderr from a single malformed file.
124
- */
125
- const _warnedRawConfigPaths = new Set();
126
- /**
127
- * Read a raw config.json file and perform a guarded nested-lookup of a single
128
- * dotted key. Returns { found: false } if the file is missing (ENOENT) or if
129
- * the key is absent/forbidden. On a genuine JSON parse error: warns once to
130
- * stderr and returns { found: false } — never throws.
131
- */
132
- function _readRawConfigKey(filePath, dotKey) {
133
- try {
134
- const raw = node_fs_1.default.readFileSync(filePath, 'utf8');
135
- let parsed;
136
- try {
137
- parsed = JSON.parse(raw);
138
- }
139
- catch {
140
- if (!_warnedRawConfigPaths.has(filePath)) {
141
- _warnedRawConfigPaths.add(filePath);
142
- try {
143
- process.stderr.write(`gsd-tools: warning: failed to parse ${filePath} as JSON — skipping for activation resolution\n`);
144
- }
145
- catch { /* stderr might be closed */ }
146
- }
147
- return { found: false, value: undefined };
148
- }
149
- return _getNestedConfigValue(parsed, dotKey);
150
- }
151
- catch {
152
- // ENOENT (missing file) is expected → skip silently. All other errors → also skip (defensive).
153
- return { found: false, value: undefined };
154
- }
155
- }
156
- /**
157
- * FIX 1: Resolve the effective value for a hook's `when` key using the
158
- * four-level precedence:
159
- *
160
- * 1. loadConfig result (`config` arg) — guarded nested-lookup of the dotted key.
161
- * This is the post-cutover federated path (covers keys that loadConfig now exposes).
162
- * 2. Raw workstream `.planning/.../config.json` — guarded single-key lookup.
163
- * Workstream wins over root (mirrors loadConfig inheritance).
164
- * 3. Raw root `.planning/config.json` — guarded single-key lookup.
165
- * 4. `registry.configSchema[when]?.default` — schema default.
166
- * A `default: true` hook is active out-of-the-box without any config.
167
- * 5. Absent → inactive (return false).
168
- *
169
- * Never constructs a merged object from raw JSON keys — only reads the single
170
- * leaf value at the guarded dotted path. Prototype-pollution sink is eliminated.
171
- */
172
- function _resolveActivationValue(dotKey, config, cwd, registry) {
173
- // Level 1: loadConfig result
174
- const fromConfig = _getNestedConfigValue(config, dotKey);
175
- if (fromConfig.found)
176
- return Boolean(fromConfig.value);
177
- // Level 2 + 3: raw config.json files (only when cwd is available)
178
- if (cwd) {
179
- // Level 2: workstream config (planningDir respects GSD_WORKSTREAM env)
180
- const wsConfigPath = node_path_1.default.join(planningDir(cwd), 'config.json');
181
- // Level 3: root config (planningRoot = cwd/.planning always)
182
- const rootConfigPath = node_path_1.default.join(planningRoot(cwd), 'config.json');
183
- // Workstream wins over root (mirroring loadConfig root→workstream precedence:
184
- // workstream overlays root, so workstream value takes precedence).
185
- const fromWs = _readRawConfigKey(wsConfigPath, dotKey);
186
- if (fromWs.found)
187
- return Boolean(fromWs.value);
188
- // Only read root if it differs from the workstream path (avoids double-read
189
- // when no workstream is active and both paths resolve to the same file).
190
- if (wsConfigPath !== rootConfigPath) {
191
- const fromRoot = _readRawConfigKey(rootConfigPath, dotKey);
192
- if (fromRoot.found)
193
- return Boolean(fromRoot.value);
194
- }
195
- }
196
- // Level 4: registry configSchema default
197
- const schemaEntry = registry['configSchema']?.[dotKey];
198
- if (schemaEntry && typeof schemaEntry === 'object' && schemaEntry !== null) {
199
- const def = schemaEntry['default'];
200
- if (def !== undefined)
201
- return Boolean(def);
202
- }
203
- // Level 5: absent → inactive
204
- return false;
205
- }
206
91
  // ─── Pure resolver ─────────────────────────────────────────────────────────────
207
92
  /**
208
93
  * Pure resolver: given a point, registry, and config, returns the active hooks.
209
94
  *
210
95
  * Throws if `point` is not one of the 12 canonical points (caller converts to
211
- * core.error). Never throws for malformed registry/hook entries — skips and
96
+ * io.error). Never throws for malformed registry/hook entries — skips and
212
97
  * continues.
213
98
  *
214
99
  * Ordering: steps first, then contributions, then gates. Within each array,
@@ -248,7 +133,7 @@ function resolveLoopHooks(input) {
248
133
  return false;
249
134
  return _resolveActivationValue(when, config, cwd, registry);
250
135
  }
251
- function isCapabilityEnabled(capId) {
136
+ function isCapabilityActive(capId) {
252
137
  if (!capabilityStatesById)
253
138
  return true;
254
139
  const state = capabilityStatesById instanceof Map
@@ -256,7 +141,12 @@ function resolveLoopHooks(input) {
256
141
  : capabilityStatesById[capId];
257
142
  if (!state)
258
143
  return false;
259
- return state.enabled !== false;
144
+ // Fail-closed gate: only render the hook when active is explicitly true.
145
+ // A capability can be installed and surfaced (enabled=true) but config-disabled
146
+ // (active=false); in that case the hook must not render.
147
+ // Phase 4 tri-state alignment: `active` is now required (not optional), so
148
+ // `=== true` is the correct fail-closed check (not `!== false`).
149
+ return state.active === true;
260
150
  }
261
151
  // Helper: safe string array
262
152
  function toStringArray(v) {
@@ -294,39 +184,9 @@ function resolveLoopHooks(input) {
294
184
  continue;
295
185
  if (typeof dotKey !== 'string')
296
186
  continue;
297
- // Level 1: loadConfig result
298
- const fromConfig = _getNestedConfigValue(config, dotKey);
299
- if (fromConfig.found) {
300
- resolved[alias] = fromConfig.value;
301
- continue;
302
- }
303
- // Level 2 + 3: raw config.json files
304
- if (cwd) {
305
- const wsConfigPath = node_path_1.default.join(planningDir(cwd), 'config.json');
306
- const rootConfigPath = node_path_1.default.join(planningRoot(cwd), 'config.json');
307
- const fromWs = _readRawConfigKey(wsConfigPath, dotKey);
308
- if (fromWs.found) {
309
- resolved[alias] = fromWs.value;
310
- continue;
311
- }
312
- if (wsConfigPath !== rootConfigPath) {
313
- const fromRoot = _readRawConfigKey(rootConfigPath, dotKey);
314
- if (fromRoot.found) {
315
- resolved[alias] = fromRoot.value;
316
- continue;
317
- }
318
- }
319
- }
320
- // Level 4: registry configSchema default
321
- const schemaEntry = registry['configSchema']?.[dotKey];
322
- if (schemaEntry && typeof schemaEntry === 'object' && schemaEntry !== null) {
323
- const def = schemaEntry['default'];
324
- if (def !== undefined) {
325
- resolved[alias] = def;
326
- continue;
327
- }
328
- }
329
- // Level 5: absent → undefined (omit from resolved map)
187
+ const r = resolveConfigKey(dotKey, { config, cwd, registry });
188
+ if (r.found)
189
+ resolved[alias] = r.value;
330
190
  }
331
191
  return Object.keys(resolved).length > 0 ? resolved : undefined;
332
192
  }
@@ -337,7 +197,7 @@ function resolveLoopHooks(input) {
337
197
  if (!hook || typeof hook !== 'object')
338
198
  continue;
339
199
  const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
340
- if (!isCapabilityEnabled(capId))
200
+ if (!isCapabilityActive(capId))
341
201
  continue;
342
202
  if (!isActive(hook))
343
203
  continue;
@@ -371,7 +231,7 @@ function resolveLoopHooks(input) {
371
231
  if (!hook || typeof hook !== 'object')
372
232
  continue;
373
233
  const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
374
- if (!isCapabilityEnabled(capId))
234
+ if (!isCapabilityActive(capId))
375
235
  continue;
376
236
  if (!isActive(hook))
377
237
  continue;
@@ -406,7 +266,7 @@ function resolveLoopHooks(input) {
406
266
  if (!hook || typeof hook !== 'object')
407
267
  continue;
408
268
  const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
409
- if (!isCapabilityEnabled(capId))
269
+ if (!isCapabilityActive(capId))
410
270
  continue;
411
271
  if (!isActive(hook))
412
272
  continue;
@@ -524,7 +384,7 @@ function renderLoopHooks(resolved) {
524
384
  * Command entry point: load registry + config, resolve + render, emit envelope.
525
385
  *
526
386
  * Envelope: { point, activeHooks, rendered }
527
- * On invalid point, emits core.error instead of throwing.
387
+ * On invalid point, emits io.error instead of throwing.
528
388
  *
529
389
  * Config note: FIX 1 replaced _loadMergedConfig (whole-config deep-merge) with a
530
390
  * per-hook single-key activation resolver (_resolveActivationValue). The resolver
@@ -555,9 +415,24 @@ function cmdLoopRenderHooks(cwd, point, raw, options = {}) {
555
415
  const runtimeConfigDir = typeof options['configDir'] === 'string'
556
416
  ? options['configDir']
557
417
  : undefined;
558
- const state = resolveCapabilityRuntimeState(cwd, runtimeConfigDir);
559
- const registry = state.registry;
560
- const config = state.config || loadConfig(cwd);
418
+ // Load the config snapshot ONCE and share it with both the capability-state
419
+ // resolver (via configOverride) and loop-hook resolution, so federated keys
420
+ // present in loadConfig resolve identically for `active` and for hook when/
421
+ // configValues — eliminating the previous double loadConfig() call. Note: keys
422
+ // absent from loadConfig still fall through to raw .planning/config.json reads
423
+ // (precedence levels 2-3) in each pass; that residual re-read window is
424
+ // pre-existing (unchanged by this consolidation), not introduced here.
425
+ let config;
426
+ try {
427
+ config = loadConfig(cwd);
428
+ }
429
+ catch {
430
+ config = {};
431
+ }
432
+ const state = resolveCapabilityRuntimeState(cwd, runtimeConfigDir, config);
433
+ // Registry is the static generated module — same object capability-state uses internally.
434
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
435
+ const registry = require('./capability-registry.cjs');
561
436
  const capabilityStatesById = new Map();
562
437
  for (const cap of state.capabilities || []) {
563
438
  capabilityStatesById.set(cap.id, cap);
@@ -596,6 +471,9 @@ module.exports = {
596
471
  _getNestedConfigValue,
597
472
  _resolveActivationValue,
598
473
  _readRawConfigKey,
474
+ // Re-exported for identity parity guard (FIX 2: resolveConfigValues in this module
475
+ // calls resolveConfigKey; exporting it here makes the single-owner contract testable).
476
+ resolveConfigKey,
599
477
  CANONICAL_POINTS_FALLBACK,
600
478
  CANONICAL_POINTS,
601
479
  };
@@ -11,8 +11,6 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
11
11
  };
12
12
  const node_fs_1 = __importDefault(require("node:fs"));
13
13
  const node_path_1 = __importDefault(require("node:path"));
14
- // eslint-disable-next-line @typescript-eslint/no-require-imports -- core.cjs is an export= CommonJS module
15
- const core = require("./core.cjs");
16
14
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
17
15
  const planningWorkspace = require("./planning-workspace.cjs");
18
16
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
@@ -21,7 +19,18 @@ const frontmatterMod = require("./frontmatter.cjs");
21
19
  const stateMod = require("./state.cjs");
22
20
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
23
21
  const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
24
- const { escapeRegex, getMilestonePhaseFilter, extractOneLinerFromBody, normalizePhaseName, phaseTokenMatches, output, error, } = core;
22
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
23
+ const ioMod = require("./io.cjs");
24
+ const { output, error } = ioMod;
25
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
26
+ const phaseIdMod = require("./phase-id.cjs");
27
+ const { escapeRegex, normalizePhaseName, phaseTokenMatches } = phaseIdMod;
28
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
29
+ const roadmapParserMod = require("./roadmap-parser.cjs");
30
+ const { getMilestonePhaseFilter, extractCurrentMilestone } = roadmapParserMod;
31
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
32
+ const coreUtilsMod = require("./core-utils.cjs");
33
+ const { extractOneLinerFromBody } = coreUtilsMod;
25
34
  const { planningPaths } = planningWorkspace;
26
35
  const { extractFrontmatter } = frontmatterMod;
27
36
  const { writeStateMd, stateReplaceFieldWithFallback } = stateMod;
@@ -109,7 +118,7 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
109
118
  // Ensure archive directory exists
110
119
  (0, shell_command_projection_cjs_1.platformEnsureDir)(archiveDir);
111
120
  // Scope stats and accomplishments to only the phases belonging to the
112
- // current milestone's ROADMAP. Uses the shared filter from core.cjs
121
+ // current milestone's ROADMAP. Uses the shared filter from roadmap-parser.cjs
113
122
  // (same logic used by cmdPhasesList and other callers).
114
123
  const isDirInMilestone = getMilestonePhaseFilter(cwd, version);
115
124
  if (isDirInMilestone.missingExplicitVersion) {
@@ -138,7 +147,6 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
138
147
  /* skip */
139
148
  }
140
149
  if (stateVersion && stateVersion === version) {
141
- const { extractCurrentMilestone } = core;
142
150
  const roadmapContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
143
151
  const scopedContent = extractCurrentMilestone(roadmapContent, cwd);
144
152
  const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
@@ -7,11 +7,10 @@
7
7
  * planning granularity, reasoning effort, and fast-mode for a given agent by
8
8
  * reading project config and resolving against the model profiles and catalog.
9
9
  * Behaviour is preserved byte-for-behaviour from the prior location; only
10
- * the module boundary moved. core.cjs re-exports the resolvers for back-compat.
10
+ * the module boundary moved. The core.cjs re-export spine was retired in
11
+ * epic #1267; callers import resolvers from model-resolver.cjs directly.
11
12
  *
12
- * New imports should pull resolvers from model-resolver.cjs directly.
13
- *
14
- * Dependencies (leaf modules only — no core.cjs):
13
+ * Dependencies (leaf modules only):
15
14
  * - node:fs / node:path (stdlib, not currently needed — included for future use)
16
15
  * - ./config-loader.cjs (loadConfig)
17
16
  * - ./configuration.cjs (CONFIG_DEFAULTS as CANONICAL_CONFIG_DEFAULTS)
@@ -5,10 +5,8 @@
5
5
  *
6
6
  * Extracted from core.cts (ADR-857 rollout phase 2a / issue #865).
7
7
  * The hand-written bodies are preserved byte-for-behaviour; only the module
8
- * boundary moved. core.cts re-exports every symbol here under its own
9
- * `export =` object so existing consumers are unaffected.
10
- *
11
- * New imports should pull phase-id helpers from phase-id.cjs directly.
8
+ * boundary moved. The core.cjs re-export spine was retired in epic #1267;
9
+ * callers import phase-id helpers from phase-id.cjs directly.
12
10
  *
13
11
  * Dependencies: none (pure string/regex, no Node built-ins required).
14
12
  */
@@ -175,7 +173,7 @@ function extractPhaseToken(dirName) {
175
173
  const tokenSegments = [];
176
174
  for (let i = 0; i < segments.length; i++) {
177
175
  const seg = segments[i];
178
- if (/^\d/.test(seg)) {
176
+ if (/^\d/.test(seg) || (i === 0 && /^[A-Za-z]{1,3}\d/.test(seg))) {
179
177
  tokenSegments.push(seg);
180
178
  }
181
179
  else {
@@ -7,13 +7,10 @@
7
7
  * (`searchPhaseInDir`, `findPhaseInternal`) and archived-phase-dir
8
8
  * enumeration (`getArchivedPhaseDirs`), matching phase ids/tokens against
9
9
  * the filesystem. Behaviour is preserved byte-for-behaviour from the prior
10
- * location; only the module boundary moved. core.cjs re-exports
11
- * `searchPhaseInDir`, `findPhaseInternal`, and `getArchivedPhaseDirs` for back-compat.
10
+ * location; only the module boundary moved. The core.cjs re-export spine
11
+ * was retired in epic #1267; callers import phase-locator helpers directly.
12
12
  *
13
- * New imports should pull phase-locator helpers from phase-locator.cjs
14
- * directly.
15
- *
16
- * Dependencies (leaf modules only — no core.cjs, no loadConfig):
13
+ * Dependencies (leaf modules only — no loadConfig):
17
14
  * - node:fs / node:path (stdlib)
18
15
  * - ./phase-id.cjs (normalizePhaseName, phaseTokenMatches, extractPhaseToken)
19
16
  * - ./core-utils.cjs (readSubdirectories, getPhaseFileStats, extractCanonicalPlanId, toPosixPath)
@@ -21,8 +21,24 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
21
21
  };
22
22
  const node_fs_1 = __importDefault(require("node:fs"));
23
23
  const node_path_1 = __importDefault(require("node:path"));
24
- // eslint-disable-next-line @typescript-eslint/no-require-imports -- core.cjs is an export= CommonJS module
25
- const core = require("./core.cjs");
24
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- io.cjs is an export= CommonJS module
25
+ const ioMod = require("./io.cjs");
26
+ const { output, error, ERROR_REASON } = ioMod;
27
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- config-loader.cjs is an export= CommonJS module
28
+ const configLoaderMod = require("./config-loader.cjs");
29
+ const { loadConfig } = configLoaderMod;
30
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- core-utils.cjs is an export= CommonJS module
31
+ const coreUtilsMod = require("./core-utils.cjs");
32
+ const { toPosixPath, generateSlugInternal, readSubdirectories } = coreUtilsMod;
33
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
34
+ const phaseIdMod = require("./phase-id.cjs");
35
+ const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, phaseTokenMatches } = phaseIdMod;
36
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-locator.cjs is an export= CommonJS module
37
+ const phaseLocatorMod = require("./phase-locator.cjs");
38
+ const { findPhaseInternal, getArchivedPhaseDirs } = phaseLocatorMod;
39
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- roadmap-parser.cjs is an export= CommonJS module
40
+ const roadmapParserMod = require("./roadmap-parser.cjs");
41
+ const { stripShippedMilestones, extractCurrentMilestone, getMilestonePhaseFilter } = roadmapParserMod;
26
42
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
27
43
  const planningWorkspace = require("./planning-workspace.cjs");
28
44
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
@@ -36,14 +52,9 @@ const clock_cjs_1 = require("./clock.cjs");
36
52
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- uat-predicate.cjs is an export= CommonJS module
37
53
  const uatPredicate = require("./uat-predicate.cjs");
38
54
  const { evaluateUatPassed } = uatPredicate;
39
- const { escapeRegex, loadConfig, normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, toPosixPath, output, error, readSubdirectories, phaseTokenMatches, ERROR_REASON, } = core;
40
55
  const { planningDir, withPlanningLock } = planningWorkspace;
41
56
  const { extractFrontmatter } = frontmatterMod;
42
57
  const { readModifyWriteStateMd, stateExtractField, stateReplaceField, stateReplaceFieldWithFallback, syncStateFrontmatter, withStateLock, updatePerformanceMetricsSection, } = stateMod;
43
- // Unused import silences TS — keep for structural parity with .cjs (stripShippedMilestones,
44
- // replaceInCurrentMilestone are exported from core but only used in phase.cjs as-is).
45
- void stripShippedMilestones;
46
- void replaceInCurrentMilestone;
47
58
  // #2893 — strict canonical filter: `{padded_phase}-{NN}-PLAN.md` or `PLAN.md`.
48
59
  const isCanonicalPlanFile = (f) => f.endsWith('-PLAN.md') || f === 'PLAN.md';
49
60
  // Any .md file with PLAN anywhere in the basename — diagnostic net
@@ -1029,6 +1040,22 @@ function writePlanningFileSet(writes) {
1029
1040
  throw err;
1030
1041
  }
1031
1042
  }
1043
+ function phaseDisplayNameFromRoadmap(roadmapContent, phaseNum) {
1044
+ if (!roadmapContent || !phaseNum)
1045
+ return null;
1046
+ const phaseEscaped = phaseMarkdownRegexSource(phaseNum);
1047
+ const heading = roadmapContent.match(new RegExp(`^#{2,4}\\s*Phase\\s+${phaseEscaped}\\s*:\\s*([^\\n]+)`, 'im'));
1048
+ if (!heading)
1049
+ return null;
1050
+ const name = heading[1].replace(/\(INSERTED\)/i, '').trim();
1051
+ return name || null;
1052
+ }
1053
+ function phaseDisplayNameFromSlug(slug) {
1054
+ if (!slug)
1055
+ return null;
1056
+ const name = slug.replace(/-/g, ' ').trim();
1057
+ return name || null;
1058
+ }
1032
1059
  function cmdPhaseComplete(cwd, phaseNum, raw) {
1033
1060
  if (!phaseNum) {
1034
1061
  error('phase number required for phase complete');
@@ -1276,6 +1303,8 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1276
1303
  const originalStateContent = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
1277
1304
  let stateContent = originalStateContent;
1278
1305
  const phaseValue = nextPhaseNum || phaseNum;
1306
+ const nextPhaseDisplayName = phaseDisplayNameFromRoadmap(roadmapContent, nextPhaseNum) ??
1307
+ phaseDisplayNameFromSlug(nextPhaseName);
1279
1308
  const existingPhaseField = stateExtractField(stateContent, 'Current Phase') ||
1280
1309
  stateExtractField(stateContent, 'Phase');
1281
1310
  let newPhaseValue = String(phaseValue);
@@ -1284,22 +1313,39 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1284
1313
  const nameMatch = existingPhaseField.match(/\(([^)]+)\)/);
1285
1314
  if (totalMatch) {
1286
1315
  const total = totalMatch[1];
1287
- const nameStr = nextPhaseName
1288
- ? ` (${nextPhaseName.replace(/-/g, ' ')})`
1316
+ const nameStr = nextPhaseDisplayName
1317
+ ? ` (${nextPhaseDisplayName})`
1289
1318
  : nameMatch
1290
1319
  ? ` (${nameMatch[1]})`
1291
1320
  : '';
1292
1321
  newPhaseValue = `${phaseValue} of ${total}${nameStr}`;
1293
1322
  }
1323
+ else if (nextPhaseDisplayName) {
1324
+ newPhaseValue = `${phaseValue} — ${nextPhaseDisplayName}`;
1325
+ }
1294
1326
  }
1295
1327
  stateContent = stateReplaceFieldWithFallback(stateContent, 'Current Phase', 'Phase', newPhaseValue);
1296
- if (nextPhaseName) {
1297
- stateContent = stateReplaceFieldWithFallback(stateContent, 'Current Phase Name', null, nextPhaseName.replace(/-/g, ' '));
1328
+ if (nextPhaseDisplayName) {
1329
+ stateContent =
1330
+ stateReplaceField(stateContent, 'Current Phase Name', nextPhaseDisplayName) ||
1331
+ stateContent;
1298
1332
  }
1299
1333
  stateContent = stateReplaceFieldWithFallback(stateContent, 'Status', null, isLastPhase ? 'Milestone complete' : 'Ready to plan');
1300
1334
  stateContent = stateReplaceFieldWithFallback(stateContent, 'Current Plan', 'Plan', 'Not started');
1301
- stateContent = stateReplaceFieldWithFallback(stateContent, 'Last Activity', 'Last activity', today);
1302
- stateContent = stateReplaceFieldWithFallback(stateContent, 'Last Activity Description', null, `Phase ${phaseNum} complete${nextPhaseNum ? `, transitioned to Phase ${nextPhaseNum}` : ''}`);
1335
+ const lastActivityDescription = `Phase ${phaseNum} complete${nextPhaseNum ? `, transitioned to Phase ${nextPhaseNum}` : ''}`;
1336
+ if (/^Last activity:/m.test(stateContent)) {
1337
+ stateContent =
1338
+ stateReplaceField(stateContent, 'Last activity', `${today} — ${lastActivityDescription}`) ||
1339
+ stateContent;
1340
+ }
1341
+ else {
1342
+ stateContent =
1343
+ stateReplaceField(stateContent, 'Last Activity', today) ||
1344
+ stateContent;
1345
+ }
1346
+ stateContent =
1347
+ stateReplaceField(stateContent, 'Last Activity Description', lastActivityDescription) ||
1348
+ stateContent;
1303
1349
  const completedRaw = stateExtractField(stateContent, 'Completed Phases');
1304
1350
  if (completedRaw !== null) {
1305
1351
  let newCompleted = parseInt(completedRaw, 10);
@@ -241,6 +241,15 @@ function validateProhibitionResolution(resolution) {
241
241
  * and are intentionally NOT projected into the plan block (which is keyed on the must-NOT
242
242
  * statement, not the source requirement). A non-array input projects to `[]` (fail-soft on the
243
243
  * empty/zero-prohibition case), never a throw.
244
+ *
245
+ * An OPTIONAL wired-check descriptor (#1278) projects as the LOCKED flat scalar keys
246
+ * `check_kind`/`check_target`/`check_rule` (NEVER a nested `check:{}` object; `failFirst` is never
247
+ * projected). These ride the EXISTING continuation-KV path of `parseMustHavesBlock`
248
+ * (src/frontmatter.cts:344) with NO shared-parser rewrite (IMPL-SCOPING §3 Option 1). The keys are
249
+ * emitted ONLY for a well-formed descriptor (valid `check_kind` + non-empty `check_target`; plus
250
+ * `check_rule` only for a lint-rule that carries one); a descriptor-less or under-specified item is
251
+ * byte-identical to today (CHK-07), so an under-specified descriptor projects absent and fails closed
252
+ * at the producer downstream (CHK-06), never as a partial-but-locatable green.
244
253
  */
245
254
  function projectProhibitions(items) {
246
255
  if (!Array.isArray(items))
@@ -259,6 +268,27 @@ function projectProhibitions(items) {
259
268
  entry.verification = String(p.verification);
260
269
  if (p.reason != null && String(p.reason).trim())
261
270
  entry.reason = String(p.reason);
271
+ // Optional wired-check descriptor (#1278): emit flat scalars ONLY when well-formed. A valid kind
272
+ // plus a non-empty target is the minimum; under that bar nothing is emitted (CHK-07 byte-identity,
273
+ // and the producer fails closed on the absent descriptor — CHK-06).
274
+ const kind = p.check_kind;
275
+ const targetOk = typeof p.check_target === 'string' && p.check_target.trim() !== '';
276
+ if ((kind === 'node-test' || kind === 'lint-rule') && targetOk) {
277
+ entry.check_kind = kind;
278
+ entry.check_target = String(p.check_target);
279
+ // `check_rule` rides only the lint-rule path (node-test never carries one); a lint-rule missing
280
+ // its rule leaves check_rule absent so the producer's fail-closed locate rejects it (CHK-06).
281
+ if (kind === 'lint-rule' && typeof p.check_rule === 'string' && p.check_rule.trim() !== '') {
282
+ entry.check_rule = String(p.check_rule);
283
+ }
284
+ // `check_violation_fixture` (#1346) rides BOTH kinds — it's what the #1279 prover machine-proves
285
+ // fail-first against. Emit ONLY a non-empty fixture (a blank one projects absent so green still
286
+ // hard-gates downstream — never a partial green); meaningless without the descriptor, so it lives
287
+ // inside this well-formed-descriptor branch.
288
+ if (typeof p.check_violation_fixture === 'string' && p.check_violation_fixture.trim() !== '') {
289
+ entry.check_violation_fixture = String(p.check_violation_fixture);
290
+ }
291
+ }
262
292
  out.push(entry);
263
293
  }
264
294
  return out;
@@ -270,11 +300,10 @@ function projectProhibitions(items) {
270
300
  * This is the cheap safety guarantee: a well-formed prohibition that reaches verify-phase with NO
271
301
  * wired enforcement evidence can NEVER be a silent pass. It is `{ status: 'unverified', flagged:
272
302
  * true }` — never `green` — exactly like an unresolved judgment item. The HEAVY half (a real
273
- * fail-first negative-test enforcement mechanism that, given evidence, would flip a test-tier item
274
- * to green) is OUT of #644 scope and defers to a follow-up PR: #644's corpus is entirely
275
- * judgment-tier, so wiring a contrived test-tier consumer here would be the delete-bad-tests /
276
- * gold-plating failure mode. Until that follow-up lands, ANY prohibition without enforcement
277
- * evidence — test- or judgment-tier — disposes as flagged-unverified.
303
+ * negative-test enforcement mechanism that, given evidence, flips a test-tier item to green) was OUT
304
+ * of #644 scope and LANDED in #1259 as the `prohibition-enforcement` producer (it builds the
305
+ * `enforcementEvidence` this helper reads). This helper's policy is unchanged: ANY prohibition
306
+ * without enforcement evidence — test- or judgment-tier — disposes as flagged-unverified.
278
307
  *
279
308
  * The function is pure: same input always yields the same disposition (no LLM judgment, ADR-550
280
309
  * D5). The LLM-judge soft-gate for judgment-tier items is a verify-phase PROSE concern (the
@@ -287,23 +316,23 @@ function dispositionForProhibition(prohibition, context = {}) {
287
316
  const evidence = Array.isArray(context.enforcementEvidence) ? context.enforcementEvidence : [];
288
317
  const hasEnforcement = evidence.length > 0;
289
318
  // FAIL CLOSED: no wired enforcement evidence -> flagged unverified, never green. This holds for
290
- // every tier today (the real enforcement mechanism that could flip a test-tier item to green is
291
- // deferred to a follow-up PR). The guard the safety assertion proves: an unwired item can never
292
- // be silently skipped.
319
+ // every tier (the producer that builds enforcement evidence for a test-tier item — the
320
+ // `prohibition-enforcement` module — landed in #1259). The guard the safety assertion proves: an
321
+ // unwired item can never be silently skipped.
293
322
  if (!hasEnforcement) {
294
323
  return {
295
324
  status: 'unverified',
296
325
  flagged: true,
297
326
  tier,
298
327
  reason: tier === 'test'
299
- ? 'test-tier prohibition has no wired enforcement evidence — flagged unverified (fail-closed; real negative-test enforcement deferred to a follow-up PR, ADR-550 D5d)'
328
+ ? 'test-tier prohibition has no passing wired enforcement check — flagged unverified (fail-closed; never a silent pass, ADR-550 D5d)'
300
329
  : 'prohibition has no enforcement evidence — flagged unverified (fail-closed; never a silent pass, ADR-550 D5d)',
301
330
  };
302
331
  }
303
332
  // D4 GUARD: a judgment-tier (or unknown-tier) prohibition is NEVER a silent green from this
304
333
  // deterministic helper — it always routes to human/LLM judgment review (ADR-550 D4; verify-phase.md).
305
- // Only a test-tier item with wired enforcement evidence may go green, and even that is the deferred
306
- // heavy half until the real negative-test enforcement mechanism lands (no #644 caller passes evidence).
334
+ // Only a test-tier item with wired enforcement evidence may go green; the producer that supplies
335
+ // that evidence (`prohibition-enforcement`, #1259) runs the wired check and requires a genuine pass.
307
336
  if (tier === 'test') {
308
337
  return {
309
338
  status: 'green',
@@ -20,8 +20,11 @@ const node_fs_1 = __importDefault(require("node:fs"));
20
20
  const node_path_1 = __importDefault(require("node:path"));
21
21
  const node_os_1 = __importDefault(require("node:os"));
22
22
  // eslint-disable-next-line @typescript-eslint/no-require-imports
23
- const core = require("./core.cjs");
24
- const { output, error, loadConfig } = core;
23
+ const io = require("./io.cjs");
24
+ const { output, error } = io;
25
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
26
+ const configLoader = require("./config-loader.cjs");
27
+ const { loadConfig } = configLoader;
25
28
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
26
29
  const runtime_homes_cjs_1 = require("./runtime-homes.cjs");
27
30
  const runtime_slash_cjs_1 = require("./runtime-slash.cjs");