@event4u/agent-config 9.17.0 → 9.18.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 (128) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +79 -182
  3. package/dist/agent-src/commands/challenge-me/vision.md +5 -0
  4. package/dist/agent-src/commands/evals/rule-compliance-audit.json +46 -0
  5. package/dist/agent-src/commands/feature/plan.md +20 -0
  6. package/dist/agent-src/commands/feature/roadmap.md +19 -0
  7. package/dist/agent-src/commands/pr/create.md +29 -0
  8. package/dist/agent-src/commands/roadmap/create.md +29 -0
  9. package/dist/agent-src/commands/rule-compliance-audit.md +28 -5
  10. package/dist/agent-src/contexts/execution/plan-confidence-gate.md +133 -0
  11. package/dist/agent-src/rules/active-remediation.md +2 -0
  12. package/dist/agent-src/rules/architecture.md +2 -0
  13. package/dist/agent-src/rules/augment-edit-discipline.md +2 -0
  14. package/dist/agent-src/rules/brand-consistency.md +10 -62
  15. package/dist/agent-src/rules/brand-source-of-truth.md +62 -11
  16. package/dist/agent-src/rules/broken-access-control.md +4 -0
  17. package/dist/agent-src/rules/cli-output-handling.md +5 -0
  18. package/dist/agent-src/rules/code-comment-discipline.md +3 -0
  19. package/dist/agent-src/rules/commit-conventions.md +2 -0
  20. package/dist/agent-src/rules/cross-source-consistency.md +3 -0
  21. package/dist/agent-src/rules/design-fidelity.md +2 -0
  22. package/dist/agent-src/rules/doc-screenshot-hygiene.md +2 -0
  23. package/dist/agent-src/rules/docker-commands.md +4 -0
  24. package/dist/agent-src/rules/domain-adoption-policy.md +2 -0
  25. package/dist/agent-src/rules/domain-safety-disclaimer.md +5 -2
  26. package/dist/agent-src/rules/engineering-safety-floor.md +2 -0
  27. package/dist/agent-src/rules/framework-neutrality-in-generic-skills.md +9 -0
  28. package/dist/agent-src/rules/image-likeness-and-rights.md +4 -0
  29. package/dist/agent-src/rules/improve-before-implement.md +4 -0
  30. package/dist/agent-src/rules/laravel-routing.md +4 -0
  31. package/dist/agent-src/rules/legal-safety-floor.md +3 -0
  32. package/dist/agent-src/rules/lethal-trifecta-guard.md +3 -0
  33. package/dist/agent-src/rules/media-governance-routing.md +5 -0
  34. package/dist/agent-src/rules/minimal-safe-diff.md +2 -0
  35. package/dist/agent-src/rules/no-roadmap-references.md +2 -0
  36. package/dist/agent-src/rules/notes-first-reasoning.md +2 -0
  37. package/dist/agent-src/rules/php-coding.md +2 -0
  38. package/dist/agent-src/rules/prefer-enums-over-literals.md +3 -0
  39. package/dist/agent-src/rules/provider-lifecycle-discipline.md +5 -0
  40. package/dist/agent-src/rules/roadmap-ci-steps-policy.md +2 -0
  41. package/dist/agent-src/rules/roadmap-progress-sync.md +2 -0
  42. package/dist/agent-src/rules/rule-type-governance.md +2 -0
  43. package/dist/agent-src/rules/scale-discipline.md +3 -0
  44. package/dist/agent-src/rules/secret-vcs-guard.md +2 -0
  45. package/dist/agent-src/rules/security-sensitive-stop.md +9 -2
  46. package/dist/agent-src/rules/senior-engineering-discipline.md +6 -0
  47. package/dist/agent-src/rules/skill-quality.md +2 -0
  48. package/dist/agent-src/rules/source-confidentiality.md +4 -0
  49. package/dist/agent-src/rules/source-discovery-gate.md +3 -0
  50. package/dist/agent-src/rules/source-of-truth.md +2 -0
  51. package/dist/agent-src/rules/strategy-safety-floor.md +2 -0
  52. package/dist/agent-src/rules/think-before-action.md +5 -0
  53. package/dist/agent-src/rules/ui-audit-gate.md +2 -0
  54. package/dist/agent-src/skills/design-intelligence/SKILL.md +6 -180
  55. package/dist/agent-src/skills/design-intelligence/references/context-and-registers.md +100 -0
  56. package/dist/agent-src/skills/design-intelligence/references/integration-mapping.md +91 -0
  57. package/dist/agent-src/skills/design-review/SKILL.md +4 -141
  58. package/dist/agent-src/skills/design-review/references/review-communication.md +92 -0
  59. package/dist/agent-src/skills/design-review/references/verification-automation.md +61 -0
  60. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +8 -27
  61. package/dist/agent-src/skills/existing-ui-audit/references/anti-slop-cross-reference.md +31 -0
  62. package/dist/agent-src/skills/fe-design/SKILL.md +4 -289
  63. package/dist/agent-src/skills/fe-design/references/design-patterns.md +227 -0
  64. package/dist/agent-src/skills/fe-design/references/design-read-and-memory.md +75 -0
  65. package/dist/agent-src/skills/roadmap-management/SKILL.md +10 -0
  66. package/dist/agent-src/skills/roadmap-writing/SKILL.md +12 -1
  67. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  68. package/dist/agent-src/templates/roadmaps.md +12 -0
  69. package/dist/agent-src/templates/scripts/work_engine/directives/ui/_fix_lane.ts +52 -0
  70. package/dist/agent-src/templates/scripts/work_engine/directives/ui/audit.ts +8 -0
  71. package/dist/agent-src/templates/scripts/work_engine/directives/ui/design.ts +6 -0
  72. package/dist/agent-src/templates/scripts/work_engine/intent/classify.ts +56 -4
  73. package/dist/cli/registry.js +2 -0
  74. package/dist/cli/registry.js.map +1 -1
  75. package/dist/cli-delegate/chunk-AIIUU73N.js +2040 -0
  76. package/dist/cli-delegate/{chunk-IMSXPRVH.js → chunk-ELMFOQPW.js} +11 -0
  77. package/dist/cli-delegate/chunk-LF2KKJ4Q.js +183 -0
  78. package/dist/cli-delegate/cmd_explain.js +36 -36
  79. package/dist/cli-delegate/cmd_export.js +1 -1
  80. package/dist/cli-delegate/cmd_handoff.js +203 -2228
  81. package/dist/cli-delegate/cmd_prune.js +1 -1
  82. package/dist/cli-delegate/cmd_route_audit.js +249 -0
  83. package/dist/cli-delegate/cmd_route_explain.js +285 -0
  84. package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
  85. package/dist/cli-delegate/cmd_sync.js +1 -1
  86. package/dist/cli-delegate/cmd_uninstall.js +1 -1
  87. package/dist/cli-delegate/cmd_validate.js +1 -1
  88. package/dist/discovery/deprecation-report.md +1 -1
  89. package/dist/discovery/discovery-manifest.json +57 -57
  90. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  91. package/dist/discovery/discovery-manifest.summary.md +1 -1
  92. package/dist/discovery/orphan-report.md +1 -1
  93. package/dist/discovery/packs.json +2 -2
  94. package/dist/discovery/trust-report.md +1 -1
  95. package/dist/discovery/workspaces.json +2 -2
  96. package/dist/install/install.mjs +11 -0
  97. package/dist/mcp/registry-manifest.json +1 -1
  98. package/dist/mcp/server.json +2 -2
  99. package/dist/router.json +1 -1
  100. package/dist/server/schemas/settings.js +12 -0
  101. package/dist/server/schemas/settings.js.map +1 -1
  102. package/package.json +1 -1
  103. package/src/agent-src/templates/scripts/work_engine/directives/ui/_fix_lane.ts +52 -0
  104. package/src/agent-src/templates/scripts/work_engine/directives/ui/audit.ts +8 -0
  105. package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +6 -0
  106. package/src/agent-src/templates/scripts/work_engine/intent/classify.ts +56 -4
  107. package/src/config/agent-settings.template.yml +46 -0
  108. package/src/config/evaluator-budgets.json +3 -3
  109. package/src/config/gate-coverage.yml +40 -0
  110. package/src/scripts/_cli/cmd_explain.ts +41 -36
  111. package/src/scripts/_cli/cmd_route_audit.ts +290 -0
  112. package/src/scripts/_cli/cmd_route_explain.ts +269 -0
  113. package/src/scripts/_dispatch.bash +23 -0
  114. package/src/scripts/_lib/git_env.ts +40 -0
  115. package/src/scripts/_lib/md_table.ts +27 -0
  116. package/src/scripts/_lib/planning_settings.ts +43 -0
  117. package/src/scripts/_lib/router_match.ts +301 -0
  118. package/src/scripts/annotate_r1_outcomes.ts +258 -0
  119. package/src/scripts/check_completion_review.ts +1140 -0
  120. package/src/scripts/compile_router.ts +20 -3
  121. package/src/scripts/dispatch_r2_reviewer.ts +968 -0
  122. package/src/scripts/eval_ui_triviality.ts +156 -0
  123. package/src/scripts/lint_abstraction_thresholds.ts +3 -1
  124. package/src/scripts/lint_plan_risk_register.ts +931 -0
  125. package/src/scripts/lint_trigger_collisions.ts +257 -0
  126. package/src/scripts/router_telemetry.ts +16 -290
  127. package/src/scripts/schemas/rule.schema.json +10 -0
  128. package/src/server/schemas/settings.ts +18 -0
@@ -550,6 +550,52 @@ roadmap:
550
550
  # only if your team genuinely commits work on a calendar plate.
551
551
  horizon_weeks: 0
552
552
 
553
+ # --- Plan governance gates ---
554
+ #
555
+ # Three governance gates around the plan/delivery workflow (contract:
556
+ # docs/contracts/plan-review-gates.md). Scope is every plan artifact —
557
+ # roadmaps AND feature plans — which is why these keys do not live under
558
+ # `roadmap:`. All three default to true; a missing key behaves as true.
559
+ planning:
560
+ # Gate C — plan-confidence gate, BEFORE authoring (true, false).
561
+ # true = default. A plan-artifact authoring ask (/roadmap:create,
562
+ # roadmap-writing, /feature:plan, /feature:roadmap) first runs
563
+ # the four 95%-confidence conditions from /challenge-me vision.
564
+ # All four hold → the agent creates directly and emits exactly
565
+ # one marker line ("Confidence ≥ 95% — creating directly").
566
+ # Any gap → the ask routes into the /challenge-me vision
567
+ # interview first; hosts without that command run the inline
568
+ # degrade interview (one question per turn, same stop
569
+ # conditions — contexts/execution/plan-confidence-gate.md).
570
+ # An explicit user bypass ("just write it") wins for that turn
571
+ # and is counted (gate_c_bypass_rate).
572
+ # false = inert; plan asks author directly (today's behavior).
573
+ challenge_on_create: true
574
+
575
+ # Gate R1 — plan-risk review at plan creation / substantial change
576
+ # (true, false).
577
+ # true = default. Every ready (non-draft) plan carries a schema-valid
578
+ # `## Risk Register` section (ranked risks, mitigation +
579
+ # in-document anchor per row, freshness marker). Enforced by
580
+ # lint_plan_risk_register at pre-push + CI; drafts are exempt
581
+ # until flipped to ready. Honest-null: an empty register is
582
+ # valid only with the exact marker grammar from the contract.
583
+ # false = escape hatch; the validator skips (governance default is on).
584
+ risk_review: true
585
+
586
+ # Gate R2 — completion review at 100% roadmap completion / pre-PR
587
+ # (true, false).
588
+ # true = default. A findings-BEFORE-fixes review by a fresh reviewer
589
+ # context: the findings artifact (agents/evidence/reviews/…) must exist
590
+ # for the current diff hash before the first fix commit; every
591
+ # finding ends fixed | accepted-risk | deferred. No code
592
+ # surface → explicit skip declaration, never a silent skip.
593
+ # Enforced by check_completion_review at pre-push + CI (CI
594
+ # authoritative); a crashed validator (exit 2) warns and
595
+ # allows — a broken gate never blocks its own fix.
596
+ # false = escape hatch; the validator skips (governance default is on).
597
+ completion_review: true
598
+
553
599
  # --- Quality / CI execution ---
554
600
  #
555
601
  # Controls whether the agent runs the project's quality pipeline
@@ -58,11 +58,11 @@
58
58
  "deterministic": false
59
59
  },
60
60
  "cli_help_command_count": {
61
- "max": 82,
62
- "last_measured": 82,
61
+ "max": 84,
62
+ "last_measured": 84,
63
63
  "last_measured_at": "2026-08-04",
64
64
  "method": "registry enumeration — count of `{ name: '...', disposition` entries in src/cli/registry.ts (NOT --help prose parsing; methods historically disagreed 74 vs 76 vs 79)",
65
- "baseline_note": "80 → 81 on 2026-08-03 (release 9.15.0 gate). This RECORDS growth that already shipped through its own reviewed PR; it does not authorize new surface. The +1 is f02db54c2 (fix(cli): register routing:doctor in the CLI registry) — the command existed, only its registry entry was missing, and the registering PR did not move this budget. The gate did its job again: breach WARNS on main and BLOCKS on release, which is the conscious-decision point rather than a silently raised cap. Re-measured 2026-08-03 via the pinned method: 81. Headroom stays zero by design — the next registry addition must move this number in its own PR. 81 → 82 on 2026-08-04: the `handoff` subcommand (road-to-agent-handoff-resume Phase 4) registers in the same PR that adds the surface, per this record's own contract. Re-measured via the pinned method: 82."
65
+ "baseline_note": "80 → 81 on 2026-08-03 (release 9.15.0 gate). This RECORDS growth that already shipped through its own reviewed PR; it does not authorize new surface. The +1 is f02db54c2 (fix(cli): register routing:doctor in the CLI registry) — the command existed, only its registry entry was missing, and the registering PR did not move this budget. The gate did its job again: breach WARNS on main and BLOCKS on release, which is the conscious-decision point rather than a silently raised cap. Re-measured 2026-08-03 via the pinned method: 81. Headroom stays zero by design — the next registry addition must move this number in its own PR. 81 → 82 on 2026-08-04: the `handoff` subcommand (road-to-agent-handoff-resume Phase 4) registers in the same PR that adds the surface, per this record's own contract. Re-measured via the pinned method: 82. 82 → 84 on 2026-08-04: the `route:explain` + `route:audit` verbs (routing-correctness validation surface) register in the same PR that adds the surface. Re-measured via the pinned method: 84."
66
66
  },
67
67
  "mcp_public_tool_count": {
68
68
  "max": 19,
@@ -84,6 +84,12 @@
84
84
  # contract is `dist == rewrite(src)`; `check_ci_local_parity`, whose corpus is
85
85
  # workflow files) carries no recipe and is reported UNPROVEN, never green.
86
86
  gates:
87
+ - id: lint_trigger_collisions
88
+ argv: []
89
+ min_scanned: 90
90
+ corpus: "src/rules/*.md frontmatters (111 at baseline)"
91
+ status: enforced
92
+
87
93
  - id: check_condensation
88
94
  argv: []
89
95
  min_scanned: 380
@@ -302,3 +308,37 @@ gates:
302
308
  count. Its inventory-file exemption was also dead: it compared against
303
309
  the retired root AND against 'dist/agent-src' as a single path segment,
304
310
  which a '/'-split can never yield.
311
+
312
+ - id: lint_plan_risk_register
313
+ argv: []
314
+ min_scanned: 12
315
+ corpus: "ready roadmap files under agents/roadmaps/ top level (19 at baseline: 14 grandfathered + 4 draft-exempt + 1 carrying a live register)"
316
+ status: enforced
317
+ note: >
318
+ Gate R1 (docs/contracts/plan-review-gates.md § 1). The count is READY
319
+ FILES INSPECTED incl. grandfathered/draft-exempt ones — exemptions are
320
+ reported, never silently skipped, so a collapse below the floor means
321
+ the roadmaps root moved, not that the corpus went clean.
322
+
323
+ - id: check_completion_review
324
+ argv: ["--advisory", "--base", "origin/main"]
325
+ min_scanned: 1
326
+ corpus: "findings artefacts under agents/evidence/reviews/ + the current-diff evaluation (1 at baseline — the evaluation itself; artefact count grows with gated PRs)"
327
+ status: enforced
328
+ note: >
329
+ Gate R2 (docs/contracts/plan-review-gates.md § 2). HONEST LIMIT, stated
330
+ because a floor that cannot fail is exactly the false green this file
331
+ exists to prevent: scanned = artefacts inspected + 1 for the diff
332
+ evaluation, so it is >= 1 by construction and `min_scanned: 1` can never
333
+ trip. NOTHING ELSE TRIPS EITHER DURING THE ADVISORY WINDOW. The argv above
334
+ mirrors the CI invocation, and under `--advisory` every violation kind —
335
+ dead-scan-scope included — is printed as a warning and the exit code is 0.
336
+ So for the duration of the Stage-A window this entry proves only that the
337
+ gate RAN: it has no blocking path and no trippable floor. The blocking
338
+ behaviour is real but dormant — the dead-scan-scope assertion is a policy
339
+ violation, exit 1 (contract § 6 carve-out: a gate that read nothing has not
340
+ passed), which is what makes a moved reviews root fail loudly instead of
341
+ reporting a clean scope. It takes effect when Stage B drops `--advisory`
342
+ from the CI invocation; nothing else changes at that switch. The entry
343
+ stays registered so the guard reports this gate rather than skipping it
344
+ silently.
@@ -61,6 +61,8 @@ import {
61
61
  load_agent_settings,
62
62
  resolve_project_root,
63
63
  } from '../_lib/agent_settings.js';
64
+ import { trigger_matches } from '../_lib/router_match.js';
65
+ import type { Trigger } from '../_lib/router_match.js';
64
66
  import * as presets from '../config/presets.js';
65
67
  import * as profiles from '../config/profiles.js';
66
68
  import { build_trace } from './explain_last/index.js';
@@ -410,52 +412,51 @@ function pyReprAny(value: unknown): string {
410
412
  return String(value);
411
413
  }
412
414
 
413
- function _matches_trigger(trigger: Dict, text: string, lowered: string): string | null {
414
- if ('keyword' in trigger) {
415
- const kw = String(trigger['keyword']).toLowerCase();
416
- if (kw && lowered.includes(kw)) {
417
- return `keyword: ${kw}`;
415
+ /** `kind: value` label for a trigger's single match key. */
416
+ function _trigger_reason(trigger: Dict): string {
417
+ for (const kind of ['keyword', 'phrase', 'command', 'path_prefix', 'file_pattern']) {
418
+ if (kind in trigger) {
419
+ return `${kind}: ${String(trigger[kind])}`;
418
420
  }
419
421
  }
420
- if ('phrase' in trigger) {
421
- const ph = String(trigger['phrase']).toLowerCase();
422
- if (ph && lowered.includes(ph)) {
423
- return `phrase: ${ph}`;
424
- }
425
- }
426
- if ('path_prefix' in trigger) {
427
- const prefix = String(trigger['path_prefix']);
428
- if (prefix && text.includes(prefix)) {
429
- return `path_prefix: ${prefix}`;
430
- }
431
- }
432
- return null;
422
+ return `trigger: ${JSON.stringify(trigger)}`;
433
423
  }
434
424
 
435
425
  function _explain_route(project_root: string, text: string, as_json: boolean): number {
436
426
  const router = _load_router(project_root);
437
- const lowered = text.toLowerCase();
438
- const matches: Dict[] = [];
439
- const tier_1 = (router['tier_1'] as Dict[] | undefined) || [];
440
- for (const entry of tier_1) {
441
- const triggers = (entry['triggers'] as Dict[] | undefined) || [];
442
- for (const trig of triggers) {
443
- const reason = _matches_trigger(trig, text, lowered);
444
- if (reason !== null) {
445
- matches.push({ id: entry['id'], tier: 'tier_1', reason });
446
- break;
427
+ // Matching goes through the SHARED matcher (_lib/router_match) — anchored
428
+ // keyword semantics, both tiers. This surface carries no open-files /
429
+ // command context, so path_prefix / file_pattern / command triggers
430
+ // cannot match here (previously path_prefix was probed against the
431
+ // prompt TEXT — a divergent second matcher, removed on purpose).
432
+ const tier_1_matches: Dict[] = [];
433
+ const tier_2_matches: Dict[] = [];
434
+ for (const [tier, sink] of [
435
+ ['tier_1', tier_1_matches],
436
+ ['tier_2', tier_2_matches],
437
+ ] as Array<[string, Dict[]]>) {
438
+ const rules = (router[tier] as Dict[] | undefined) || [];
439
+ for (const entry of rules) {
440
+ const triggers = (entry['triggers'] as Dict[] | undefined) || [];
441
+ for (const trig of triggers) {
442
+ if (trigger_matches(trig as Trigger, text, null, null)) {
443
+ sink.push({ id: entry['id'], tier, reason: _trigger_reason(trig) });
444
+ break;
445
+ }
447
446
  }
448
447
  }
449
448
  }
449
+ const total = tier_1_matches.length + tier_2_matches.length;
450
450
  const kernel_always = [...((router['kernel'] as unknown[] | undefined) || [])];
451
451
  const payload: Dict = {
452
452
  input: text,
453
453
  kernel_always,
454
- tier_1_matches: matches,
454
+ tier_1_matches,
455
+ tier_2_matches,
455
456
  };
456
457
  if (as_json) {
457
458
  _dumpJson(payload);
458
- return 0;
459
+ return total === 0 ? 1 : 0;
459
460
  }
460
461
  print(` input: ${pyReprStr(text)}`);
461
462
  print();
@@ -464,14 +465,19 @@ function _explain_route(project_root: string, text: string, as_json: boolean): n
464
465
  print(` · ${pyStr(kid)}`);
465
466
  }
466
467
  print();
467
- print(` tier-1 matches (${matches.length}):`);
468
- if (matches.length === 0) {
469
- print(' · (no trigger matched — only kernel rules active)');
470
- return 1;
468
+ print(` tier-1 matches (${tier_1_matches.length}):`);
469
+ for (const match of tier_1_matches) {
470
+ print(` · ${match['id']} (${match['reason']})`);
471
471
  }
472
- for (const match of matches) {
472
+ print();
473
+ print(` tier-2 matches (${tier_2_matches.length}):`);
474
+ for (const match of tier_2_matches) {
473
475
  print(` · ${match['id']} (${match['reason']})`);
474
476
  }
477
+ if (total === 0) {
478
+ print(' · (no trigger matched — only kernel rules active)');
479
+ return 1;
480
+ }
475
481
  return 0;
476
482
  }
477
483
 
@@ -757,7 +763,6 @@ export {
757
763
  _explain_config,
758
764
  _find_rule,
759
765
  _explain_rule,
760
- _matches_trigger,
761
766
  _explain_route,
762
767
  _explain_last,
763
768
  _LAST_HELP,
@@ -0,0 +1,290 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * `agent-config route:audit [--last N] [--record] [--weekly] [--json]` —
4
+ * replay the shared router matcher over the last N USER prompts of the
5
+ * repo-local chat-history log and render matched-vs-unmatched per prompt.
6
+ * Read-only over the inputs; no LLM call; deterministic.
7
+ *
8
+ * Measurement level (ADR-126): trigger matching only — what the host actually
9
+ * invoked on those turns is NOT measured here. "Should-have-matched" is the
10
+ * human reviewer's judgment over this table, not a computed verdict.
11
+ *
12
+ * `--record` appends one JSONL record per audited prompt to the opt-in
13
+ * routing recorder (`telemetry.routing_recorder.enabled`, default OFF —
14
+ * disabled means zero file IO and a silent exit contribution). The record is
15
+ * PII-excluded by construction: it carries a truncated sha256 of the prompt,
16
+ * never the prompt text, plus matched rule ids / trigger labels and an
17
+ * enforcement join against `agents/runtime/state/rule-trips.json` where a
18
+ * same-named hook concern exists. Delete-and-rerun rebuilds it from chat
19
+ * history — losing it changes no answer (state-store test, ADR-124 § 6).
20
+ *
21
+ * `--weekly` renders the rolling last-7-days picture from the recorder log.
22
+ * Resolver authority note: this instrument is corpus-growth raw material for
23
+ * `internal/bench/layer1-resolver-PREREG.md` (its P2 condition) as a side
24
+ * effect only — the PREREG stays the sole authority over any resolver
25
+ * revival; nothing here builds or scores a resolver.
26
+ *
27
+ * Exit codes: 0 rendered (even when the recorder is disabled), 1 no prompts /
28
+ * no records to audit, 2 invocation or IO error.
29
+ */
30
+
31
+ import * as crypto from 'node:crypto';
32
+ import * as fs from 'node:fs';
33
+ import * as path from 'node:path';
34
+ import { fileURLToPath, pathToFileURL } from 'node:url';
35
+
36
+ import { match_prompt, type Router } from '../_lib/router_match.js';
37
+ import { load_agent_settings } from '../_lib/agent_settings.js';
38
+ import { read_entries } from '../chat_history.js';
39
+
40
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
41
+ const ROUTER_JSON = path.join(REPO_ROOT, 'dist', 'router.json');
42
+
43
+ // cache-version: v1 — rebuildable audit log: delete-and-rerun rebuilds it from
44
+ // chat history (`route:audit --record`); losing it changes no answer.
45
+ export const RECORDER_REL = 'agents/runtime/state/routing-telemetry.jsonl';
46
+ const RULE_TRIPS_REL = 'agents/runtime/state/rule-trips.json';
47
+ export const RECORD_SCHEMA_VERSION = 1;
48
+
49
+ export const MEASUREMENT_HEADER =
50
+ 'Measurement level: trigger matching only — what the host actually invokes is NOT measured here (ADR-126).';
51
+ export const PREREG_AUTHORITY_LINE =
52
+ 'Resolver authority: internal/bench/layer1-resolver-PREREG.md — this corpus feeds its P2 condition as a side effect; nothing here revives a resolver.';
53
+
54
+ function recorder_path(): string {
55
+ return process.env['AGENT_ROUTING_TELEMETRY_FILE'] ?? path.join(REPO_ROOT, RECORDER_REL);
56
+ }
57
+
58
+ export function recorder_enabled(): boolean {
59
+ try {
60
+ const settings = load_agent_settings({ cwd: REPO_ROOT }) as Record<string, unknown>;
61
+ const t = settings['telemetry'];
62
+ if (t === null || typeof t !== 'object' || Array.isArray(t)) return false;
63
+ const rr = (t as Record<string, unknown>)['routing_recorder'];
64
+ if (rr === null || typeof rr !== 'object' || Array.isArray(rr)) return false;
65
+ const enabled = (rr as Record<string, unknown>)['enabled'];
66
+ return enabled === true || enabled === 'true' || enabled === 'on' || enabled === 'yes';
67
+ } catch {
68
+ return false; // unparseable means disabled — default-off doctrine
69
+ }
70
+ }
71
+
72
+ interface AuditedPrompt {
73
+ prompt: string;
74
+ matched: { rule: string; tier: string; triggers: string[] }[];
75
+ }
76
+
77
+ function _trigger_label(trigger: Record<string, unknown>): string {
78
+ for (const kind of ['keyword', 'phrase', 'command', 'path_prefix', 'file_pattern']) {
79
+ if (kind in trigger) return `${kind}: ${String(trigger[kind])}`;
80
+ }
81
+ return JSON.stringify(trigger);
82
+ }
83
+
84
+ export function audit_prompts(router: Router, prompts: string[]): AuditedPrompt[] {
85
+ return prompts.map((prompt) => {
86
+ const result = match_prompt(router, prompt, 'full', null, null);
87
+ const byRule = new Map<string, { tier: string; triggers: string[] }>();
88
+ for (const mt of result.matched_triggers) {
89
+ const id = String(mt.rule);
90
+ const entry = byRule.get(id) ?? { tier: mt.tier.replace('_', '-'), triggers: [] };
91
+ entry.triggers.push(_trigger_label(mt.trigger as Record<string, unknown>));
92
+ byRule.set(id, entry);
93
+ }
94
+ const matched = [...byRule.entries()]
95
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
96
+ .map(([rule, v]) => ({ rule, tier: v.tier, triggers: v.triggers }));
97
+ return { prompt, matched };
98
+ });
99
+ }
100
+
101
+ /** Last N user prompts from the repo chat-history log, oldest first. */
102
+ export function load_user_prompts(last: number, history_path?: string | null): string[] {
103
+ let entries: Array<{ t?: unknown; text?: unknown }> = [];
104
+ try {
105
+ entries = read_entries({ path: history_path ?? null }) as Array<{ t?: unknown; text?: unknown }>;
106
+ } catch {
107
+ return [];
108
+ }
109
+ const prompts: string[] = [];
110
+ for (const e of entries) {
111
+ if (e === null || typeof e !== 'object') continue;
112
+ if (e.t !== 'user' || typeof e.text !== 'string') continue;
113
+ const text = e.text.trim();
114
+ // Host-injected meta turns are not user prompts (same filter as the
115
+ // handoff session reader).
116
+ if (text === '' || text.startsWith('<') || text.startsWith('Caveat:')) continue;
117
+ prompts.push(text);
118
+ }
119
+ return prompts.slice(-last);
120
+ }
121
+
122
+ interface TripsDoc {
123
+ concerns?: Record<string, { block?: number; warn?: number }>;
124
+ }
125
+
126
+ function _load_trips(): TripsDoc {
127
+ try {
128
+ return JSON.parse(fs.readFileSync(path.join(REPO_ROOT, RULE_TRIPS_REL), 'utf-8')) as TripsDoc;
129
+ } catch {
130
+ return {};
131
+ }
132
+ }
133
+
134
+ export function build_records(audits: AuditedPrompt[], now_iso: string): Record<string, unknown>[] {
135
+ const trips = _load_trips();
136
+ return audits.map((a) => {
137
+ const enforcement_trips: Record<string, { block: number; warn: number }> = {};
138
+ for (const m of a.matched) {
139
+ const concern = trips.concerns?.[m.rule];
140
+ if (concern) {
141
+ enforcement_trips[m.rule] = { block: concern.block ?? 0, warn: concern.warn ?? 0 };
142
+ }
143
+ }
144
+ // PII-exclusion-by-construction: no free-form prompt field exists in
145
+ // this record type — only a truncated digest and closed-shape labels.
146
+ return {
147
+ schema_version: RECORD_SCHEMA_VERSION,
148
+ ts: now_iso,
149
+ prompt_sha16: crypto.createHash('sha256').update(a.prompt).digest('hex').slice(0, 16),
150
+ matched: a.matched,
151
+ enforcement_trips,
152
+ };
153
+ });
154
+ }
155
+
156
+ export function append_records(records: Record<string, unknown>[], target: string): number {
157
+ if (process.env['AGENT_CONFIG_REPLAY'] === '1') return 0; // replay mode: no state writes
158
+ fs.mkdirSync(path.dirname(target), { recursive: true });
159
+ const fd = fs.openSync(target, 'a', 0o644);
160
+ try {
161
+ for (const r of records) fs.writeSync(fd, JSON.stringify(r) + '\n');
162
+ fs.fsyncSync(fd);
163
+ } finally {
164
+ fs.closeSync(fd);
165
+ }
166
+ return records.length;
167
+ }
168
+
169
+ export function render_weekly(target: string, now: Date): { text: string; records: number } {
170
+ let lines: string[] = [];
171
+ try {
172
+ lines = fs.readFileSync(target, 'utf-8').split('\n').filter(Boolean);
173
+ } catch {
174
+ lines = [];
175
+ }
176
+ const cutoff = now.getTime() - 7 * 24 * 60 * 60 * 1000;
177
+ const perRule = new Map<string, number>();
178
+ let inWindow = 0;
179
+ for (const line of lines) {
180
+ let rec: { ts?: string; matched?: { rule: string }[] };
181
+ try {
182
+ rec = JSON.parse(line) as typeof rec;
183
+ } catch {
184
+ continue;
185
+ }
186
+ const ts = Date.parse(rec.ts ?? '');
187
+ if (Number.isNaN(ts) || ts < cutoff) continue;
188
+ inWindow += 1;
189
+ for (const m of rec.matched ?? []) {
190
+ perRule.set(m.rule, (perRule.get(m.rule) ?? 0) + 1);
191
+ }
192
+ }
193
+ const out: string[] = [MEASUREMENT_HEADER, ''];
194
+ out.push(`routing recorder — rolling 7-day window (${inWindow} recorded prompt(s))`);
195
+ if (inWindow === 0) {
196
+ out.push(' (no records in the window — enable telemetry.routing_recorder and run `route:audit --record`)');
197
+ } else {
198
+ const rows = [...perRule.entries()].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1));
199
+ for (const [rule, n] of rows) out.push(` ${String(n).padStart(4)} × ${rule}`);
200
+ }
201
+ out.push('');
202
+ out.push(PREREG_AUTHORITY_LINE);
203
+ return { text: out.join('\n') + '\n', records: inWindow };
204
+ }
205
+
206
+ export function main(argv: string[]): number {
207
+ const args = [...argv];
208
+ const as_json = args.includes('--json');
209
+ const weekly = args.includes('--weekly');
210
+ const record = args.includes('--record');
211
+ let last = 10;
212
+ for (let i = 0; i < args.length; i++) {
213
+ const a = args[i] as string;
214
+ if (a === '--last') last = Number(args[++i] ?? '10') || 10;
215
+ else if (a.startsWith('--last=')) last = Number(a.slice('--last='.length)) || 10;
216
+ else if (a === '-h' || a === '--help') {
217
+ process.stdout.write('usage: agent-config route:audit [--last N] [--record] [--weekly] [--json]\n');
218
+ return 0;
219
+ }
220
+ }
221
+
222
+ if (weekly) {
223
+ const { text, records } = render_weekly(recorder_path(), new Date());
224
+ process.stdout.write(text);
225
+ return records === 0 ? 1 : 0;
226
+ }
227
+
228
+ let router: Router;
229
+ try {
230
+ router = JSON.parse(fs.readFileSync(ROUTER_JSON, 'utf-8')) as Router;
231
+ } catch (e) {
232
+ process.stderr.write(`route:audit: cannot read ${ROUTER_JSON}: ${String(e)}\n`);
233
+ return 2;
234
+ }
235
+ const prompts = load_user_prompts(last);
236
+ if (prompts.length === 0) {
237
+ process.stdout.write(MEASUREMENT_HEADER + '\n\n');
238
+ process.stdout.write('no user prompts found in the chat-history log — nothing to audit\n');
239
+ return 1;
240
+ }
241
+ const audits = audit_prompts(router, prompts);
242
+
243
+ let recorded = 0;
244
+ let recorder_note = '';
245
+ if (record) {
246
+ if (recorder_enabled()) {
247
+ recorded = append_records(build_records(audits, new Date().toISOString()), recorder_path());
248
+ recorder_note = `recorded ${recorded} prompt(s) to ${RECORDER_REL}`;
249
+ } else {
250
+ recorder_note = 'recorder disabled (telemetry.routing_recorder.enabled is off — default) — nothing written';
251
+ }
252
+ }
253
+
254
+ if (as_json) {
255
+ process.stdout.write(
256
+ JSON.stringify(
257
+ { measurement_level: MEASUREMENT_HEADER, audited: audits.length, recorded, prompts: audits },
258
+ null,
259
+ 2,
260
+ ) + '\n',
261
+ );
262
+ return 0;
263
+ }
264
+ const out: string[] = [MEASUREMENT_HEADER, ''];
265
+ out.push(`audited the last ${audits.length} user prompt(s) — matched rules per prompt`);
266
+ out.push('(should-have-matched is the reviewer\'s judgment over this table, not a computed verdict)');
267
+ out.push('');
268
+ audits.forEach((a, i) => {
269
+ const shown = a.prompt.length > 100 ? a.prompt.slice(0, 97) + '...' : a.prompt;
270
+ out.push(`${String(i + 1).padStart(3)}. ${JSON.stringify(shown)}`);
271
+ if (a.matched.length === 0) {
272
+ out.push(' → (no trigger matched — kernel only)');
273
+ } else {
274
+ for (const m of a.matched) {
275
+ out.push(` → ${m.rule} [${m.tier}] (${m.triggers.join(' · ')})`);
276
+ }
277
+ }
278
+ });
279
+ if (recorder_note) {
280
+ out.push('');
281
+ out.push(recorder_note);
282
+ }
283
+ process.stdout.write(out.join('\n') + '\n');
284
+ return 0;
285
+ }
286
+
287
+ const _HERE = fileURLToPath(import.meta.url);
288
+ if (process.argv[1] && (import.meta.url === pathToFileURL(process.argv[1]).href || path.resolve(process.argv[1]) === _HERE)) {
289
+ process.exitCode = main(process.argv.slice(2));
290
+ }