@opengsd/gsd-core 1.5.0-rc.5 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/LICENSE +1 -1
- package/agents/gsd-executor.md +22 -0
- package/bin/install.js +53 -99
- package/commands/gsd/autonomous.md +1 -1
- package/commands/gsd/execute-phase.md +1 -1
- package/commands/gsd/plan-phase.md +1 -1
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +36 -5
- package/gsd-core/bin/lib/decisions.cjs +19 -1
- package/gsd-core/bin/lib/phase-id.cjs +1 -1
- package/gsd-core/bin/lib/phase.cjs +41 -6
- package/gsd-core/bin/lib/probe-core.cjs +7 -0
- package/gsd-core/bin/lib/prohibition-enforcement.cjs +201 -26
- package/gsd-core/bin/lib/roadmap-parser.cjs +25 -20
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +4 -1
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +24 -3
- package/gsd-core/bin/lib/state.cjs +64 -15
- package/gsd-core/bin/lib/teams-status.cjs +74 -0
- package/gsd-core/bin/lib/uat.cjs +56 -0
- package/gsd-core/bin/lib/worktree-safety.cjs +7 -1
- package/gsd-core/references/prohibition-probe.md +48 -19
- package/gsd-core/references/worktree-branch-check.md +11 -5
- package/gsd-core/workflows/docs-update.md +23 -31
- package/gsd-core/workflows/execute-phase.md +2 -1
- package/gsd-core/workflows/map-codebase.md +8 -10
- package/gsd-core/workflows/plan-phase.md +6 -0
- package/gsd-core/workflows/quick.md +23 -2
- package/gsd-core/workflows/spec-phase.md +11 -6
- package/gsd-core/workflows/verify-phase.md +3 -3
- package/hooks/dist/gsd-worktree-path-guard.js +34 -18
- package/hooks/gsd-worktree-path-guard.js +34 -18
- package/package.json +2 -2
- package/scripts/ci-prepare-test-scope.cjs +56 -14
- package/scripts/diff-touches-shipped-paths.cjs +5 -11
- package/scripts/run-tests.cjs +1 -0
|
@@ -19,12 +19,14 @@
|
|
|
19
19
|
* (flagged, non-green) in BOTH interactive and autonomous modes (ADR-550 D4 / D3) — never a silent
|
|
20
20
|
* green.
|
|
21
21
|
*
|
|
22
|
-
* FAIL-FIRST IS
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
22
|
+
* FAIL-FIRST IS MACHINE-PROVEN (#1279): the producer no longer greens on caller attestation. The
|
|
23
|
+
* green verdict requires the injectable prover (`proveFailFirst`, default `defaultProveFailFirst`) to
|
|
24
|
+
* INDEPENDENTLY run the check against a known violation fixture and observe it go red, AND the runner
|
|
25
|
+
* to observe a real non-vacuous pass: `passed = proof.provenFailFirst === true && run.passed === true`.
|
|
26
|
+
* Caller attestation (`CheckDescriptor.failFirst`) is demoted to a non-authoritative hint kept only
|
|
27
|
+
* for backward route-JSON shape — no path greens on it alone (FF-08). The proof method is recorded in
|
|
28
|
+
* the evidence (`failFirstProof`, FF-07). This replaces the #1259 caller-attested red-first property
|
|
29
|
+
* with machine proof, closing the gap the ADR-550 D5d note tracked as a follow-up.
|
|
28
30
|
*
|
|
29
31
|
* Authored as strict TypeScript (`src/prohibition-enforcement.cts`) and compiled by
|
|
30
32
|
* `tsc -p tsconfig.build.json` (`npm run build:lib`) to the gitignored runtime artifact
|
|
@@ -46,11 +48,15 @@ exports.descriptorFromProjection = descriptorFromProjection;
|
|
|
46
48
|
exports.buildNodeTestArgs = buildNodeTestArgs;
|
|
47
49
|
exports.buildLintArgs = buildLintArgs;
|
|
48
50
|
exports.parseNodeTestSummary = parseNodeTestSummary;
|
|
51
|
+
exports.isNodeTestRed = isNodeTestRed;
|
|
49
52
|
exports.tapTestNames = tapTestNames;
|
|
50
53
|
exports.isNonVacuousNodeTestPass = isNonVacuousNodeTestPass;
|
|
54
|
+
exports.tapFailedTestNames = tapFailedTestNames;
|
|
55
|
+
exports.isNonVacuousNodeTestRed = isNonVacuousNodeTestRed;
|
|
51
56
|
exports.eslintFileResultCount = eslintFileResultCount;
|
|
52
57
|
exports.eslintHasFatalError = eslintHasFatalError;
|
|
53
58
|
exports.eslintJsonHasRule = eslintJsonHasRule;
|
|
59
|
+
exports.defaultProveFailFirst = defaultProveFailFirst;
|
|
54
60
|
exports.runProhibitionEnforcement = runProhibitionEnforcement;
|
|
55
61
|
exports.routeProhibitionEnforcement = routeProhibitionEnforcement;
|
|
56
62
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
@@ -73,7 +79,8 @@ const probe_core_cjs_1 = require("./probe-core.cjs");
|
|
|
73
79
|
* - `null`/`undefined`/non-object input -> `null`.
|
|
74
80
|
* - `check_kind` ABSENT -> `null` (no descriptor -> producer locates nothing -> fail-closed).
|
|
75
81
|
* - `check_kind` present -> `{ kind: check_kind, target: check_target }`, adding `rule: check_rule`
|
|
76
|
-
* ONLY when `check_rule` is a non-empty string
|
|
82
|
+
* ONLY when `check_rule` is a non-empty string, and `violationFixture: check_violation_fixture`
|
|
83
|
+
* ONLY when that scalar is a non-empty string (#1346 — composes #1278 locate with #1279 proof).
|
|
77
84
|
* - `failFirst` is NEVER sourced from the projection — it stays a verify-time caller attestation
|
|
78
85
|
* (#1279 machine-proves it; out of scope here). The returned descriptor carries no `failFirst`.
|
|
79
86
|
* - The adapter does NOT strictly validate kind/target/rule: it faithfully reconstructs whatever
|
|
@@ -104,6 +111,13 @@ function descriptorFromProjection(projected) {
|
|
|
104
111
|
if (rule.trim().length > 0)
|
|
105
112
|
descriptor.rule = rule;
|
|
106
113
|
}
|
|
114
|
+
// `violationFixture` (#1346) rides BOTH kinds — reconstruct it from `check_violation_fixture` so the
|
|
115
|
+
// deterministic #1278 locate path and the #1279 machine-proof COMPOSE: a projected fixture lets the
|
|
116
|
+
// default prover green end-to-end with zero hand-authoring. Absent/blank -> no fixture -> the prover
|
|
117
|
+
// hard-gates (fail-closed; green requires a fixture), never fabricated.
|
|
118
|
+
const fixture = scalar(projected.check_violation_fixture);
|
|
119
|
+
if (fixture.trim().length > 0)
|
|
120
|
+
descriptor.violationFixture = fixture;
|
|
107
121
|
return descriptor;
|
|
108
122
|
}
|
|
109
123
|
/** node --test argv. Forces the TAP reporter so the summary counts are parseable + version-stable;
|
|
@@ -157,6 +171,16 @@ function parseNodeTestSummary(out) {
|
|
|
157
171
|
cancelled: num(/^# cancelled (\d+)/m),
|
|
158
172
|
};
|
|
159
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* Pure: did a `node --test` run go RED on the violation fixture? True iff the TAP summary reports at
|
|
176
|
+
* least one failure (`# fail >= 1`). The default node-test prover requires this — a negative test
|
|
177
|
+
* that does NOT go red against a known-bad subject is toothless and must not prove fail-first.
|
|
178
|
+
* Mutation-pinned (`>= 1` boundary): a mutant flipping `>=`→`>` (or the threshold) is caught by the
|
|
179
|
+
* `# fail 1` unit case. An unparseable summary yields `fail: 0` → false (fail-closed for the prover).
|
|
180
|
+
*/
|
|
181
|
+
function isNodeTestRed(out) {
|
|
182
|
+
return parseNodeTestSummary(out).fail >= 1;
|
|
183
|
+
}
|
|
160
184
|
/** The names of REAL (run) tests from TAP `ok N - <name>` / `not ok N - <name>` lines. A line with a
|
|
161
185
|
* `# SKIP` / `# TODO` directive is EXCLUDED — a skipped/todo negative test never executed, so it must
|
|
162
186
|
* not count toward non-vacuity (#1259 m1). */
|
|
@@ -197,6 +221,41 @@ function isNonVacuousNodeTestPass(out, target) {
|
|
|
197
221
|
const tgtBase = baseOf(target);
|
|
198
222
|
return tapTestNames(out).some((n) => baseOf(n) !== tgtBase);
|
|
199
223
|
}
|
|
224
|
+
/** The names of FAILING (run) tests from TAP `not ok N - <name>` lines, excluding `# SKIP`/`# TODO`
|
|
225
|
+
* directives (a skipped/todo line never ran). The fail-first analog of `tapTestNames`. */
|
|
226
|
+
function tapFailedTestNames(out) {
|
|
227
|
+
if (typeof out !== 'string')
|
|
228
|
+
return [];
|
|
229
|
+
const names = [];
|
|
230
|
+
const re = /^not ok \d+ - (.+)$/gm;
|
|
231
|
+
let m;
|
|
232
|
+
while ((m = re.exec(out)) !== null) {
|
|
233
|
+
const rest = m[1];
|
|
234
|
+
if (/\s#\s*(?:SKIP|TODO)\b/i.test(rest))
|
|
235
|
+
continue; // skipped/todo did not run
|
|
236
|
+
names.push(rest.replace(/\s+#\s.*$/, '').trim());
|
|
237
|
+
}
|
|
238
|
+
return names;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* A NON-VACUOUS node-test RED — the fail-first proof analog of `isNonVacuousNodeTestPass`. True iff
|
|
242
|
+
* the run reports `# fail >= 1` AND at least one FAILING test is named DISTINCTLY from the target file.
|
|
243
|
+
*
|
|
244
|
+
* Why the distinct-name guard: a violation fixture that makes the negative test CRASH at load
|
|
245
|
+
* (ENOENT / throw-on-require / syntax error) emits a FILE-NAMED `not ok 1 - <file>` with `# fail 1`.
|
|
246
|
+
* That is a crash, NOT the negative assertion firing red — so it must not "prove" the test is a
|
|
247
|
+
* regression guard (the RED-side mirror of the BL-01 vacuity hole on the pass side). Requiring a
|
|
248
|
+
* failing test named distinctly from the file closes that hole, symmetric with the clean-pass guard.
|
|
249
|
+
*
|
|
250
|
+
* KNOWN CONSTRAINT (fail-closed, not a hole): a negative test whose `test('...')` name is EXACTLY its
|
|
251
|
+
* own file basename is conservatively rejected — same benign authoring constraint, same safe direction.
|
|
252
|
+
*/
|
|
253
|
+
function isNonVacuousNodeTestRed(out, target) {
|
|
254
|
+
if (!isNodeTestRed(out))
|
|
255
|
+
return false; // no `# fail >= 1` summary -> not red (fail-closed)
|
|
256
|
+
const tgtBase = baseOf(target);
|
|
257
|
+
return tapFailedTestNames(out).some((n) => baseOf(n) !== tgtBase);
|
|
258
|
+
}
|
|
200
259
|
/** Number of file results in an eslint `--format json` report (0 if unparseable / not an array). */
|
|
201
260
|
function eslintFileResultCount(jsonText) {
|
|
202
261
|
try {
|
|
@@ -263,7 +322,8 @@ function eslintJsonHasRule(jsonText, rule) {
|
|
|
263
322
|
/**
|
|
264
323
|
* The default REAL check runner (used when no `runCheck` is injected). Reports only an OBSERVED,
|
|
265
324
|
* genuinely-non-vacuous pass; guarded so a missing tool / non-zero exit yields a non-passing result,
|
|
266
|
-
* NEVER an uncaught throw (the no-throw contract). It does NOT determine fail-first
|
|
325
|
+
* NEVER an uncaught throw (the no-throw contract). It does NOT determine fail-first — that is the
|
|
326
|
+
* separate `proveFailFirst` seam's job (machine-proven against the violation fixture, #1279).
|
|
267
327
|
* - node-test: runs `node --test` (TAP) and requires a NON-VACUOUS pass (>=1 test, >=1 pass, 0 fail
|
|
268
328
|
* AND a reported test named distinctly from the file). A bare exit 0 for an empty/zero-test file
|
|
269
329
|
* — which `node --test` counts as one passing "test" named after the file — is NOT a pass (the
|
|
@@ -360,18 +420,121 @@ function defaultRunCheck(check, cwd, timeoutMs) {
|
|
|
360
420
|
}
|
|
361
421
|
}
|
|
362
422
|
/**
|
|
363
|
-
*
|
|
423
|
+
* The default REAL fail-first prover (#1279; used when no `proveFailFirst` is injected). It runs the
|
|
424
|
+
* wired check against the descriptor's `violationFixture` (a KNOWN-BAD subject) and requires it to go
|
|
425
|
+
* RED — the machine proof that replaces caller attestation. Like `defaultRunCheck`, it is the
|
|
426
|
+
* impure/injectable seam (spawns eslint / `node --test`), reuses the identical bounded-subprocess
|
|
427
|
+
* machinery (`childEnv`/`posTimeout`/`CHECK_MAX_BUFFER`, `execFileSync(process.execPath, …)`, arg
|
|
428
|
+
* arrays → no shell), and NEVER throws — every un-provable path returns `{ provenFailFirst: false }`.
|
|
429
|
+
*
|
|
430
|
+
* - lint-rule: lint the `violationFixture` via the project flat config (so `local/*` plugins load)
|
|
431
|
+
* and require the target to actually lint (>=1 file result) AND no fatal/parse error AND the rule
|
|
432
|
+
* id to appear in the report (messages OR suppressedMessages — an inline-disabled violation still
|
|
433
|
+
* proves the rule has teeth, #1259 B1). Absent fixture / unresolvable eslint → not proven.
|
|
434
|
+
* - node-test: spawn the negative test (TAP) with `GSD_PROHIB_SUBJECT` set to the `violationFixture`
|
|
435
|
+
* — the CONVENTION (#1279) by which a negative test reads its subject-under-test — and require a
|
|
436
|
+
* NON-VACUOUS red (`isNonVacuousNodeTestRed`: `# fail >= 1` AND a failing test named distinctly
|
|
437
|
+
* from the file, so a load-CRASH on the bad subject is not mistaken for the assertion firing red).
|
|
438
|
+
* A toothless test that passes anyway → not proven. Absent fixture → not proven (fail-closed;
|
|
439
|
+
* NEVER falls back to attestation).
|
|
440
|
+
*/
|
|
441
|
+
function defaultProveFailFirst(check, cwd, timeoutMs) {
|
|
442
|
+
try {
|
|
443
|
+
if (check.kind === 'lint-rule') {
|
|
444
|
+
const fixture = check.violationFixture;
|
|
445
|
+
if (!fixture)
|
|
446
|
+
return { provenFailFirst: false }; // can't prove without a known violation -> hard-gate
|
|
447
|
+
const eslintCli = resolveEslintCli(cwd);
|
|
448
|
+
if (!eslintCli)
|
|
449
|
+
return { provenFailFirst: false }; // eslint not installed -> fail closed, never throw
|
|
450
|
+
let json = '';
|
|
451
|
+
try {
|
|
452
|
+
json = (0, node_child_process_1.execFileSync)(process.execPath, [eslintCli, ...buildLintArgs({ ...check, target: fixture })], {
|
|
453
|
+
cwd,
|
|
454
|
+
encoding: 'utf-8',
|
|
455
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
456
|
+
windowsHide: true,
|
|
457
|
+
env: childEnv(),
|
|
458
|
+
timeout: posTimeout(timeoutMs, ESLINT_TIMEOUT_MS),
|
|
459
|
+
maxBuffer: CHECK_MAX_BUFFER,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
catch (e) {
|
|
463
|
+
// eslint exits non-zero on any error; the JSON report is still on stdout. A timeout/kill
|
|
464
|
+
// leaves no parseable JSON -> eslintHasFatalError(unparseable) -> not proven (fail-closed).
|
|
465
|
+
const stdout = e && typeof e === 'object' && 'stdout' in e ? e.stdout : '';
|
|
466
|
+
json = typeof stdout === 'string' ? stdout : '';
|
|
467
|
+
}
|
|
468
|
+
// Proven iff: the fixture actually linted (>=1 file result), the rule RAN (no fatal/parse
|
|
469
|
+
// error), and the rule id appears (the violation was flagged -> the rule has teeth).
|
|
470
|
+
const proven = eslintFileResultCount(json) >= 1
|
|
471
|
+
&& !eslintHasFatalError(json)
|
|
472
|
+
&& eslintJsonHasRule(json, check.rule);
|
|
473
|
+
return { provenFailFirst: proven, method: 'violation-fixture' };
|
|
474
|
+
}
|
|
475
|
+
if (check.kind === 'node-test') {
|
|
476
|
+
const fixture = check.violationFixture;
|
|
477
|
+
// Fail-CLOSED on a missing/typo'd/stale fixture path, SYMMETRIC with the lint-rule path's
|
|
478
|
+
// `eslintFileResultCount >= 1` guard. Without the existence check, a non-existent fixture makes
|
|
479
|
+
// `GSD_PROHIB_SUBJECT` point at a missing file; an honest negative test reading that subject
|
|
480
|
+
// throws ENOENT *inside its callback* — a failing test named DISTINCTLY from the file, which
|
|
481
|
+
// `isNonVacuousNodeTestRed` would accept as proof. That is fail-OPEN: a typo forges a green from
|
|
482
|
+
// a setup crash, not from the prohibition firing. Requiring the fixture to exist before spawning
|
|
483
|
+
// closes the realistic typo/stale-path case (#1279 review, Major 1).
|
|
484
|
+
//
|
|
485
|
+
// KNOWN RESIDUAL (documented, fail-open direction, tracked follow-up #1346): existence is
|
|
486
|
+
// necessary but not sufficient — a deliberately deceptive negative test that reds merely BECAUSE
|
|
487
|
+
// `GSD_PROHIB_SUBJECT` is set (rather than because the subject's CONTENT violates the must-NOT)
|
|
488
|
+
// is still accepted. Proving "the red was CAUSED BY the violation" cannot be done generically for
|
|
489
|
+
// an arbitrary author-supplied test, so it is recorded as a constraint, not silently implied-solved.
|
|
490
|
+
// Resolve the fixture against `cwd` (NOT the verify process's cwd): the spawned test reads
|
|
491
|
+
// `GSD_PROHIB_SUBJECT` and resolves a relative subject against `cwd`, so the existence check must
|
|
492
|
+
// use the SAME base or it could pass here yet ENOENT in the child (re-opening the fail-open hole).
|
|
493
|
+
if (!fixture || !node_fs_1.default.existsSync(node_path_1.default.resolve(cwd, fixture)))
|
|
494
|
+
return { provenFailFirst: false };
|
|
495
|
+
let out = '';
|
|
496
|
+
try {
|
|
497
|
+
out = (0, node_child_process_1.execFileSync)(process.execPath, buildNodeTestArgs(check), {
|
|
498
|
+
cwd,
|
|
499
|
+
encoding: 'utf-8',
|
|
500
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
501
|
+
windowsHide: true,
|
|
502
|
+
// CONVENTION (#1279): the negative test reads its subject-under-test from this env var.
|
|
503
|
+
env: { ...childEnv(), GSD_PROHIB_SUBJECT: fixture },
|
|
504
|
+
timeout: posTimeout(timeoutMs, NODE_TEST_TIMEOUT_MS),
|
|
505
|
+
maxBuffer: CHECK_MAX_BUFFER,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
catch (e) {
|
|
509
|
+
// A negative test that goes RED exits non-zero; the partial TAP (with the `# fail` summary)
|
|
510
|
+
// is on stdout. Parse what we have: a real failure here is the PROOF the test is fail-first.
|
|
511
|
+
const stdout = e && typeof e === 'object' && 'stdout' in e ? e.stdout : '';
|
|
512
|
+
out = typeof stdout === 'string' ? stdout : '';
|
|
513
|
+
}
|
|
514
|
+
return { provenFailFirst: isNonVacuousNodeTestRed(out, check.target), method: 'violation-fixture' };
|
|
515
|
+
}
|
|
516
|
+
// Unknown kind — defensive; the LOCATE guard already rejects it.
|
|
517
|
+
return { provenFailFirst: false };
|
|
518
|
+
}
|
|
519
|
+
catch {
|
|
520
|
+
return { provenFailFirst: false };
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* LOCATE -> PROVE fail-first -> RUN -> build enforcementEvidence -> dispositionForProhibition.
|
|
364
525
|
*
|
|
365
526
|
* (1) LOCATE: if no well-formed check descriptor is locatable -> fail-closed
|
|
366
527
|
* (`dispositionForProhibition` with empty evidence) plus `{ located: false, kind: null, evidence: [] }`.
|
|
367
|
-
* (2)
|
|
368
|
-
* the
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
528
|
+
* (2) PROVE + RUN: machine-prove fail-first via `proveFailFirst` (default `defaultProveFailFirst`) AND
|
|
529
|
+
* run the check via `runCheck`. The green AND is `proof.provenFailFirst === true && run.passed ===
|
|
530
|
+
* true` — caller attestation is NOT consulted (FF-08). A check that cannot be proven fail-first, or
|
|
531
|
+
* that does not genuinely (non-vacuously) PASS -> fail-closed disposition with `located: true` (a
|
|
532
|
+
* real located miss, non-green, flagged) in BOTH modes.
|
|
533
|
+
* (3) PASS: build a typed `enforcementEvidence` array (recording the proof method) and call
|
|
534
|
+
* `dispositionForProhibition` — the non-empty array flips a test-tier item to green.
|
|
373
535
|
*
|
|
374
|
-
* Pure/deterministic:
|
|
536
|
+
* Pure/deterministic DECISION layer: the only impure seams are the injectable `runCheck`/`proveFailFirst`;
|
|
537
|
+
* given their results the disposition is same-input-same-output and mutation-survivable.
|
|
375
538
|
*/
|
|
376
539
|
function runProhibitionEnforcement(prohibition, check, options = {}) {
|
|
377
540
|
const mode = options.mode;
|
|
@@ -388,13 +551,21 @@ function runProhibitionEnforcement(prohibition, check, options = {}) {
|
|
|
388
551
|
return { ...disposition, located: false, kind: null, evidence: [], ...(mode ? { mode } : {}) };
|
|
389
552
|
}
|
|
390
553
|
const runCheck = options.runCheck ?? ((toRun) => defaultRunCheck(toRun, options.cwd ?? process.cwd(), options.timeoutMs));
|
|
391
|
-
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
|
|
396
|
-
//
|
|
397
|
-
//
|
|
554
|
+
const proveFailFirst = options.proveFailFirst ?? ((toProve) => defaultProveFailFirst(toProve, options.cwd ?? process.cwd(), options.timeoutMs));
|
|
555
|
+
// (2) PROVE fail-first (MACHINE) + RUN. The prover must INDEPENDENTLY run the check against the
|
|
556
|
+
// descriptor's violation fixture and observe it go red (`proof.provenFailFirst`), AND the runner
|
|
557
|
+
// must observe a genuine NON-VACUOUS pass. Caller attestation (`c.failFirst`) is NOT consulted for
|
|
558
|
+
// the green verdict (FF-08) — a check that cannot be machine-proven fail-first hard-gates (never
|
|
559
|
+
// green) in BOTH modes, even if attested.
|
|
560
|
+
// No-throw contract end-to-end: even a (test-injected) prover/runner that throws must fail closed,
|
|
561
|
+
// never propagate. The default real prover/runner already never throw.
|
|
562
|
+
let proof;
|
|
563
|
+
try {
|
|
564
|
+
proof = proveFailFirst(c);
|
|
565
|
+
}
|
|
566
|
+
catch {
|
|
567
|
+
proof = { provenFailFirst: false };
|
|
568
|
+
}
|
|
398
569
|
let run;
|
|
399
570
|
try {
|
|
400
571
|
run = runCheck(c);
|
|
@@ -402,9 +573,11 @@ function runProhibitionEnforcement(prohibition, check, options = {}) {
|
|
|
402
573
|
catch {
|
|
403
574
|
run = { passed: false };
|
|
404
575
|
}
|
|
405
|
-
|
|
576
|
+
// The `&&` and `=== true` are mutation-load-bearing — both directions (proven-red AND clean-pass)
|
|
577
|
+
// must hold for green; Plan 01/02 guards pin them.
|
|
578
|
+
const passed = proof.provenFailFirst === true && run.passed === true;
|
|
406
579
|
if (!passed) {
|
|
407
|
-
// NOT
|
|
580
|
+
// NOT machine-proven fail-first OR did not genuinely pass -> fail-closed, located: true (an actual
|
|
408
581
|
// located miss/fail). Hard-gate applies in BOTH modes; the disposition stays non-green / flagged.
|
|
409
582
|
const disposition = (0, probe_core_cjs_1.dispositionForProhibition)(prohibition, { enforcementEvidence: [] });
|
|
410
583
|
return {
|
|
@@ -416,13 +589,15 @@ function runProhibitionEnforcement(prohibition, check, options = {}) {
|
|
|
416
589
|
};
|
|
417
590
|
}
|
|
418
591
|
// (3) PASS -> build typed enforcementEvidence and let the policy flip a test-tier item green.
|
|
419
|
-
// `failFirst` here
|
|
592
|
+
// `failFirst: true` here means MACHINE-PROVEN (the green AND required `proof.provenFailFirst`),
|
|
593
|
+
// and `failFirstProof` records HOW it was proven (FF-07).
|
|
420
594
|
const evidence = [{
|
|
421
595
|
kind: c.kind,
|
|
422
596
|
target: c.target,
|
|
423
597
|
...(typeof c.rule === 'string' ? { rule: c.rule } : {}),
|
|
424
598
|
failFirst: true,
|
|
425
599
|
passed: true,
|
|
600
|
+
...(proof.method ? { failFirstProof: proof.method } : {}),
|
|
426
601
|
}];
|
|
427
602
|
const disposition = (0, probe_core_cjs_1.dispositionForProhibition)(prohibition, { enforcementEvidence: evidence });
|
|
428
603
|
return {
|
|
@@ -178,6 +178,27 @@ function replaceInCurrentMilestone(content, pattern, replacement) {
|
|
|
178
178
|
const after = content.slice(offset);
|
|
179
179
|
return before + after.replace(pattern, replacement);
|
|
180
180
|
}
|
|
181
|
+
function findRoadmapPhaseInContent(content, phaseNum) {
|
|
182
|
+
const phasePattern = new RegExp(`#{2,4}\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${phaseMarkdownRegexSource(phaseNum)}:\\s*([^\\n]+)`, 'i');
|
|
183
|
+
const headerMatch = content.match(phasePattern);
|
|
184
|
+
if (!headerMatch)
|
|
185
|
+
return null;
|
|
186
|
+
const phaseName = headerMatch[1].trim();
|
|
187
|
+
const headerIndex = headerMatch.index;
|
|
188
|
+
const restOfContent = content.slice(headerIndex);
|
|
189
|
+
const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]+\]\s*)?Phase\s+[\w]/i);
|
|
190
|
+
const sectionEnd = nextHeaderMatch ? headerIndex + nextHeaderMatch.index : content.length;
|
|
191
|
+
const section = content.slice(headerIndex, sectionEnd).trim();
|
|
192
|
+
const goalMatch = section.match(/\*\*Goal(?:\*\*:|\*?\*?:\*\*)\s*([^\n]+)/i);
|
|
193
|
+
const goal = goalMatch ? goalMatch[1].trim() : null;
|
|
194
|
+
return {
|
|
195
|
+
found: true,
|
|
196
|
+
phase_number: String(phaseNum),
|
|
197
|
+
phase_name: phaseName,
|
|
198
|
+
goal,
|
|
199
|
+
section,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
181
202
|
function getRoadmapPhaseInternal(cwd, phaseNum) {
|
|
182
203
|
if (!phaseNum)
|
|
183
204
|
return null;
|
|
@@ -189,26 +210,10 @@ function getRoadmapPhaseInternal(cwd, phaseNum) {
|
|
|
189
210
|
if (roadmapRaw === null)
|
|
190
211
|
throw new Error('missing');
|
|
191
212
|
const content = extractCurrentMilestone(roadmapRaw, cwd);
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const phaseName = headerMatch[1].trim();
|
|
197
|
-
const headerIndex = headerMatch.index;
|
|
198
|
-
const restOfContent = content.slice(headerIndex);
|
|
199
|
-
const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]+\]\s*)?Phase\s+[\w]/i);
|
|
200
|
-
const sectionEnd = nextHeaderMatch ? headerIndex + nextHeaderMatch.index : content.length;
|
|
201
|
-
const section = content.slice(headerIndex, sectionEnd).trim();
|
|
202
|
-
const goalMatch = section.match(/\*\*Goal(?:\*\*:|\*?\*?:\*\*)\s*([^\n]+)/i);
|
|
203
|
-
const goal = goalMatch ? goalMatch[1].trim() : null;
|
|
204
|
-
return {
|
|
205
|
-
found: true,
|
|
206
|
-
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
|
207
|
-
phase_number: String(phaseNum),
|
|
208
|
-
phase_name: phaseName,
|
|
209
|
-
goal,
|
|
210
|
-
section,
|
|
211
|
-
};
|
|
213
|
+
const scopedResult = findRoadmapPhaseInContent(content, phaseNum);
|
|
214
|
+
if (scopedResult)
|
|
215
|
+
return scopedResult;
|
|
216
|
+
return findRoadmapPhaseInContent(stripShippedMilestones(roadmapRaw), phaseNum);
|
|
212
217
|
}
|
|
213
218
|
catch {
|
|
214
219
|
return null;
|
|
@@ -287,6 +287,9 @@ function skillFrontmatterName(skillDirName) {
|
|
|
287
287
|
// Return the hyphen form as-is (gsd-<cmd>) — canonical since #2808.
|
|
288
288
|
return skillDirName;
|
|
289
289
|
}
|
|
290
|
+
function normalizeClaudeSkillEffort(effort) {
|
|
291
|
+
return effort === 'xhigh' ? 'max' : effort;
|
|
292
|
+
}
|
|
290
293
|
/**
|
|
291
294
|
* Qwen Code skills accept an optional numeric `priority` frontmatter field.
|
|
292
295
|
* Per the Qwen skills spec (qwen-code/docs/users/features/skills.md, verified
|
|
@@ -386,7 +389,7 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c
|
|
|
386
389
|
if (context)
|
|
387
390
|
fm += `context: ${context}\n`;
|
|
388
391
|
if (effort)
|
|
389
|
-
fm += `effort: ${effort}\n`;
|
|
392
|
+
fm += `effort: ${normalizeClaudeSkillEffort(effort)}\n`;
|
|
390
393
|
if (toolsBlock)
|
|
391
394
|
fm += toolsBlock;
|
|
392
395
|
fm += '---';
|
|
@@ -406,7 +406,25 @@ function reconcileCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
|
|
|
406
406
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
407
407
|
parsed = {};
|
|
408
408
|
const usesNestedHooksObject = parsed['hooks'] && typeof parsed['hooks'] === 'object' && !Array.isArray(parsed['hooks']);
|
|
409
|
-
|
|
409
|
+
// #1348: canonicalize every write to the nested { hooks: { <Event>: [...] } }
|
|
410
|
+
// shape. Lift ANY top-level event array (legacy, empty, OR mixed nested+top-level)
|
|
411
|
+
// into the nested table — merging when the same event exists in both — so
|
|
412
|
+
// user/legacy entries are preserved under `hooks` and no stray top-level event
|
|
413
|
+
// key survives (Codex deny_unknown_fields rejects them). Mirrors reconcileCursorHooksJson.
|
|
414
|
+
const hookTable = usesNestedHooksObject
|
|
415
|
+
? parsed['hooks']
|
|
416
|
+
: {};
|
|
417
|
+
for (const key of Object.keys(parsed)) {
|
|
418
|
+
if (key === 'hooks')
|
|
419
|
+
continue;
|
|
420
|
+
if (Array.isArray(parsed[key])) {
|
|
421
|
+
const lifted = parsed[key];
|
|
422
|
+
const existing = Array.isArray(hookTable[key]) ? hookTable[key] : [];
|
|
423
|
+
hookTable[key] = [...lifted, ...existing];
|
|
424
|
+
delete parsed[key];
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
parsed['hooks'] = hookTable;
|
|
410
428
|
const eventEntries = Array.isArray(hookTable[eventName]) ? hookTable[eventName] : [];
|
|
411
429
|
let removedLegacy = false;
|
|
412
430
|
const sanitizedEntries = [];
|
|
@@ -452,8 +470,11 @@ function reconcileCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
|
|
|
452
470
|
else {
|
|
453
471
|
delete hookTable[eventName];
|
|
454
472
|
}
|
|
455
|
-
|
|
456
|
-
|
|
473
|
+
// Avoid writing an empty `{ "hooks": {} }` artifact (e.g. removal on an absent
|
|
474
|
+
// file): collapse an empty hook table back to `{}` so the existing
|
|
475
|
+
// shouldWrite/no-write-on-empty behavior is preserved.
|
|
476
|
+
if (Object.keys(hookTable).length === 0)
|
|
477
|
+
delete parsed['hooks'];
|
|
457
478
|
const nextContent = `${JSON.stringify(parsed, null, 2)}\n`;
|
|
458
479
|
const changed = currentContent !== nextContent;
|
|
459
480
|
const shouldWrite = changed && (currentContent !== null || Object.keys(parsed).length > 0);
|
|
@@ -34,6 +34,19 @@ const { extractFrontmatter, reconstructFrontmatter } = frontmatter;
|
|
|
34
34
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
35
35
|
const scanPhasePlans = require("./plan-scan.cjs");
|
|
36
36
|
const state_document_cjs_1 = require("./state-document.cjs");
|
|
37
|
+
const STATE_PROGRESS_RESYNC_FIELDS = new Set([
|
|
38
|
+
'Progress',
|
|
39
|
+
'Total Plans in Phase',
|
|
40
|
+
'Total Phases',
|
|
41
|
+
]);
|
|
42
|
+
function shouldResyncStateProgress(fields) {
|
|
43
|
+
for (const field of fields) {
|
|
44
|
+
if (STATE_PROGRESS_RESYNC_FIELDS.has(field)) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
37
50
|
// ─── Cache ────────────────────────────────────────────────────────────────────
|
|
38
51
|
// Cache disk scan results from buildStateFrontmatter per cwd per process (#1967).
|
|
39
52
|
// Avoids re-reading N+1 directories on every state write when the phase structure
|
|
@@ -157,6 +170,7 @@ function cmdStatePatch(cwd, patches, raw) {
|
|
|
157
170
|
const statePath = planningPaths(cwd).state;
|
|
158
171
|
try {
|
|
159
172
|
const results = { updated: [], failed: [] };
|
|
173
|
+
const shouldResync = shouldResyncStateProgress(Object.keys(patches));
|
|
160
174
|
// Use atomic read-modify-write to prevent lost updates from concurrent agents
|
|
161
175
|
readModifyWriteStateMd(statePath, (content) => {
|
|
162
176
|
for (const [field, value] of Object.entries(patches)) {
|
|
@@ -170,7 +184,7 @@ function cmdStatePatch(cwd, patches, raw) {
|
|
|
170
184
|
}
|
|
171
185
|
}
|
|
172
186
|
return content;
|
|
173
|
-
}, cwd);
|
|
187
|
+
}, cwd, { resync: shouldResync });
|
|
174
188
|
output(results, raw, results.updated.length > 0 ? 'true' : 'false');
|
|
175
189
|
}
|
|
176
190
|
catch {
|
|
@@ -191,7 +205,7 @@ function cmdStateUpdate(cwd, field, value) {
|
|
|
191
205
|
const statePath = planningPaths(cwd).state;
|
|
192
206
|
try {
|
|
193
207
|
let updated = false;
|
|
194
|
-
const shouldResync = [
|
|
208
|
+
const shouldResync = shouldResyncStateProgress([field]);
|
|
195
209
|
// Preserve curated progress for body-only updates, but allow fields that
|
|
196
210
|
// directly project into progress.* frontmatter to rebuild after mutation.
|
|
197
211
|
readModifyWriteStateMd(statePath, (content) => {
|
|
@@ -931,6 +945,32 @@ function matchSessionSection(body) {
|
|
|
931
945
|
return body.match(/(?:^|\n)##[ \t]*Session[ \t]*\n([\s\S]*?)(?=\n##|$)/i)
|
|
932
946
|
|| body.match(/(?:^|\n)##[ \t]*Session Continuity[ \t]*\n([\s\S]*?)(?=\n##|$)/i);
|
|
933
947
|
}
|
|
948
|
+
function parseProsePhaseField(value) {
|
|
949
|
+
if (!value)
|
|
950
|
+
return { phase: null, name: null };
|
|
951
|
+
const phaseMatch = value.match(/\b(\d+[A-Z]?(?:\.\d+)*)\b/i);
|
|
952
|
+
const parenName = value.match(/\(([^)]+)\)/);
|
|
953
|
+
const dashName = value.match(/—\s*([^(\n]+?)(?:\s*\(|$)/);
|
|
954
|
+
const rawName = parenName?.[1] ?? dashName?.[1] ?? null;
|
|
955
|
+
const name = rawName && !/^(?:complete|executing|not started)$/i.test(rawName.trim())
|
|
956
|
+
? rawName.trim()
|
|
957
|
+
: null;
|
|
958
|
+
return {
|
|
959
|
+
phase: phaseMatch ? phaseMatch[1] : null,
|
|
960
|
+
name,
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
function parseProseLastActivityField(value) {
|
|
964
|
+
if (!value)
|
|
965
|
+
return { date: null, description: null };
|
|
966
|
+
const match = value.match(/^(\d{4}-\d{2}-\d{2})(?:\s+[—-]{1,2}\s+(.+))?$/);
|
|
967
|
+
if (!match)
|
|
968
|
+
return { date: value, description: null };
|
|
969
|
+
return {
|
|
970
|
+
date: match[1],
|
|
971
|
+
description: match[2]?.trim() || null,
|
|
972
|
+
};
|
|
973
|
+
}
|
|
934
974
|
function cmdStateSnapshot(cwd, raw) {
|
|
935
975
|
const statePath = planningPaths(cwd).state;
|
|
936
976
|
if (!node_fs_1.default.existsSync(statePath)) {
|
|
@@ -959,15 +999,18 @@ function cmdStateSnapshot(cwd, raw) {
|
|
|
959
999
|
return null;
|
|
960
1000
|
};
|
|
961
1001
|
// Extract basic fields — frontmatter keys take precedence over body
|
|
962
|
-
const
|
|
963
|
-
const
|
|
1002
|
+
const prosePhase = parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(body, 'Phase'));
|
|
1003
|
+
const currentPhase = fmScalar('current_phase') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Current Phase') ?? prosePhase.phase;
|
|
1004
|
+
const currentPhaseName = fmScalar('current_phase_name') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Current Phase Name') ?? prosePhase.name;
|
|
964
1005
|
const totalPhasesRaw = fmScalar('total_phases') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Total Phases');
|
|
965
1006
|
const currentPlan = fmScalar('current_plan') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Current Plan');
|
|
966
1007
|
const totalPlansRaw = fmScalar('total_plans_in_phase') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Total Plans in Phase');
|
|
967
1008
|
const status = fmScalar('status') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Status');
|
|
968
1009
|
const progressRaw = fmScalar('progress') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Progress');
|
|
969
|
-
const
|
|
970
|
-
const
|
|
1010
|
+
const rawLastActivity = (0, state_document_cjs_1.stateExtractField)(body, 'Last Activity') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Last activity');
|
|
1011
|
+
const proseLastActivity = parseProseLastActivityField(rawLastActivity);
|
|
1012
|
+
const lastActivity = fmScalar('last_activity') ?? proseLastActivity.date ?? rawLastActivity;
|
|
1013
|
+
const lastActivityDesc = fmScalar('last_activity_desc') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Last Activity Description') ?? proseLastActivity.description;
|
|
971
1014
|
const pausedAt = fmScalar('paused_at') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Paused At');
|
|
972
1015
|
// Parse numeric fields
|
|
973
1016
|
const totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
|
|
@@ -1052,14 +1095,18 @@ function cmdStateSnapshot(cwd, raw) {
|
|
|
1052
1095
|
* reliably via `state json` instead of fragile regex parsing.
|
|
1053
1096
|
*/
|
|
1054
1097
|
function buildStateFrontmatter(bodyContent, cwd) {
|
|
1055
|
-
const
|
|
1056
|
-
const
|
|
1098
|
+
const prosePhase = parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(bodyContent, 'Phase'));
|
|
1099
|
+
const currentPhase = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Current Phase') ?? prosePhase.phase;
|
|
1100
|
+
const currentPhaseName = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Current Phase Name') ?? prosePhase.name;
|
|
1057
1101
|
const currentPlan = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Current Plan');
|
|
1058
1102
|
const totalPhasesRaw = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Total Phases');
|
|
1059
1103
|
const totalPlansRaw = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Total Plans in Phase');
|
|
1060
1104
|
const status = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Status');
|
|
1061
1105
|
const progressRaw = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Progress');
|
|
1062
|
-
const
|
|
1106
|
+
const rawLastActivity = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Last Activity') ?? (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Last activity');
|
|
1107
|
+
const proseLastActivity = parseProseLastActivityField(rawLastActivity);
|
|
1108
|
+
const lastActivity = proseLastActivity.date ?? rawLastActivity;
|
|
1109
|
+
const lastActivityDesc = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Last Activity Description') ?? proseLastActivity.description;
|
|
1063
1110
|
// Bug #2444: scope Stopped At extraction to the ## Session section so that
|
|
1064
1111
|
// historical "Stopped at:" prose elsewhere in the body (e.g. in a
|
|
1065
1112
|
// Session Continuity Archive section) never overwrites the current value.
|
|
@@ -1203,6 +1250,8 @@ function buildStateFrontmatter(bodyContent, cwd) {
|
|
|
1203
1250
|
fm['last_updated'] = clock_cjs_1.realClock.nowIso();
|
|
1204
1251
|
if (lastActivity)
|
|
1205
1252
|
fm['last_activity'] = lastActivity;
|
|
1253
|
+
if (lastActivityDesc)
|
|
1254
|
+
fm['last_activity_desc'] = lastActivityDesc;
|
|
1206
1255
|
const progress = {};
|
|
1207
1256
|
if (totalPhases !== null)
|
|
1208
1257
|
progress['total_phases'] = totalPhases;
|
|
@@ -1754,14 +1803,14 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
|
|
|
1754
1803
|
posBody = replaced;
|
|
1755
1804
|
}
|
|
1756
1805
|
// Update Last activity line if present
|
|
1757
|
-
const newActivity = `Last activity: ${today}
|
|
1806
|
+
const newActivity = `Last activity: ${today} — Phase ${phaseNumber} execution started`;
|
|
1758
1807
|
if (/^Last activity:/im.test(posBody)) {
|
|
1759
1808
|
posBody = posBody.replace(/^Last activity:.*$/im, newActivity);
|
|
1760
1809
|
}
|
|
1761
1810
|
else {
|
|
1762
1811
|
// Pipe-table format in Current Position (#1255)
|
|
1763
1812
|
// Value must match the inline branch (date + narrative), not bare date.
|
|
1764
|
-
const activityValue = `${today}
|
|
1813
|
+
const activityValue = `${today} — Phase ${phaseNumber} execution started`;
|
|
1765
1814
|
const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last Activity', activityValue)
|
|
1766
1815
|
?? (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last activity', activityValue);
|
|
1767
1816
|
if (replaced !== null)
|
|
@@ -1779,7 +1828,7 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
|
|
|
1779
1828
|
if (positionMatch) {
|
|
1780
1829
|
const header = positionMatch[1];
|
|
1781
1830
|
let posBody = positionMatch[2];
|
|
1782
|
-
const resumeActivity = `Last activity: ${today}
|
|
1831
|
+
const resumeActivity = `Last activity: ${today} — Phase ${phaseNumber} execution resumed (wave continue)`;
|
|
1783
1832
|
if (/^Last activity:/im.test(posBody)) {
|
|
1784
1833
|
posBody = posBody.replace(/^Last activity:.*$/im, resumeActivity);
|
|
1785
1834
|
body = body.replace(positionPattern, () => `${header}${posBody}`);
|
|
@@ -1942,7 +1991,7 @@ function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) {
|
|
|
1942
1991
|
// Update Current Position section
|
|
1943
1992
|
body = updateCurrentPositionFields(body, {
|
|
1944
1993
|
status: 'Ready to execute',
|
|
1945
|
-
lastActivity: `${today}
|
|
1994
|
+
lastActivity: `${today} — Phase ${phaseNumber} planning complete`,
|
|
1946
1995
|
});
|
|
1947
1996
|
return reassemble(body);
|
|
1948
1997
|
}, cwd, { resync: false });
|
|
@@ -2481,14 +2530,14 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
|
|
|
2481
2530
|
posBody = replaced;
|
|
2482
2531
|
}
|
|
2483
2532
|
// Update Last activity line if present
|
|
2484
|
-
const newActivity = `Last activity: ${today}
|
|
2533
|
+
const newActivity = `Last activity: ${today} — Phase ${currentPhase} marked complete`;
|
|
2485
2534
|
if (/^Last activity:/im.test(posBody)) {
|
|
2486
2535
|
posBody = posBody.replace(/^Last activity:.*$/im, newActivity);
|
|
2487
2536
|
}
|
|
2488
2537
|
else {
|
|
2489
2538
|
// Pipe-table format in Current Position (#1255)
|
|
2490
2539
|
// Value must match the inline branch (date + narrative), not bare date.
|
|
2491
|
-
const activityValue = `${today}
|
|
2540
|
+
const activityValue = `${today} — Phase ${currentPhase} marked complete`;
|
|
2492
2541
|
const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last Activity', activityValue)
|
|
2493
2542
|
?? (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last activity', activityValue);
|
|
2494
2543
|
if (replaced !== null)
|