@event4u/agent-config 12.0.0 → 12.1.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 (89) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +122 -0
  3. package/agents/templates/.ai-council.yml.example +46 -3
  4. package/dist/agent-src/contexts/model-recommendations.md +6 -4
  5. package/dist/agent-src/rules/low-impact-corpus-privacy-floor.md +1 -0
  6. package/dist/agent-src/scripts/roadmap_gates.ts +30 -2
  7. package/dist/agent-src/scripts/update_roadmap_progress.ts +32 -1
  8. package/dist/agent-src/skills/roadmap-writing/SKILL.md +7 -7
  9. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  10. package/dist/agent-src/templates/roadmaps.md +41 -3
  11. package/dist/cli/registry.js +1 -0
  12. package/dist/cli/registry.js.map +1 -1
  13. package/dist/cli-delegate/chunk-K34QTSD5.js +3895 -0
  14. package/dist/cli-delegate/{chunk-L565YFXH.js → chunk-LKZ5DOYW.js} +557 -297
  15. package/dist/cli-delegate/{chunk-NN4KW4HZ.js → chunk-O52B4F5S.js} +123 -2338
  16. package/dist/cli-delegate/cmd_conformance.js +4 -4
  17. package/dist/cli-delegate/cmd_doctor.js +2 -2
  18. package/dist/cli-delegate/cmd_export.js +1 -1
  19. package/dist/cli-delegate/cmd_handoff.js +1 -1
  20. package/dist/cli-delegate/cmd_prune.js +1 -1
  21. package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
  22. package/dist/cli-delegate/cmd_sync.js +1 -1
  23. package/dist/cli-delegate/cmd_uninstall.js +1 -1
  24. package/dist/cli-delegate/cmd_validate.js +1 -1
  25. package/dist/discovery/deprecation-report.md +1 -1
  26. package/dist/discovery/discovery-manifest.json +4 -4
  27. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  28. package/dist/discovery/discovery-manifest.summary.md +1 -1
  29. package/dist/discovery/dormancy-report.md +1 -1
  30. package/dist/discovery/orphan-report.md +1 -1
  31. package/dist/discovery/packs.json +2 -2
  32. package/dist/discovery/trust-report.md +1 -1
  33. package/dist/discovery/workspaces.json +2 -2
  34. package/dist/hooks/dispatch.js +2574 -1080
  35. package/dist/install/install.mjs +744 -482
  36. package/dist/mcp/registry-manifest.json +2 -2
  37. package/dist/mcp/server.json +2 -2
  38. package/dist/mcp/server.mjs +113 -10
  39. package/dist/router.json +1 -1
  40. package/dist/scripts/_lib/model_tier.js +9 -0
  41. package/dist/scripts/_lib/model_tier.js.map +1 -1
  42. package/dist/scripts/condense.js +50 -0
  43. package/dist/scripts/condense.js.map +1 -1
  44. package/docs/MIGRATION.md +20 -1
  45. package/package.json +1 -1
  46. package/src/agent-src/scripts/roadmap_gates.ts +30 -2
  47. package/src/agent-src/scripts/update_roadmap_progress.ts +32 -1
  48. package/src/config/evaluator-budgets.json +4 -4
  49. package/src/config/gate-coverage.yml +39 -9
  50. package/src/config/gate-violation-baselines.json +6 -0
  51. package/src/config/hook-token-budget.json +7 -1
  52. package/src/config/preamble-payload-budget.json +28 -0
  53. package/src/scripts/_dispatch.bash +10 -0
  54. package/src/scripts/_lib/git_common_dir.ts +173 -0
  55. package/src/scripts/_lib/model_tier.ts +9 -0
  56. package/src/scripts/_lib/release_highlights.ts +108 -7
  57. package/src/scripts/_lib/skill_catalogue.ts +862 -0
  58. package/src/scripts/ai_council/clients.ts +182 -3
  59. package/src/scripts/ai_council/config.ts +16 -1
  60. package/src/scripts/ai_council/low_impact.ts +14 -1
  61. package/src/scripts/ai_council/orchestrator.ts +14 -1
  62. package/src/scripts/ai_council/transport_resolver.ts +32 -2
  63. package/src/scripts/capture_skill_catalogue.ts +185 -328
  64. package/src/scripts/check_branch_freshness.ts +7 -1
  65. package/src/scripts/check_release_published.ts +6 -2
  66. package/src/scripts/check_release_trunk_sync.ts +18 -2
  67. package/src/scripts/condense.ts +50 -1
  68. package/src/scripts/dispatch_r2_reviewer.ts +15 -5
  69. package/src/scripts/evidence_report.ts +15 -4
  70. package/src/scripts/hook_manifest.yaml +47 -13
  71. package/src/scripts/hooks/concern_registry.ts +2 -0
  72. package/src/scripts/hooks/orchestration_record_hook.ts +21 -0
  73. package/src/scripts/hooks/tool_result_bytes_hook.ts +219 -0
  74. package/src/scripts/hot_context_hook.ts +13 -1
  75. package/src/scripts/install.ts +129 -0
  76. package/src/scripts/janitor.ts +77 -2
  77. package/src/scripts/lint_plan_risk_register.ts +22 -9
  78. package/src/scripts/lint_roadmap_blockers.ts +109 -6
  79. package/src/scripts/lint_scheduled_deprecations.ts +738 -0
  80. package/src/scripts/migration_status.ts +17 -1
  81. package/src/scripts/probe_review_binding_drift.ts +731 -0
  82. package/src/scripts/release.ts +95 -1
  83. package/src/scripts/schemas/command.schema.json +2 -2
  84. package/src/scripts/schemas/skill.schema.json +2 -1
  85. package/src/scripts/schemas/subagent.schema.json +2 -2
  86. package/src/scripts/session_eol_report.ts +81 -0
  87. package/src/scripts/workspace_doctor.ts +464 -0
  88. package/src/scripts/worktree_cleanup_check.ts +2 -1
  89. package/dist/cli-delegate/chunk-AYKQGIBG.js +0 -1594
@@ -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
 
@@ -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 };