@opengsd/gsd-core 1.6.0-rc.2 → 1.6.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 (157) hide show
  1. package/.claude-plugin/plugin.json +2 -1
  2. package/agents/gsd-advisor-researcher.md +2 -0
  3. package/agents/gsd-ai-researcher.md +2 -0
  4. package/agents/gsd-assumptions-analyzer.md +2 -0
  5. package/agents/gsd-doc-classifier.md +2 -0
  6. package/agents/gsd-doc-synthesizer.md +2 -0
  7. package/agents/gsd-domain-researcher.md +2 -0
  8. package/agents/gsd-eval-auditor.md +6 -9
  9. package/agents/gsd-phase-researcher.md +2 -0
  10. package/agents/gsd-planner.md +8 -57
  11. package/agents/gsd-project-researcher.md +2 -0
  12. package/agents/gsd-research-synthesizer.md +2 -0
  13. package/agents/gsd-security-auditor.md +37 -18
  14. package/agents/gsd-ui-researcher.md +2 -0
  15. package/bin/install.js +370 -18
  16. package/gemini-extension.json +1 -1
  17. package/gsd-core/bin/gsd-tools.cjs +46 -4
  18. package/gsd-core/bin/lib/audit-command-router.cjs +52 -14
  19. package/gsd-core/bin/lib/capability-lifecycle.cjs +30 -7
  20. package/gsd-core/bin/lib/capability-registry.cjs +96 -83
  21. package/gsd-core/bin/lib/capability-validator.cjs +22 -0
  22. package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +40 -2
  23. package/gsd-core/bin/lib/command-aliases.cjs +10 -1
  24. package/gsd-core/bin/lib/command-routing-hub.cjs +10 -3
  25. package/gsd-core/bin/lib/config-schema.cjs +1 -0
  26. package/gsd-core/bin/lib/config.cjs +67 -24
  27. package/gsd-core/bin/lib/coverage.cjs +464 -0
  28. package/gsd-core/bin/lib/decisions.cjs +27 -0
  29. package/gsd-core/bin/lib/eval-command-router.cjs +21 -0
  30. package/gsd-core/bin/lib/eval.cjs +60 -0
  31. package/gsd-core/bin/lib/frontmatter.cjs +132 -13
  32. package/gsd-core/bin/lib/graphify-command-router.cjs +53 -36
  33. package/gsd-core/bin/lib/init.cjs +139 -30
  34. package/gsd-core/bin/lib/install-profiles.cjs +6 -3
  35. package/gsd-core/bin/lib/intel-command-router.cjs +79 -60
  36. package/gsd-core/bin/lib/io.cjs +1 -0
  37. package/gsd-core/bin/lib/phase.cjs +16 -2
  38. package/gsd-core/bin/lib/plan-scan.cjs +2 -2
  39. package/gsd-core/bin/lib/planning-workspace.cjs +157 -13
  40. package/gsd-core/bin/lib/profile-output.cjs +18 -6
  41. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +53 -16
  42. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +21 -3
  43. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +15 -0
  44. package/gsd-core/bin/lib/runtime-name-policy.cjs +47 -2
  45. package/gsd-core/bin/lib/shell-command-projection.cjs +10 -0
  46. package/gsd-core/bin/lib/state.cjs +398 -60
  47. package/gsd-core/bin/lib/surface.cjs +42 -10
  48. package/gsd-core/bin/lib/uat-predicate.cjs +13 -6
  49. package/gsd-core/bin/lib/update-context.cjs +2 -2
  50. package/gsd-core/bin/lib/verification.cjs +67 -6
  51. package/gsd-core/bin/lib/verify.cjs +8 -1
  52. package/gsd-core/bin/shared/config-defaults.manifest.json +3 -0
  53. package/gsd-core/bin/shared/config-schema.manifest.json +2 -1
  54. package/gsd-core/references/planner-guidance.md +66 -0
  55. package/gsd-core/references/planning-config.md +2 -2
  56. package/gsd-core/references/security-asvs-levels.md +27 -0
  57. package/gsd-core/references/untrusted-input-boundary.md +13 -0
  58. package/gsd-core/templates/SECURITY.md +6 -4
  59. package/gsd-core/templates/summary-complex.md +4 -0
  60. package/gsd-core/templates/summary-minimal.md +3 -0
  61. package/gsd-core/templates/summary-standard.md +4 -0
  62. package/gsd-core/templates/summary.md +41 -0
  63. package/gsd-core/workflows/autonomous.md +53 -46
  64. package/gsd-core/workflows/complete-milestone.md +27 -8
  65. package/gsd-core/workflows/execute-phase.md +1 -1
  66. package/gsd-core/workflows/execute-plan.md +5 -0
  67. package/gsd-core/workflows/manager.md +17 -7
  68. package/gsd-core/workflows/new-project.md +82 -16
  69. package/gsd-core/workflows/plan-phase.md +15 -0
  70. package/gsd-core/workflows/profile-user.md +6 -2
  71. package/gsd-core/workflows/progress.md +37 -4
  72. package/gsd-core/workflows/quick.md +3 -1
  73. package/gsd-core/workflows/secure-phase.md +13 -7
  74. package/gsd-core/workflows/ship.md +3 -1
  75. package/gsd-core/workflows/spec-phase.md +3 -1
  76. package/gsd-core/workflows/transition.md +14 -12
  77. package/gsd-core/workflows/ui-review.md +2 -6
  78. package/gsd-core/workflows/verify-work.md +74 -1
  79. package/hooks/dist/gsd-read-injection-scanner.js +49 -25
  80. package/hooks/gsd-read-injection-scanner.js +49 -25
  81. package/hooks/hooks.json +1 -1
  82. package/package.json +4 -2
  83. package/scripts/check-alias-drift.cjs +5 -0
  84. package/scripts/gen-plugin-skills.cjs +117 -0
  85. package/scripts/lint-test-file-count.allowlist.json +2 -1
  86. package/scripts/prompt-injection-scan.sh +9 -0
  87. package/scripts/release-notes/conventional-title.cjs +88 -0
  88. package/scripts/release-notes/format-github-release-notes.cjs +4 -3
  89. package/skills/gsd-add-tests/SKILL.md +38 -0
  90. package/skills/gsd-ai-integration-phase/SKILL.md +37 -0
  91. package/skills/gsd-audit-fix/SKILL.md +33 -0
  92. package/skills/gsd-audit-milestone/SKILL.md +37 -0
  93. package/skills/gsd-audit-uat/SKILL.md +25 -0
  94. package/skills/gsd-autonomous/SKILL.md +51 -0
  95. package/skills/gsd-capture/SKILL.md +67 -0
  96. package/skills/gsd-cleanup/SKILL.md +24 -0
  97. package/skills/gsd-code-review/SKILL.md +59 -0
  98. package/skills/gsd-complete-milestone/SKILL.md +142 -0
  99. package/skills/gsd-config/SKILL.md +56 -0
  100. package/skills/gsd-debug/SKILL.md +53 -0
  101. package/skills/gsd-discuss-phase/SKILL.md +77 -0
  102. package/skills/gsd-docs-update/SKILL.md +49 -0
  103. package/skills/gsd-eval-review/SKILL.md +33 -0
  104. package/skills/gsd-execute-phase/SKILL.md +65 -0
  105. package/skills/gsd-explore/SKILL.md +28 -0
  106. package/skills/gsd-extract-learnings/SKILL.md +22 -0
  107. package/skills/gsd-fast/SKILL.md +31 -0
  108. package/skills/gsd-forensics/SKILL.md +56 -0
  109. package/skills/gsd-graphify/SKILL.md +204 -0
  110. package/skills/gsd-health/SKILL.md +31 -0
  111. package/skills/gsd-help/SKILL.md +29 -0
  112. package/skills/gsd-import/SKILL.md +46 -0
  113. package/skills/gsd-inbox/SKILL.md +39 -0
  114. package/skills/gsd-ingest-docs/SKILL.md +43 -0
  115. package/skills/gsd-manager/SKILL.md +45 -0
  116. package/skills/gsd-map-codebase/SKILL.md +83 -0
  117. package/skills/gsd-mempalace-capture/SKILL.md +71 -0
  118. package/skills/gsd-mempalace-recall/SKILL.md +102 -0
  119. package/skills/gsd-milestone-summary/SKILL.md +51 -0
  120. package/skills/gsd-mvp-phase/SKILL.md +45 -0
  121. package/skills/gsd-new-milestone/SKILL.md +45 -0
  122. package/skills/gsd-new-project/SKILL.md +47 -0
  123. package/skills/gsd-ns-context/SKILL.md +24 -0
  124. package/skills/gsd-ns-ideate/SKILL.md +23 -0
  125. package/skills/gsd-ns-manage/SKILL.md +35 -0
  126. package/skills/gsd-ns-project/SKILL.md +26 -0
  127. package/skills/gsd-ns-review/SKILL.md +28 -0
  128. package/skills/gsd-ns-workflow/SKILL.md +33 -0
  129. package/skills/gsd-pause-work/SKILL.md +43 -0
  130. package/skills/gsd-phase/SKILL.md +57 -0
  131. package/skills/gsd-plan-phase/SKILL.md +63 -0
  132. package/skills/gsd-plan-review-convergence/SKILL.md +60 -0
  133. package/skills/gsd-pr-branch/SKILL.md +26 -0
  134. package/skills/gsd-profile-user/SKILL.md +47 -0
  135. package/skills/gsd-progress/SKILL.md +49 -0
  136. package/skills/gsd-quick/SKILL.md +174 -0
  137. package/skills/gsd-resume-work/SKILL.md +31 -0
  138. package/skills/gsd-review/SKILL.md +42 -0
  139. package/skills/gsd-review-backlog/SKILL.md +63 -0
  140. package/skills/gsd-secure-phase/SKILL.md +36 -0
  141. package/skills/gsd-settings/SKILL.md +29 -0
  142. package/skills/gsd-ship/SKILL.md +24 -0
  143. package/skills/gsd-sketch/SKILL.md +60 -0
  144. package/skills/gsd-spec-phase/SKILL.md +63 -0
  145. package/skills/gsd-spike/SKILL.md +57 -0
  146. package/skills/gsd-stats/SKILL.md +20 -0
  147. package/skills/gsd-surface/SKILL.md +162 -0
  148. package/skills/gsd-thread/SKILL.md +24 -0
  149. package/skills/gsd-ui-phase/SKILL.md +35 -0
  150. package/skills/gsd-ui-review/SKILL.md +33 -0
  151. package/skills/gsd-ultraplan-phase/SKILL.md +34 -0
  152. package/skills/gsd-undo/SKILL.md +35 -0
  153. package/skills/gsd-update/SKILL.md +50 -0
  154. package/skills/gsd-validate-phase/SKILL.md +36 -0
  155. package/skills/gsd-verify-work/SKILL.md +39 -0
  156. package/skills/gsd-workspace/SKILL.md +53 -0
  157. package/skills/gsd-workstreams/SKILL.md +70 -0
@@ -19,7 +19,7 @@ const configLoaderMod = require("./config-loader.cjs");
19
19
  const { loadConfig } = configLoaderMod;
20
20
  // eslint-disable-next-line @typescript-eslint/no-require-imports
21
21
  const phaseIdMod = require("./phase-id.cjs");
22
- const { escapeRegex } = phaseIdMod;
22
+ const { escapeRegex, normalizePhaseName, extractPhaseToken } = phaseIdMod;
23
23
  // eslint-disable-next-line @typescript-eslint/no-require-imports
24
24
  const roadmapParserMod = require("./roadmap-parser.cjs");
25
25
  const { getMilestoneInfo, getMilestonePhaseFilter, extractCurrentMilestone } = roadmapParserMod;
@@ -65,8 +65,83 @@ process.on('exit', () => {
65
65
  catch { /* already gone */ }
66
66
  }
67
67
  });
68
+ // ---------------------------------------------------------------------------
69
+ // Lock liveness probe (test seam) — audit M1
70
+ //
71
+ // mtime is a LEAKY proxy for "the holder is still alive": a live-but-slow writer
72
+ // whose critical section runs past staleThresholdMs ages out and a waiter would
73
+ // steal its lock → two writers in STATE.md's read-modify-write window → lost
74
+ // update / corruption (the recurring #500/#905/#1230 family). The real signal —
75
+ // process.kill(pid, 0) — is already used by capability-lock.cts. We backport it
76
+ // here. The indirection lets unit tests inject a deterministic isPidAlive without
77
+ // real pids (mirrors capability-lock's _lockProbes / _setLockProbes seam).
78
+ // ---------------------------------------------------------------------------
79
+ /** Is `pid` a live process? process.kill(pid, 0) succeeds for a live (signalable) process. */
80
+ function _realIsPidAlive(pid) {
81
+ try {
82
+ process.kill(pid, 0);
83
+ return true; // signalable → alive
84
+ }
85
+ catch (err) {
86
+ // EPERM = process exists but we cannot signal it (still ALIVE). ESRCH = gone.
87
+ return err.code === 'EPERM';
88
+ }
89
+ }
90
+ const _stateLockProbes = { isPidAlive: _realIsPidAlive };
91
+ const _stateLockTestHooks = {};
92
+ /**
93
+ * Consume the one-shot simulateWriteError errno, if set. Returns an Error with the
94
+ * configured `.code` and self-clears so only the NEXT writeSync throws (the retry
95
+ * then succeeds). Returns null when no injection is pending.
96
+ */
97
+ function _consumeSimulatedWriteError() {
98
+ const code = _stateLockTestHooks.simulateWriteError;
99
+ if (!code)
100
+ return null;
101
+ _stateLockTestHooks.simulateWriteError = null; // one-shot
102
+ const e = new Error('simulated writeSync failure (' + code + ')');
103
+ e.code = code;
104
+ return e;
105
+ }
106
+ function _stateLockIsPidAlive(pid) {
107
+ return _stateLockProbes.isPidAlive(pid);
108
+ }
109
+ /**
110
+ * Is the holder recorded in the lock body VERIFIED-LIVE? The STATE.md lock body is
111
+ * a bare pid (written at acquire time). Returns true ONLY when the body parses to a
112
+ * positive integer pid AND that pid signals alive. A garbage / non-numeric / legacy
113
+ * body (or a dead pid) is NOT verified-live, so the lock stays stealable — corrupt
114
+ * locks never block forever, and a live holder is never stolen.
115
+ */
116
+ function _stateHolderVerifiedLive(lockPath) {
117
+ const pid = _stateLockBodyPid(lockPath);
118
+ return pid !== null && _stateLockIsPidAlive(pid);
119
+ }
120
+ /**
121
+ * Parse the lock body to its recorded pid, or null when the body is empty / non-numeric
122
+ * / unreadable (legacy or mid-creation). Distinguishing a COMPLETE dead-pid body (steal
123
+ * promptly) from an EMPTY/unparseable one (the create→write window — do not steal while
124
+ * fresh) is what `_stateHolderVerifiedLive` alone cannot express, so the steal decision
125
+ * in acquireStateLock reads the pid directly (PR #1532 review, window a).
126
+ */
127
+ function _stateLockBodyPid(lockPath) {
128
+ let body;
129
+ try {
130
+ body = node_fs_1.default.readFileSync(lockPath, 'utf-8');
131
+ }
132
+ catch {
133
+ return null; // unreadable body → cannot verify
134
+ }
135
+ const trimmed = body.trim();
136
+ const pid = parseInt(trimmed, 10);
137
+ if (!Number.isInteger(pid) || pid <= 0 || String(pid) !== trimmed)
138
+ return null;
139
+ return pid;
140
+ }
141
+ // Monotonic sequence for unique stale-steal rename targets (no crypto dependency).
142
+ let _stateStealSeq = 0;
68
143
  // Hoisted to module scope — compiled once, not per call (#320). Stateless (/i, used with .match).
69
- const byPhaseTablePattern = /(\|\s*Phase\s*\|\s*Plans\s*\|\s*Total\s*\|\s*Avg\/Plan\s*\|[ \t]*\n\|(?:[- :\t]+\|)+[ \t]*\n)((?:[ \t]*\|[^\n]*\n)*)(?=\n|$)/i;
144
+ const byPhaseTablePattern = /(\|\s*Phase\s*\|\s*Plans\s*\|\s*Total\s*\|\s*Avg\/Plan\s*\|[ \t]*\r?\n\|(?:[- :\t]+\|)+[ \t]*\r?\n)((?:[ \t]*\|[^\n]*\n)*)(?=\r?\n|$)/i;
70
145
  // ─── ADR-1372 T6: seam-based section splice helper ───────────────────────────
71
146
  // Shared stop predicates corresponding to the regex lookaheads used in state.cts:
72
147
  // STOP_H2_PLUS : (?=\n##|$) — stops at any heading with level ≥ 2
@@ -1192,6 +1267,63 @@ function cmdStateSnapshot(cwd, raw) {
1192
1267
  output(result, raw, undefined);
1193
1268
  }
1194
1269
  // ─── State Frontmatter Sync ──────────────────────────────────────────────────
1270
+ /**
1271
+ * Canonical key for matching a ROADMAP phase token against an on-disk phase
1272
+ * directory: normalizePhaseName collapses padding/case, strips the project-code
1273
+ * prefix, and handles decimals/letter-suffixes/milestone-prefixed IDs, so
1274
+ * "Phase 4"/"Phase 04"/dir "04-delta" and "Phase PROJ-42"/dir "PROJ-42-foo"
1275
+ * each map to one key. For a directory, extract its phase token first.
1276
+ *
1277
+ * Stripping the project-code prefix is GSD's canonical phase identity (a
1278
+ * project_code is a display prefix; normalizePhaseName / phaseTokenMatches treat
1279
+ * `CK-01` and `01` as the same phase, which is what lets a prefixed dir match a
1280
+ * bare ROADMAP token). A consistent project uses one scheme, so a bare numeric
1281
+ * and a same-suffix project-code phase never coexist in one milestone.
1282
+ */
1283
+ function phaseKeyFromToken(token) {
1284
+ return normalizePhaseName(token).toUpperCase();
1285
+ }
1286
+ function phaseKeyFromDir(dir) {
1287
+ return phaseKeyFromToken(extractPhaseToken(dir));
1288
+ }
1289
+ /**
1290
+ * Extract the set of retired/folded phase keys from a ROADMAP milestone scope
1291
+ * (#1514). A retired phase is struck through with GFM strikethrough,
1292
+ * e.g. `- [x] ~~**Phase 04: Delta**~~ — folded into Phase 05; number retired`.
1293
+ * Such a phase keeps a `[x]` mark and often a directory but ships no completion
1294
+ * artifact, so it would otherwise inflate `total_phases` (the denominator)
1295
+ * without ever satisfying the numerator, freezing a shipped milestone below
1296
+ * 100%.
1297
+ *
1298
+ * Detection is scoped to the lines that canonically mark a phase retired — a
1299
+ * checklist entry (`- [x] …`) or a phase heading (`#### Phase …`) — and within
1300
+ * those, only a struck span whose SUBJECT is the phase counts: the phase
1301
+ * reference must sit at the start of the `~~…~~` span (after optional markdown
1302
+ * emphasis), as in `~~**Phase 04: Delta**~~`, `~~Phase 04~~`, or
1303
+ * `~~Phase PROJ-42~~`. This ignores struck PROSE that merely mentions a phase
1304
+ * (a goal line `~~folded into Phase 05~~`, or `~~Phase 04 was renamed~~`) and
1305
+ * the fold target in `~~Phase 04~~ — folded into Phase 05` (outside the span).
1306
+ * The phase token shape mirrors the heading counter's `[\w][\w.-]*` so numeric,
1307
+ * decimal, and project-code IDs are detected alike. Returns canonical keys
1308
+ * (see phaseKeyFromToken).
1309
+ */
1310
+ function extractRetiredPhaseNumbers(scope) {
1311
+ const retired = new Set();
1312
+ const isChecklistOrHeading = /^\s*(?:[-*+]\s*\[[ xX]\]|#{1,6}\s)/;
1313
+ for (const line of scope.split(/\r?\n/)) {
1314
+ if (!isChecklistOrHeading.test(line))
1315
+ continue;
1316
+ const strikeSpan = /~~([^~]*?)~~/g;
1317
+ let s;
1318
+ while ((s = strikeSpan.exec(line)) !== null) {
1319
+ const phaseRef = /^[\s*_]*Phase\s+([\w][\w.-]*)/i.exec(s[1]);
1320
+ // Require a digit so struck prose like ~~Phase Overview~~ is ignored.
1321
+ if (phaseRef && /\d/.test(phaseRef[1]))
1322
+ retired.add(phaseKeyFromToken(phaseRef[1]));
1323
+ }
1324
+ }
1325
+ return retired;
1326
+ }
1195
1327
  /**
1196
1328
  * Extract machine-readable fields from STATE.md markdown body and build
1197
1329
  * a YAML frontmatter object. Allows hooks and scripts to read state
@@ -1242,6 +1374,21 @@ function buildStateFrontmatter(bodyContent, cwd) {
1242
1374
  // on repeated buildStateFrontmatter invocations within the same process (#1967)
1243
1375
  let cached = _diskScanCache.get(cwd);
1244
1376
  if (!cached) {
1377
+ // Read the current-milestone ROADMAP scope once: it feeds both the
1378
+ // heading-based phase count below and the retired/folded-phase
1379
+ // exclusion (#1514). Computed before the disk scan so retired phases
1380
+ // can be dropped from the dir set too.
1381
+ let roadmapScope = null;
1382
+ let retiredPhaseNums = new Set();
1383
+ try {
1384
+ const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
1385
+ const roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
1386
+ if (roadmapRaw !== null) {
1387
+ roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
1388
+ retiredPhaseNums = extractRetiredPhaseNumbers(roadmapScope);
1389
+ }
1390
+ }
1391
+ catch { /* fall through: no roadmap scope → no retired exclusion */ }
1245
1392
  const isDirInMilestone = getMilestonePhaseFilter(cwd);
1246
1393
  const allMatchingDirs = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
1247
1394
  .filter(e => e.isDirectory()).map(e => e.name)
@@ -1252,6 +1399,12 @@ function buildStateFrontmatter(bodyContent, cwd) {
1252
1399
  // modified dir. This prevents double-counting (e.g. two "Phase 1" dirs).
1253
1400
  const seenPhaseNums = new Map(); // normalizedNum -> dirName
1254
1401
  for (const dir of allMatchingDirs) {
1402
+ // #1514: a retired/folded phase keeps a directory but no completion
1403
+ // artifact; drop it from the disk phase set so it counts toward
1404
+ // neither the denominator nor the numerator (mirrors the heading
1405
+ // exclusion below). Project-code-aware via phaseKeyFromDir.
1406
+ if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir)))
1407
+ continue;
1255
1408
  const m = dir.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/);
1256
1409
  const key = m ? m[1].toLowerCase() : dir;
1257
1410
  if (!seenPhaseNums.has(key)) {
@@ -1287,24 +1440,23 @@ function buildStateFrontmatter(bodyContent, cwd) {
1287
1440
  // `## Phase Overview:` or `## Phase Details:` — single source of
1288
1441
  // truth for total_phases (#549).
1289
1442
  let roadmapPhaseCount = 0;
1290
- try {
1291
- const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
1292
- const roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
1293
- if (roadmapRaw !== null) {
1294
- const roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
1295
- const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
1296
- let m;
1297
- while ((m = phaseHeadingPattern.exec(roadmapScope)) !== null) {
1298
- // Only count tokens that contain at least one digit — excludes
1299
- // pure-word section headings (Overview, Details) while keeping
1300
- // numeric phases (01, 05.1) and project-code IDs (PROJ-42).
1301
- // Also exclude 999.x backlog phases. Mirrors init.cts filter.
1302
- if (/\d/.test(m[1]) && !/^999\b/.test(m[1]))
1303
- roadmapPhaseCount++;
1304
- }
1443
+ if (roadmapScope !== null) {
1444
+ const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
1445
+ let m;
1446
+ while ((m = phaseHeadingPattern.exec(roadmapScope)) !== null) {
1447
+ // Only count tokens that contain at least one digit — excludes
1448
+ // pure-word section headings (Overview, Details) while keeping
1449
+ // numeric phases (01, 05.1) and project-code IDs (PROJ-42).
1450
+ // Also exclude 999.x backlog phases. Mirrors init.cts filter.
1451
+ if (!/\d/.test(m[1]) || /^999\b/.test(m[1]))
1452
+ continue;
1453
+ // #1514: retired/folded phases are struck through in the ROADMAP;
1454
+ // exclude them from the denominator (they can never be completed).
1455
+ if (retiredPhaseNums.has(phaseKeyFromToken(m[1])))
1456
+ continue;
1457
+ roadmapPhaseCount++;
1305
1458
  }
1306
1459
  }
1307
- catch { /* fall through: phaseDirs.length used as sole count */ }
1308
1460
  cached = {
1309
1461
  totalPhases: roadmapPhaseCount > 0
1310
1462
  ? Math.max(phaseDirs.length, roadmapPhaseCount)
@@ -1486,8 +1638,23 @@ function acquireStateLock(statePath, clock) {
1486
1638
  clock = clock_cjs_1.realClock;
1487
1639
  const lockPath = statePath + '.lock';
1488
1640
  const retryDelay = 200; // ms
1489
- const staleThresholdMs = 10000;
1490
1641
  const maxWaitMs = 30000;
1642
+ // Deadman ceiling (audit M1) — set ABOVE maxWaitMs so a holder that reads as
1643
+ // VERIFIED-LIVE is NEVER stolen within the wait budget; only a crashed (dead
1644
+ // pid) or unparseable-body lock is stolen, and a pid-reuse holder (reads alive
1645
+ // but is unrelated) is recovered once age crosses this absolute ceiling rather
1646
+ // than blocking forever. The prior mtime-only `staleThresholdMs = 10000` gate
1647
+ // was BELOW maxWaitMs, so a live-but-slow holder >10 s was robbed mid-write.
1648
+ const deadmanCeilingMs = 60000;
1649
+ // Fresh-create floor (PR #1532 review, window a) — a lock with an EMPTY/unparseable
1650
+ // body is either mid-creation (O_EXCL create done, pid not yet written by the holder)
1651
+ // or a genuine orphan. While such a body is younger than this floor it is treated as
1652
+ // mid-creation and is NEVER stolen — stealing it at age ≈ 0 robs a holder still
1653
+ // writing its pid (the lost-update window capability-lock.cts's `age <= LOCK_STALE_MS`
1654
+ // floor closes). The create→write gap is sub-millisecond; this floor is orders of
1655
+ // magnitude larger yet well under maxWaitMs so a real orphan still clears within budget.
1656
+ // A COMPLETE dead-pid body is NOT subject to this floor — it is stolen promptly.
1657
+ const freshCreateFloorMs = 1000;
1491
1658
  const startedAt = clock.now();
1492
1659
  // Shared helper: check the time budget then back off with jitter before the
1493
1660
  // next retry. Both the EEXIST contention path and the recoverable-errno path
@@ -1502,11 +1669,42 @@ function acquireStateLock(statePath, clock) {
1502
1669
  const jitter = Math.floor(Math.random() * 50);
1503
1670
  clock.sleep(retryDelay + jitter);
1504
1671
  };
1672
+ let _loopIteration = 0;
1505
1673
  while (true) {
1674
+ if (_stateLockTestHooks.onLoopIteration)
1675
+ _stateLockTestHooks.onLoopIteration({ iteration: _loopIteration++ });
1506
1676
  try {
1507
1677
  const fd = node_fs_1.default.openSync(lockPath, node_fs_1.default.constants.O_CREAT | node_fs_1.default.constants.O_EXCL | node_fs_1.default.constants.O_WRONLY);
1508
- node_fs_1.default.writeSync(fd, String(process.pid));
1509
- node_fs_1.default.closeSync(fd);
1678
+ // Audit M9 (resource-safety): once the exclusive create SUCCEEDS, a
1679
+ // writeSync/closeSync failure must NOT leak the fd or strand the just-created
1680
+ // (now empty) lock — an orphan body self-blocks every later acquirer until a
1681
+ // liveness steal or the deadman. On any write/close error, guardedly close the
1682
+ // fd and unlink the file we created, then re-throw to the existing outer catch
1683
+ // (which keeps classifying recoverable vs fatal errnos — DRY). A FATAL errno
1684
+ // still propagates after cleanup; a RECOVERABLE one retries from a clean slate.
1685
+ // Mirrors capability-lock.cts:415-425.
1686
+ try {
1687
+ const injected = _consumeSimulatedWriteError();
1688
+ if (injected)
1689
+ throw injected; // test seam: one-shot writeSync failure (M9)
1690
+ node_fs_1.default.writeSync(fd, String(process.pid));
1691
+ node_fs_1.default.closeSync(fd);
1692
+ }
1693
+ catch (writeErr) {
1694
+ try {
1695
+ node_fs_1.default.closeSync(fd);
1696
+ }
1697
+ catch { /* best-effort — fd may already be closed */ }
1698
+ // Best-effort unlink of the lock WE just created. Guarded so we never throw
1699
+ // here; if another acquirer already stole the empty lock the unlink is a
1700
+ // harmless ENOENT no-op (we do not double-unlink someone else's lock — the
1701
+ // open(O_EXCL) above guarantees we created this path this iteration).
1702
+ try {
1703
+ node_fs_1.default.unlinkSync(lockPath);
1704
+ }
1705
+ catch { /* best-effort — no orphan */ }
1706
+ throw writeErr; // re-throw to the outer catch for recoverable/fatal classification
1707
+ }
1510
1708
  // Exit-time cleanup keeps a crashed locked region from leaving a stale file (#1916).
1511
1709
  _heldStateLocks.add(lockPath);
1512
1710
  return lockPath;
@@ -1522,36 +1720,91 @@ function acquireStateLock(statePath, clock) {
1522
1720
  }
1523
1721
  if (err.code !== 'EEXIST')
1524
1722
  throw err; // propagate — silent bypass causes lost updates
1525
- // Only unlink a lock we did not place when it has crossed the staleness
1526
- // threshold (crashed holder). Nuking a fresh lock held by a slow-but-live
1527
- // writer causes lost updates (#3711 regression).
1723
+ // Liveness-gated steal (audit M1) + steal-safety (PR #1532 review). The steal
1724
+ // decision is three-way on the lock body:
1725
+ // - VERIFIED-LIVE holder (parseable pid that signals alive): NEVER stolen until
1726
+ // its age crosses the absolute deadman ceiling (the pid-reuse backstop) —
1727
+ // nuking a slow-but-live writer's lock causes lost updates (#3711 / #500/#905/
1728
+ // #1230 family).
1729
+ // - COMPLETE DEAD pid (parseable pid, not alive): stolen PROMPTLY regardless of
1730
+ // age — a crashed holder left a full body.
1731
+ // - EMPTY / unparseable body: liveness is unknowable. While FRESH (age <=
1732
+ // freshCreateFloorMs) it is a lock still mid-creation (O_EXCL done, pid not yet
1733
+ // written) and is NOT stolen (window a); only once aged past the floor is it a
1734
+ // genuine orphan and stealable.
1735
+ // The steal itself is an ATOMIC rename-then-recreate (only one racer can rename the
1736
+ // inode) guarded by an identity re-confirm, so a racer that recreates a fresh lock
1737
+ // in the decision→steal gap never has its replacement deleted (window b). Mirrors
1738
+ // capability-lock.cts:455-499.
1528
1739
  try {
1529
1740
  const stat = node_fs_1.default.statSync(lockPath);
1530
- if ((clock).now() - stat.mtimeMs > staleThresholdMs) {
1531
- let removed = false;
1741
+ const ageMs = clock.now() - stat.mtimeMs;
1742
+ const bodyPid = _stateLockBodyPid(lockPath);
1743
+ const holderLive = bodyPid !== null && _stateLockIsPidAlive(bodyPid);
1744
+ let steal;
1745
+ if (holderLive) {
1746
+ steal = ageMs > deadmanCeilingMs; // pid-reuse backstop only
1747
+ }
1748
+ else if (bodyPid !== null) {
1749
+ steal = true; // complete dead pid → prompt steal
1750
+ }
1751
+ else {
1752
+ steal = ageMs > freshCreateFloorMs; // empty/garbage → protect the create window
1753
+ }
1754
+ if (steal) {
1755
+ if (_stateLockTestHooks.beforeSteal)
1756
+ _stateLockTestHooks.beforeSteal({ lockPath });
1757
+ // Identity re-confirm immediately before the steal: a racer that stole +
1758
+ // recreated a fresh lock in the decision→steal gap changes (dev, ino) and/or
1759
+ // the body pid → do NOT delete the replacement; re-evaluate from scratch.
1760
+ let confirmStat;
1532
1761
  try {
1533
- node_fs_1.default.unlinkSync(lockPath);
1534
- removed = true;
1762
+ confirmStat = node_fs_1.default.statSync(lockPath);
1763
+ }
1764
+ catch {
1765
+ continue; // lock vanished between decision and steal — retry the create.
1535
1766
  }
1536
- catch { /* swallow: bounded below */ }
1537
- if (removed) {
1767
+ const sameInstance = typeof stat.dev === 'number' && typeof stat.ino === 'number' &&
1768
+ confirmStat.dev === stat.dev && confirmStat.ino === stat.ino &&
1769
+ _stateLockBodyPid(lockPath) === bodyPid;
1770
+ if (!sameInstance) {
1771
+ // The lock changed under us (a racer won the steal + recreated). Back off
1772
+ // and re-evaluate rather than deleting the racer's fresh replacement.
1773
+ checkBudgetAndSleep('lock changed before steal');
1774
+ continue;
1775
+ }
1776
+ // Atomic steal: rename the inode aside, then remove it. Only ONE racer can
1777
+ // win the rename; a failed rename means another process already stole it, so
1778
+ // we must NOT fall through to a delete — back off and retry the create.
1779
+ const stolen = lockPath + '.stale-' + process.pid + '-' + clock.now() + '-' + (_stateStealSeq++);
1780
+ let renamed = false;
1781
+ try {
1782
+ node_fs_1.default.renameSync(lockPath, stolen);
1783
+ renamed = true;
1784
+ }
1785
+ catch { /* another racer won */ }
1786
+ if (renamed) {
1787
+ try {
1788
+ node_fs_1.default.rmSync(stolen, { force: true });
1789
+ }
1790
+ catch { /* best-effort */ }
1538
1791
  // Successful steal — retry immediately to grab the just-freed lock.
1539
- // Must NOT call checkBudgetAndSleep here: a throw-after-delete would
1540
- // corrupt the filesystem state, and the budget is already bounded on
1541
- // the next iteration's EEXIST or open attempt (#1217 regression fix).
1792
+ // Must NOT call checkBudgetAndSleep here: a throw-after-rename would
1793
+ // corrupt filesystem state, and the budget is already bounded on the next
1794
+ // iteration's EEXIST or open attempt (#1217 regression fix).
1542
1795
  continue;
1543
1796
  }
1544
- // Persistent unlinkSync failure — apply budget + backoff so it cannot
1545
- // busy-spin (#1217).
1546
- checkBudgetAndSleep('stale lock removal failed');
1797
+ // Lost the steal race (or a transient rename failure) — apply budget + backoff
1798
+ // so it cannot busy-spin (#1217).
1799
+ checkBudgetAndSleep('stale lock steal lost to racer');
1547
1800
  continue;
1548
1801
  }
1549
1802
  }
1550
1803
  catch (err) {
1551
- // Re-throw a budget-exceeded error from the unlinkSync failure path above
1552
- // unchanged — its message already names the real cause ("stale lock removal
1553
- // failed") and double-wrapping it would replace that with the misleading
1554
- // "statSync failed after EEXIST" context string (#1217 diagnostic fix).
1804
+ // Re-throw a budget-exceeded error from the steal path above unchanged — its
1805
+ // message already names the real cause ("lock changed before steal" / "stale
1806
+ // lock steal lost to racer") and double-wrapping it would replace that with the
1807
+ // misleading "statSync failed after EEXIST" context string (#1217 diagnostic fix).
1555
1808
  if (err?.lockBudgetExceeded)
1556
1809
  throw err;
1557
1810
  // statSync failed — lock was likely released between our EEXIST and this
@@ -1593,14 +1846,26 @@ function withStateLock(statePath, fn) {
1593
1846
  * Optional clock seam; defaults to realClock. Passed through to acquireStateLock.
1594
1847
  */
1595
1848
  function writeStateMd(statePath, content, cwd, clock) {
1596
- // Invalidate disk scan cache before computing new frontmatter — the write
1597
- // may create new PLAN/SUMMARY files that buildStateFrontmatter must see.
1598
- // Safe for any calling pattern, not just short-lived CLI processes (#1967).
1599
- if (cwd)
1600
- _diskScanCache.delete(cwd);
1601
- const synced = syncStateFrontmatter(content, cwd);
1602
1849
  const lockPath = acquireStateLock(statePath, clock);
1850
+ // Test seam (audit M8): fire AFTER the lock is taken so a test can simulate a
1851
+ // concurrent writer landing in the (now-closed) scan→lock window.
1852
+ if (_stateLockTestHooks.afterAcquire)
1853
+ _stateLockTestHooks.afterAcquire(lockPath);
1603
1854
  try {
1855
+ // Audit M8 (leaky-abstractions): the disk scan that counts PLAN/SUMMARY files
1856
+ // to build the frontmatter is the READ half of this read-modify-write — it must
1857
+ // run INSIDE the lock (mirroring readModifyWriteStateMd), not before it. Scanning
1858
+ // before acquireStateLock left a TOCTOU window where a concurrent writer that
1859
+ // committed a new PLAN/SUMMARY between our scan and our lock made writeStateMd
1860
+ // stamp STALE progress counts (lost update — the #500/#905/#1230 family). The
1861
+ // scan order is otherwise byte-for-behaviour identical for single-threaded
1862
+ // callers — only the concurrent-writer window closes.
1863
+ //
1864
+ // Invalidate the disk scan cache first — the write may create new PLAN/SUMMARY
1865
+ // files that buildStateFrontmatter must see (#1967).
1866
+ if (cwd)
1867
+ _diskScanCache.delete(cwd);
1868
+ const synced = syncStateFrontmatter(content, cwd);
1604
1869
  (0, shell_command_projection_cjs_1.platformWriteSync)(statePath, synced);
1605
1870
  }
1606
1871
  finally {
@@ -2030,16 +2295,20 @@ function cmdSignalResume(cwd, raw) {
2030
2295
  * Returns modified content string.
2031
2296
  */
2032
2297
  function updatePerformanceMetricsSection(content, cwd, phaseNum, planCount, summaryCount) {
2033
- // Update Velocity: Total plans completed
2034
- const totalMatch = content.match(/Total plans completed:\s*(\d+|\[N\])/);
2035
- const prevTotal = totalMatch && totalMatch[1] !== '[N]' ? parseInt(totalMatch[1], 10) : 0;
2036
- const newTotal = prevTotal + summaryCount;
2037
- content = content.replace(/Total plans completed:\s*(\d+|\[N\])/, `Total plans completed: ${newTotal}`);
2038
- // Update By Phase table — upsert row for this phase
2298
+ // By Phase table — upsert the row for THIS phase FIRST. The velocity total is then
2299
+ // DERIVED from the table's Plans column so it stays idempotent on re-run: completing
2300
+ // the same phase again upserts the same row, so the column sum is stable. The previous
2301
+ // blind-add (prevTotal + summaryCount) re-read the cumulative total each call and
2302
+ // double-counted on every re-run. (#1582)
2039
2303
  const byPhaseMatch = content.match(byPhaseTablePattern);
2040
2304
  if (byPhaseMatch) {
2041
2305
  let tableBody = byPhaseMatch[2].trim();
2042
- const phaseRowPattern = new RegExp(`^\\|\\s*${escapeRegex(String(phaseNum))}\\s*\\|.*$`, 'm');
2306
+ // Match the existing row for this phase, tolerating leading-zero padding in either
2307
+ // direction (#1659): canonicalize a numeric phase to its integer form so a seeded
2308
+ // "| 05 |" row is upserted (not duplicated) by `phase complete 5`, and vice-versa.
2309
+ const phaseNumStr = String(phaseNum);
2310
+ const canonCell = /^\d+$/.test(phaseNumStr) ? `0*${Number(phaseNumStr)}` : escapeRegex(phaseNumStr);
2311
+ const phaseRowPattern = new RegExp(`^\\|\\s*${canonCell}\\s*\\|.*$`, 'm');
2043
2312
  const newRow = `| ${phaseNum} | ${summaryCount} | - | - |`;
2044
2313
  if (phaseRowPattern.test(tableBody)) {
2045
2314
  // Update existing row
@@ -2052,6 +2321,28 @@ function updatePerformanceMetricsSection(content, cwd, phaseNum, planCount, summ
2052
2321
  }
2053
2322
  content = content.replace(byPhaseTablePattern, (_match, tableHeader) => `${tableHeader}${tableBody}\n`);
2054
2323
  }
2324
+ // Velocity: Total plans completed — DERIVED as the sum of the By-Phase Plans column
2325
+ // (the second cell) across all data rows. Idempotent by construction (re-running phase
2326
+ // complete upserts the same row → same sum) and self-healing (a hand-edited inflated
2327
+ // total is corrected to the true sum on the next completion). When the By-Phase table
2328
+ // is absent, leave the velocity total unchanged rather than guess. (#1582)
2329
+ if (/Total plans completed:\s*(\d+|\[N\])/.test(content)) {
2330
+ const tableForSum = content.match(byPhaseTablePattern);
2331
+ if (tableForSum) {
2332
+ let sum = 0;
2333
+ for (const row of tableForSum[2].split(/\r?\n/)) {
2334
+ // Data rows look like `| <phase> | <plans> | … |`, optionally indented (the
2335
+ // byPhaseTablePattern data-row capture allows `[ \t]*` leading whitespace, so the
2336
+ // sum must too or hand-edited/legacy indented rows are silently skipped — #1582
2337
+ // codex review). Header (`| Phase | Plans | …`) and separator (`| --- | --- | …`)
2338
+ // rows have a non-numeric second cell and are skipped; non-numeric cells → 0.
2339
+ const cellMatch = row.match(/^\s*\|\s*[^|]+\s*\|\s*(\d+)\s*\|/);
2340
+ if (cellMatch)
2341
+ sum += parseInt(cellMatch[1], 10);
2342
+ }
2343
+ content = content.replace(/Total plans completed:\s*(\d+|\[N\])/, `Total plans completed: ${sum}`);
2344
+ }
2345
+ }
2055
2346
  return content;
2056
2347
  }
2057
2348
  /**
@@ -2274,12 +2565,27 @@ function cmdStateSync(cwd, options, raw) {
2274
2565
  output({ synced: true, changes: [], dry_run: !!verify }, raw, undefined);
2275
2566
  return;
2276
2567
  }
2568
+ // #1514: read the current-milestone ROADMAP scope once so retired/folded
2569
+ // phases are excluded from BOTH the disk scan and the heading count here,
2570
+ // exactly as buildStateFrontmatter does — otherwise `state sync --verify`
2571
+ // would keep re-deriving the inflated denominator and report "no drift".
2572
+ let syncRoadmapScope = null;
2573
+ let syncRetiredPhaseNums = new Set();
2574
+ try {
2575
+ const roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(planningDir(cwd), 'ROADMAP.md'));
2576
+ if (roadmapRaw !== null) {
2577
+ syncRoadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
2578
+ syncRetiredPhaseNums = extractRetiredPhaseNumbers(syncRoadmapScope);
2579
+ }
2580
+ }
2581
+ catch { /* fall through: no roadmap scope → no retired exclusion */ }
2277
2582
  // Scan all phases
2278
2583
  let entries;
2279
2584
  try {
2280
2585
  entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
2281
2586
  .filter(e => e.isDirectory())
2282
2587
  .map(e => e.name)
2588
+ .filter(name => !(syncRetiredPhaseNums.size > 0 && syncRetiredPhaseNums.has(phaseKeyFromDir(name))))
2283
2589
  .sort();
2284
2590
  }
2285
2591
  catch {
@@ -2324,18 +2630,19 @@ function cmdStateSync(cwd, options, raw) {
2324
2630
  let syncTotalPhases = null;
2325
2631
  try {
2326
2632
  let roadmapPhaseCount = 0;
2327
- const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
2328
- const roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
2329
- if (roadmapRaw !== null) {
2330
- const roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
2633
+ if (syncRoadmapScope !== null) {
2331
2634
  const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
2332
2635
  let m;
2333
- while ((m = phaseHeadingPattern.exec(roadmapScope)) !== null) {
2636
+ while ((m = phaseHeadingPattern.exec(syncRoadmapScope)) !== null) {
2334
2637
  // Only count tokens that contain at least one digit — excludes
2335
2638
  // pure-word section headings (Overview, Details) while keeping
2336
2639
  // numeric phases (01, 05.1) and project-code IDs (PROJ-42).
2337
- if (/\d/.test(m[1]))
2338
- roadmapPhaseCount++;
2640
+ if (!/\d/.test(m[1]))
2641
+ continue;
2642
+ // #1514: retired/folded phases are struck through; exclude from total.
2643
+ if (syncRetiredPhaseNums.has(phaseKeyFromToken(m[1])))
2644
+ continue;
2645
+ roadmapPhaseCount++;
2339
2646
  }
2340
2647
  }
2341
2648
  if (roadmapPhaseCount > 0) {
@@ -2740,4 +3047,35 @@ module.exports = {
2740
3047
  cmdStateMilestoneSwitch,
2741
3048
  cmdSignalWaiting,
2742
3049
  cmdSignalResume,
3050
+ // Test seam (#1514): the pure retired/folded-phase parser, exposed so its
3051
+ // strikethrough-detection logic can be property-tested directly.
3052
+ _extractRetiredPhaseNumbers: extractRetiredPhaseNumbers,
3053
+ // Test seam (audit M1): inject a deterministic isPidAlive so the liveness-gated
3054
+ // steal decision is exercised without real pids. Mirrors capability-lock.cts.
3055
+ _setLockProbes(probes) {
3056
+ if (typeof probes.isPidAlive === 'function')
3057
+ _stateLockProbes.isPidAlive = probes.isPidAlive;
3058
+ },
3059
+ _resetLockProbes() {
3060
+ _stateLockProbes.isPidAlive = _realIsPidAlive;
3061
+ },
3062
+ // Test seam (audit M8/M9): inject deterministic hooks for the scan-in-lock window
3063
+ // (afterAcquire), the one-shot recoverable writeSync failure (simulateWriteError),
3064
+ // and per-iteration orphan-lock snapshots (onLoopIteration). See _stateLockTestHooks.
3065
+ _setStateLockTestHooks(hooks) {
3066
+ if ('afterAcquire' in hooks)
3067
+ _stateLockTestHooks.afterAcquire = hooks.afterAcquire;
3068
+ if ('simulateWriteError' in hooks)
3069
+ _stateLockTestHooks.simulateWriteError = hooks.simulateWriteError;
3070
+ if ('onLoopIteration' in hooks)
3071
+ _stateLockTestHooks.onLoopIteration = hooks.onLoopIteration;
3072
+ if ('beforeSteal' in hooks)
3073
+ _stateLockTestHooks.beforeSteal = hooks.beforeSteal;
3074
+ },
3075
+ _resetStateLockTestHooks() {
3076
+ delete _stateLockTestHooks.afterAcquire;
3077
+ delete _stateLockTestHooks.simulateWriteError;
3078
+ delete _stateLockTestHooks.onLoopIteration;
3079
+ delete _stateLockTestHooks.beforeSteal;
3080
+ },
2743
3081
  };