@event4u/agent-config 9.29.0 → 9.31.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 (142) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +32 -214
  3. package/README.md +1 -0
  4. package/dist/agent-src/commands/analyze/inbox.md +22 -1
  5. package/dist/agent-src/commands/explain-run.md +31 -1
  6. package/dist/agent-src/commands/team/delegate.md +1 -0
  7. package/dist/agent-src/contexts/execution/orchestration-telemetry.md +4 -1
  8. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +17 -0
  9. package/dist/agent-src/contexts/execution/subagent-response-contract.md +11 -1
  10. package/dist/agent-src/rules/preservation-guard.md +9 -0
  11. package/dist/agent-src/scripts/roadmap_gates.ts +312 -0
  12. package/dist/agent-src/scripts/update_roadmap_progress.ts +72 -4
  13. package/dist/agent-src/skills/subagent-orchestration/SKILL.md +10 -0
  14. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  15. package/dist/cli/registry.js +2 -0
  16. package/dist/cli/registry.js.map +1 -1
  17. package/dist/cli-delegate/{chunk-RCIA67XN.js → chunk-2WCLVDWC.js} +45 -2
  18. package/dist/cli-delegate/{chunk-66OVWGB5.js → chunk-AG47LAGQ.js} +1 -1
  19. package/dist/cli-delegate/{chunk-MP3JT3IU.js → chunk-DS6BLDMG.js} +1 -1
  20. package/dist/cli-delegate/chunk-EJITJBJX.js +172 -0
  21. package/dist/cli-delegate/{chunk-NZ5N2XQL.js → chunk-ITIQSGOQ.js} +3 -3
  22. package/dist/cli-delegate/{chunk-LCFJIQ4J.js → chunk-JEJABGKB.js} +110 -6
  23. package/dist/cli-delegate/chunk-MMJL4TIX.js +18 -0
  24. package/dist/cli-delegate/{chunk-REKCMYOU.js → chunk-PFBV6XRT.js} +36 -20
  25. package/dist/cli-delegate/{chunk-OJM3MBJM.js → chunk-QEEB4LHO.js} +22 -35
  26. package/dist/cli-delegate/chunk-RVGBH4XK.js +142 -0
  27. package/dist/cli-delegate/{chunk-MMXA5GY7.js → chunk-UVKNIJH2.js} +14 -0
  28. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  29. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  30. package/dist/cli-delegate/cmd_conformance.js +2861 -96
  31. package/dist/cli-delegate/cmd_doctor.js +3 -2
  32. package/dist/cli-delegate/cmd_explain.js +2 -2
  33. package/dist/cli-delegate/cmd_export.js +4 -4
  34. package/dist/cli-delegate/cmd_handoff.js +87 -7
  35. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  36. package/dist/cli-delegate/cmd_migrate.js +2 -2
  37. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  38. package/dist/cli-delegate/cmd_prune.js +4 -4
  39. package/dist/cli-delegate/cmd_route_audit.js +3 -2
  40. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  41. package/dist/cli-delegate/cmd_session_recycle.js +177 -0
  42. package/dist/cli-delegate/cmd_settings_get.js +3 -56
  43. package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
  44. package/dist/cli-delegate/cmd_settings_set.js +2 -2
  45. package/dist/cli-delegate/cmd_sync.js +4 -4
  46. package/dist/cli-delegate/cmd_uninstall.js +4 -4
  47. package/dist/cli-delegate/cmd_update.js +1 -1
  48. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  49. package/dist/cli-delegate/cmd_validate.js +4 -4
  50. package/dist/cli-delegate/cmd_versions.js +1 -1
  51. package/dist/discovery/deprecation-report.md +1 -1
  52. package/dist/discovery/discovery-manifest.json +7 -7
  53. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  54. package/dist/discovery/discovery-manifest.summary.md +1 -1
  55. package/dist/discovery/orphan-report.md +1 -1
  56. package/dist/discovery/packs.json +2 -2
  57. package/dist/discovery/trust-report.md +1 -1
  58. package/dist/discovery/workspaces.json +2 -2
  59. package/dist/hooks/dispatch.js +2143 -641
  60. package/dist/install/install.mjs +133 -14
  61. package/dist/mcp/registry-manifest.json +2 -2
  62. package/dist/mcp/server.json +2 -2
  63. package/dist/mcp/server.mjs +3967 -761
  64. package/dist/scripts/_lib/agent_settings.js +111 -2
  65. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  66. package/dist/server/schemas/settings.js +6 -0
  67. package/dist/server/schemas/settings.js.map +1 -1
  68. package/dist/ui/assets/index-B5qGYOJo.js +26 -0
  69. package/dist/ui/assets/index-B5qGYOJo.js.map +1 -0
  70. package/dist/ui/index.html +1 -1
  71. package/docs/contracts/settings-classes.md +21 -5
  72. package/package.json +1 -1
  73. package/src/agent-src/scripts/roadmap_gates.ts +312 -0
  74. package/src/agent-src/scripts/update_roadmap_progress.ts +72 -4
  75. package/src/config/agent-settings.template.yml +36 -0
  76. package/src/config/dispatch-economy-metrics.json +61 -0
  77. package/src/config/evaluator-budgets.json +4 -4
  78. package/src/config/hook-token-budget.json +91 -0
  79. package/src/config/pack-size-budget.json +3 -2
  80. package/src/config/recycle-threshold-budget.json +12 -0
  81. package/src/config/rule-enforcement-baseline.json +86 -0
  82. package/src/scripts/_cli/cmd_conformance.ts +17 -2
  83. package/src/scripts/_cli/cmd_session_recycle.ts +226 -0
  84. package/src/scripts/_cli/explain_last/inputs.ts +13 -0
  85. package/src/scripts/_dispatch.bash +23 -0
  86. package/src/scripts/_lib/agent_settings.ts +119 -2
  87. package/src/scripts/_lib/carrier_divergence.ts +47 -0
  88. package/src/scripts/_lib/judgment_ladder.ts +254 -2
  89. package/src/scripts/_lib/orchestration_record.ts +41 -10
  90. package/src/scripts/_lib/recycle_envelope_paths.ts +35 -0
  91. package/src/scripts/_lib/self_repair.ts +194 -0
  92. package/src/scripts/_lib/session_eol.ts +230 -0
  93. package/src/scripts/_lib/session_role.ts +42 -0
  94. package/src/scripts/_lib/subagent_capsule.ts +159 -0
  95. package/src/scripts/_lib/subagent_response.ts +75 -0
  96. package/src/scripts/ai_council/clients.ts +50 -1
  97. package/src/scripts/ask_transport.ts +221 -0
  98. package/src/scripts/bench_hook_injection.ts +234 -0
  99. package/src/scripts/check_adr_frontmatter.ts +84 -40
  100. package/src/scripts/check_branch_freshness.ts +1 -0
  101. package/src/scripts/check_corpus_staleness.ts +18 -4
  102. package/src/scripts/check_detector_corpus.ts +17 -0
  103. package/src/scripts/check_gitignore_freshness.ts +42 -8
  104. package/src/scripts/check_hook_bundle_freshness.ts +20 -0
  105. package/src/scripts/check_kernel_prefix_stability.ts +1 -0
  106. package/src/scripts/check_standing_rule_delivery.ts +73 -32
  107. package/src/scripts/check_static_layer_stability.ts +154 -0
  108. package/src/scripts/check_suppression_hygiene.ts +48 -0
  109. package/src/scripts/dispatch_economy_report.ts +448 -0
  110. package/src/scripts/dispatch_r2_reviewer.ts +15 -2
  111. package/src/scripts/explain_run.ts +206 -4
  112. package/src/scripts/generate_settings_reference.ts +1 -0
  113. package/src/scripts/handoff_context_hook.ts +112 -4
  114. package/src/scripts/hook_manifest.yaml +117 -10
  115. package/src/scripts/hooks/concern_registry.ts +8 -0
  116. package/src/scripts/hooks/delegation_nudge_hook.ts +40 -3
  117. package/src/scripts/hooks/dispatch_hook.ts +39 -2
  118. package/src/scripts/hooks/edit_shape_hook.ts +303 -0
  119. package/src/scripts/hooks/orchestration_record_hook.ts +19 -1
  120. package/src/scripts/hooks/reread_guard_hook.ts +314 -0
  121. package/src/scripts/hooks/rtk_wrap_hook.ts +133 -14
  122. package/src/scripts/hooks/session_eol_hook.ts +268 -0
  123. package/src/scripts/hooks/turn_end_gate_hook.ts +715 -0
  124. package/src/scripts/install-hooks.sh +1 -1
  125. package/src/scripts/language_mirror_hook.ts +143 -1
  126. package/src/scripts/lint_decision_impact.ts +14 -1
  127. package/src/scripts/lint_hook_manifest.ts +82 -0
  128. package/src/scripts/lint_readme_serial_comma.ts +1 -0
  129. package/src/scripts/lint_rule_enforcement_declaration.ts +347 -0
  130. package/src/scripts/measure_turn_end_gate.ts +272 -0
  131. package/src/scripts/orchestration_record.ts +4 -1
  132. package/src/scripts/rebuild_hook_bundle.ts +1 -0
  133. package/src/scripts/report_carrier_divergence.ts +72 -24
  134. package/src/scripts/report_conformance_funnel.ts +291 -0
  135. package/src/scripts/report_imperative_density.ts +1 -0
  136. package/src/scripts/routing_doctor.ts +97 -0
  137. package/src/scripts/self_repair_cli.ts +51 -3
  138. package/src/scripts/session_eol_report.ts +261 -0
  139. package/src/server/schemas/settings.ts +14 -0
  140. package/src/templates/compact-instructions.md +40 -0
  141. package/dist/ui/assets/index-JDAF00bC.js +0 -26
  142. package/dist/ui/assets/index-JDAF00bC.js.map +0 -1
@@ -35,6 +35,7 @@ import * as fs from 'node:fs';
35
35
  import * as path from 'node:path';
36
36
  import { fileURLToPath, pathToFileURL } from 'node:url';
37
37
 
38
+ import { GateLedger } from './_lib/gate_ledger.js';
38
39
  import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
39
40
 
40
41
  const _HERE = path.dirname(fileURLToPath(import.meta.url));
@@ -101,48 +102,81 @@ export function trigger_is_meaningful(value: string): boolean {
101
102
  return true;
102
103
  }
103
104
 
104
- export function check(dir: string = ADR_DIR): AdrFinding[] {
105
+ /** Every frontmatter finding for one ADR file. Pure — no I/O, no enumeration. */
106
+ export function check_one(rel: string, text: string): AdrFinding[] {
107
+ const findings: AdrFinding[] = [];
108
+ const fm = parse_frontmatter(text);
109
+
110
+ if (fm === null) {
111
+ findings.push({ file: rel, level: 'error', message: 'no YAML frontmatter block' });
112
+ return findings;
113
+ }
114
+ for (const req of REQUIRED) {
115
+ if (!fm[req]) findings.push({ file: rel, level: 'error', message: `missing \`${req}\`` });
116
+ }
117
+ if (fm['status'] && !ALLOWED_STATUS.has(fm['status'])) {
118
+ findings.push({
119
+ file: rel,
120
+ level: 'error',
121
+ message: `status \`${fm['status']}\` is not one of: ${[...ALLOWED_STATUS].sort().join(', ')}`,
122
+ });
123
+ }
124
+
125
+ const date = fm['date'] ?? '';
126
+ const grandfathered = date !== '' && date < REVIEW_TRIGGER_SINCE;
127
+ const trigger = fm['review_trigger'];
128
+
129
+ if (!trigger) {
130
+ findings.push({
131
+ file: rel,
132
+ level: grandfathered ? 'warn' : 'error',
133
+ message: grandfathered
134
+ ? `no \`review_trigger\` (grandfathered — dated ${date}, before ${REVIEW_TRIGGER_SINCE})`
135
+ : `missing \`review_trigger\` — name the CONDITION that would reopen this decision`,
136
+ });
137
+ } else if (!trigger_is_meaningful(trigger)) {
138
+ findings.push({
139
+ file: rel,
140
+ level: grandfathered ? 'warn' : 'error',
141
+ message: `\`review_trigger\` is a cadence, not a condition: "${trigger}". A calendar review is ignored; an event fires.`,
142
+ });
143
+ }
144
+ return findings;
145
+ }
146
+
147
+ /**
148
+ * Check every ADR in `dir`, optionally recording per-file completeness.
149
+ *
150
+ * The candidate set is every `*.md` in the directory, not the `ADR-*.md` subset:
151
+ * the name filter used to be a bare `continue`, so a decision record saved as
152
+ * `adr-131-foo.md` (lower case) or `131-foo.md` left the corpus without a trace
153
+ * and the gate still printed a green line over the files it did read. It is now
154
+ * an out-of-scope outcome with a reason, and it prints.
155
+ *
156
+ * A `warn`-level finding resolves as `complete`: warnings are allowed by this
157
+ * gate's exit-code contract, so `fail` is reserved for what actually reds it.
158
+ */
159
+ export function check(dir: string = ADR_DIR, ledger?: GateLedger): AdrFinding[] {
105
160
  const findings: AdrFinding[] = [];
106
161
  if (!fs.existsSync(dir)) return findings;
107
162
 
108
- for (const name of fs.readdirSync(dir).sort()) {
109
- if (!/^ADR-.*\.md$/.test(name)) continue;
110
- const rel = path.relative(REPO_ROOT, path.join(dir, name));
111
- const fm = parse_frontmatter(fs.readFileSync(path.join(dir, name), 'utf-8'));
163
+ const candidates = fs.readdirSync(dir).filter((n) => n.endsWith('.md')).sort();
164
+ ledger?.plan(candidates);
112
165
 
113
- if (fm === null) {
114
- findings.push({ file: rel, level: 'error', message: 'no YAML frontmatter block' });
166
+ for (const name of candidates) {
167
+ if (!/^ADR-.*\.md$/.test(name)) {
168
+ ledger?.outOfScope(name, 'not_applicable_kind');
115
169
  continue;
116
170
  }
117
- for (const req of REQUIRED) {
118
- if (!fm[req]) findings.push({ file: rel, level: 'error', message: `missing \`${req}\`` });
119
- }
120
- if (fm['status'] && !ALLOWED_STATUS.has(fm['status'])) {
121
- findings.push({
122
- file: rel,
123
- level: 'error',
124
- message: `status \`${fm['status']}\` is not one of: ${[...ALLOWED_STATUS].sort().join(', ')}`,
125
- });
126
- }
171
+ const rel = path.relative(REPO_ROOT, path.join(dir, name));
172
+ const own = check_one(rel, fs.readFileSync(path.join(dir, name), 'utf-8'));
173
+ findings.push(...own);
127
174
 
128
- const date = fm['date'] ?? '';
129
- const grandfathered = date !== '' && date < REVIEW_TRIGGER_SINCE;
130
- const trigger = fm['review_trigger'];
131
-
132
- if (!trigger) {
133
- findings.push({
134
- file: rel,
135
- level: grandfathered ? 'warn' : 'error',
136
- message: grandfathered
137
- ? `no \`review_trigger\` (grandfathered — dated ${date}, before ${REVIEW_TRIGGER_SINCE})`
138
- : `missing \`review_trigger\` — name the CONDITION that would reopen this decision`,
139
- });
140
- } else if (!trigger_is_meaningful(trigger)) {
141
- findings.push({
142
- file: rel,
143
- level: grandfathered ? 'warn' : 'error',
144
- message: `\`review_trigger\` is a cadence, not a condition: "${trigger}". A calendar review is ignored; an event fires.`,
145
- });
175
+ const errors = own.filter((f) => f.level === 'error').length;
176
+ if (errors > 0) {
177
+ ledger?.fail(name, `${String(errors)} frontmatter error(s)`);
178
+ } else {
179
+ ledger?.complete(name);
146
180
  }
147
181
  }
148
182
  return findings;
@@ -150,10 +184,17 @@ export function check(dir: string = ADR_DIR): AdrFinding[] {
150
184
 
151
185
  function main(argv: string[]): number {
152
186
  const as_json = argv.includes('--json');
153
- const findings = check();
154
- const total = fs.existsSync(ADR_DIR)
155
- ? fs.readdirSync(ADR_DIR).filter((f) => /^ADR-.*\.md$/.test(f)).length
156
- : 0;
187
+ const ledger = new GateLedger('check_adr_frontmatter');
188
+ const findings = check(ADR_DIR, ledger);
189
+ // Finalized here, printed later by `report()` (which re-finalizes — the call
190
+ // is pure): the denominator is needed for the scope assertion below, which
191
+ // runs before any output. It is the ledger's own per-file accounting — the
192
+ // ADRs that actually reached a checked outcome — where it used to be a SECOND
193
+ // `readdirSync` of the same directory, independent of the loop that did the
194
+ // work, so the printed count could not disagree with the scan even when the
195
+ // scan had skipped files.
196
+ const tally = ledger.finalize();
197
+ const total = tally.completed + tally.failed;
157
198
 
158
199
  // `check()` returns findings, never a count, so a moved `docs/decisions/`
159
200
  // yields an empty list — reported as "0 ADR(s) · 0 error(s)" and green.
@@ -174,7 +215,9 @@ function main(argv: string[]): number {
174
215
  }
175
216
 
176
217
  if (as_json) {
177
- process.stdout.write(JSON.stringify({ findings }, null, 2) + '\n');
218
+ // The tally rides in the payload rather than on stdout: `ledger.report()`
219
+ // would print beside the JSON and break every parser of this mode.
220
+ process.stdout.write(JSON.stringify({ findings, ledger: tally }, null, 2) + '\n');
178
221
  return 0;
179
222
  }
180
223
 
@@ -185,6 +228,7 @@ function main(argv: string[]): number {
185
228
  `ADR frontmatter: ${total} ADR(s) · ${errors.length} error(s) · ` +
186
229
  `${warns.length} grandfathered without a revisit condition\n`,
187
230
  );
231
+ ledger.report();
188
232
  for (const e of errors) process.stderr.write(` ❌ ${e.file}: ${e.message}\n`);
189
233
 
190
234
  if (errors.length > 0) {
@@ -41,6 +41,7 @@ import { fileURLToPath } from "node:url";
41
41
  import { runGateCli, runSelfTest, type SelfTestCase } from "./_lib/gate_self_test.js";
42
42
  import { reportScanned } from "./_lib/scan_scope.js";
43
43
 
44
+ // ledger-exempt: single remote-ref probe — the entire scope is ONE ls-remote answer (0 or 1 refs) resolved to one aggregate ancestor verdict, and every empty path already publishes its reason via reportScanned allowEmpty; there is no per-target collection to account.
44
45
  const DEFAULT_BASE = "main";
45
46
  const SELF_TEST_MIN_CASES = 3;
46
47
  const SELF_TEST_MIN_REJECT = 1;
@@ -49,6 +49,7 @@ import * as os from 'node:os';
49
49
  import * as path from 'node:path';
50
50
  import { fileURLToPath, pathToFileURL } from 'node:url';
51
51
 
52
+ import { GateLedger } from './_lib/gate_ledger.js';
52
53
  import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
53
54
  import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
54
55
 
@@ -423,20 +424,32 @@ export interface RunResult {
423
424
  readonly corpora: number;
424
425
  /** CSVs actually opened, reported so "checked nothing" cannot read as clean. */
425
426
  readonly csvs: number;
427
+ /** Per-corpus completeness accounting — every discovered corpus reaches one outcome. */
428
+ readonly ledger: GateLedger;
426
429
  }
427
430
 
428
431
  export function run_checks(todayStamp: number, skillsDir: string = SKILLS_DIR): RunResult {
429
432
  const corpora = discover_corpora(skillsDir);
433
+ const ledger = new GateLedger('check_corpus_staleness');
434
+ ledger.plan(corpora.map((c) => c.slug));
430
435
  const findings: CorpusFinding[] = [];
431
436
  let csvs = 0;
432
437
  for (const corpus of corpora) {
433
- findings.push(...check_staleness(corpus, todayStamp));
434
- findings.push(...check_attribution_pin(corpus));
438
+ const found: CorpusFinding[] = [
439
+ ...check_staleness(corpus, todayStamp),
440
+ ...check_attribution_pin(corpus),
441
+ ];
435
442
  const csv = check_csv_integrity(corpus);
436
- findings.push(...csv.findings);
443
+ found.push(...csv.findings);
437
444
  csvs += csv.opened;
445
+ findings.push(...found);
446
+ if (found.length > 0) {
447
+ ledger.fail(corpus.slug, `${String(found.length)} corpus finding(s)`);
448
+ } else {
449
+ ledger.complete(corpus.slug);
450
+ }
438
451
  }
439
- return { findings, corpora: corpora.length, csvs };
452
+ return { findings, corpora: corpora.length, csvs, ledger };
440
453
  }
441
454
 
442
455
  const HELP = `check_corpus_staleness — offline staleness + CSV-integrity gate for the grounding corpora
@@ -651,6 +664,7 @@ export function main(argv: string[] = process.argv.slice(2)): number {
651
664
 
652
665
  // gate-coverage contract (src/config/gate-coverage.yml): corpora inspected.
653
666
  process.stdout.write(`scanned: ${String(result.corpora)}\n`);
667
+ result.ledger.report();
654
668
 
655
669
  if (result.findings.length > 0) {
656
670
  process.stdout.write(`❌ ${String(result.findings.length)} corpus finding(s):\n`);
@@ -28,6 +28,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
28
28
 
29
29
  import * as YAML from 'yaml';
30
30
 
31
+ import { GateLedger } from './_lib/gate_ledger.js';
31
32
  import {
32
33
  type DefectFinding,
33
34
  type TurnSnapshot,
@@ -163,6 +164,22 @@ export function main(repoRoot: string = process.cwd()): number {
163
164
  roots: [CORPUS_REL],
164
165
  });
165
166
 
167
+ // Per-detector completeness: every registered detector AND every corpus
168
+ // entry reaches exactly one outcome, so a detector silently absent from the
169
+ // loop above could not read as a pass.
170
+ const ledger = new GateLedger(GATE);
171
+ const names = [...new Set([...Object.keys(DETECTORS), ...Object.keys(corpus)])].sort();
172
+ ledger.plan(names);
173
+ const failing = new Set(findings.map((f) => f.detector));
174
+ for (const name of names) {
175
+ if (failing.has(name)) {
176
+ ledger.fail(name, 'corpus finding(s) against this detector');
177
+ } else {
178
+ ledger.complete(name);
179
+ }
180
+ }
181
+ ledger.report();
182
+
166
183
  if (findings.length > 0) {
167
184
  process.stderr.write(`${GATE}: ${String(findings.length)} finding(s)\n\n`);
168
185
  for (const f of findings) {
@@ -17,6 +17,7 @@ import * as fs from 'node:fs';
17
17
  import * as path from 'node:path';
18
18
  import { fileURLToPath, pathToFileURL } from 'node:url';
19
19
 
20
+ import { GateLedger, LedgerUsageError } from './_lib/gate_ledger.js';
20
21
  import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
21
22
 
22
23
  const _HERE = fileURLToPath(import.meta.url);
@@ -108,15 +109,48 @@ function main(argv?: readonly string[]): number {
108
109
 
109
110
  const missing: string[] = [];
110
111
 
111
- for (const e of entries) {
112
- if (e.policy !== 'ignored') continue;
113
- if (e.scope !== 'consumer' && e.scope !== 'both') continue;
114
- // Carve-out negations (path starts with !) are separately tracked
115
- if (e.path.startsWith('!')) continue;
116
- if (!blockLines.has(e.path)) {
117
- missing.push(e.path);
112
+ // Per-entry completeness. Three of the four decisions below used to be bare
113
+ // `continue`s, so an entry whose `policy`/`scope` line failed to parse read
114
+ // as "correctly out of scope" and the success line still counted the rest.
115
+ // Each is now a recorded outcome with a reason, and the count printed below
116
+ // is the ledger's, not a second independent filter over the same list.
117
+ const ledger = new GateLedger('check_gitignore_freshness');
118
+ try {
119
+ ledger.plan(entries.map((e) => e.path));
120
+
121
+ for (const e of entries) {
122
+ if (e.policy !== 'ignored') {
123
+ ledger.outOfScope(e.path, 'not_applicable_kind');
124
+ continue;
125
+ }
126
+ if (e.scope !== 'consumer' && e.scope !== 'both') {
127
+ ledger.outOfScope(e.path, 'not_applicable_kind');
128
+ continue;
129
+ }
130
+ // Carve-out negations (path starts with !) are separately tracked
131
+ if (e.path.startsWith('!')) {
132
+ ledger.outOfScope(e.path, 'declared_exemption');
133
+ continue;
134
+ }
135
+ if (!blockLines.has(e.path)) {
136
+ missing.push(e.path);
137
+ ledger.fail(e.path, 'absent from gitignore-block.txt');
138
+ } else {
139
+ ledger.complete(e.path);
140
+ }
118
141
  }
142
+ } catch (err) {
143
+ // A path listed twice in the manifest makes the denominator wrong in the
144
+ // direction that hides work, so the ledger refuses it. That is a manifest
145
+ // defect, not a missing gitignore line: exit 2 ("the gate could not run"),
146
+ // never 1.
147
+ if (err instanceof LedgerUsageError) {
148
+ process.stderr.write(`❌ ${path.relative(REPO_ROOT, MANIFEST)}: ${err.message}\n`);
149
+ return 2;
150
+ }
151
+ throw err;
119
152
  }
153
+ const tally = ledger.report();
120
154
 
121
155
  if (missing.length > 0) {
122
156
  process.stdout.write(
@@ -134,7 +168,7 @@ function main(argv?: readonly string[]): number {
134
168
 
135
169
  if (!quiet) {
136
170
  process.stdout.write(
137
- `✅ gitignore-block.txt covers all ${entries.filter((e) => e.policy === 'ignored' && (e.scope === 'consumer' || e.scope === 'both')).length} consumer-scoped ignored paths.\n`,
171
+ `✅ gitignore-block.txt covers all ${tally.completed + tally.failed} consumer-scoped ignored paths.\n`,
138
172
  );
139
173
  }
140
174
  return 0;
@@ -44,6 +44,8 @@ import { fileURLToPath } from "node:url";
44
44
 
45
45
  import { parse as parseYaml } from "yaml";
46
46
 
47
+ // Single-quoted on purpose: check_gate_completeness detects ledger adoption by this exact import form.
48
+ import { GateLedger } from './_lib/gate_ledger.js';
47
49
  import { reportScanned } from "./_lib/scan_scope.js";
48
50
 
49
51
  const HERE = path.dirname(fileURLToPath(import.meta.url));
@@ -135,6 +137,24 @@ export function main(argv: string[] = process.argv.slice(2)): number {
135
137
  : {}),
136
138
  });
137
139
 
140
+ // Per-source completeness: every bundled source reaches exactly one outcome
141
+ // (fresh or stale), so a source silently dropped from the mtime sweep could
142
+ // not read as a pass. Nothing to plan when no self-hosted bundle exists.
143
+ if (!r.skipped) {
144
+ const ledger = new GateLedger("check_hook_bundle_freshness");
145
+ const sources = bundledSources(root);
146
+ ledger.plan(sources);
147
+ const staleSet = new Set(r.stale.map((s) => s.file));
148
+ for (const file of sources) {
149
+ if (staleSet.has(file)) {
150
+ ledger.fail(file, "source is newer than the built bundle");
151
+ } else {
152
+ ledger.complete(file);
153
+ }
154
+ }
155
+ ledger.report();
156
+ }
157
+
138
158
  // A gate that scans nothing must SAY it scanned nothing — a silent green
139
159
  // here is indistinguishable from a pass.
140
160
  if (r.skipped) {
@@ -39,6 +39,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
39
39
 
40
40
  import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
41
41
 
42
+ // ledger-exempt: single folded-hash comparison — the ordered kernel bodies become ONE sha256 via compute_prefix, byte-compared against the single committed snapshot, so the gate has exactly one aggregate drift verdict and no per-rule outcome exists to record; a changed byte in any body is indistinguishable from a reorder by construction, which IS the invariant. The kernel-count scope floor is already asserted by assertScanned over dist/router.json.
42
43
  const _HERE = fileURLToPath(import.meta.url);
43
44
  const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
44
45
  const ROUTER = path.join(REPO_ROOT, 'dist/router.json');
@@ -114,7 +114,7 @@ export function reasonIsStated(reason: string): boolean {
114
114
  }
115
115
 
116
116
  /** One measured layer. */
117
- interface LayerMeasure {
117
+ export interface LayerMeasure {
118
118
  readonly label: string;
119
119
  readonly dir: string;
120
120
  readonly files: number;
@@ -122,17 +122,66 @@ interface LayerMeasure {
122
122
  readonly exact: boolean;
123
123
  }
124
124
 
125
- function measureLayer(label: string, dir: string): LayerMeasure | null {
126
- const layer = rule_layer_overlap.readRuleLayer(dir);
127
- if (layer === null) return null;
128
- const joined = [...layer.files.values()].join('\n');
129
- const m = measure(joined);
125
+ function measureFiles(label: string, dir: string, files: ReadonlyMap<string, string>): LayerMeasure {
126
+ const m = measure([...files.values()].join('\n'));
127
+ return { label, dir, files: files.size, tokens: m.tokens_gpt, exact: m.tokens_gpt_exact };
128
+ }
129
+
130
+ /**
131
+ * The one measurement both the gate (`main` below) and the doctor surface
132
+ * (`routing_doctor`) consume — per-layer rule tokens, the overlap between the
133
+ * two layers, and which input the numbers came from. Exported so the doctor
134
+ * reuses it instead of growing a second, drifting copy.
135
+ */
136
+ export interface StandingDeliveryMeasure {
137
+ /** Which input produced the numbers — see `instructionsLoadedRecord`. */
138
+ readonly input: 'instructions-loaded' | 'filesystem';
139
+ readonly layers: readonly LayerMeasure[];
140
+ /** Sum over present layers — what the session receives under the host's no-dedup contract. */
141
+ readonly received_tokens: number;
142
+ /** Rules present in BOTH layers (basename match). */
143
+ readonly overlap_rules: number;
144
+ readonly duplicate_rules: number;
145
+ readonly divergent_rules: number;
146
+ /** Tokens of the project-layer copies of overlapping rules — the doubled delivery. */
147
+ readonly overlap_tokens: number;
148
+ readonly exact: boolean;
149
+ }
150
+
151
+ /** Measure both layers once. `null` when neither directory exists (nothing installed). */
152
+ export function measureStandingDelivery(
153
+ global_dir: string,
154
+ project_dir: string,
155
+ record_path: string,
156
+ ): StandingDeliveryMeasure | null {
157
+ const g = rule_layer_overlap.readRuleLayer(global_dir);
158
+ const p = rule_layer_overlap.readRuleLayer(project_dir);
159
+ if (g === null && p === null) return null;
160
+ const layers: LayerMeasure[] = [];
161
+ if (g !== null) layers.push(measureFiles('global', global_dir, g.files));
162
+ if (p !== null) layers.push(measureFiles('project', project_dir, p.files));
163
+ let overlap_rules = 0;
164
+ let duplicate_rules = 0;
165
+ let divergent_rules = 0;
166
+ let overlap_tokens = 0;
167
+ if (g !== null && p !== null) {
168
+ const r = rule_layer_overlap.compareLayers(g.files, p.files);
169
+ overlap_rules = r.overlap.length;
170
+ duplicate_rules = r.duplicate.length;
171
+ divergent_rules = r.divergent.length;
172
+ if (r.overlap.length > 0) {
173
+ overlap_tokens = measure(r.overlap.map((n) => p.files.get(n) ?? '').join('\n')).tokens_gpt;
174
+ }
175
+ }
130
176
  return {
131
- label,
132
- dir,
133
- files: layer.files.size,
134
- tokens: m.tokens_gpt,
135
- exact: m.tokens_gpt_exact,
177
+ input: fs.existsSync(record_path) ? 'instructions-loaded' : 'filesystem',
178
+ layers,
179
+ received_tokens: layers.reduce((n, l) => n + l.tokens, 0),
180
+ overlap_rules,
181
+ duplicate_rules,
182
+ divergent_rules,
183
+ overlap_tokens,
184
+ exact: layers.every((l) => l.exact),
136
185
  };
137
186
  }
138
187
 
@@ -185,10 +234,12 @@ export function main(argv: readonly string[] = process.argv.slice(2)): number {
185
234
  return 1;
186
235
  }
187
236
 
188
- const layers = [
189
- measureLayer('global', path.join(os.homedir(), '.claude', 'rules')),
190
- measureLayer('project', path.join(REPO_ROOT, '.claude', 'rules')),
191
- ].filter((l): l is LayerMeasure => l !== null);
237
+ const measured = measureStandingDelivery(
238
+ path.join(os.homedir(), '.claude', 'rules'),
239
+ path.join(REPO_ROOT, '.claude', 'rules'),
240
+ instructionsLoadedRecord(REPO_ROOT),
241
+ );
242
+ const layers = measured?.layers ?? [];
192
243
 
193
244
  const scanned = layers.reduce((n, l) => n + l.files, 0);
194
245
  // Two zeroes that mean opposite things, and conflating them is how this gate
@@ -202,7 +253,7 @@ export function main(argv: readonly string[] = process.argv.slice(2)): number {
202
253
  // - **A layer directory that exists and holds no `.md`** — a projection that
203
254
  // ran and produced nothing. That is blindness, not cleanliness, and it
204
255
  // stays exit 3.
205
- const no_layer_present = layers.length === 0;
256
+ const no_layer_present = measured === null;
206
257
  try {
207
258
  assertScanned({
208
259
  gate: PROG,
@@ -239,10 +290,7 @@ export function main(argv: readonly string[] = process.argv.slice(2)): number {
239
290
  return 0;
240
291
  }
241
292
 
242
- const record = instructionsLoadedRecord(REPO_ROOT);
243
- const input = fs.existsSync(record) ? 'instructions-loaded' : 'filesystem';
244
- const total = layers.reduce((n, l) => n + l.tokens, 0);
245
- const exact = layers.every((l) => l.exact);
293
+ const { input, received_tokens: total, exact } = measured;
246
294
 
247
295
  out(`${PROG} · input: ${input} · ${method_note()}\n`);
248
296
  if (input === 'filesystem') {
@@ -256,18 +304,11 @@ export function main(argv: readonly string[] = process.argv.slice(2)): number {
256
304
  out(` ${l.label.padEnd(8)} ${String(l.files).padStart(4)} file(s) ${String(l.tokens).padStart(8)} tok\n`);
257
305
  }
258
306
 
259
- if (layers.length === 2) {
260
- const g = rule_layer_overlap.readRuleLayer(layers[0]!.dir);
261
- const p = rule_layer_overlap.readRuleLayer(layers[1]!.dir);
262
- if (g !== null && p !== null) {
263
- const r = rule_layer_overlap.compareLayers(g.files, p.files);
264
- if (r.overlap.length > 0) {
265
- out(
266
- ` overlap ${String(r.overlap.length).padStart(4)} rule(s) in both layers `
267
- + `(${r.duplicate.length} duplicate, ${r.divergent.length} divergent)\n`,
268
- );
269
- }
270
- }
307
+ if (measured.overlap_rules > 0) {
308
+ out(
309
+ ` overlap ${String(measured.overlap_rules).padStart(4)} rule(s) in both layers `
310
+ + `(${measured.duplicate_rules} duplicate, ${measured.divergent_rules} divergent)\n`,
311
+ );
271
312
  }
272
313
 
273
314
  const pct = total / budget.total_cap_tokens;