@event4u/agent-config 12.0.0 → 13.0.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 (148) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +273 -0
  3. package/README.md +1 -1
  4. package/agents/templates/.ai-council.yml.example +46 -3
  5. package/dist/agent-src/commands/cost/report.md +23 -0
  6. package/dist/agent-src/contexts/execution/subagent-routing.md +148 -33
  7. package/dist/agent-src/contexts/model-recommendations.md +6 -4
  8. package/dist/agent-src/contexts/subagent-configuration.md +69 -7
  9. package/dist/agent-src/rules/autonomous-execution.md +1 -1
  10. package/dist/agent-src/rules/design-fidelity.md +1 -1
  11. package/dist/agent-src/rules/design-review-after-ui-write.md +10 -5
  12. package/dist/agent-src/rules/evaluator-independence.md +27 -5
  13. package/dist/agent-src/rules/git-history-discipline.md +5 -1
  14. package/dist/agent-src/rules/low-impact-corpus-privacy-floor.md +1 -0
  15. package/dist/agent-src/rules/missing-skill-recovery.md +89 -0
  16. package/dist/agent-src/rules/ui-audit-gate.md +9 -4
  17. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
  18. package/dist/agent-src/scripts/roadmap_gates.ts +30 -2
  19. package/dist/agent-src/scripts/update_roadmap_progress.ts +47 -1
  20. package/dist/agent-src/skills/authz-review/SKILL.md +4 -0
  21. package/dist/agent-src/skills/design-system-capture/SKILL.md +4 -0
  22. package/dist/agent-src/skills/design-system-capture/references/snapshot-preference-order.md +77 -0
  23. package/dist/agent-src/skills/merge-conflicts/SKILL.md +4 -0
  24. package/dist/agent-src/skills/prompt-optimizer/SKILL.md +17 -1
  25. package/dist/agent-src/skills/roadmap-writing/SKILL.md +11 -11
  26. package/dist/agent-src/skills/systematic-debugging/SKILL.md +4 -0
  27. package/dist/agent-src/skills/threat-modeling/SKILL.md +3 -0
  28. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  29. package/dist/agent-src/templates/roadmaps.md +41 -3
  30. package/dist/cli/registry.js +1 -0
  31. package/dist/cli/registry.js.map +1 -1
  32. package/dist/cli-delegate/{chunk-NN4KW4HZ.js → chunk-3B72LSZL.js} +123 -2338
  33. package/dist/cli-delegate/{chunk-55WZN5VF.js → chunk-EVTY7ISR.js} +2 -2
  34. package/dist/cli-delegate/chunk-N7FEST6D.js +3895 -0
  35. package/dist/cli-delegate/{chunk-L565YFXH.js → chunk-XURJ4AQC.js} +566 -298
  36. package/dist/cli-delegate/cmd_conformance.js +4 -4
  37. package/dist/cli-delegate/cmd_doctor.js +2 -2
  38. package/dist/cli-delegate/cmd_export.js +2 -2
  39. package/dist/cli-delegate/cmd_handoff.js +1 -1
  40. package/dist/cli-delegate/cmd_prune.js +2 -2
  41. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  42. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  43. package/dist/cli-delegate/cmd_sync.js +2 -2
  44. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  45. package/dist/cli-delegate/cmd_validate.js +2 -2
  46. package/dist/discovery/deprecation-report.md +1 -1
  47. package/dist/discovery/discovery-manifest.json +44 -23
  48. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  49. package/dist/discovery/discovery-manifest.summary.md +3 -3
  50. package/dist/discovery/dormancy-report.md +1 -1
  51. package/dist/discovery/orphan-report.md +1 -1
  52. package/dist/discovery/packs.json +6 -5
  53. package/dist/discovery/trust-report.md +2 -2
  54. package/dist/discovery/workspaces.json +5 -4
  55. package/dist/hooks/dispatch.js +10625 -8541
  56. package/dist/install/install.mjs +754 -484
  57. package/dist/mcp/registry-manifest.json +3 -3
  58. package/dist/mcp/server.json +2 -2
  59. package/dist/mcp/server.mjs +1302 -167
  60. package/dist/router.json +1 -1
  61. package/dist/scripts/_lib/model_tier.js +9 -0
  62. package/dist/scripts/_lib/model_tier.js.map +1 -1
  63. package/dist/scripts/condense.js +50 -0
  64. package/dist/scripts/condense.js.map +1 -1
  65. package/dist/server/schemas/settings.js +2 -2
  66. package/dist/server/schemas/settings.js.map +1 -1
  67. package/docs/MIGRATION.md +20 -1
  68. package/docs/guidelines/design-fidelity-mechanics.md +21 -0
  69. package/docs/guidelines/design-handover-extraction.md +87 -0
  70. package/docs/guidelines/prompt-templates.md +24 -1
  71. package/package.json +1 -1
  72. package/src/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
  73. package/src/agent-src/scripts/roadmap_gates.ts +30 -2
  74. package/src/agent-src/scripts/update_roadmap_progress.ts +47 -1
  75. package/src/config/evaluator-budgets.json +9 -8
  76. package/src/config/gate-coverage.yml +63 -11
  77. package/src/config/gate-violation-baselines.json +12 -0
  78. package/src/config/hook-token-budget.json +15 -2
  79. package/src/config/preamble-payload-budget.json +28 -0
  80. package/src/scripts/_dispatch.bash +10 -0
  81. package/src/scripts/_lib/bench_ab_complexity.ts +439 -0
  82. package/src/scripts/_lib/bench_ab_safety_tier.ts +142 -0
  83. package/src/scripts/_lib/bench_ab_scoring_v2.ts +28 -3
  84. package/src/scripts/_lib/bench_ab_search_adherence.ts +423 -0
  85. package/src/scripts/_lib/bench_ab_size_claim.ts +206 -0
  86. package/src/scripts/_lib/bench_ab_workspace.ts +111 -0
  87. package/src/scripts/_lib/git_common_dir.ts +173 -0
  88. package/src/scripts/_lib/model_tier.ts +9 -0
  89. package/src/scripts/_lib/release_highlights.ts +108 -7
  90. package/src/scripts/_lib/skill_catalogue.ts +1115 -0
  91. package/src/scripts/_lib/tier_budget_routing.ts +31 -331
  92. package/src/scripts/ai_council/clients.ts +197 -18
  93. package/src/scripts/ai_council/config.ts +16 -1
  94. package/src/scripts/ai_council/low_impact.ts +14 -1
  95. package/src/scripts/ai_council/orchestrator.ts +14 -1
  96. package/src/scripts/ai_council/transport_resolver.ts +32 -2
  97. package/src/scripts/bench_ab_v2_complexity.ts +252 -0
  98. package/src/scripts/bench_ab_v2_run.ts +3 -42
  99. package/src/scripts/bench_ab_v2_safety.ts +296 -0
  100. package/src/scripts/bench_ab_v2_search.ts +343 -0
  101. package/src/scripts/bench_ab_v2_stats.ts +191 -0
  102. package/src/scripts/build_archive_index.ts +383 -0
  103. package/src/scripts/capture_skill_catalogue.ts +279 -317
  104. package/src/scripts/check_branch_freshness.ts +7 -1
  105. package/src/scripts/check_release_published.ts +6 -2
  106. package/src/scripts/check_release_trunk_sync.ts +18 -2
  107. package/src/scripts/check_source_size_budget.ts +428 -0
  108. package/src/scripts/check_suppression_hygiene.ts +5 -1
  109. package/src/scripts/condense.ts +50 -1
  110. package/src/scripts/cost/backfill_rates.mjs +298 -0
  111. package/src/scripts/cost/budget.mjs +12 -32
  112. package/src/scripts/cost_summary.ts +25 -0
  113. package/src/scripts/dispatch_r2_reviewer.ts +15 -5
  114. package/src/scripts/evidence_report.ts +15 -4
  115. package/src/scripts/external_sources_denylist.json +1 -0
  116. package/src/scripts/hook_manifest.yaml +75 -15
  117. package/src/scripts/hooks/concern_registry.ts +4 -0
  118. package/src/scripts/hooks/orchestration_record_hook.ts +21 -0
  119. package/src/scripts/hooks/skill_route_hook.ts +226 -0
  120. package/src/scripts/hooks/tool_result_bytes_hook.ts +219 -0
  121. package/src/scripts/hooks/ui_route_nudge_hook.ts +6 -3
  122. package/src/scripts/hot_context_hook.ts +13 -1
  123. package/src/scripts/install.ts +129 -0
  124. package/src/scripts/janitor.ts +77 -2
  125. package/src/scripts/lint_design_antipattern_parity.ts +40 -6
  126. package/src/scripts/lint_harvest_provenance.ts +35 -1
  127. package/src/scripts/lint_plan_risk_register.ts +22 -9
  128. package/src/scripts/lint_roadmap_blockers.ts +109 -6
  129. package/src/scripts/lint_rule_skill_pack_reach.ts +43 -0
  130. package/src/scripts/lint_scheduled_deprecations.ts +738 -0
  131. package/src/scripts/lint_skill_router_head.ts +21 -0
  132. package/src/scripts/mcp_server/consumer_tool_catalog.json +26 -3
  133. package/src/scripts/mcp_server/tool_catalog_source.ts +6 -24
  134. package/src/scripts/mcp_server/tools.ts +87 -0
  135. package/src/scripts/migration_status.ts +17 -1
  136. package/src/scripts/probe_review_binding_drift.ts +731 -0
  137. package/src/scripts/release.ts +100 -6
  138. package/src/scripts/routing_doctor.ts +50 -13
  139. package/src/scripts/schemas/command.schema.json +3 -3
  140. package/src/scripts/schemas/skill.schema.json +20 -2
  141. package/src/scripts/schemas/subagent.schema.json +2 -2
  142. package/src/scripts/session_eol_report.ts +81 -0
  143. package/src/scripts/trigger_coverage.ts +224 -3
  144. package/src/scripts/workspace_doctor.ts +464 -0
  145. package/src/scripts/worktree_cleanup_check.ts +2 -1
  146. package/src/server/schemas/settings.ts +2 -2
  147. package/dist/cli-delegate/chunk-AYKQGIBG.js +0 -1594
  148. package/src/config/budget-routing.json +0 -7
@@ -81,6 +81,12 @@ import * as rule_layer_overlap from './_lib/rule_layer_overlap.js';
81
81
  import * as installed_tools from './_lib/installed_tools.js';
82
82
  import { collect_drift, format_drift_report } from './_lib/install_drift.js';
83
83
  import * as user_global_paths from './_lib/user_global_paths.js';
84
+ // The LIBRARY half, deliberately not `capture_skill_catalogue.js`: that module
85
+ // is a CLI entry with a top-level `process.exit()`, and esbuild would bundle
86
+ // that exit into the installer a consumer loads (`check_installer_import_purity`
87
+ // catches exactly this). The lib has no entry and no exit.
88
+ import * as catalogue from './_lib/skill_catalogue.js';
89
+ import { isInteractiveSession, type SessionProbe } from '../shared/interactiveContext.js';
84
90
  import * as claude_desktop_bundler from './_lib/claude_desktop_bundler.js';
85
91
  import * as claude_settings_hooks from './_lib/claude_settings_hooks.js';
86
92
  import { find_project_root_with_anchor, load_agent_settings } from './_lib/agent_settings.js';
@@ -3899,6 +3905,32 @@ function install_global(
3899
3905
  if (!state.QUIET) warn(`settings-surface snapshot failed — ${String(e)}`);
3900
3906
  }
3901
3907
 
3908
+ // Catalogue-budget warning (road-to-skill-catalogue-budget Phase 1).
3909
+ // Some hosts silently truncate the skill catalogue they were handed: the
3910
+ // deploy reports success, and the model never sees most of what was
3911
+ // deployed. A host that publishes its own dropped count has a MEASURED
3912
+ // ceiling; one that does not gets no warning and no invented number.
3913
+ // Warn only — over-shipping is the safe direction, and a consumer who
3914
+ // accepts truncation is making a legitimate choice.
3915
+ if (!state.QUIET) {
3916
+ try {
3917
+ for (const line of _catalogue_truncation_warnings(deploy_results, project_root)) {
3918
+ warn(line);
3919
+ }
3920
+ const notice = _scoped_migration_notice(deploy_results, project_root, package_root, {
3921
+ env: process.env,
3922
+ stdinTty: process.stdin.isTTY === true,
3923
+ stdoutTty: process.stdout.isTTY === true,
3924
+ });
3925
+ if (notice.length > 0) {
3926
+ process.stdout.write('\n');
3927
+ for (const line of notice) info(line);
3928
+ }
3929
+ } catch {
3930
+ // A measurement must never sink an install that otherwise worked.
3931
+ }
3932
+ }
3933
+
3902
3934
  if (!state.QUIET) {
3903
3935
  process.stdout.write('\n');
3904
3936
  success(`Global install completed (v${installed_version}).`);
@@ -3907,6 +3939,103 @@ function install_global(
3907
3939
  return 0;
3908
3940
  }
3909
3941
 
3942
+ /**
3943
+ * One line per deployed host whose catalogue exceeds a ceiling this machine has
3944
+ * actually measured. Empty when nothing has been measured — the observation log
3945
+ * lives where captures accumulate, and an install on a machine that never ran
3946
+ * one has no basis to claim a limit.
3947
+ *
3948
+ * Returns lines rather than printing them so the decision is testable without
3949
+ * capturing stdout.
3950
+ */
3951
+ export function _catalogue_truncation_warnings(
3952
+ deploy_results: Record<string, DeployResult>,
3953
+ project_root: string | null,
3954
+ ): string[] {
3955
+ const log_candidates = [
3956
+ path.join(user_global_paths.event4u_root(), 'state', 'skill-catalogue.jsonl'),
3957
+ ...(project_root ? [path.join(project_root, catalogue.OBSERVATION_LOG)] : []),
3958
+ ];
3959
+ const records = log_candidates.flatMap((p) => catalogue.readObservationLog(p));
3960
+ if (records.length === 0) return [];
3961
+
3962
+ const limits = catalogue.knownHostLimits(records);
3963
+ if (limits.size === 0) return [];
3964
+
3965
+ const lines: string[] = [];
3966
+ for (const tool_id of Object.keys(deploy_results).sort()) {
3967
+ const [, , status] = deploy_results[tool_id] as DeployResult;
3968
+ if (status !== 'deployed') continue;
3969
+ const limit = limits.get(tool_id);
3970
+ if (limit === undefined) continue;
3971
+ const anchor_raw = USER_SCOPE_PATHS[tool_id];
3972
+ if (!anchor_raw) continue;
3973
+
3974
+ const volume = catalogue.measureCatalogueVolume(tool_id, expanduser(anchor_raw));
3975
+ const warning = catalogue.catalogueLimitWarning(volume, limit);
3976
+ if (warning !== null) lines.push(warning);
3977
+ }
3978
+ return lines;
3979
+ }
3980
+
3981
+ /**
3982
+ * The scoped-projection migration notice for an EXISTING install.
3983
+ *
3984
+ * Shape decided by AI council 2026-08-15 (2/2 converged, both members present):
3985
+ * option B — the existing warning PLUS an interactive notice that persists
3986
+ * NOTHING and names the manual edit and the GUI route. `projection.mode` is
3987
+ * settings class C, so no agent may write it; a notice that offered a CLI
3988
+ * write would send the reader to a command guaranteed to refuse them.
3989
+ *
3990
+ * Eligibility and delivery are separate on purpose. A non-interactive run can
3991
+ * be fully eligible; it simply gets the warning and no notice, and the two
3992
+ * outcomes stay distinguishable instead of collapsing into "nothing printed".
3993
+ *
3994
+ * Known and accepted: with nothing persisted, a qualifying install sees this
3995
+ * on every deploy. The council named that as the strongest argument for doing
3996
+ * nothing at all, and it does not outweigh making a measured, silent
3997
+ * capability loss an explicit human decision.
3998
+ */
3999
+ export function _scoped_migration_notice(
4000
+ deploy_results: Record<string, DeployResult>,
4001
+ project_root: string | null,
4002
+ package_root: string,
4003
+ probe: SessionProbe,
4004
+ ): string[] {
4005
+ if (!isInteractiveSession(probe)) return [];
4006
+
4007
+ const log_candidates = [
4008
+ path.join(user_global_paths.event4u_root(), 'state', 'skill-catalogue.jsonl'),
4009
+ ...(project_root ? [path.join(project_root, catalogue.OBSERVATION_LOG)] : []),
4010
+ ];
4011
+ const limits = catalogue.knownHostLimits(log_candidates.flatMap((p) => catalogue.readObservationLog(p)));
4012
+ if (limits.size === 0) return [];
4013
+
4014
+ const resolved = _resolve_scoped_projection(package_root);
4015
+ const settings_path =
4016
+ _resolve_global_settings_path() ??
4017
+ path.join(user_global_paths.event4u_root(), 'settings', SETTINGS_FILE);
4018
+
4019
+ for (const tool_id of Object.keys(deploy_results).sort()) {
4020
+ const [, , status] = deploy_results[tool_id] as DeployResult;
4021
+ if (status !== 'deployed') continue;
4022
+ const anchor_raw = USER_SCOPE_PATHS[tool_id];
4023
+ if (!anchor_raw) continue;
4024
+
4025
+ const volume = catalogue.measureCatalogueVolume(tool_id, expanduser(anchor_raw));
4026
+ const eligibility = catalogue.migrationEligibility(
4027
+ tool_id,
4028
+ resolved.mode,
4029
+ volume.skillEntries,
4030
+ limits,
4031
+ );
4032
+ if (eligibility.eligible) {
4033
+ return catalogue.migrationPromptLines(tool_id, eligibility, settings_path);
4034
+ }
4035
+ }
4036
+ return [];
4037
+ }
4038
+
3910
4039
  const SETTINGS_SURFACE_REL = path.join('state', 'settings-surface.json');
3911
4040
  const SETTINGS_DELTA_REL = path.join('state', 'settings-delta.json');
3912
4041
 
@@ -24,22 +24,82 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
24
24
  const _HERE = fileURLToPath(import.meta.url);
25
25
  const REPO_ROOT = path.join(path.dirname(_HERE), '..', '..');
26
26
 
27
- // TTL configuration per directory (days)
28
- const TTL_CONFIG: ReadonlyArray<{ dir: string; ttlDays: number; description: string }> = [
27
+ // TTL configuration per directory (days).
28
+ //
29
+ // `owner` / `reviewBy` (road-to-inbox-harvest-2026-08-d-context-ledger Step
30
+ // 4.1) put every TTL under the same discipline the two budget config files
31
+ // carry: a retention number nobody owns and nobody revisits is a guess that
32
+ // hardens into a fact. The falsifier is printed per run — see the reclaim
33
+ // total in `main` — so a TTL that reclaims nothing over a review window is
34
+ // removed rather than defended.
35
+ const TTL_CONFIG: ReadonlyArray<{
36
+ dir: string;
37
+ ttlDays: number;
38
+ description: string;
39
+ owner: string;
40
+ reviewBy: string;
41
+ }> = [
29
42
  {
30
43
  dir: 'agents/tmp.old',
31
44
  ttlDays: 30,
32
45
  description: 'Processed inbox archive (consumed agents/tmp/ files)',
46
+ owner: 'maintainer',
47
+ reviewBy: '2026-11-10',
33
48
  },
34
49
  {
35
50
  dir: 'agents/runtime/tmp',
36
51
  ttlDays: 7,
37
52
  description: 'Agent scratch / runtime tmp',
53
+ owner: 'maintainer',
54
+ reviewBy: '2026-11-10',
38
55
  },
39
56
  {
40
57
  dir: 'agents/runtime/council/responses',
41
58
  ttlDays: 7,
42
59
  description: 'Council response cache',
60
+ owner: 'maintainer',
61
+ reviewBy: '2026-11-10',
62
+ },
63
+ // ── Session-scoped runtime state (Step 4.1) ────────────────────────────
64
+ //
65
+ // These four directories accrete ONE entry per session behind a
66
+ // once-per-session latch, and nothing in the tree removed them: writers
67
+ // and readers existed, no reaper did. They are named INDIVIDUALLY rather
68
+ // than sweeping `agents/runtime/state` as a whole, and that is the
69
+ // load-bearing decision in this step.
70
+ //
71
+ // `agents/runtime/state` also holds the CUMULATIVE measurement streams —
72
+ // `audit/`, `injection-census.jsonl`, `tool-result-census.jsonl`,
73
+ // `ai-video-telemetry.jsonl`, the memory index — and a directory-level
74
+ // TTL would delete exactly the corpora this roadmap exists to build. A
75
+ // sweeper that eats the instrument's own data is worse than no sweeper.
76
+ {
77
+ dir: 'agents/runtime/state/session-eol',
78
+ ttlDays: 7,
79
+ description: 'Per-session end-of-life counters (one file per session)',
80
+ owner: 'maintainer',
81
+ reviewBy: '2026-11-10',
82
+ },
83
+ {
84
+ dir: 'agents/runtime/state/reread-guard',
85
+ ttlDays: 7,
86
+ description: 'Per-session re-read latch',
87
+ owner: 'maintainer',
88
+ reviewBy: '2026-11-10',
89
+ },
90
+ {
91
+ dir: 'agents/runtime/state/end-review-nudge',
92
+ ttlDays: 7,
93
+ description: 'Per-session end-review latch',
94
+ owner: 'maintainer',
95
+ reviewBy: '2026-11-10',
96
+ },
97
+ {
98
+ dir: 'agents/runtime/state/probe-throttle',
99
+ ttlDays: 7,
100
+ description: 'Per-session probe throttle latch',
101
+ owner: 'maintainer',
102
+ reviewBy: '2026-11-10',
43
103
  },
44
104
  ];
45
105
 
@@ -227,6 +287,21 @@ function report(apply: boolean): void {
227
287
  );
228
288
  } else if (apply && totalExpired > 0) {
229
289
  process.stdout.write(`Total deleted: ${totalExpired} file(s), ${_humanSize(totalSize)} freed.\n`);
290
+ } else {
291
+ // road-to-inbox-harvest-2026-08-d-context-ledger Step 4.2 — the TTL's
292
+ // own falsifier, and it only works if a ZERO is printed.
293
+ //
294
+ // Until now a run that reclaimed nothing printed no total at all, so
295
+ // "the sweeper ran and found nothing expired" and "the sweeper did not
296
+ // run" produced byte-identical output. That is the difference between
297
+ // evidence that a retention policy is unnecessary and no evidence at
298
+ // all — and the first is what removes a TTL entry rather than letting
299
+ // it harden into an unexamined default.
300
+ process.stdout.write(
301
+ `Total ${apply ? 'deleted' : 'reclaimable'}: 0 file(s), 0 B — nothing was past its TTL.\n` +
302
+ `A reclaim that stays at zero across a review window is the signal to drop the ` +
303
+ `TTL entry, not to keep it: see the owner / reviewBy fields on each entry.\n`,
304
+ );
230
305
  }
231
306
  }
232
307
 
@@ -29,6 +29,7 @@ import * as fs from 'node:fs';
29
29
  import * as path from 'node:path';
30
30
  import { fileURLToPath, pathToFileURL } from 'node:url';
31
31
 
32
+ import { GateLedger } from './_lib/gate_ledger.js';
32
33
  import { assertWatchlistResolves, DeadScopeError } from './_lib/scan_scope.js';
33
34
 
34
35
  const QUIET = process.argv.includes('--quiet');
@@ -44,6 +45,14 @@ export type Status = (typeof STATUSES)[number];
44
45
  export interface ParityFinding {
45
46
  kind: string;
46
47
  msg: string;
48
+ /**
49
+ * The catalog id this finding is about, when it is about one.
50
+ *
51
+ * Carried explicitly so the per-target ledger can attribute a failure
52
+ * without parsing it back out of `msg`. Absent on structural findings
53
+ * (a missing § Detector status section is about the document, not an id).
54
+ */
55
+ id?: string;
47
56
  }
48
57
 
49
58
  const ENTRY_ROW = /^\|\s*((?:V|C|T|L|M|CP)\d+)\s*\|/gm;
@@ -89,7 +98,7 @@ export function parityFindings(catalogMd: string, registryTs: string): ParityFin
89
98
  const entries = catalogEntries(catalogMd);
90
99
  const seen = new Set<string>();
91
100
  for (const id of entries) {
92
- if (seen.has(id)) out.push({ kind: 'duplicate-entry', msg: `${id}: appears more than once in the catalog tables` });
101
+ if (seen.has(id)) out.push({ kind: 'duplicate-entry', id, msg: `${id}: appears more than once in the catalog tables` });
93
102
  seen.add(id);
94
103
  }
95
104
 
@@ -98,13 +107,13 @@ export function parityFindings(catalogMd: string, registryTs: string): ParityFin
98
107
  // A — every catalog entry is classified.
99
108
  for (const id of seen) {
100
109
  if (!rows.has(id)) {
101
- out.push({ kind: 'unclassified', msg: `${id}: catalog entry missing from § Detector status` });
110
+ out.push({ kind: 'unclassified', id, msg: `${id}: catalog entry missing from § Detector status` });
102
111
  }
103
112
  }
104
113
  // B — every classified row names a real entry.
105
114
  for (const id of rows.keys()) {
106
115
  if (!seen.has(id)) {
107
- out.push({ kind: 'orphan-status', msg: `${id}: § Detector status row names an entry that no catalog table defines` });
116
+ out.push({ kind: 'orphan-status', id, msg: `${id}: § Detector status row names an entry that no catalog table defines` });
108
117
  }
109
118
  }
110
119
  // D + E — status vocabulary and reasons.
@@ -112,12 +121,13 @@ export function parityFindings(catalogMd: string, registryTs: string): ParityFin
112
121
  if (!(STATUSES as readonly string[]).includes(row.status)) {
113
122
  out.push({
114
123
  kind: 'bad-status',
124
+ id,
115
125
  msg: `${id}: status '${row.status || '(empty)'}' is not one of ${STATUSES.join(', ')}`,
116
126
  });
117
127
  continue;
118
128
  }
119
129
  if (row.status !== 'backed' && row.note === '') {
120
- out.push({ kind: 'missing-reason', msg: `${id}: status '${row.status}' carries no reason` });
130
+ out.push({ kind: 'missing-reason', id, msg: `${id}: status '${row.status}' carries no reason` });
121
131
  }
122
132
  }
123
133
 
@@ -126,7 +136,7 @@ export function parityFindings(catalogMd: string, registryTs: string): ParityFin
126
136
  const registry = new Set(registryCatalogIds(registryTs));
127
137
  for (const id of backed) {
128
138
  if (!registry.has(id)) {
129
- out.push({ kind: 'backed-without-rule', msg: `${id}: marked backed but no registry rule declares that catalogId` });
139
+ out.push({ kind: 'backed-without-rule', id, msg: `${id}: marked backed but no registry rule declares that catalogId` });
130
140
  }
131
141
  }
132
142
  for (const id of registry) {
@@ -134,6 +144,7 @@ export function parityFindings(catalogMd: string, registryTs: string): ParityFin
134
144
  const known = rows.get(id);
135
145
  out.push({
136
146
  kind: 'rule-without-backed',
147
+ id,
137
148
  msg: known
138
149
  ? `${id}: a registry rule declares this catalogId but § Detector status marks it '${known.status}'`
139
150
  : `${id}: a registry rule declares this catalogId and no catalog entry carries it`,
@@ -167,8 +178,31 @@ export function main(): number {
167
178
  const registryTs = fs.readFileSync(REGISTRY, 'utf-8');
168
179
  const findings = parityFindings(catalogMd, registryTs);
169
180
 
181
+ const rows = statusRows(catalogMd);
170
182
  const entries = catalogEntries(catalogMd).length;
171
- const backed = [...statusRows(catalogMd).values()].filter((r) => r.status === 'backed').length;
183
+ const backed = [...rows.values()].filter((r) => r.status === 'backed').length;
184
+
185
+ // Per-target completeness accounting. The target is a catalog ID, and the
186
+ // planned set is the UNION of the three places an id can appear — catalog
187
+ // tables, the § Detector status table, and the rule registry — because this
188
+ // gate's whole subject is parity BETWEEN those sets. Planning only the
189
+ // catalog would leave an orphan status row or a registry-only id
190
+ // unaccounted, which is the direction the gate exists to catch.
191
+ const ledger = new GateLedger('lint_design_antipattern_parity');
192
+ const targets = [
193
+ ...new Set([
194
+ ...catalogEntries(catalogMd),
195
+ ...rows.keys(),
196
+ ...registryCatalogIds(registryTs),
197
+ ]),
198
+ ].sort();
199
+ ledger.plan(targets);
200
+ const failedIds = new Set(findings.map((f) => f.id).filter((v): v is string => v !== undefined));
201
+ for (const id of targets) {
202
+ if (failedIds.has(id)) ledger.fail(id, 'parity finding');
203
+ else ledger.complete(id);
204
+ }
205
+ ledger.report();
172
206
 
173
207
  if (findings.length === 0) {
174
208
  _print(`✅ catalog ↔ rule parity OK — ${entries} entries classified, ${backed} detector-backed`);
@@ -49,6 +49,7 @@ import * as fs from 'node:fs';
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 { assertWatchlistResolves, DeadScopeError } from './_lib/scan_scope.js';
53
54
 
54
55
  const _FILE = fileURLToPath(import.meta.url);
@@ -432,7 +433,40 @@ export function main(argv: string[] = process.argv.slice(2)): number {
432
433
  const text = fs.readFileSync(ledgerPath, 'utf-8');
433
434
  const { records, findings } = lintLedgerText(text, REPO);
434
435
  const citations = [...collectCitations(REPO), ...collectPersonaSources(REPO)];
435
- const allFindings = [...findings, ...findOrphanCitations(citations, records)];
436
+ const orphans = findOrphanCitations(citations, records);
437
+ const allFindings = [...findings, ...orphans];
438
+
439
+ // Per-target completeness accounting over the two populations this gate
440
+ // reasons about: the ledger's own rows, and the citations that point at
441
+ // them. Both are legitimately EMPTY today, and that is exactly why the
442
+ // accounting is worth having — a green line over zero rows and a green line
443
+ // over a hundred checked rows read identically without it, which is the
444
+ // failure the success message above already tries to talk its way out of in
445
+ // prose. The ledger states it structurally instead.
446
+ const ledger = new GateLedger('lint_harvest_provenance');
447
+ const known = new Set(records.map((r) => r.harvest_id));
448
+ // The row population is every PARSED ledger line, not `records`: a row that
449
+ // fails validation or uniqueness is `continue`d and never reaches `records`,
450
+ // so planning `records` would plan exactly the rows that passed and account
451
+ // for none of the ones that did not — an accounting that can only ever read
452
+ // 100 %. Lines are the stable key; a Finding already carries one.
453
+ const { parsed } = parseLedgerText(text);
454
+ const badLines = new Set(findings.filter((f) => f.line > 0).map((f) => f.line));
455
+ ledger.plan([
456
+ ...parsed.map((r) => `row:${String(r.line)}`),
457
+ ...citations.map((c) => `citation:${c.id}@${c.file}`),
458
+ ]);
459
+ for (const r of parsed) {
460
+ const id = `row:${String(r.line)}`;
461
+ if (badLines.has(r.line)) ledger.fail(id, 'ledger-row finding');
462
+ else ledger.complete(id);
463
+ }
464
+ for (const c of citations) {
465
+ const id = `citation:${c.id}@${c.file}`;
466
+ if (known.has(c.id)) ledger.complete(id);
467
+ else ledger.fail(id, 'orphan citation — no ledger row carries this harvest id');
468
+ }
469
+ ledger.report();
436
470
 
437
471
  if (allFindings.length > 0) {
438
472
  for (const f of allFindings) {
@@ -39,6 +39,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
39
39
 
40
40
  import { parse as parseYaml } from 'yaml';
41
41
 
42
+ import { workspaceIdentity } from './_lib/git_common_dir.js';
42
43
  import { gitEnv } from './_lib/git_env.js';
43
44
  import { splitMarkdownRow } from './_lib/md_table.js';
44
45
  import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
@@ -505,12 +506,26 @@ interface HistoryEntry {
505
506
  date: string; // %cs — YYYY-MM-DD
506
507
  }
507
508
 
509
+ /**
510
+ * Repo root containing `dir`, or `null` outside a repository.
511
+ *
512
+ * Census rows R5–R7. The three call sites below each spawned
513
+ * `rev-parse --show-toplevel` through `_git` — already `GIT_DIR`-safe via
514
+ * `gitEnv()`, so this migration is behaviour-identical by construction and
515
+ * exists to remove the third, fourth and fifth re-derivation of one question,
516
+ * not to fix a defect. The resolver reads files instead of spawning, so the
517
+ * three checks also stop costing three subprocesses.
518
+ */
519
+ function _repoRootOf(dir: string): string | null {
520
+ const root = workspaceIdentity(dir).repoRoot;
521
+ return root.resolved ? root.value : null;
522
+ }
523
+
508
524
  /** Newest-first commit history of a file; empty for untracked / non-repo paths. */
509
525
  export function fileHistory(absPath: string): HistoryEntry[] {
510
526
  const dir = path.dirname(absPath);
511
- const top = _git(dir, ['rev-parse', '--show-toplevel']);
512
- if (top === null) return [];
513
- const root = top.trim();
527
+ const root = _repoRootOf(dir);
528
+ if (root === null) return [];
514
529
  // Normalize separators: git pathspecs use `/` on every platform, so a raw
515
530
  // Windows `path.relative()` result matches nothing and would silently yield
516
531
  // an empty history — which the grandfather clause reads as "no baseline".
@@ -540,9 +555,8 @@ export function fileHistory(absPath: string): HistoryEntry[] {
540
555
  */
541
556
  export function hasUncommittedChanges(absPath: string): boolean {
542
557
  const dir = path.dirname(absPath);
543
- const top = _git(dir, ['rev-parse', '--show-toplevel']);
544
- if (top === null) return false;
545
- const root = top.trim();
558
+ const root = _repoRootOf(dir);
559
+ if (root === null) return false;
546
560
  const rel = path.relative(root, absPath).split(path.sep).join('/');
547
561
  const out = _git(root, ['status', '--porcelain', '--', rel]);
548
562
  if (out === null) return false;
@@ -551,9 +565,8 @@ export function hasUncommittedChanges(absPath: string): boolean {
551
565
 
552
566
  function _blobAt(absPath: string, sha: string): string | null {
553
567
  const dir = path.dirname(absPath);
554
- const top = _git(dir, ['rev-parse', '--show-toplevel']);
555
- if (top === null) return null;
556
- const root = top.trim();
568
+ const root = _repoRootOf(dir);
569
+ if (root === null) return null;
557
570
  const rel = path.relative(root, absPath).split(path.sep).join('/');
558
571
  return _git(root, ['show', `${sha}:${rel}`]);
559
572
  }
@@ -20,6 +20,7 @@ import * as fs from 'node:fs';
20
20
  import * as path from 'node:path';
21
21
  import { fileURLToPath, pathToFileURL } from 'node:url';
22
22
 
23
+ import { checkRatchet } from './_lib/gate_baseline.js';
23
24
  import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
24
25
 
25
26
  const _HERE = fileURLToPath(import.meta.url);
@@ -46,6 +47,41 @@ const REQUIRED_FIELDS: ReadonlyArray<readonly [string, RegExp]> = [
46
47
  ['Resolved when', /^-[ \t]*\*\*Resolved when:\*\*/im],
47
48
  ];
48
49
 
50
+ /**
51
+ * The fields that make a blocker DECIDABLE rather than merely described.
52
+ *
53
+ * Separated from `REQUIRED_FIELDS` on purpose: the five above are a settled
54
+ * contract every entry in the tree already satisfies, so a missing one is a
55
+ * hard failure. These three are new, and 36 open entries predate them — held
56
+ * to the same bar on day one they would red the whole backlog, which is the
57
+ * "strict gate fires on ~283 files" failure this repo has already recorded
58
+ * once. They run through the ratchet instead: the backlog is legal at its
59
+ * measured size, and every entry added or edited after this must clear them.
60
+ */
61
+ const DECIDABILITY_FIELDS: ReadonlyArray<readonly [string, RegExp]> = [
62
+ ['Recommendation', /^-[ \t]*\*\*Recommendation:\*\*[ \t]*\S/im],
63
+ ['If you do nothing', /^-[ \t]*\*\*If you do nothing:\*\*[ \t]*\S/im],
64
+ ];
65
+
66
+ /** `What to do:` body — everything up to the next field marker. */
67
+ const WHAT_TO_DO_SLICE_RE =
68
+ /^-[ \t]*\*\*What to do:\*\*([\s\S]*?)(?=^-[ \t]*\*\*[A-Z]|^###[ \t]|$(?![\s\S]))/im;
69
+
70
+ /**
71
+ * Does `What to do` carry anything the owner can actually execute?
72
+ *
73
+ * A backticked span (a command, a path, a flag) or an enumerated option set
74
+ * (`(a)` / `1.`) both count. Bare prose does not: "pick exactly one — accept
75
+ * the reading, or re-cut the criterion" names no file, no command and no
76
+ * consequence, and hands the analysis back to the person least equipped to
77
+ * redo it. Measured 2026-08-15: 14 of 46 entries in this tree are that shape.
78
+ */
79
+ function _hasExecutableSubstance(body: string): boolean {
80
+ const m = WHAT_TO_DO_SLICE_RE.exec(body);
81
+ const slice = m ? (m[1] as string) : '';
82
+ return /`[^`\n]+`/.test(slice) || /(^|\s)\((?:[a-z]|[0-9]+)\)\s/i.test(slice);
83
+ }
84
+
49
85
  interface Violation {
50
86
  line: number;
51
87
  message: string;
@@ -68,7 +104,19 @@ function _stripFencedCode(text: string): string {
68
104
  }
69
105
 
70
106
  function _scan(rawText: string): Violation[] {
107
+ return _scanBoth(rawText).hard;
108
+ }
109
+
110
+ interface ScanResult {
111
+ /** Contract violations — always fatal. */
112
+ hard: Violation[];
113
+ /** Open entries that are not yet decidable — counted against the ratchet. */
114
+ decidability: Violation[];
115
+ }
116
+
117
+ function _scanBoth(rawText: string): ScanResult {
71
118
  const violations: Violation[] = [];
119
+ const decidability: Violation[] = [];
72
120
  const text = _stripFencedCode(rawText);
73
121
  const declaredIds = new Set<string>();
74
122
 
@@ -105,6 +153,23 @@ function _scan(rawText: string): Violation[] {
105
153
  message: `blocker '${cur.id}' missing required field(s): ${missing.join(', ')}`,
106
154
  });
107
155
  }
156
+ // Resolved entries are history — re-litigating a decision already
157
+ // made would be churn, and the ratchet would never reach zero.
158
+ const isResolved = /^-[ \t]*\*\*Status:\*\*[ \t]*resolved/im.test(body);
159
+ if (!isResolved) {
160
+ const gaps = DECIDABILITY_FIELDS.filter(([, re]) => !re.test(body)).map(
161
+ ([name]) => name,
162
+ );
163
+ if (!_hasExecutableSubstance(body)) {
164
+ gaps.push('What to do (no command, path or option set)');
165
+ }
166
+ if (gaps.length) {
167
+ decidability.push({
168
+ line: _lineAt(text, sectionStart + cur.start),
169
+ message: `blocker '${cur.id}' is not decidable yet: ${gaps.join(', ')}`,
170
+ });
171
+ }
172
+ }
108
173
  }
109
174
  }
110
175
 
@@ -131,7 +196,8 @@ function _scan(rawText: string): Violation[] {
131
196
  }
132
197
  }
133
198
  violations.sort((a, b) => a.line - b.line);
134
- return violations;
199
+ decidability.sort((a, b) => a.line - b.line);
200
+ return { hard: violations, decidability };
135
201
  }
136
202
 
137
203
  /** Sorted `agents/roadmaps/*.md` (non-recursive — active roadmaps only). */
@@ -206,14 +272,18 @@ function main(): number {
206
272
  return 0;
207
273
  }
208
274
  let failed = 0;
275
+ const undecidable: Array<{ rel: string; v: Violation }> = [];
209
276
  for (const roadmap of roadmaps) {
210
277
  const rel = _relPosix(roadmap, REPO_ROOT);
211
278
  const text = fs.readFileSync(roadmap, 'utf-8');
212
- const violations = _scan(text);
213
- if (violations.length) {
279
+ const { hard, decidability } = _scanBoth(text);
280
+ for (const v of decidability) {
281
+ undecidable.push({ rel, v });
282
+ }
283
+ if (hard.length) {
214
284
  failed += 1;
215
285
  process.stderr.write(`❌ ${rel}\n`);
216
- for (const v of violations) {
286
+ for (const v of hard) {
217
287
  process.stderr.write(` line ${v.line}: ${v.message}\n`);
218
288
  }
219
289
  } else if (!QUIET) {
@@ -228,8 +298,31 @@ function main(): number {
228
298
  );
229
299
  return 1;
230
300
  }
301
+
302
+ // The decidability ratchet. A blocker that names no option, no command and
303
+ // no recommendation is a research task handed to the person with the least
304
+ // context — the defect this half of the gate exists to stop growing.
305
+ const verdict = checkRatchet({
306
+ gate: 'lint_roadmap_blockers:decidability',
307
+ actual: undecidable.length,
308
+ repoRoot: REPO_ROOT,
309
+ });
310
+ if (!verdict.ok) {
311
+ process.stderr.write(`❌ ${verdict.message}\n`);
312
+ for (const { rel, v } of undecidable) {
313
+ process.stderr.write(` ${rel}:${v.line}: ${v.message}\n`);
314
+ }
315
+ process.stderr.write(
316
+ '\n Every open blocker needs **Recommendation:** (which option, and why),\n' +
317
+ ' **If you do nothing:** (the cost of the non-decision), and a\n' +
318
+ ' **What to do:** carrying a command, a path or an enumerated option set.\n' +
319
+ ' See templates/roadmaps.md rule 20.\n',
320
+ );
321
+ return 1;
322
+ }
231
323
  if (!QUIET) {
232
324
  process.stdout.write(`\n✅ ${roadmaps.length} roadmap(s) blocker-contract-clean\n`);
325
+ process.stdout.write(`✅ ${verdict.message}\n`);
233
326
  }
234
327
  return 0;
235
328
  }
@@ -260,5 +353,15 @@ if (_isCliEntry() || process.argv[1] === _HERE) {
260
353
  process.exit(main());
261
354
  }
262
355
 
263
- export { REPO_ROOT, ROADMAP_GLOB, REQUIRED_FIELDS, _scan, _globRoadmaps, main };
264
- export type { Violation };
356
+ export {
357
+ REPO_ROOT,
358
+ ROADMAP_GLOB,
359
+ REQUIRED_FIELDS,
360
+ DECIDABILITY_FIELDS,
361
+ _hasExecutableSubstance,
362
+ _scan,
363
+ _scanBoth,
364
+ _globRoadmaps,
365
+ main,
366
+ };
367
+ export type { Violation, ScanResult };