@event4u/agent-config 14.1.0 → 14.4.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 (120) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +291 -181
  3. package/agents/templates/.ai-council.yml.example +35 -0
  4. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +83 -12
  5. package/dist/agent-src/rules/roadmap-progress-sync.md +48 -4
  6. package/dist/agent-src/scripts/resume_probe.ts +154 -9
  7. package/dist/agent-src/scripts/roadmap_gates.ts +210 -6
  8. package/dist/agent-src/skills/roadmap-management/SKILL.md +9 -4
  9. package/dist/agent-src/templates/agent-settings.md +70 -0
  10. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  11. package/dist/agent-src/templates/scripts/telemetry/remote.ts +571 -0
  12. package/dist/agent-src/templates/scripts/telemetry/settings.ts +222 -44
  13. package/dist/cli/python/workspace_drive.js +8 -3
  14. package/dist/cli/python/workspace_drive.js.map +1 -1
  15. package/dist/cli/python/workspace_hosts.js +16 -12
  16. package/dist/cli/python/workspace_hosts.js.map +1 -1
  17. package/dist/cli/registry.js +2 -0
  18. package/dist/cli/registry.js.map +1 -1
  19. package/dist/cli-delegate/{chunk-F3YUF7SA.js → chunk-3UCJWMMP.js} +1 -1
  20. package/dist/cli-delegate/chunk-F7DQWYR5.js +69 -0
  21. package/dist/cli-delegate/{chunk-7PZ4FSZB.js → chunk-FBKL6JWT.js} +61 -2
  22. package/dist/cli-delegate/{chunk-MNWRDMR5.js → chunk-PKOBQADP.js} +1 -1
  23. package/dist/cli-delegate/cmd_conformance.js +26 -11
  24. package/dist/cli-delegate/cmd_doctor.js +2 -2
  25. package/dist/cli-delegate/cmd_handoff.js +3 -3
  26. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  27. package/dist/cli-delegate/cmd_settings_set.js +17 -3
  28. package/dist/discovery/deprecation-report.md +1 -1
  29. package/dist/discovery/discovery-manifest.json +4 -4
  30. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  31. package/dist/discovery/discovery-manifest.summary.md +1 -1
  32. package/dist/discovery/dormancy-report.md +1 -1
  33. package/dist/discovery/orphan-report.md +1 -1
  34. package/dist/discovery/packs.json +2 -2
  35. package/dist/discovery/trust-report.md +1 -1
  36. package/dist/discovery/workspaces.json +2 -2
  37. package/dist/hooks/dispatch.js +3773 -1731
  38. package/dist/mcp/registry-manifest.json +2 -2
  39. package/dist/mcp/server.json +2 -2
  40. package/dist/mcp/server.mjs +1070 -408
  41. package/dist/scripts/_lib/host_launch.js +66 -0
  42. package/dist/scripts/_lib/host_launch.js.map +1 -0
  43. package/dist/server/io/yamlIO.js +175 -3
  44. package/dist/server/io/yamlIO.js.map +1 -1
  45. package/dist/server/routes/wizard.js +20 -1
  46. package/dist/server/routes/wizard.js.map +1 -1
  47. package/dist/shared/settingsConsent.js +25 -0
  48. package/dist/shared/settingsConsent.js.map +1 -1
  49. package/docs/contracts/settings-classes.md +11 -0
  50. package/docs/guidelines/agent-infra/roadmap-progress-mechanics.md +2 -1
  51. package/package.json +1 -1
  52. package/src/agent-src/scripts/resume_probe.ts +154 -9
  53. package/src/agent-src/scripts/roadmap_gates.ts +210 -6
  54. package/src/agent-src/templates/scripts/telemetry/remote.ts +571 -0
  55. package/src/agent-src/templates/scripts/telemetry/settings.ts +222 -44
  56. package/src/config/estate-count-budget.json +120 -0
  57. package/src/config/evaluator-budgets.json +4 -4
  58. package/src/config/gate-coverage.yml +32 -4
  59. package/src/config/gate-violation-baselines.json +63 -63
  60. package/src/config/gitignore-block.txt +5 -0
  61. package/src/config/hook-latency-budget.json +40 -1
  62. package/src/config/hook-token-budget.json +22 -0
  63. package/src/config/rule-stub-ceilings.json +11 -3
  64. package/src/scripts/_dispatch.bash +42 -1
  65. package/src/scripts/_lib/council_fallback_posture.ts +81 -0
  66. package/src/scripts/_lib/council_fallback_wiring.ts +126 -0
  67. package/src/scripts/_lib/council_settings_block.ts +152 -0
  68. package/src/scripts/_lib/headless_invocation.ts +285 -0
  69. package/src/scripts/_lib/host_launch.ts +72 -0
  70. package/src/scripts/_lib/map_to_object.ts +16 -0
  71. package/src/scripts/_lib/roadmap_checkboxes.ts +88 -0
  72. package/src/scripts/_lib/run_checkpoint.ts +409 -0
  73. package/src/scripts/_lib/session_register.ts +32 -1
  74. package/src/scripts/_lib/skill_catalogue_series.ts +91 -0
  75. package/src/scripts/_lib/stdin.ts +35 -1
  76. package/src/scripts/_lib/turn_end_refusals.ts +12 -4
  77. package/src/scripts/_lib/unattended_guard.ts +371 -0
  78. package/src/scripts/ai_council/config.ts +27 -1
  79. package/src/scripts/ai_council/events_log.ts +12 -1
  80. package/src/scripts/ai_council/fallback_config.ts +114 -0
  81. package/src/scripts/ai_council/mid_flight_fallback.ts +295 -0
  82. package/src/scripts/ai_council/orchestrator.ts +251 -292
  83. package/src/scripts/ai_council/py_parity.ts +139 -0
  84. package/src/scripts/ai_council/response_render.ts +99 -0
  85. package/src/scripts/ai_council/spend_gate.ts +123 -0
  86. package/src/scripts/ai_council/transport_resolver.ts +81 -13
  87. package/src/scripts/bench_hook_injection.ts +100 -6
  88. package/src/scripts/bench_hook_latency.ts +442 -16
  89. package/src/scripts/check_estate_count.ts +924 -0
  90. package/src/scripts/compile_hook_manifest.ts +67 -0
  91. package/src/scripts/council_cli.ts +100 -125
  92. package/src/scripts/decision_memo.ts +325 -0
  93. package/src/scripts/dispatch_r2_reviewer.ts +169 -15
  94. package/src/scripts/hook_manifest.json +1 -0
  95. package/src/scripts/hook_manifest.yaml +172 -25
  96. package/src/scripts/hooks/concern_registry.ts +6 -0
  97. package/src/scripts/hooks/dispatch_hook.ts +183 -10
  98. package/src/scripts/hooks/dispatch_issues.ts +26 -0
  99. package/src/scripts/hooks/end_review_nudge_hook.ts +97 -24
  100. package/src/scripts/hooks/hook_stdin.ts +111 -5
  101. package/src/scripts/hooks/injection_budget.ts +595 -0
  102. package/src/scripts/hooks/payload_stub.ts +446 -0
  103. package/src/scripts/hooks/run_continuation_hook.ts +627 -0
  104. package/src/scripts/hooks/session_eol_hook.ts +52 -0
  105. package/src/scripts/hooks/skill_route_hook.ts +172 -11
  106. package/src/scripts/hooks/telemetry_usage_hook.ts +339 -0
  107. package/src/scripts/hooks/tool_result_bytes_hook.ts +27 -0
  108. package/src/scripts/hooks/turn_end_gate_hook.ts +45 -7
  109. package/src/scripts/hooks_doctor.ts +16 -2
  110. package/src/scripts/install-hooks.sh +26 -0
  111. package/src/scripts/interruption_report.ts +330 -13
  112. package/src/scripts/lint_hook_manifest.ts +167 -0
  113. package/src/scripts/roadmap_progress_hook.ts +254 -46
  114. package/src/scripts/run_supervise.ts +623 -0
  115. package/src/scripts/session_register_hook.ts +108 -8
  116. package/src/scripts/sessions_cli.ts +18 -6
  117. package/src/scripts/telemetry_disclosure_hook.ts +244 -0
  118. package/src/server/io/yamlIO.ts +167 -3
  119. package/src/shared/settingsConsent.ts +49 -2
  120. package/dist/cli-delegate/chunk-MMJL4TIX.js +0 -18
@@ -179,11 +179,177 @@ function _check_concerns(manifest: YamlObject, errors: string[]): Set<string> {
179
179
  }
180
180
  }
181
181
  }
182
+ // `needs_payload_bodies:` — the payload opt-in
183
+ // (`road-to-per-turn-hook-economy` step 2.1). Validated for the same
184
+ // reason `tools:` is, with the polarity reversed: the dispatcher ignores
185
+ // anything it does not recognise, so `true`, `"input"`, or a misspelled
186
+ // `[inputs]` all resolve to "keep nothing" and the concern silently
187
+ // receives stubs it was meant to opt out of. A concern that reads a body
188
+ // and gets a stub does not crash — it reads `undefined` and reports
189
+ // nothing, which is the failure this check exists to keep a YAML typo from
190
+ // introducing. The runtime deliberately does NOT widen on a malformed
191
+ // value; that would make this check unenforceable.
192
+ if ("needs_payload_bodies" in spec) {
193
+ const raw = spec["needs_payload_bodies"];
194
+ if (!Array.isArray(raw)) {
195
+ errors.push(
196
+ `concerns.${name}: 'needs_payload_bodies' must be a list of body ` +
197
+ `classes (got ${pyTypeName(raw)}) — use [input], [result], or ` +
198
+ `[input, result]; omit the key to receive stubs, which is the default`,
199
+ );
200
+ } else if (raw.length === 0) {
201
+ errors.push(
202
+ `concerns.${name}: 'needs_payload_bodies' is an empty list — omit ` +
203
+ `the key rather than declaring a need for nothing`,
204
+ );
205
+ } else {
206
+ for (const cls of raw) {
207
+ if (cls !== "input" && cls !== "result") {
208
+ errors.push(
209
+ `concerns.${name}: 'needs_payload_bodies' entries must be ` +
210
+ `'input' or 'result', got ${JSON.stringify(cls)}`,
211
+ );
212
+ }
213
+ }
214
+ }
215
+ }
182
216
  names.add(name);
183
217
  }
184
218
  return names;
185
219
  }
186
220
 
221
+ /** The two slots whose payload carries a stubbable tool body. */
222
+ const TOOL_BODY_EVENTS: ReadonlySet<string> = new Set([
223
+ "pre_tool_use",
224
+ "post_tool_use",
225
+ ]);
226
+
227
+ /**
228
+ * Payload keys per body class, mirroring `hooks/payload_stub.ts::BODY_KEYS`.
229
+ *
230
+ * Duplicated deliberately rather than imported: this gate must keep working if
231
+ * the dispatcher module moves, and the pair is pinned by a test that asserts
232
+ * the two lists agree. A silent divergence would make the gate scan for keys
233
+ * the dispatcher no longer stubs.
234
+ */
235
+ const BODY_KEY_LITERALS: Readonly<Record<'input' | 'result', readonly string[]>> = {
236
+ input: ["tool_input", "toolInput"],
237
+ result: ["tool_response", "toolResponse", "tool_result", "toolUseResult"],
238
+ };
239
+
240
+ /** `// payload-bodies-waiver: <class> — <reason>` in a concern's own source. */
241
+ const WAIVER_RE = /payload-bodies-waiver:\s*(input|result)\s*[—:-]\s*(.+)/g;
242
+
243
+ /**
244
+ * A concern that READS a payload body must declare it
245
+ * (`road-to-per-turn-hook-economy` step 2.1).
246
+ *
247
+ * ## Why this is derived from source rather than from trust
248
+ *
249
+ * The first version of this check covered guards only, and the R2 review named
250
+ * the gap precisely: the failure mode the change itself introduces — a NEW
251
+ * advisory `post_tool_use` concern that reads `tool_response` and forgets the
252
+ * declaration — passed the lint, passed every test, and then read `undefined`
253
+ * in silence. Runtime cannot catch it either: the dispatcher's stub counter is
254
+ * a function of the declaration and the payload shape, so it says how often a
255
+ * concern ran without a body and never whether it wanted one.
256
+ *
257
+ * Authoring time is where the question is decidable, so the requirement is
258
+ * computed from the concern's own script: if it mentions a body key, it
259
+ * declares that class or says in the file why it does not.
260
+ *
261
+ * ## Over-detection is the safe direction, on purpose
262
+ *
263
+ * The scan is a literal match on the key name in quotes or after a dot, so a
264
+ * COMMENT mentioning `tool_response` also trips it. That is deliberate: a false
265
+ * positive costs one concern receiving a body it does not need, which is
266
+ * exactly the status quo before this phase; a false negative costs silence. The
267
+ * escape hatch is a `payload-bodies-waiver:` line naming the class and a
268
+ * reason, which keeps the claim next to the code that makes it instead of in a
269
+ * manifest a concern author may never open.
270
+ *
271
+ * ## The guard floor stays, and is stricter than the source scan
272
+ *
273
+ * `fail_closed` / `severity: blocking` on a tool slot requires `input`
274
+ * regardless of what the source scan finds, and a waiver cannot lift it: a
275
+ * guard served a stub has nothing to match and exits ALLOW. The dispatcher
276
+ * enforces the same floor independently (`_concern_body_classes` returns every
277
+ * class for a guard) and does not depend on this lint having run.
278
+ *
279
+ * Scoped to the two tool slots deliberately. `turn-end-gate` is blocking and
280
+ * binds `stop`, whose payload carries no tool body at all — requiring a
281
+ * declaration there would put a "give me the bodies" line on a concern that
282
+ * can never receive one, which documents nothing and misleads the next reader.
283
+ */
284
+ function _check_guard_payload_bodies(
285
+ manifest: YamlObject,
286
+ errors: string[],
287
+ ): void {
288
+ const concernsRaw = manifest["concerns"];
289
+ const platformsRaw = manifest["platforms"];
290
+ if (!isObject(concernsRaw) || !isObject(platformsRaw)) return;
291
+ const onToolSlot = new Set<string>();
292
+ for (const block of Object.values(platformsRaw)) {
293
+ if (!isObject(block)) continue;
294
+ for (const [event, names] of Object.entries(block)) {
295
+ if (!TOOL_BODY_EVENTS.has(event) || !Array.isArray(names)) continue;
296
+ for (const n of names) {
297
+ if (typeof n === "string") onToolSlot.add(n);
298
+ }
299
+ }
300
+ }
301
+ for (const [name, spec] of Object.entries(concernsRaw)) {
302
+ if (!isObject(spec) || !onToolSlot.has(name)) continue;
303
+ const isGuard = spec["fail_closed"] === true || spec["severity"] === "blocking";
304
+ const declared = spec["needs_payload_bodies"];
305
+ const declaredSet = new Set(
306
+ Array.isArray(declared)
307
+ ? declared.filter((c): c is string => typeof c === "string")
308
+ : [],
309
+ );
310
+ if (isGuard && !declaredSet.has("input")) {
311
+ errors.push(
312
+ `concerns.${name}: bound on a tool slot and fail_closed / ` +
313
+ `severity: blocking, so it must declare 'needs_payload_bodies' ` +
314
+ `including 'input' — a guard served a payload stub has nothing to ` +
315
+ `match and exits ALLOW`,
316
+ );
317
+ }
318
+ const script = spec["script"];
319
+ if (typeof script !== "string") continue;
320
+ let source = "";
321
+ try {
322
+ source = fs.readFileSync(path.join(REPO_ROOT, script), "utf8");
323
+ } catch {
324
+ continue; // missing script is already reported by _check_concerns
325
+ }
326
+ const waived = new Map<string, string>();
327
+ for (const m of source.matchAll(WAIVER_RE)) {
328
+ const cls = m[1];
329
+ const reason = (m[2] ?? "").trim();
330
+ if (cls !== undefined && reason) waived.set(cls, reason);
331
+ }
332
+ for (const [cls, keys] of Object.entries(BODY_KEY_LITERALS)) {
333
+ if (declaredSet.has(cls)) continue;
334
+ const hit = keys.find(
335
+ (k) =>
336
+ source.includes(`'${k}'`) ||
337
+ source.includes(`"${k}"`) ||
338
+ source.includes(`.${k}`),
339
+ );
340
+ if (hit === undefined) continue;
341
+ if (waived.has(cls)) continue;
342
+ errors.push(
343
+ `concerns.${name}: ${script} references '${hit}' but does not declare ` +
344
+ `'${cls}' in needs_payload_bodies — it would receive a stub and read ` +
345
+ `undefined in silence. Declare the class, or put ` +
346
+ `\`payload-bodies-waiver: ${cls} — <reason>\` in the script saying ` +
347
+ `why the body is not needed`,
348
+ );
349
+ }
350
+ }
351
+ }
352
+
187
353
  function _check_platforms(
188
354
  manifest: YamlObject,
189
355
  concernNames: Set<string>,
@@ -425,6 +591,7 @@ export function lint(manifestPath: string, strict: boolean): number {
425
591
  const warnings: string[] = [];
426
592
  const concernNames = _check_concerns(manifest, errors);
427
593
  const bound = _check_platforms(manifest, concernNames, errors, warnings);
594
+ _check_guard_payload_bodies(manifest, errors);
428
595
  _check_roles(manifest, concernNames, errors);
429
596
  _check_aliases(manifest, errors);
430
597
  _check_orphan_trampolines(manifest, errors);
@@ -34,6 +34,7 @@
34
34
  */
35
35
 
36
36
  import { spawnSync } from "node:child_process";
37
+ import * as crypto from "node:crypto";
37
38
  import fs from "node:fs";
38
39
  import path from "node:path";
39
40
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -41,9 +42,146 @@ import { fileURLToPath, pathToFileURL } from "node:url";
41
42
  import { hardenedSpawnEnv } from "./_lib/spawn_env.js";
42
43
  import { log_dispatch_issue } from "./hooks/dispatch_issues.js";
43
44
  import { readHookStdin } from "./hooks/hook_stdin.js";
45
+ import { atomic_write_json } from "./hooks/state_io.js";
44
46
 
45
47
  export const REPLAY_ENV_VAR = "AGENT_CONFIG_REPLAY";
46
48
 
49
+ /**
50
+ * Debounce ledger (road-to-per-turn-hook-economy step 3.1 / D-3).
51
+ *
52
+ * The hook used to re-shell the regenerator through tsx on EVERY roadmap write.
53
+ * A cold tsx start per write, in the roadmap-heavy workflow this repo runs, is
54
+ * the second of the two spawns that escape the in-process concern table.
55
+ *
56
+ * It now records the touched repo roots and regenerates ONCE at turn end.
57
+ * Two properties make that safe rather than merely cheaper:
58
+ *
59
+ * - **The ledger lives under `consumer_root`, not under the target root.** A
60
+ * touched root may be a sibling worktree, and at `stop` the payload carries no
61
+ * roadmap path — so a marker written beside the edited file would be
62
+ * unfindable at flush time. `--project-dir` is the same value on both events,
63
+ * which makes one ledger per consumer the only shape that closes.
64
+ * - **A ledger write that fails regenerates immediately instead.** Losing the
65
+ * dashboard-stays-in-sync guarantee silently is worse than paying the spawn,
66
+ * so the debounce degrades to the old behaviour rather than to a no-op.
67
+ *
68
+ * Flushed by `stop` AND `session_end`, so the worst case is a regeneration
69
+ * deferred to turn end (risk 4) and never a dropped one. The per-write guarantee
70
+ * was never a stated contract; session granularity is.
71
+ */
72
+ export const DIRTY_LEDGER_REL = path.join(
73
+ "agents",
74
+ "runtime",
75
+ "state",
76
+ "roadmap-progress",
77
+ "dirty",
78
+ );
79
+
80
+ /** Events that flush the ledger rather than appending to it. */
81
+ export const FLUSH_EVENTS: ReadonlySet<string> = new Set([
82
+ "stop",
83
+ "session_end",
84
+ // The dispatcher envelope carries the agent-config event id; a bare host
85
+ // payload carries the native name, and the hook accepts either shape on the
86
+ // write path already.
87
+ "Stop",
88
+ "SessionEnd",
89
+ "sessionEnd",
90
+ // Native aliases `hook_manifest.yaml` maps onto those two events. Missing them
91
+ // was a real gap rather than a completeness nit: the write path keys on
92
+ // `tool_name`, not on the event, so a host whose turn end arrives under one of
93
+ // these names accumulated dirty roots that nothing ever flushed. Found by the
94
+ // R2 review of the commit that introduced this ledger.
95
+ "TaskComplete",
96
+ "TaskCancel",
97
+ "AfterAgent",
98
+ ]);
99
+
100
+ function _ledger_dir(consumer_root: string): string {
101
+ return path.join(consumer_root, DIRTY_LEDGER_REL);
102
+ }
103
+
104
+ /**
105
+ * One entry file per root, named from a hash of the root path.
106
+ *
107
+ * **This shape is the concurrency fix, not a style choice.** The first version
108
+ * kept a single JSON array and did a read-modify-write on it, which is a
109
+ * lost-update: two `post_tool_use` dispatches from parallel tool calls in one
110
+ * turn both read `[]`, the second write drops the first root, and `mark_dirty`
111
+ * still returns `true` — so the inline-regeneration fallback never fires and the
112
+ * dashboard goes silently stale, which is exactly what the debounce exists to
113
+ * prevent. Found by the R2 review of the commit that introduced it.
114
+ *
115
+ * Independent per-root files remove the race by construction rather than by
116
+ * holding a lock across a read and a write: concurrent dispatches touch
117
+ * different paths, and the same root twice is the same path — so N writes to one
118
+ * repo still leave ONE entry.
119
+ */
120
+ function _entry_path(consumer_root: string, root: string): string {
121
+ const digest = crypto.createHash("sha256").update(root).digest("hex").slice(0, 16);
122
+ return path.join(_ledger_dir(consumer_root), `${digest}.json`);
123
+ }
124
+
125
+ /** Read the ledger; a missing directory or an unparseable entry reads as empty. */
126
+ export function read_dirty_roots(consumer_root: string): string[] {
127
+ let names: string[];
128
+ try {
129
+ names = fs.readdirSync(_ledger_dir(consumer_root));
130
+ } catch {
131
+ return [];
132
+ }
133
+ const roots: string[] = [];
134
+ for (const name of names.sort()) {
135
+ if (!name.endsWith(".json")) continue;
136
+ try {
137
+ const raw = fs.readFileSync(path.join(_ledger_dir(consumer_root), name), "utf8");
138
+ const parsed = JSON.parse(raw) as unknown;
139
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) continue;
140
+ const root = (parsed as { root?: unknown }).root;
141
+ if (typeof root === "string" && root.trim() !== "") roots.push(root);
142
+ } catch {
143
+ // A torn or hand-edited entry is dropped, never fatal.
144
+ }
145
+ }
146
+ return Array.from(new Set(roots)).sort();
147
+ }
148
+
149
+ /**
150
+ * Record each root as its own entry. Returns false when any write failed — the
151
+ * caller then regenerates inline, so a broken state directory costs latency
152
+ * rather than a silently stale dashboard.
153
+ *
154
+ * Writes go through `atomic_write_json` (tmp → rename under the dispatcher
155
+ * lock), which `hook-architecture-v1` requires for everything written under
156
+ * `agents/runtime/state/`. That call is a no-op under `AGENT_CONFIG_REPLAY=1`,
157
+ * which is why the replay guard stays in the caller rather than relying on it.
158
+ */
159
+ export function mark_dirty(consumer_root: string, roots: readonly string[]): boolean {
160
+ if (roots.length === 0) return true;
161
+ let wrote = 0;
162
+ for (const root of roots) {
163
+ try {
164
+ atomic_write_json(_entry_path(consumer_root, root), {
165
+ root,
166
+ marked_at: new Date().toISOString(),
167
+ });
168
+ wrote += 1;
169
+ } catch {
170
+ // A partial write still needs the caller's inline fallback.
171
+ }
172
+ }
173
+ return wrote === roots.length;
174
+ }
175
+
176
+ /** Drop every entry. A missing directory is success — flush is idempotent. */
177
+ export function clear_dirty(consumer_root: string): void {
178
+ try {
179
+ fs.rmSync(_ledger_dir(consumer_root), { recursive: true, force: true });
180
+ } catch {
181
+ // observability never breaks the hook
182
+ }
183
+ }
184
+
47
185
  // Tools whose successful execution can write to a roadmap file. We keep
48
186
  // the list explicit so an unknown tool name (e.g. a new MCP tool that
49
187
  // happens to mention a roadmap path in its input) does not trigger a
@@ -322,6 +460,59 @@ function _resolve_tsx_invocation(scriptPath: string): { command: string; args: s
322
460
  return { command: "npx", args: ["tsx", scriptPath] };
323
461
  }
324
462
 
463
+ /**
464
+ * Run the regenerator once per root. Extracted by step 3.1 so the write path
465
+ * and the flush path share ONE implementation — a debounce whose flush
466
+ * regenerated differently from the per-write path would be a second code path
467
+ * nobody compares.
468
+ */
469
+ function _regenerate(roots: Iterable<string>, verbose: boolean): void {
470
+ for (const root of roots) {
471
+ const script = _resolve_regenerator(root);
472
+ if (script === null) {
473
+ // Phase 1 of road-to-hooks-actually-fire-in-consumers: log dispatch
474
+ // issue directly (this hook runs as a subprocess from the universal
475
+ // dispatcher; routing through the dispatcher would add latency for
476
+ // no benefit).
477
+ try {
478
+ log_dispatch_issue(
479
+ root,
480
+ "roadmap-progress",
481
+ "prerequisite_missing",
482
+ "update_roadmap_progress.ts not found at any of: " +
483
+ ".augment/scripts/, dist/agent-src/scripts/, " +
484
+ "src/agent-src/scripts/",
485
+ "./agent-config hooks:install --regen " + "(or ./agent-config init)",
486
+ );
487
+ } catch {
488
+ // observability never breaks the hook
489
+ }
490
+ if (verbose) {
491
+ process.stderr.write(
492
+ `roadmap-progress-hook: regenerator not found for ${root}, skipping\n`,
493
+ );
494
+ }
495
+ continue;
496
+ }
497
+
498
+ try {
499
+ // The regenerator is a `.ts` script; run it through tsx (resolved from
500
+ // a node_modules/.bin found by walking up from the regenerator's dir,
501
+ // falling back to `npx tsx`), mirroring the dispatcher's `.ts` concerns.
502
+ // `--repo-root` + cwd pin the regeneration to the edited file's own repo.
503
+ const inv = _resolve_tsx_invocation(script);
504
+ spawnSync(inv.command, [...inv.args, "--repo-root", root], {
505
+ cwd: root,
506
+ stdio: ["ignore", "ignore", "ignore"],
507
+ timeout: 30000,
508
+ env: hardenedSpawnEnv(),
509
+ });
510
+ } catch {
511
+ // never propagate regenerator failures into the agent loop
512
+ }
513
+ }
514
+ }
515
+
325
516
  export function run(
326
517
  stdin_text: string,
327
518
  options: { consumer_root: string; verbose?: boolean },
@@ -341,8 +532,18 @@ export function run(
341
532
  }
342
533
  }
343
534
 
344
- // Unwrap dispatcher envelope (Phase 7.3, hook-architecture-v1.md).
535
+ // Unwrap dispatcher envelope (Phase 7.3, hook-architecture-v1.md). The
536
+ // envelope's `event` is read BEFORE the unwrap and kept: step 3.1 needs it to
537
+ // tell a write (append to the ledger) from a turn end (flush it), and the
538
+ // inner payload does not carry it.
539
+ let envelope_event = "";
540
+ const raw_native = payload["hook_event_name"];
541
+ if (typeof raw_native === "string") {
542
+ envelope_event = raw_native;
543
+ }
345
544
  if (["schema_version", "platform", "event", "payload"].every((k) => k in payload)) {
545
+ const raw_event = payload["event"];
546
+ envelope_event = typeof raw_event === "string" ? raw_event : envelope_event;
346
547
  const inner = payload["payload"];
347
548
  payload =
348
549
  typeof inner === "object" && inner !== null && !Array.isArray(inner)
@@ -350,6 +551,38 @@ export function run(
350
551
  : {};
351
552
  }
352
553
 
554
+ const replay = (process.env[REPLAY_ENV_VAR] ?? "").trim() === "1";
555
+
556
+ // Flush path (step 3.1): `stop` / `session_end` carry no roadmap path, so the
557
+ // roots come from the ledger the write path appended to. Nothing dirty → a
558
+ // read of one small file and exit, which is what makes this cheap enough to
559
+ // bind on a slot that already carries ten concerns.
560
+ if (FLUSH_EVENTS.has(envelope_event)) {
561
+ if (replay) {
562
+ if (verbose) {
563
+ process.stderr.write("roadmap-progress-hook: replay mode, skipping flush\n");
564
+ }
565
+ return 0;
566
+ }
567
+ const dirty = read_dirty_roots(consumer_root);
568
+ if (dirty.length === 0) {
569
+ return 0;
570
+ }
571
+ // Clear FIRST: a regenerator that hangs or throws must not leave a ledger
572
+ // that replays the same roots on every subsequent turn end. `_regenerate`
573
+ // already swallows its own failures, so a cleared ledger plus a failed
574
+ // regeneration degrades to "stale until the next roadmap write", which is
575
+ // the same worst case the debounce already accepts.
576
+ clear_dirty(consumer_root);
577
+ _regenerate(dirty, verbose);
578
+ if (verbose) {
579
+ process.stderr.write(
580
+ `roadmap-progress-hook: flushed ${dirty.length} dirty root(s) at ${envelope_event}\n`,
581
+ );
582
+ }
583
+ return 0;
584
+ }
585
+
353
586
  const tool = payload["tool_name"] || payload["toolName"] || payload["tool"];
354
587
  if (!(typeof tool === "string" && WRITE_TOOLS.has(tool))) {
355
588
  return 0;
@@ -369,59 +602,34 @@ export function run(
369
602
  return 0;
370
603
  }
371
604
 
372
- // Replay mode (`AGENT_CONFIG_REPLAY=1`) skips the regenerator subprocess
373
- // so fixture dispatches never rewrite agents/roadmaps-progress.md.
374
- if ((process.env[REPLAY_ENV_VAR] ?? "").trim() === "1") {
605
+ // Replay mode (`AGENT_CONFIG_REPLAY=1`) skips both the ledger write and the
606
+ // regenerator subprocess, so fixture dispatches leave no state and never
607
+ // rewrite agents/roadmaps-progress.md.
608
+ if (replay) {
375
609
  if (verbose) {
376
610
  process.stderr.write("roadmap-progress-hook: replay mode, skipping regenerator\n");
377
611
  }
378
612
  return 0;
379
613
  }
380
614
 
381
- for (const root of targetRoots) {
382
- const script = _resolve_regenerator(root);
383
- if (script === null) {
384
- // Phase 1 of road-to-hooks-actually-fire-in-consumers: log dispatch
385
- // issue directly (this hook runs as a subprocess from the universal
386
- // dispatcher; routing through the dispatcher would add latency for
387
- // no benefit).
388
- try {
389
- log_dispatch_issue(
390
- root,
391
- "roadmap-progress",
392
- "prerequisite_missing",
393
- "update_roadmap_progress.ts not found at any of: " +
394
- ".augment/scripts/, dist/agent-src/scripts/, " +
395
- "src/agent-src/scripts/",
396
- "./agent-config hooks:install --regen " + "(or ./agent-config init)",
397
- );
398
- } catch {
399
- // observability never breaks the hook
400
- }
401
- if (verbose) {
402
- process.stderr.write(
403
- `roadmap-progress-hook: regenerator not found for ${root}, skipping\n`,
404
- );
405
- }
406
- continue;
407
- }
408
-
409
- try {
410
- // The regenerator is a `.ts` script; run it through tsx (resolved from
411
- // a node_modules/.bin found by walking up from the regenerator's dir,
412
- // falling back to `npx tsx`), mirroring the dispatcher's `.ts` concerns.
413
- // `--repo-root` + cwd pin the regeneration to the edited file's own repo.
414
- const inv = _resolve_tsx_invocation(script);
415
- spawnSync(inv.command, [...inv.args, "--repo-root", root], {
416
- cwd: root,
417
- stdio: ["ignore", "ignore", "ignore"],
418
- timeout: 30000,
419
- env: hardenedSpawnEnv(),
420
- });
421
- } catch {
422
- // never propagate regenerator failures into the agent loop
615
+ // Write path (step 3.1): record and return. The spawn this used to pay per
616
+ // write now happens once, at turn end. A ledger that cannot be written falls
617
+ // back to regenerating inline — the debounce is an optimisation and must never
618
+ // be the reason a dashboard silently stops updating.
619
+ if (mark_dirty(consumer_root, Array.from(targetRoots))) {
620
+ if (verbose) {
621
+ process.stderr.write(
622
+ `roadmap-progress-hook: marked ${targetRoots.size} root(s) dirty for tool=${tool}\n`,
623
+ );
423
624
  }
625
+ return 0;
626
+ }
627
+ if (verbose) {
628
+ process.stderr.write(
629
+ "roadmap-progress-hook: ledger unwritable, regenerating inline\n",
630
+ );
424
631
  }
632
+ _regenerate(targetRoots, verbose);
425
633
 
426
634
  if (verbose) {
427
635
  process.stderr.write(`roadmap-progress-hook: regenerated for tool=${tool}\n`);