@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.
- package/.claude-plugin/plugin.json +2 -1
- package/agents/gsd-advisor-researcher.md +2 -0
- package/agents/gsd-ai-researcher.md +2 -0
- package/agents/gsd-assumptions-analyzer.md +2 -0
- package/agents/gsd-doc-classifier.md +2 -0
- package/agents/gsd-doc-synthesizer.md +2 -0
- package/agents/gsd-domain-researcher.md +2 -0
- package/agents/gsd-eval-auditor.md +6 -9
- package/agents/gsd-phase-researcher.md +2 -0
- package/agents/gsd-planner.md +8 -57
- package/agents/gsd-project-researcher.md +2 -0
- package/agents/gsd-research-synthesizer.md +2 -0
- package/agents/gsd-security-auditor.md +37 -18
- package/agents/gsd-ui-researcher.md +2 -0
- package/bin/install.js +370 -18
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +46 -4
- package/gsd-core/bin/lib/audit-command-router.cjs +52 -14
- package/gsd-core/bin/lib/capability-lifecycle.cjs +30 -7
- package/gsd-core/bin/lib/capability-registry.cjs +96 -83
- package/gsd-core/bin/lib/capability-validator.cjs +22 -0
- package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +40 -2
- package/gsd-core/bin/lib/command-aliases.cjs +10 -1
- package/gsd-core/bin/lib/command-routing-hub.cjs +10 -3
- package/gsd-core/bin/lib/config-schema.cjs +1 -0
- package/gsd-core/bin/lib/config.cjs +67 -24
- package/gsd-core/bin/lib/coverage.cjs +464 -0
- package/gsd-core/bin/lib/decisions.cjs +27 -0
- package/gsd-core/bin/lib/eval-command-router.cjs +21 -0
- package/gsd-core/bin/lib/eval.cjs +60 -0
- package/gsd-core/bin/lib/frontmatter.cjs +132 -13
- package/gsd-core/bin/lib/graphify-command-router.cjs +53 -36
- package/gsd-core/bin/lib/init.cjs +139 -30
- package/gsd-core/bin/lib/install-profiles.cjs +6 -3
- package/gsd-core/bin/lib/intel-command-router.cjs +79 -60
- package/gsd-core/bin/lib/io.cjs +1 -0
- package/gsd-core/bin/lib/phase.cjs +16 -2
- package/gsd-core/bin/lib/plan-scan.cjs +2 -2
- package/gsd-core/bin/lib/planning-workspace.cjs +157 -13
- package/gsd-core/bin/lib/profile-output.cjs +18 -6
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +53 -16
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +21 -3
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +15 -0
- package/gsd-core/bin/lib/runtime-name-policy.cjs +47 -2
- package/gsd-core/bin/lib/shell-command-projection.cjs +10 -0
- package/gsd-core/bin/lib/state.cjs +398 -60
- package/gsd-core/bin/lib/surface.cjs +42 -10
- package/gsd-core/bin/lib/uat-predicate.cjs +13 -6
- package/gsd-core/bin/lib/update-context.cjs +2 -2
- package/gsd-core/bin/lib/verification.cjs +67 -6
- package/gsd-core/bin/lib/verify.cjs +8 -1
- package/gsd-core/bin/shared/config-defaults.manifest.json +3 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +2 -1
- package/gsd-core/references/planner-guidance.md +66 -0
- package/gsd-core/references/planning-config.md +2 -2
- package/gsd-core/references/security-asvs-levels.md +27 -0
- package/gsd-core/references/untrusted-input-boundary.md +13 -0
- package/gsd-core/templates/SECURITY.md +6 -4
- package/gsd-core/templates/summary-complex.md +4 -0
- package/gsd-core/templates/summary-minimal.md +3 -0
- package/gsd-core/templates/summary-standard.md +4 -0
- package/gsd-core/templates/summary.md +41 -0
- package/gsd-core/workflows/autonomous.md +53 -46
- package/gsd-core/workflows/complete-milestone.md +27 -8
- package/gsd-core/workflows/execute-phase.md +1 -1
- package/gsd-core/workflows/execute-plan.md +5 -0
- package/gsd-core/workflows/manager.md +17 -7
- package/gsd-core/workflows/new-project.md +82 -16
- package/gsd-core/workflows/plan-phase.md +15 -0
- package/gsd-core/workflows/profile-user.md +6 -2
- package/gsd-core/workflows/progress.md +37 -4
- package/gsd-core/workflows/quick.md +3 -1
- package/gsd-core/workflows/secure-phase.md +13 -7
- package/gsd-core/workflows/ship.md +3 -1
- package/gsd-core/workflows/spec-phase.md +3 -1
- package/gsd-core/workflows/transition.md +14 -12
- package/gsd-core/workflows/ui-review.md +2 -6
- package/gsd-core/workflows/verify-work.md +74 -1
- package/hooks/dist/gsd-read-injection-scanner.js +49 -25
- package/hooks/gsd-read-injection-scanner.js +49 -25
- package/hooks/hooks.json +1 -1
- package/package.json +4 -2
- package/scripts/check-alias-drift.cjs +5 -0
- package/scripts/gen-plugin-skills.cjs +117 -0
- package/scripts/lint-test-file-count.allowlist.json +2 -1
- package/scripts/prompt-injection-scan.sh +9 -0
- package/scripts/release-notes/conventional-title.cjs +88 -0
- package/scripts/release-notes/format-github-release-notes.cjs +4 -3
- package/skills/gsd-add-tests/SKILL.md +38 -0
- package/skills/gsd-ai-integration-phase/SKILL.md +37 -0
- package/skills/gsd-audit-fix/SKILL.md +33 -0
- package/skills/gsd-audit-milestone/SKILL.md +37 -0
- package/skills/gsd-audit-uat/SKILL.md +25 -0
- package/skills/gsd-autonomous/SKILL.md +51 -0
- package/skills/gsd-capture/SKILL.md +67 -0
- package/skills/gsd-cleanup/SKILL.md +24 -0
- package/skills/gsd-code-review/SKILL.md +59 -0
- package/skills/gsd-complete-milestone/SKILL.md +142 -0
- package/skills/gsd-config/SKILL.md +56 -0
- package/skills/gsd-debug/SKILL.md +53 -0
- package/skills/gsd-discuss-phase/SKILL.md +77 -0
- package/skills/gsd-docs-update/SKILL.md +49 -0
- package/skills/gsd-eval-review/SKILL.md +33 -0
- package/skills/gsd-execute-phase/SKILL.md +65 -0
- package/skills/gsd-explore/SKILL.md +28 -0
- package/skills/gsd-extract-learnings/SKILL.md +22 -0
- package/skills/gsd-fast/SKILL.md +31 -0
- package/skills/gsd-forensics/SKILL.md +56 -0
- package/skills/gsd-graphify/SKILL.md +204 -0
- package/skills/gsd-health/SKILL.md +31 -0
- package/skills/gsd-help/SKILL.md +29 -0
- package/skills/gsd-import/SKILL.md +46 -0
- package/skills/gsd-inbox/SKILL.md +39 -0
- package/skills/gsd-ingest-docs/SKILL.md +43 -0
- package/skills/gsd-manager/SKILL.md +45 -0
- package/skills/gsd-map-codebase/SKILL.md +83 -0
- package/skills/gsd-mempalace-capture/SKILL.md +71 -0
- package/skills/gsd-mempalace-recall/SKILL.md +102 -0
- package/skills/gsd-milestone-summary/SKILL.md +51 -0
- package/skills/gsd-mvp-phase/SKILL.md +45 -0
- package/skills/gsd-new-milestone/SKILL.md +45 -0
- package/skills/gsd-new-project/SKILL.md +47 -0
- package/skills/gsd-ns-context/SKILL.md +24 -0
- package/skills/gsd-ns-ideate/SKILL.md +23 -0
- package/skills/gsd-ns-manage/SKILL.md +35 -0
- package/skills/gsd-ns-project/SKILL.md +26 -0
- package/skills/gsd-ns-review/SKILL.md +28 -0
- package/skills/gsd-ns-workflow/SKILL.md +33 -0
- package/skills/gsd-pause-work/SKILL.md +43 -0
- package/skills/gsd-phase/SKILL.md +57 -0
- package/skills/gsd-plan-phase/SKILL.md +63 -0
- package/skills/gsd-plan-review-convergence/SKILL.md +60 -0
- package/skills/gsd-pr-branch/SKILL.md +26 -0
- package/skills/gsd-profile-user/SKILL.md +47 -0
- package/skills/gsd-progress/SKILL.md +49 -0
- package/skills/gsd-quick/SKILL.md +174 -0
- package/skills/gsd-resume-work/SKILL.md +31 -0
- package/skills/gsd-review/SKILL.md +42 -0
- package/skills/gsd-review-backlog/SKILL.md +63 -0
- package/skills/gsd-secure-phase/SKILL.md +36 -0
- package/skills/gsd-settings/SKILL.md +29 -0
- package/skills/gsd-ship/SKILL.md +24 -0
- package/skills/gsd-sketch/SKILL.md +60 -0
- package/skills/gsd-spec-phase/SKILL.md +63 -0
- package/skills/gsd-spike/SKILL.md +57 -0
- package/skills/gsd-stats/SKILL.md +20 -0
- package/skills/gsd-surface/SKILL.md +162 -0
- package/skills/gsd-thread/SKILL.md +24 -0
- package/skills/gsd-ui-phase/SKILL.md +35 -0
- package/skills/gsd-ui-review/SKILL.md +33 -0
- package/skills/gsd-ultraplan-phase/SKILL.md +34 -0
- package/skills/gsd-undo/SKILL.md +35 -0
- package/skills/gsd-update/SKILL.md +50 -0
- package/skills/gsd-validate-phase/SKILL.md +36 -0
- package/skills/gsd-verify-work/SKILL.md +39 -0
- package/skills/gsd-workspace/SKILL.md +53 -0
- 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
|
-
|
|
1291
|
-
const
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
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
|
-
|
|
1509
|
-
|
|
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
|
-
//
|
|
1526
|
-
//
|
|
1527
|
-
//
|
|
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
|
-
|
|
1531
|
-
|
|
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.
|
|
1534
|
-
|
|
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
|
-
|
|
1537
|
-
|
|
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-
|
|
1540
|
-
// corrupt
|
|
1541
|
-
//
|
|
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
|
-
//
|
|
1545
|
-
// busy-spin (#1217).
|
|
1546
|
-
checkBudgetAndSleep('stale lock
|
|
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
|
|
1552
|
-
//
|
|
1553
|
-
//
|
|
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
|
-
//
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
2338
|
-
|
|
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
|
};
|