@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.
Files changed (36) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/LICENSE +1 -1
  3. package/agents/gsd-executor.md +22 -0
  4. package/bin/install.js +53 -99
  5. package/commands/gsd/autonomous.md +1 -1
  6. package/commands/gsd/execute-phase.md +1 -1
  7. package/commands/gsd/plan-phase.md +1 -1
  8. package/gemini-extension.json +1 -1
  9. package/gsd-core/bin/gsd-tools.cjs +36 -5
  10. package/gsd-core/bin/lib/decisions.cjs +19 -1
  11. package/gsd-core/bin/lib/phase-id.cjs +1 -1
  12. package/gsd-core/bin/lib/phase.cjs +41 -6
  13. package/gsd-core/bin/lib/probe-core.cjs +7 -0
  14. package/gsd-core/bin/lib/prohibition-enforcement.cjs +201 -26
  15. package/gsd-core/bin/lib/roadmap-parser.cjs +25 -20
  16. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +4 -1
  17. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +24 -3
  18. package/gsd-core/bin/lib/state.cjs +64 -15
  19. package/gsd-core/bin/lib/teams-status.cjs +74 -0
  20. package/gsd-core/bin/lib/uat.cjs +56 -0
  21. package/gsd-core/bin/lib/worktree-safety.cjs +7 -1
  22. package/gsd-core/references/prohibition-probe.md +48 -19
  23. package/gsd-core/references/worktree-branch-check.md +11 -5
  24. package/gsd-core/workflows/docs-update.md +23 -31
  25. package/gsd-core/workflows/execute-phase.md +2 -1
  26. package/gsd-core/workflows/map-codebase.md +8 -10
  27. package/gsd-core/workflows/plan-phase.md +6 -0
  28. package/gsd-core/workflows/quick.md +23 -2
  29. package/gsd-core/workflows/spec-phase.md +11 -6
  30. package/gsd-core/workflows/verify-phase.md +3 -3
  31. package/hooks/dist/gsd-worktree-path-guard.js +34 -18
  32. package/hooks/gsd-worktree-path-guard.js +34 -18
  33. package/package.json +2 -2
  34. package/scripts/ci-prepare-test-scope.cjs +56 -14
  35. package/scripts/diff-touches-shipped-paths.cjs +5 -11
  36. 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 CALLER-ATTESTED (honest scope, #1259): the producer requires the caller to ATTEST
23
- * `failFirst: true` AND requires the runner to observe a real non-vacuous pass — but it does NOT
24
- * independently prove the check fails-on-violation. Genuine fail-first confirmation needs running
25
- * the check against a known violation fixture and is a tracked follow-up (recorded in ADR-550's D5d
26
- * note). What ships here closes the permanent-`gaps_found` dead-end with a genuinely-executed gate;
27
- * it does not yet replace caller attestation with machine proof of the red-first property.
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 (caller-attested).
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
- * LOCATE -> CONFIRM fail-first -> RUN -> build enforcementEvidence -> dispositionForProhibition.
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) ATTEST + RUN: require the caller to attest `failFirst: true` and run it via `runCheck`. A check
368
- * the caller does not attest as fail-first, or that does not genuinely (non-vacuously) PASS ->
369
- * fail-closed disposition with `located: true` (a real located miss, non-green, flagged) in BOTH
370
- * modes. Fail-first is caller-attested, not independently proven (see module docstring).
371
- * (3) PASS: build a typed `enforcementEvidence` array and call `dispositionForProhibition` — the
372
- * non-empty array flips a test-tier item to green (the previously-unreachable branch).
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: same (prohibition, check, runCheck) -> same result.
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
- // (2) ATTEST fail-first (CALLER-DECLARED) + RUN. The caller must attest `failFirst: true` AND the
392
- // runner must observe a genuine NON-VACUOUS pass. The producer does NOT independently prove
393
- // fail-first (that needs a violation fixture — tracked follow-up, ADR-550 D5d). A non-attested or
394
- // non-passing check hard-gates (never green) in BOTH modes.
395
- const attestedFailFirst = c.failFirst === true;
396
- // No-throw contract end-to-end: even a (test-injected) runCheck that throws must fail closed,
397
- // never propagate. The default real runner already never throws.
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
- const passed = attestedFailFirst && run.passed === true;
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 attested fail-first OR did not genuinely pass -> fail-closed, located: true (an actual
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 is the caller's attestation (recorded for provenance), not a machine proof.
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 phasePattern = new RegExp(`#{2,4}\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${phaseMarkdownRegexSource(phaseNum)}:\\s*([^\\n]+)`, 'i');
193
- const headerMatch = content.match(phasePattern);
194
- if (!headerMatch)
195
- return null;
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
- const hookTable = usesNestedHooksObject ? parsed['hooks'] : parsed;
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
- if (usesNestedHooksObject)
456
- parsed['hooks'] = hookTable;
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 = ['Progress', 'Total Plans in Phase', 'Total Phases'].includes(field);
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 currentPhase = fmScalar('current_phase') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Current Phase');
963
- const currentPhaseName = fmScalar('current_phase_name') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Current Phase Name');
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 lastActivity = fmScalar('last_activity') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Last Activity');
970
- const lastActivityDesc = fmScalar('last_activity_desc') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Last Activity Description');
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 currentPhase = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Current Phase');
1056
- const currentPhaseName = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Current Phase Name');
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 lastActivity = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Last Activity');
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} -- Phase ${phaseNumber} execution started`;
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} -- Phase ${phaseNumber} execution started`;
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} -- Phase ${phaseNumber} execution resumed (wave continue)`;
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} -- Phase ${phaseNumber} planning complete`,
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} -- Phase ${currentPhase} marked complete`;
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} -- Phase ${currentPhase} marked complete`;
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)