@event4u/agent-config 9.29.0 → 9.30.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 (122) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +108 -7
  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-OJM3MBJM.js → chunk-BTJNRUX3.js} +21 -34
  18. package/dist/cli-delegate/{chunk-MMXA5GY7.js → chunk-DDUNNFYX.js} +14 -0
  19. package/dist/cli-delegate/{chunk-NZ5N2XQL.js → chunk-EQY4FLJB.js} +1 -1
  20. package/dist/cli-delegate/{chunk-RCIA67XN.js → chunk-IYTGIG2Y.js} +44 -1
  21. package/dist/cli-delegate/chunk-MMJL4TIX.js +18 -0
  22. package/dist/cli-delegate/chunk-O3CO5BFB.js +161 -0
  23. package/dist/cli-delegate/{chunk-REKCMYOU.js → chunk-PFBV6XRT.js} +36 -20
  24. package/dist/cli-delegate/chunk-RVGBH4XK.js +142 -0
  25. package/dist/cli-delegate/cmd_conformance.js +2798 -95
  26. package/dist/cli-delegate/cmd_doctor.js +2 -1
  27. package/dist/cli-delegate/cmd_export.js +2 -2
  28. package/dist/cli-delegate/cmd_handoff.js +86 -6
  29. package/dist/cli-delegate/cmd_prune.js +2 -2
  30. package/dist/cli-delegate/cmd_route_audit.js +2 -1
  31. package/dist/cli-delegate/cmd_session_recycle.js +177 -0
  32. package/dist/cli-delegate/cmd_settings_get.js +1 -1
  33. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  34. package/dist/cli-delegate/cmd_settings_set.js +2 -2
  35. package/dist/cli-delegate/cmd_sync.js +2 -2
  36. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  37. package/dist/cli-delegate/cmd_validate.js +2 -2
  38. package/dist/discovery/deprecation-report.md +1 -1
  39. package/dist/discovery/discovery-manifest.json +7 -7
  40. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  41. package/dist/discovery/discovery-manifest.summary.md +1 -1
  42. package/dist/discovery/orphan-report.md +1 -1
  43. package/dist/discovery/packs.json +2 -2
  44. package/dist/discovery/trust-report.md +1 -1
  45. package/dist/discovery/workspaces.json +2 -2
  46. package/dist/hooks/dispatch.js +1904 -575
  47. package/dist/install/install.mjs +14 -0
  48. package/dist/mcp/registry-manifest.json +2 -2
  49. package/dist/mcp/server.json +2 -2
  50. package/dist/mcp/server.mjs +3760 -726
  51. package/dist/server/schemas/settings.js +6 -0
  52. package/dist/server/schemas/settings.js.map +1 -1
  53. package/dist/ui/assets/index-B5qGYOJo.js +26 -0
  54. package/dist/ui/assets/index-B5qGYOJo.js.map +1 -0
  55. package/dist/ui/index.html +1 -1
  56. package/docs/contracts/settings-classes.md +21 -5
  57. package/package.json +1 -1
  58. package/src/agent-src/scripts/roadmap_gates.ts +312 -0
  59. package/src/agent-src/scripts/update_roadmap_progress.ts +72 -4
  60. package/src/config/agent-settings.template.yml +36 -0
  61. package/src/config/dispatch-economy-metrics.json +61 -0
  62. package/src/config/evaluator-budgets.json +4 -4
  63. package/src/config/hook-token-budget.json +91 -0
  64. package/src/config/recycle-threshold-budget.json +12 -0
  65. package/src/config/rule-enforcement-baseline.json +86 -0
  66. package/src/scripts/_cli/cmd_conformance.ts +17 -2
  67. package/src/scripts/_cli/cmd_session_recycle.ts +226 -0
  68. package/src/scripts/_dispatch.bash +23 -0
  69. package/src/scripts/_lib/judgment_ladder.ts +254 -2
  70. package/src/scripts/_lib/orchestration_record.ts +41 -10
  71. package/src/scripts/_lib/recycle_envelope_paths.ts +35 -0
  72. package/src/scripts/_lib/self_repair.ts +194 -0
  73. package/src/scripts/_lib/session_eol.ts +230 -0
  74. package/src/scripts/_lib/session_role.ts +42 -0
  75. package/src/scripts/_lib/subagent_capsule.ts +159 -0
  76. package/src/scripts/_lib/subagent_response.ts +75 -0
  77. package/src/scripts/ai_council/clients.ts +50 -1
  78. package/src/scripts/ask_transport.ts +221 -0
  79. package/src/scripts/bench_hook_injection.ts +234 -0
  80. package/src/scripts/check_adr_frontmatter.ts +84 -40
  81. package/src/scripts/check_branch_freshness.ts +1 -0
  82. package/src/scripts/check_corpus_staleness.ts +18 -4
  83. package/src/scripts/check_detector_corpus.ts +17 -0
  84. package/src/scripts/check_gitignore_freshness.ts +42 -8
  85. package/src/scripts/check_hook_bundle_freshness.ts +20 -0
  86. package/src/scripts/check_kernel_prefix_stability.ts +1 -0
  87. package/src/scripts/check_standing_rule_delivery.ts +73 -32
  88. package/src/scripts/check_static_layer_stability.ts +154 -0
  89. package/src/scripts/check_suppression_hygiene.ts +48 -0
  90. package/src/scripts/dispatch_economy_report.ts +448 -0
  91. package/src/scripts/dispatch_r2_reviewer.ts +15 -2
  92. package/src/scripts/explain_run.ts +206 -4
  93. package/src/scripts/generate_settings_reference.ts +1 -0
  94. package/src/scripts/handoff_context_hook.ts +112 -4
  95. package/src/scripts/hook_manifest.yaml +117 -10
  96. package/src/scripts/hooks/concern_registry.ts +8 -0
  97. package/src/scripts/hooks/delegation_nudge_hook.ts +40 -3
  98. package/src/scripts/hooks/dispatch_hook.ts +39 -2
  99. package/src/scripts/hooks/edit_shape_hook.ts +303 -0
  100. package/src/scripts/hooks/orchestration_record_hook.ts +19 -1
  101. package/src/scripts/hooks/reread_guard_hook.ts +314 -0
  102. package/src/scripts/hooks/rtk_wrap_hook.ts +133 -14
  103. package/src/scripts/hooks/session_eol_hook.ts +268 -0
  104. package/src/scripts/hooks/turn_end_gate_hook.ts +507 -0
  105. package/src/scripts/install-hooks.sh +1 -1
  106. package/src/scripts/language_mirror_hook.ts +89 -0
  107. package/src/scripts/lint_decision_impact.ts +14 -1
  108. package/src/scripts/lint_hook_manifest.ts +82 -0
  109. package/src/scripts/lint_readme_serial_comma.ts +1 -0
  110. package/src/scripts/lint_rule_enforcement_declaration.ts +347 -0
  111. package/src/scripts/measure_turn_end_gate.ts +194 -0
  112. package/src/scripts/orchestration_record.ts +4 -1
  113. package/src/scripts/rebuild_hook_bundle.ts +1 -0
  114. package/src/scripts/report_conformance_funnel.ts +284 -0
  115. package/src/scripts/report_imperative_density.ts +1 -0
  116. package/src/scripts/routing_doctor.ts +97 -0
  117. package/src/scripts/self_repair_cli.ts +51 -3
  118. package/src/scripts/session_eol_report.ts +261 -0
  119. package/src/server/schemas/settings.ts +14 -0
  120. package/src/templates/compact-instructions.md +40 -0
  121. package/dist/ui/assets/index-JDAF00bC.js +0 -26
  122. package/dist/ui/assets/index-JDAF00bC.js.map +0 -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;
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Static-layer stability lint (road-to-token-economy-cache Phase 2.2 — the
4
+ * hygiene half the Phase-1 null left standing; spike note:
5
+ * agents/settings/contexts/cache-injection-anatomy.md).
6
+ *
7
+ * The always-loaded layer is re-sent on every session and every spawn; a
8
+ * MACHINE-VOLATILE marker inside it (an absolute local path naming a real
9
+ * user, a session/run-id shape) is both a cache-stability hazard and a
10
+ * portability leak. Scope is deliberately NARROW and honest:
11
+ *
12
+ * - scanned: dist/agent-src/rules/*.md EXCLUDING the kernel set (the
13
+ * kernel prefix has its own byte-drift guard,
14
+ * check_kernel_prefix_stability — no double coverage, roadmap Risk 8)
15
+ * - flagged: absolute home paths (/Users/<name>/, /home/<name>/,
16
+ * C:\Users\<name>\) and UUID-shaped run/session ids
17
+ * - NOT flagged: dates in prose (stable content, not build-volatile) —
18
+ * build-time volatility is already impossible for .md by the ADR-201
19
+ * byte-exact projection (dist == rewrite(src)); this lint catches what
20
+ * an AUTHOR pastes, which the projection faithfully preserves.
21
+ *
22
+ * Exit 0 green · 1 findings · 2 unusable. Dead-scope guarded via
23
+ * assertScanned. Self-test: tests/scripts/check_static_layer_stability.test.ts
24
+ * proves red on a fixture carrying a home path and a UUID.
25
+ */
26
+ import * as fs from 'node:fs';
27
+ import * as path from 'node:path';
28
+ import { fileURLToPath, pathToFileURL } from 'node:url';
29
+
30
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
31
+
32
+ declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
33
+ const _IN_BUNDLE = typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__;
34
+ const REPO_ROOT = path.resolve(
35
+ path.dirname(fileURLToPath(import.meta.url)),
36
+ ...(_IN_BUNDLE ? ['..'] : ['..', '..']),
37
+ );
38
+ const RULES_DIR = path.join(REPO_ROOT, 'dist', 'agent-src', 'rules');
39
+ const ROUTER = path.join(REPO_ROOT, 'dist', 'router.json');
40
+
41
+ /** Machine-volatile shapes. Home paths anchor on the OS home prefix; the
42
+ * UUID shape requires all five groups so ordinary hex constants stay quiet. */
43
+ const HOME_PATH_RE = /(?:\/(?:Users|home)\/[A-Za-z][\w.-]*\/|[A-Za-z]:\\Users\\[A-Za-z][\w.-]*\\)/;
44
+ const UUID_RE = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i;
45
+ /** Per-line opt-out for a deliberate, justified exception. */
46
+ const IGNORE_MARKER = 'static-layer-allow';
47
+
48
+ export interface Finding {
49
+ file: string;
50
+ line: number;
51
+ kind: 'home-path' | 'uuid';
52
+ excerpt: string;
53
+ }
54
+
55
+ function kernelBasenames(routerPath: string): Set<string> {
56
+ try {
57
+ const router = JSON.parse(fs.readFileSync(routerPath, 'utf8')) as { kernel?: unknown };
58
+ if (Array.isArray(router.kernel)) {
59
+ return new Set(
60
+ router.kernel
61
+ .filter((k): k is string => typeof k === 'string')
62
+ .map((k) => (k.endsWith('.md') ? k : `${k}.md`)),
63
+ );
64
+ }
65
+ } catch {
66
+ /* fall through — an unreadable router means we scan everything (fail toward coverage) */
67
+ }
68
+ return new Set();
69
+ }
70
+
71
+ export function scan(opts: { rulesDir?: string; routerPath?: string } = {}): { findings: Finding[]; scanned: number } {
72
+ const rulesDir = opts.rulesDir ?? RULES_DIR;
73
+ const kernel = kernelBasenames(opts.routerPath ?? ROUTER);
74
+ const findings: Finding[] = [];
75
+ let scanned = 0;
76
+ const files = fs.existsSync(rulesDir) ? fs.readdirSync(rulesDir).filter((f) => f.endsWith('.md')) : [];
77
+ for (const f of files) {
78
+ if (kernel.has(f)) continue; // kernel prefix has its own drift guard
79
+ scanned += 1;
80
+ const text = fs.readFileSync(path.join(rulesDir, f), 'utf8');
81
+ const lines = text.split('\n');
82
+ for (let i = 0; i < lines.length; i++) {
83
+ const line = lines[i] ?? '';
84
+ if (line.includes(IGNORE_MARKER)) continue;
85
+ if (HOME_PATH_RE.test(line)) {
86
+ findings.push({ file: f, line: i + 1, kind: 'home-path', excerpt: line.trim().slice(0, 120) });
87
+ } else if (UUID_RE.test(line)) {
88
+ findings.push({ file: f, line: i + 1, kind: 'uuid', excerpt: line.trim().slice(0, 120) });
89
+ }
90
+ }
91
+ }
92
+ return { findings, scanned };
93
+ }
94
+
95
+ export function main(argv: readonly string[]): number {
96
+ let rulesDir: string | undefined;
97
+ let routerPath: string | undefined;
98
+ for (let i = 0; i < argv.length; i++) {
99
+ if (argv[i] === '--rules-dir') rulesDir = argv[++i];
100
+ else if (argv[i] === '--router') routerPath = argv[++i];
101
+ }
102
+ const opts: Parameters<typeof scan>[0] = {
103
+ ...(rulesDir !== undefined ? { rulesDir } : {}),
104
+ ...(routerPath !== undefined ? { routerPath } : {}),
105
+ };
106
+ let result: ReturnType<typeof scan>;
107
+ try {
108
+ result = scan(opts);
109
+ } catch (err) {
110
+ process.stderr.write(`check_static_layer_stability: ${err instanceof Error ? err.message : String(err)}\n`);
111
+ return 2;
112
+ }
113
+ try {
114
+ assertScanned({
115
+ gate: 'check_static_layer_stability',
116
+ scanned: result.scanned,
117
+ units: 'non-kernel always-loaded rule file(s)',
118
+ roots: [rulesDir ?? RULES_DIR],
119
+ });
120
+ } catch (exc) {
121
+ if (exc instanceof DeadScopeError) {
122
+ process.stderr.write(`error: ${exc.message}\n`);
123
+ return 1;
124
+ }
125
+ throw exc;
126
+ }
127
+ for (const f of result.findings) {
128
+ process.stderr.write(
129
+ `❌ ${f.file}:${f.line} — ${f.kind} in the always-loaded layer (machine-volatile marker; re-sent on every spawn): ${f.excerpt}\n`,
130
+ );
131
+ }
132
+ if (result.findings.length > 0) {
133
+ process.stderr.write(
134
+ `check_static_layer_stability: ${result.findings.length} finding(s) across ${result.scanned} scanned file(s). ` +
135
+ `A deliberate exception carries '${IGNORE_MARKER}' on the line with its reason.\n`,
136
+ );
137
+ return 1;
138
+ }
139
+ process.stdout.write(`✅ check_static_layer_stability: ${result.scanned} non-kernel always-loaded file(s) clean\n`);
140
+ return 0;
141
+ }
142
+
143
+ function _isCliEntry(): boolean {
144
+ if (_IN_BUNDLE) return false;
145
+ if (process.argv[1] === undefined) return false;
146
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
147
+ if (import.meta.url === argvUrl) return true;
148
+ try {
149
+ return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(path.resolve(process.argv[1]));
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
154
+ if (_isCliEntry()) process.exit(main(process.argv.slice(2)));
@@ -117,6 +117,24 @@ export interface SuppressionSpec {
117
117
  * migration to content anchors visible instead of aspirational.
118
118
  */
119
119
  driftKey?: { positionField: string; anchorField: string };
120
+ /**
121
+ * Accept this baseline as absent at the base ref — for the ONE change that
122
+ * introduces it, then removed.
123
+ *
124
+ * Without it a brand-new baseline is a permanent red: the ratchet compares
125
+ * against `origin/main`, where the file does not exist yet, and
126
+ * `ratchet_base_ref` is deliberately fail-closed there because
127
+ * absent-at-base and mistyped-path look identical from inside. Setting it
128
+ * makes the verdict `new_baseline` rather than growth, so the bootstrap
129
+ * entries are not each reported as a new hole.
130
+ *
131
+ * Remove the flag once the introducing commit is an ancestor of the base
132
+ * ref — leaving it set would silently accept a later mistyped path. That
133
+ * removal is **enforced, not remembered**: the scan below fails when the
134
+ * flag is set and the baseline already resolves at the base ref, so the
135
+ * flag closes itself one merge after it was needed.
136
+ */
137
+ newInThisChange?: boolean;
120
138
  /** Why this list exists at all — printed with the count, so absence is visible. */
121
139
  what: string;
122
140
  }
@@ -199,6 +217,19 @@ export const SUPPRESSION_INVENTORY: readonly SuppressionSpec[] = [
199
217
  growth: 'requires_falsifier',
200
218
  what: 'per-gate known-debt counts',
201
219
  },
220
+ {
221
+ // The root IS the list — a bare array of rule filenames, so entries are
222
+ // content-keyed by construction and cannot drift on line numbers.
223
+ // `forbidden` growth is the whole point: this baseline exists so a NEW
224
+ // rule cannot join the silent set, and `lint_rule_enforcement_declaration`
225
+ // additionally fails when an entry stops being undeclared, so the list is
226
+ // shrink-only from both directions.
227
+ file: 'src/config/rule-enforcement-baseline.json',
228
+ listKey: null,
229
+ tier: 'string_list',
230
+ newInThisChange: true,
231
+ what: 'rules predating the enforced_by-declaration ratchet',
232
+ },
202
233
  ];
203
234
 
204
235
  export interface SuppressionFinding {
@@ -367,7 +398,24 @@ export function main(): number {
367
398
  baseRef,
368
399
  repoRoot: REPO_ROOT,
369
400
  entriesOf: (parsed) => entriesOfSpec(spec, parsed),
401
+ ...(spec.newInThisChange === true ? { allowNewBaseline: true } : {}),
370
402
  });
403
+ // Self-closing bootstrap flag. `newInThisChange` exists for the ONE
404
+ // change that introduces a baseline, and the comment on the field says
405
+ // to remove it afterwards — which is exactly the kind of instruction
406
+ // that gets forgotten, because nothing goes red when it is ignored and
407
+ // a stale flag silently accepts a later mistyped path. So the check
408
+ // closes itself: once the baseline resolves at the base ref, the
409
+ // verdict is no longer `new_baseline`, and keeping the flag is a
410
+ // finding rather than a note in someone's follow-up list.
411
+ if (spec.newInThisChange === true && comparison.verdict !== 'new_baseline') {
412
+ const detail =
413
+ `\`newInThisChange\` is still set, but the baseline now resolves at ${baseRef} — ` +
414
+ 'the flag has served its purpose and must be removed from SUPPRESSION_INVENTORY. ' +
415
+ 'Leaving it set makes a future mistyped path pass as a new baseline forever.';
416
+ ledger.fail(spec.file, detail);
417
+ findings.push({ file: spec.file, entry: '(whole file)', kind: 'uninventoried', detail });
418
+ }
371
419
  } catch (exc) {
372
420
  if (exc instanceof BaseRefUnavailableError) {
373
421
  ledger.fail(spec.file, exc.message);