@event4u/agent-config 9.33.0 → 9.35.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 (105) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +154 -145
  3. package/CONTRIBUTING.md +23 -14
  4. package/dist/agent-src/commands/agent-handoff.md +15 -3
  5. package/dist/agent-src/commands/roadmap/next.md +37 -5
  6. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
  7. package/dist/agent-src/scripts/roadmap_gates.ts +119 -4
  8. package/dist/agent-src/skills/corpus-grounding/SKILL.md +21 -12
  9. package/dist/agent-src/skills/design-tokens/SKILL.md +15 -0
  10. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +24 -0
  11. package/dist/agent-src/skills/rule-writing/SKILL.md +23 -0
  12. package/dist/agent-src/skills/skill-writing/SKILL.md +80 -2
  13. package/dist/agent-src/skills/systematic-debugging/SKILL.md +5 -3
  14. package/dist/agent-src/skills/tailwind-engineer/SKILL.md +24 -0
  15. package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +25 -0
  16. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  17. package/dist/agent-src/templates/scripts/memory_lookup.ts +1 -2
  18. package/dist/agent-src/templates/scripts/memory_signal.ts +2 -4
  19. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
  20. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
  21. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
  22. package/dist/cli/registry.js +2 -2
  23. package/dist/cli/registry.js.map +1 -1
  24. package/dist/cli-delegate/{chunk-PMQ6Z3T3.js → chunk-2TGMGLBL.js} +11 -4
  25. package/dist/cli-delegate/{chunk-YPAWXXXR.js → chunk-55RI5UXK.js} +17 -0
  26. package/dist/cli-delegate/{chunk-MA4GFWDY.js → chunk-H6F2CQFI.js} +1 -1
  27. package/dist/cli-delegate/{chunk-2WELHO3P.js → chunk-I5EAFRII.js} +35 -0
  28. package/dist/cli-delegate/{chunk-3EWYC3YK.js → chunk-UEZ7D35A.js} +4 -3
  29. package/dist/cli-delegate/{chunk-UVKNIJH2.js → chunk-ZAXH3ZWU.js} +3 -0
  30. package/dist/cli-delegate/cmd_conformance.js +2 -2
  31. package/dist/cli-delegate/cmd_doctor.js +2 -2
  32. package/dist/cli-delegate/cmd_export.js +2 -2
  33. package/dist/cli-delegate/cmd_handoff.js +3 -3
  34. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  35. package/dist/cli-delegate/cmd_prune.js +2 -2
  36. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  37. package/dist/cli-delegate/cmd_session_recycle.js +7 -2
  38. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  39. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  40. package/dist/cli-delegate/cmd_sync.js +2 -2
  41. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  42. package/dist/cli-delegate/cmd_validate.js +2 -2
  43. package/dist/discovery/deprecation-report.md +1 -1
  44. package/dist/discovery/discovery-manifest.json +13 -13
  45. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  46. package/dist/discovery/discovery-manifest.summary.md +2 -2
  47. package/dist/discovery/dormancy-report.md +17 -0
  48. package/dist/discovery/orphan-report.md +1 -1
  49. package/dist/discovery/packs.json +3 -3
  50. package/dist/discovery/trust-report.md +1 -1
  51. package/dist/discovery/workspaces.json +3 -3
  52. package/dist/hooks/dispatch.js +355 -85
  53. package/dist/install/install.mjs +3 -0
  54. package/dist/mcp/registry-manifest.json +3 -3
  55. package/dist/mcp/server.json +2 -2
  56. package/dist/mcp/server.mjs +192 -12
  57. package/dist/server/schemas/settings.js +1 -0
  58. package/dist/server/schemas/settings.js.map +1 -1
  59. package/docs/contracts/settings-classes.md +3 -2
  60. package/docs/guidelines/agent-infra/failure-signatures.md +47 -12
  61. package/package.json +2 -2
  62. package/src/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
  63. package/src/agent-src/scripts/roadmap_gates.ts +119 -4
  64. package/src/agent-src/templates/scripts/memory_lookup.ts +1 -2
  65. package/src/agent-src/templates/scripts/memory_signal.ts +2 -4
  66. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
  67. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
  68. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
  69. package/src/config/agent-settings.template.yml +11 -2
  70. package/src/config/quorum-attendance-budget.json +11 -3
  71. package/src/scripts/_cli/cmd_session_recycle.ts +6 -1
  72. package/src/scripts/_dispatch.bash +6 -2
  73. package/src/scripts/_lib/env_kill_switch.ts +37 -0
  74. package/src/scripts/_lib/security_lint.ts +18 -4
  75. package/src/scripts/_lib/self_repair.ts +60 -0
  76. package/src/scripts/_lib/self_repair_store.ts +65 -5
  77. package/src/scripts/_lib/session_register.ts +67 -1
  78. package/src/scripts/_lib/subagent_capsule.ts +78 -1
  79. package/src/scripts/ai_council/config.ts +60 -0
  80. package/src/scripts/ai_council/events_log.ts +105 -6
  81. package/src/scripts/ai_council/orchestrator.ts +38 -0
  82. package/src/scripts/ai_council/prompts.ts +165 -3
  83. package/src/scripts/ai_council/quorum.ts +87 -0
  84. package/src/scripts/ai_team/review_gate.ts +2 -2
  85. package/src/scripts/build_discovery_manifest.ts +165 -0
  86. package/src/scripts/check_claims.ts +51 -2
  87. package/src/scripts/ci_time_ratio.ts +3 -1
  88. package/src/scripts/council_cli.ts +36 -1
  89. package/src/scripts/discovery_graph.ts +201 -27
  90. package/src/scripts/dispatch_r2_reviewer.ts +102 -1
  91. package/src/scripts/hooks/turn_end_gate_hook.ts +172 -11
  92. package/src/scripts/lint_framework_leakage.ts +195 -28
  93. package/src/scripts/lint_framework_leakage_allowlist.json +94 -86
  94. package/src/scripts/lint_handoffs.ts +92 -2
  95. package/src/scripts/lint_hedge_words.ts +317 -0
  96. package/src/scripts/lint_mcp_config_security.ts +1 -1
  97. package/src/scripts/lint_skill_frontmatter_safety.ts +1 -1
  98. package/src/scripts/memory_lookup.ts +1 -2
  99. package/src/scripts/schemas/command.schema.json +4 -0
  100. package/src/scripts/schemas/skill.schema.json +4 -0
  101. package/src/scripts/self_repair_cli.ts +26 -1
  102. package/src/scripts/session_register_hook.ts +238 -18
  103. package/src/scripts/sessions_cli.ts +377 -12
  104. package/src/scripts/skill_linter.ts +58 -2
  105. package/src/server/schemas/settings.ts +3 -0
@@ -460,9 +460,19 @@ export const REQUIRED_SYNTHESIS_SECTIONS = ['Kill criteria', 'Concrete next step
460
460
  * fills the Convergence / Divergence slot) — not the template prompt. Every
461
461
  * lens must close with a non-empty `### Kill criteria` and `### Concrete next
462
462
  * step`; a missing or placeholder-empty section throws `SynthesisRenderError`
463
- * naming the first offender. Called on the synthesis-emit path (the chairman
464
- * synthesis in Phase 2, and any future synthesis-record step); safe to call on
465
- * decision and creative lenses alike, since both now require the two sections.
463
+ * naming the first offender. Safe to call on decision and creative lenses
464
+ * alike, since both now require the two sections.
465
+ *
466
+ * **Not wired onto the emit path, deliberately.** This docstring used to claim
467
+ * it was "called on the synthesis-emit path"; measured, the function had zero
468
+ * production call sites and only the test imported it. Wiring it
469
+ * unconditionally into `render()` is not available either: with no chairman the
470
+ * rendered body is the literal `*to be summarised by the host agent*`, which
471
+ * carries neither section, so every templated render would throw (verified —
472
+ * the probe raises `missing the required "### Kill criteria" section`). A
473
+ * caller that HAS a completed synthesis calls this; the templated default path
474
+ * has nothing to validate. The tally check below is the half that could be
475
+ * wired, because it is conditional on a verdict line the template never emits.
466
476
  */
467
477
  export function assert_synthesis_sections(text: string): void {
468
478
  const lines = text.split('\n');
@@ -488,6 +498,158 @@ export function assert_synthesis_sections(text: string): void {
488
498
  }
489
499
  }
490
500
 
501
+ /** The reserved verdict label a synthesis uses when the tally did not converge. */
502
+ export const SPLIT_VERDICT_LABEL = 'split';
503
+
504
+ // Mirrors `_STANCE_RE`'s shape discipline: an explicit, single-field closing
505
+ // line, never read out of surrounding prose.
506
+ //
507
+ // **Case-SENSITIVE, deliberately.** The `i` flag was here and was wrong: the
508
+ // pattern is line-anchored, so `Verdict: option A is the stronger choice` —
509
+ // ordinary chairman prose opening a line — parsed as a machine-readable verdict
510
+ // carrying the whole sentence as its label. That is precisely the prose
511
+ // inference the paragraph above forbids, arriving through the regex instead of
512
+ // through a fallback. `VERDICT_LINE_CONTRACT` asks for the literal uppercase
513
+ // token, so requiring it costs a compliant author nothing and closes the class.
514
+ const _VERDICT_RE = /^\s*VERDICT:\s*(.+?)\s*$/gm;
515
+
516
+ /**
517
+ * Mandatory closing-line contract for a synthesis rendered alongside a stance
518
+ * tally. The synthesis states its verdict as a machine-readable label rather
519
+ * than leaving it to be mined out of the recommendation prose — the same
520
+ * discipline `STANCE_LINE_CONTRACT` imposes on members.
521
+ */
522
+ export const VERDICT_LINE_CONTRACT = `Close the synthesis with EXACTLY this line, and nothing after it:
523
+ VERDICT: <option-label>
524
+ Use the SAME <option-label> the Vote Tally cleared. Use \`${SPLIT_VERDICT_LABEL}\` when no option cleared the threshold — an honest split is a verdict, and claiming a winner the tally did not record is a render error.`;
525
+
526
+ /**
527
+ * Parse the synthesis verdict line. Returns the LAST match (the final,
528
+ * most-authoritative one) or `null` when no well-formed line exists.
529
+ *
530
+ * `null` is a repair marker, never a guess: this function does not fall back to
531
+ * reading agreement out of the prose. That is the invariant `stance_tally`
532
+ * states for members ("the tally never infers a stance from the surrounding
533
+ * prose") and the one `check_finding_dispositions` states for the release
534
+ * ledger ("a comment is mutable and unaudited; it is transport, not a record").
535
+ */
536
+ export function parse_verdict_line(text: string): { label: string; display: string } | null {
537
+ _VERDICT_RE.lastIndex = 0;
538
+ let last: RegExpExecArray | null = null;
539
+ let m: RegExpExecArray | null;
540
+ while ((m = _VERDICT_RE.exec(text)) !== null) {
541
+ last = m;
542
+ }
543
+ if (last === null) {
544
+ return null;
545
+ }
546
+ const raw = (last[1] ?? '').trim();
547
+ if (raw.length === 0) {
548
+ return null;
549
+ }
550
+ // An angle-bracketed label is the CONTRACT's own placeholder, not a verdict.
551
+ // This matters on the templated path: with no chairman the rendered body IS
552
+ // `VERDICT_LINE_CONTRACT`'s template text, so a parser without this guard
553
+ // would read `<option-label>` as a claimed winner and throw on every
554
+ // un-summarised render. Same treatment `assert_synthesis_sections` gives a
555
+ // placeholder-empty section.
556
+ if (/^<.*>$/.test(raw)) {
557
+ return null;
558
+ }
559
+ return { label: raw.toLowerCase(), display: raw };
560
+ }
561
+
562
+ /**
563
+ * Validate a completed synthesis against the stance tally it was rendered
564
+ * beside: a verdict naming a winner the tally did not clear throws
565
+ * `SynthesisRenderError`, the same way a missing section does.
566
+ *
567
+ * The defect this closes: `assert_synthesis_sections` is a *shape* check, so a
568
+ * synthesis could report agreement over a tally that recorded dissent and
569
+ * nothing downstream disagreed. That is the class behind the recorded 9.14.0
570
+ * release failure, where the head read "Security and correctness: none" over a
571
+ * critical finding — `check_finding_dispositions.ts` is the same check on the
572
+ * release surface.
573
+ *
574
+ * **An absent verdict line does not throw.** It is a repair marker under the
575
+ * same rule as an unparseable stance: the fail-safe direction is to record that
576
+ * the claim was not made, never to infer one from prose. That also keeps every
577
+ * synthesis rendered before `VERDICT_LINE_CONTRACT` shipped green.
578
+ */
579
+ export function assert_synthesis_matches_tally(text: string, tally: TallyView): void {
580
+ const mismatch = describe_verdict_mismatch(text, tally);
581
+ if (mismatch !== null) {
582
+ throw new SynthesisRenderError(mismatch);
583
+ }
584
+ }
585
+
586
+ /** The slice of a `StanceTallyResult` the verdict check reads. */
587
+ export interface TallyView {
588
+ consensus: { label: string } | null;
589
+ split: boolean;
590
+ options: ReadonlyArray<{ label: string }>;
591
+ }
592
+
593
+ /**
594
+ * The same comparison as `assert_synthesis_matches_tally`, returning the
595
+ * mismatch as a string instead of throwing — `null` means "no contradiction".
596
+ *
597
+ * **Why both shapes exist.** Throwing is right for a caller holding a finished
598
+ * synthesis it can refuse. It is wrong on the render path: `render()` throws
599
+ * before the tally and the Convergence blocks are pushed, so a mismatch would
600
+ * destroy the ENTIRE artifact — every member response, the peer review, the
601
+ * quorum bookkeeping — *after* every provider call has already been paid for.
602
+ * The module's own answer for an unverifiable claim is the `needs_repair`
603
+ * marker `render_vote_tally` prints, not a discarded pass, and the roadmap had
604
+ * already recorded that unconditionally wiring the sibling shape check was
605
+ * unsafe for this same reason. So the render path surfaces the contradiction
606
+ * where a reader will see it and keeps the artifact.
607
+ */
608
+ export function describe_verdict_mismatch(text: string, tally: TallyView): string | null {
609
+ const verdict = parse_verdict_line(text);
610
+ if (verdict === null) {
611
+ return null; // repair marker, not a guess
612
+ }
613
+ // The reserved sense of `split` yields to a real option that carries the
614
+ // same label (R2 finding 8): the label vocabulary is free text mined from
615
+ // member `STANCE:` lines, so nothing stops an option genuinely called
616
+ // `split`, and when the tally clears it the CORRECT verdict would otherwise
617
+ // read as a contradiction. Deciding by whether the tally actually holds
618
+ // that option keeps one namespace without an escape syntax authors would
619
+ // have to learn.
620
+ const optionNamedSplit = tally.options.some(
621
+ (o) => o.label.toLowerCase() === SPLIT_VERDICT_LABEL,
622
+ );
623
+ const claimsSplit = verdict.label === SPLIT_VERDICT_LABEL && !optionNamedSplit;
624
+
625
+ if (tally.consensus === null) {
626
+ if (claimsSplit) {
627
+ return null;
628
+ }
629
+ // `split` is the tally's own word for this state; reading it here rather
630
+ // than re-deriving "no consensus ⇒ split" keeps the message honest when
631
+ // the tally is empty (zero options is not a split across zero options).
632
+ const how = tally.split
633
+ ? `split across ${tally.options.length} option(s)`
634
+ : 'no option-level stances parsed';
635
+ return (
636
+ `synthesis claims "${verdict.display}" but the tally recorded no consensus ` +
637
+ `(${how}); use \`${SPLIT_VERDICT_LABEL}\` or re-run the round`
638
+ );
639
+ }
640
+
641
+ if (claimsSplit) {
642
+ return `synthesis claims a split but the tally cleared "${tally.consensus.label}"`;
643
+ }
644
+ if (verdict.label !== tally.consensus.label.toLowerCase()) {
645
+ return (
646
+ `synthesis claims "${verdict.display}" but the tally cleared ` +
647
+ `"${tally.consensus.label}"`
648
+ );
649
+ }
650
+ return null;
651
+ }
652
+
491
653
  /**
492
654
  * Drop any *whole line* containing a host-agent identity substring.
493
655
  *
@@ -93,6 +93,17 @@ export function evaluateQuorum(
93
93
  * full-attendance one. Advisory render and telemetry only: no gate reads it,
94
94
  * and nothing downstream may branch on it without its own decision record.
95
95
  *
96
+ * ONE such record now exists, and it is deliberately narrow: ADR-224
97
+ * (`docs/decisions/ADR-224-gate-scoped-solo-attendance-floor.md`) authorizes a
98
+ * `min_present: 2` floor to branch on this predicate **for gate-class passes
99
+ * only**, decided against a measured 12.5 % solo-conclusion rate. What landed
100
+ * from that authorization is `wouldSoloFloorHold` below, which is a
101
+ * counterfactual and branches nothing — so the sentence above still describes
102
+ * the code: no caller holds a pass on solo status today. It is stated here so
103
+ * a reader finds the authorization instead of re-deriving the prohibition, and
104
+ * so the next branch is visibly a separate argument: the exception is
105
+ * gate-class passes, not solo status generally.
106
+ *
96
107
  * `total` is deliberately NOT consulted. A council configured with a single
97
108
  * member concludes solo by construction, and that is still a conclusion
98
109
  * reached on one voice — collapsing it into "not solo" would hide exactly
@@ -102,3 +113,79 @@ export function evaluateQuorum(
102
113
  export function isSoloConcluded(result: QuorumResult): boolean {
103
114
  return result.status === 'concluded' && result.present === 1;
104
115
  }
116
+
117
+ /**
118
+ * The floor value ADR-224 named: a gate-class pass wants at least two voices.
119
+ *
120
+ * A constant rather than a bare `2` at the call site so the shadow telemetry
121
+ * below and any future enforcement read the same number, and so changing it
122
+ * is one edit with one place to argue about.
123
+ */
124
+ export const SOLO_FLOOR_MIN_PRESENT = 2;
125
+
126
+ /**
127
+ * WOULD a `min_present` floor have held this pass? Counterfactual only — this
128
+ * predicate changes nothing and no caller branches on it to hold a gate.
129
+ *
130
+ * ADR-224 chose a gate-scoped `min_present: 2` floor against a solo-conclusion
131
+ * rate of 1 of 8 passes, whose 95 % interval spans roughly 0.3 %–53 %. Two
132
+ * facts, both read off the tree rather than assumed, decided that what lands
133
+ * first is the measurement and not the enforcement:
134
+ *
135
+ * - **Nothing in the tree branches on `QuorumStatus` to hold anything.** The
136
+ * only non-`quorum.ts` reader of `'inconclusive'` is
137
+ * `council_cli.ts::_deserialise_quorum`, which validates a persisted string.
138
+ * An enforced floor would therefore change zero behaviour today — there is
139
+ * no gate for it to hold — while still being able to hang an advisory pass
140
+ * the moment a consumer appears.
141
+ * - **ADR-224's own review trigger (b) is "the gate-class floor lands and its
142
+ * own fire-rate telemetry accumulates"**, which presumes exactly this
143
+ * counterfactual is being recorded.
144
+ *
145
+ * So the floor is evaluated on EVERY pass and recorded, never applied. The
146
+ * fire-rate that accumulates from it is what makes the enforcement decision an
147
+ * evidence call later instead of a second point estimate.
148
+ *
149
+ * `minPresent` is clamped with the same discipline `resolveQuorumThreshold`
150
+ * applies, and the clamp carries meaning rather than only safety: where the
151
+ * ceiling resolves to 1 the floor never fires, so a council **configured**
152
+ * with one member is not counted as a degraded pass. That is the split
153
+ * `quorum-attendance-budget.json` already insists on for the solo-conclusion
154
+ * rate — a one-member roster and a two-member roster that lost one are
155
+ * different findings, and an unclamped floor would merge them.
156
+ *
157
+ * **The ceiling is the larger of `result.total` and `configuredTotal`, and it
158
+ * has to be.** Clamping against `result.total` alone was the first version of
159
+ * this function and it was structurally unable to fire on the exact case
160
+ * ADR-224 was decided on: `_postRunQuorum` computes `total` over the roster
161
+ * that CONSTRUCTED, so a two-member council where one member fails to
162
+ * construct reads `total = 1, present = 1`, the ceiling collapses to 1, and a
163
+ * conclusion reached on one of two configured voices records as "the floor
164
+ * would not have held". The series review trigger (b) accumulates would have
165
+ * been biased low by construction — a metric blind to its own target
166
+ * population. `configured_total` exists on the event line precisely to keep
167
+ * that pass distinguishable, and the fix is to consult it.
168
+ *
169
+ * `max`, not `configuredTotal` outright: `total > configuredTotal` is
170
+ * legitimate and not a shortfall, because `--siblings` fans one enabled config
171
+ * ENTRY into N clients. `configuredTotal` counts entries, `total` counts
172
+ * clients, and the honest ceiling is the largest roster the pass could have
173
+ * drawn on either way.
174
+ *
175
+ * A pass that never reached `concluded` is not held by the floor: it failed
176
+ * the threshold, which is a different outcome with a different cause. The two
177
+ * are mutually exclusive by construction, which is what makes them readable
178
+ * apart from the event log alone.
179
+ */
180
+ export function wouldSoloFloorHold(
181
+ result: QuorumResult,
182
+ minPresent: number = SOLO_FLOOR_MIN_PRESENT,
183
+ configuredTotal?: number,
184
+ ): boolean {
185
+ if (result.status !== 'concluded') {
186
+ return false;
187
+ }
188
+ const ceiling = Math.max(result.total, configuredTotal ?? result.total);
189
+ const floor = Math.min(Math.max(Math.trunc(minPresent), 1), ceiling);
190
+ return result.present < floor;
191
+ }
@@ -42,6 +42,7 @@ import * as os from 'node:os';
42
42
  import * as path from 'node:path';
43
43
 
44
44
  import { claude_config_dir, CODEX_MARKETPLACE_NAME, CODEX_PLUGIN_ID } from '../_lib/claude_plugin.js';
45
+ import { isEnvKillSwitchActive } from '../_lib/env_kill_switch.js';
45
46
  import { atomic_write_json } from '../hooks/state_io.js';
46
47
  import { AI_TEAM_DEFAULTS, load_ai_team_config, type AiTeamConfig } from './config.js';
47
48
 
@@ -170,8 +171,7 @@ export function circuit_breaker_notice(bound: number): string {
170
171
  // ── ledger (events-log convention) ──────────────────────────────────
171
172
 
172
173
  function _kill_switch_active(): boolean {
173
- const value = process.env[_KILL_SWITCH_ENV] ?? '';
174
- return !(value === '' || value === '0' || value === 'false' || value === 'False');
174
+ return isEnvKillSwitchActive(_KILL_SWITCH_ENV);
175
175
  }
176
176
 
177
177
  function _iso_seconds_z(d: Date): string {
@@ -38,6 +38,7 @@
38
38
  *
39
39
  * Schema: docs/contracts/discovery-manifest.schema.json
40
40
  */
41
+ import * as child_process from 'node:child_process';
41
42
  import * as crypto from 'node:crypto';
42
43
  import * as fs from 'node:fs';
43
44
  import * as path from 'node:path';
@@ -83,6 +84,7 @@ interface ModuleConfig {
83
84
  DEFAULT_DEPRECATION_REPORT: string;
84
85
  DEFAULT_TRUST_REPORT: string;
85
86
  DEFAULT_ORPHAN_REPORT: string;
87
+ DEFAULT_DORMANCY_REPORT: string;
86
88
  DEFAULT_WORKSPACES_JSON: string;
87
89
  DEFAULT_PACKS_JSON: string;
88
90
  // Patchable seams (Python monkeypatches these module attributes).
@@ -101,6 +103,7 @@ function _deriveConfig(root: string): ModuleConfig {
101
103
  DEFAULT_DEPRECATION_REPORT: path.join(disc, 'deprecation-report.md'),
102
104
  DEFAULT_TRUST_REPORT: path.join(disc, 'trust-report.md'),
103
105
  DEFAULT_ORPHAN_REPORT: path.join(disc, 'orphan-report.md'),
106
+ DEFAULT_DORMANCY_REPORT: path.join(disc, 'dormancy-report.md'),
104
107
  DEFAULT_WORKSPACES_JSON: path.join(disc, 'workspaces.json'),
105
108
  DEFAULT_PACKS_JSON: path.join(disc, 'packs.json'),
106
109
  artefact_roots: _artefact_roots,
@@ -937,6 +940,156 @@ function _deprecation_report(manifest: JsonObject): string {
937
940
  return lines.join('\n') + '\n';
938
941
  }
939
942
 
943
+ /** Dormancy window, in months. `docs/governance.md` § Skill lifecycle policy. */
944
+ const _DORMANCY_MONTHS = 6;
945
+
946
+ /**
947
+ * What the dormancy report could actually establish from git in this checkout.
948
+ *
949
+ * `ok: false` is a first-class outcome, not an error: a shallow clone or a
950
+ * repository younger than the window cannot distinguish "untouched for six
951
+ * months" from "history truncated three months ago", and CI clones are shallow
952
+ * by default. The report NAMES that instead of emitting a list built on it.
953
+ */
954
+ export interface DormancyProbe {
955
+ ok: boolean;
956
+ /** Why the signal is unavailable — set iff `ok` is false. */
957
+ reason?: string;
958
+ /** Repo-relative paths with at least one commit inside the window. */
959
+ touched: Set<string>;
960
+ }
961
+
962
+ function _git(root: string, args: readonly string[]): string | null {
963
+ try {
964
+ return child_process.execFileSync('git', [...args], {
965
+ cwd: root,
966
+ encoding: 'utf-8',
967
+ maxBuffer: 64 * 1024 * 1024,
968
+ stdio: ['ignore', 'pipe', 'ignore'],
969
+ });
970
+ } catch {
971
+ return null;
972
+ }
973
+ }
974
+
975
+ /**
976
+ * Probe git once for the whole estate: one bounded `--since` walk, exactly the
977
+ * command `governance.md` names, rather than one `git log` per artefact.
978
+ */
979
+ function _dormancy_probe(root: string, now: Date = new Date()): DormancyProbe {
980
+ const since = `${String(_DORMANCY_MONTHS)} months ago`;
981
+ if (_git(root, ['rev-parse', '--git-dir']) === null) {
982
+ return { ok: false, reason: 'not a git checkout — commit history is unavailable', touched: new Set() };
983
+ }
984
+ if ((_git(root, ['rev-parse', '--is-shallow-repository']) ?? '').trim() === 'true') {
985
+ return {
986
+ ok: false,
987
+ reason:
988
+ 'shallow clone — a truncated history is indistinguishable from a dormant artefact. ' +
989
+ 'Re-run after `git fetch --unshallow`.',
990
+ touched: new Set(),
991
+ };
992
+ }
993
+ const oldest = (_git(root, ['log', '--reverse', '--format=%cI', '--max-count=1']) ?? '').trim();
994
+ if (oldest !== '') {
995
+ const cutoff = new Date(now);
996
+ cutoff.setMonth(cutoff.getMonth() - _DORMANCY_MONTHS);
997
+ if (new Date(oldest) > cutoff) {
998
+ return {
999
+ ok: false,
1000
+ reason:
1001
+ `repository history starts ${oldest.slice(0, 10)}, inside the ` +
1002
+ `${String(_DORMANCY_MONTHS)}-month window — every artefact would read as dormant`,
1003
+ touched: new Set(),
1004
+ };
1005
+ }
1006
+ }
1007
+ const out = _git(root, ['log', `--since=${since}`, '--name-only', '--format=']);
1008
+ if (out === null) {
1009
+ return { ok: false, reason: 'git log failed', touched: new Set() };
1010
+ }
1011
+ const touched = new Set(out.split('\n').map((l) => l.trim()).filter((l) => l !== ''));
1012
+ return { ok: true, touched };
1013
+ }
1014
+
1015
+ /**
1016
+ * The unit dormancy is measured over. A skill is a directory (governance names
1017
+ * `git log -- src/skills/<name>/`), so a commit touching its `reference.md`
1018
+ * keeps the skill alive; every other artefact is its own file.
1019
+ */
1020
+ function _dormancy_unit(artefactPath: string): string {
1021
+ return path.basename(artefactPath) === 'SKILL.md' ? path.dirname(artefactPath) : artefactPath;
1022
+ }
1023
+
1024
+ /**
1025
+ * Artefacts with no commit inside the dormancy window.
1026
+ *
1027
+ * Report-only by construction: `governance.md` § Skill lifecycle policy says
1028
+ * dormancy triggers review, never auto-deprecation — and a finished artefact is
1029
+ * indistinguishable from an abandoned one from commit dates, so the
1030
+ * false-positive class is not empty and no argument makes it so.
1031
+ */
1032
+ function _dormant_artefacts(manifest: JsonObject, probe: DormancyProbe): JsonObject[] {
1033
+ if (!probe.ok) {
1034
+ return [];
1035
+ }
1036
+ const alive = new Set<string>();
1037
+ for (const p of probe.touched) {
1038
+ alive.add(p);
1039
+ // A touched file keeps its containing directory-shaped artefact alive.
1040
+ for (let d = path.dirname(p); d !== '.' && d !== path.sep; d = path.dirname(d)) {
1041
+ alive.add(d);
1042
+ }
1043
+ }
1044
+ const out: JsonObject[] = [];
1045
+ for (const a of manifest['artefacts'] as JsonObject[]) {
1046
+ const unit = _dormancy_unit(a['path'] as string);
1047
+ if (!alive.has(unit)) {
1048
+ out.push({ path: a['path'], unit, category: a['category'] });
1049
+ }
1050
+ }
1051
+ out.sort((a, b) => _cmpStr(a['path'] as string, b['path'] as string));
1052
+ return out;
1053
+ }
1054
+
1055
+ /** The dormancy section of the discovery report family (governance.md § Skill lifecycle policy). */
1056
+ function _dormancy_report(manifest: JsonObject, probe: DormancyProbe): string {
1057
+ const lines = ['# Discovery — Dormancy Report', ''];
1058
+ lines.push(`- Generated: \`${String(manifest['generated_at'])}\``);
1059
+ lines.push(`- Window: **${String(_DORMANCY_MONTHS)} months** without a commit`);
1060
+ lines.push('');
1061
+ lines.push('> Dormancy triggers **review, never auto-deprecation**');
1062
+ lines.push('> (`docs/governance.md` § Skill lifecycle policy). A finished artefact and an');
1063
+ lines.push('> abandoned one look identical from commit dates, so this is a prompt to ask');
1064
+ lines.push('> "still earning its slot?" — never a removal list, and never a gate.');
1065
+ lines.push('');
1066
+ if (!probe.ok) {
1067
+ lines.push('## Signal unavailable');
1068
+ lines.push('');
1069
+ lines.push(`**No dormancy list is published**: ${String(probe.reason)}`);
1070
+ lines.push('');
1071
+ lines.push('An empty section here would read as "nothing is dormant", which is a');
1072
+ lines.push('different claim from "the signal could not be computed".');
1073
+ lines.push('');
1074
+ return lines.join('\n') + '\n';
1075
+ }
1076
+ const items = _dormant_artefacts(manifest, probe);
1077
+ lines.push(`- Dormant artefacts: **${items.length}**`);
1078
+ lines.push('');
1079
+ if (items.length === 0) {
1080
+ lines.push('_None. Every artefact was touched inside the window._');
1081
+ lines.push('');
1082
+ return lines.join('\n') + '\n';
1083
+ }
1084
+ lines.push('| Path | Unit | Category |');
1085
+ lines.push('|---|---|---|');
1086
+ for (const a of items) {
1087
+ lines.push(`| \`${String(a['path'])}\` | \`${String(a['unit'])}\` | ${String(a['category'])} |`);
1088
+ }
1089
+ lines.push('');
1090
+ return lines.join('\n') + '\n';
1091
+ }
1092
+
940
1093
  /** Trust-level breakdown by workspace + human-review sanity flag. Mirrors `_trust_report`. */
941
1094
  function _trust_report(manifest: JsonObject): string {
942
1095
  const byWs = new Map<string, JsonObject>();
@@ -1195,6 +1348,7 @@ interface ParsedArgs {
1195
1348
  deprecation_report: string;
1196
1349
  trust_report: string;
1197
1350
  orphan_report: string;
1351
+ dormancy_report: string;
1198
1352
  workspaces_json: string;
1199
1353
  packs_json: string;
1200
1354
  strict: boolean;
@@ -1231,6 +1385,7 @@ function parse_args(argv: readonly string[]): ParsedArgs {
1231
1385
  deprecation_report: _config.DEFAULT_DEPRECATION_REPORT,
1232
1386
  trust_report: _config.DEFAULT_TRUST_REPORT,
1233
1387
  orphan_report: _config.DEFAULT_ORPHAN_REPORT,
1388
+ dormancy_report: _config.DEFAULT_DORMANCY_REPORT,
1234
1389
  workspaces_json: _config.DEFAULT_WORKSPACES_JSON,
1235
1390
  packs_json: _config.DEFAULT_PACKS_JSON,
1236
1391
  strict: false,
@@ -1242,6 +1397,7 @@ function parse_args(argv: readonly string[]): ParsedArgs {
1242
1397
  ' [--deprecation-report DEPRECATION_REPORT]\n' +
1243
1398
  ' [--trust-report TRUST_REPORT]\n' +
1244
1399
  ' [--orphan-report ORPHAN_REPORT]\n' +
1400
+ ' [--dormancy-report DORMANCY_REPORT]\n' +
1245
1401
  ' [--workspaces-json WORKSPACES_JSON]\n' +
1246
1402
  ' [--packs-json PACKS_JSON] [--strict]\n' +
1247
1403
  ' [--quiet]\n';
@@ -1252,6 +1408,7 @@ function parse_args(argv: readonly string[]): ParsedArgs {
1252
1408
  '--deprecation-report': 'deprecation_report',
1253
1409
  '--trust-report': 'trust_report',
1254
1410
  '--orphan-report': 'orphan_report',
1411
+ '--dormancy-report': 'dormancy_report',
1255
1412
  '--workspaces-json': 'workspaces_json',
1256
1413
  '--packs-json': 'packs_json',
1257
1414
  };
@@ -1324,6 +1481,10 @@ function main(argv: readonly string[]): number {
1324
1481
  _writeFileMkdir(args.deprecation_report, _deprecation_report(manifest));
1325
1482
  _writeFileMkdir(args.trust_report, _trust_report(manifest));
1326
1483
  _writeFileMkdir(args.orphan_report, _orphan_report(manifest));
1484
+ _writeFileMkdir(
1485
+ args.dormancy_report,
1486
+ _dormancy_report(manifest, _dormancy_probe(_config.ROOT)),
1487
+ );
1327
1488
  // Phase 5 sub-views.
1328
1489
  _writeFileMkdir(
1329
1490
  args.workspaces_json,
@@ -1425,6 +1586,10 @@ export {
1425
1586
  _trust_report,
1426
1587
  _orphan_artefacts,
1427
1588
  _orphan_report,
1589
+ _dormancy_probe,
1590
+ _dormancy_unit,
1591
+ _dormant_artefacts,
1592
+ _dormancy_report,
1428
1593
  _workspaces_view,
1429
1594
  _packs_view,
1430
1595
  _summary,
@@ -55,7 +55,14 @@ const LEDGER_REL = 'docs/CLAIMS.md';
55
55
  const SURFACE_ROOTS = ['README.md', 'docs'];
56
56
 
57
57
  const CLAIM_MARKER = /<!--\s*claim:([A-Za-z0-9._-]+)\s*-->/g;
58
- const URL_DATED = /^https?:\/\/\S+\(\d{4}-\d{2}-\d{2}\)\s*$/;
58
+ // The documented grammar is `https://… (YYYY-MM-DD)` — with a space, in both
59
+ // docs/CLAIMS.md § Evidence-pointer grammar and this file's own header. The
60
+ // original pattern required the stamp to abut the URL (`\S+\(`), so every
61
+ // pointer written the documented way was rejected. That went unnoticed because
62
+ // the form had zero usage until the external research citations were bound;
63
+ // the space is now optional, which accepts both spellings and keeps the
64
+ // enforcement matching the spec rather than the other way round.
65
+ const URL_DATED = /^https?:\/\/\S+\s*\(\d{4}-\d{2}-\d{2}\)\s*$/;
59
66
 
60
67
  /** Witness sweep (P1.2) — marketing-class surfaces where a bare number must
61
68
  * not appear as a capability claim. docs/proof.md and docs/comparison.yaml
@@ -118,6 +125,17 @@ export interface LedgerEntry {
118
125
  evidence: string;
119
126
  status: string;
120
127
  last_verified: string;
128
+ /**
129
+ * Optional forward link on a `resolved-null` entry: the id of the claim that
130
+ * reopened the same question by a different mechanism. The retire-never-delete
131
+ * lifecycle keeps the null readable forever, but a reader arriving at a closed
132
+ * question has no way to learn it was later reopened — this is that link.
133
+ *
134
+ * Parsed and gated rather than merely documented: an unparsed field would be a
135
+ * documented claim the code does not honour, which is the defect class this
136
+ * ledger exists to make impossible.
137
+ */
138
+ superseded_by: string;
121
139
  }
122
140
 
123
141
  interface Args {
@@ -158,6 +176,7 @@ function load_ledger(): Map<string, LedgerEntry> {
158
176
  evidence: cur.evidence ?? '',
159
177
  status: cur.status ?? '',
160
178
  last_verified: cur.last_verified ?? '',
179
+ superseded_by: cur.superseded_by ?? '',
161
180
  });
162
181
  }
163
182
  cur = {};
@@ -170,7 +189,7 @@ function load_ledger(): Map<string, LedgerEntry> {
170
189
  continue;
171
190
  }
172
191
  if (!cur.id) continue;
173
- const field = line.match(/^-\s+(claim|kind|evidence|status|last_verified):\s*(.*)$/);
192
+ const field = line.match(/^-\s+(claim|kind|evidence|status|last_verified|superseded_by):\s*(.*)$/);
174
193
  if (field) {
175
194
  const key = field[1] as keyof LedgerEntry;
176
195
  (cur as Record<string, string>)[key] = (field[2] ?? '').trim();
@@ -320,6 +339,36 @@ function main(argv: string[] = process.argv.slice(2)): number {
320
339
  if (un) findings.push({ id: entry.id, file: LEDGER_REL, reason: `backed entry has dangling evidence — ${un}` });
321
340
  }
322
341
 
342
+ // 2b. Successor pointers: a `superseded_by` must name a real ledger entry,
343
+ // and only a closed question can have a successor. The field exists so a
344
+ // reader arriving at a `resolved-null` learns the question was reopened
345
+ // by a different mechanism — a dangling id would send them nowhere, and
346
+ // the field on a live entry would claim a closure that never happened.
347
+ for (const entry of ledger.values()) {
348
+ if (!entry.superseded_by) continue;
349
+ if (entry.status !== 'resolved-null') {
350
+ findings.push({
351
+ id: entry.id,
352
+ file: LEDGER_REL,
353
+ reason: `superseded_by is only meaningful on a resolved-null entry (status: ${entry.status || 'missing'})`,
354
+ });
355
+ continue;
356
+ }
357
+ if (!ledger.has(entry.superseded_by)) {
358
+ findings.push({
359
+ id: entry.id,
360
+ file: LEDGER_REL,
361
+ reason: `superseded_by names claim:${entry.superseded_by}, which is not in the ledger`,
362
+ });
363
+ } else if (entry.superseded_by === entry.id) {
364
+ findings.push({
365
+ id: entry.id,
366
+ file: LEDGER_REL,
367
+ reason: 'superseded_by points at its own entry',
368
+ });
369
+ }
370
+ }
371
+
323
372
  // 3. Witness sweep (road-to-opt-subagent-harvest P1.2): a QUANTIFIED
324
373
  // capability claim on a marketing-class surface must be witnessed — a
325
374
  // `<!-- claim:ID -->` marker on the same line, or an explicit
@@ -20,7 +20,9 @@
20
20
  * - Median ratio < 3× across all classes → structural overhead acceptable
21
21
  *
22
22
  * Output: human-readable table on stdout + JSON to
23
- * `agents/runtime/reports/ci-time-ratio.json`.
23
+ * `agents/reports/ci-time-ratio.json` — the tracked report directory, and
24
+ * the path `DEFAULT_OUT` has always written. This docstring named a
25
+ * different, untracked directory until 2026-08-11; the code never did.
24
26
  *
25
27
  * Usage:
26
28
  * node ci_time_ratio.js --limit 30