@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +291 -181
- package/agents/templates/.ai-council.yml.example +35 -0
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +83 -12
- package/dist/agent-src/rules/roadmap-progress-sync.md +48 -4
- package/dist/agent-src/scripts/resume_probe.ts +154 -9
- package/dist/agent-src/scripts/roadmap_gates.ts +210 -6
- package/dist/agent-src/skills/roadmap-management/SKILL.md +9 -4
- package/dist/agent-src/templates/agent-settings.md +70 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/scripts/telemetry/remote.ts +571 -0
- package/dist/agent-src/templates/scripts/telemetry/settings.ts +222 -44
- package/dist/cli/python/workspace_drive.js +8 -3
- package/dist/cli/python/workspace_drive.js.map +1 -1
- package/dist/cli/python/workspace_hosts.js +16 -12
- package/dist/cli/python/workspace_hosts.js.map +1 -1
- package/dist/cli/registry.js +2 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-F3YUF7SA.js → chunk-3UCJWMMP.js} +1 -1
- package/dist/cli-delegate/chunk-F7DQWYR5.js +69 -0
- package/dist/cli-delegate/{chunk-7PZ4FSZB.js → chunk-FBKL6JWT.js} +61 -2
- package/dist/cli-delegate/{chunk-MNWRDMR5.js → chunk-PKOBQADP.js} +1 -1
- package/dist/cli-delegate/cmd_conformance.js +26 -11
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +3 -3
- package/dist/cli-delegate/cmd_route_audit.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +17 -3
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +4 -4
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +3773 -1731
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1070 -408
- package/dist/scripts/_lib/host_launch.js +66 -0
- package/dist/scripts/_lib/host_launch.js.map +1 -0
- package/dist/server/io/yamlIO.js +175 -3
- package/dist/server/io/yamlIO.js.map +1 -1
- package/dist/server/routes/wizard.js +20 -1
- package/dist/server/routes/wizard.js.map +1 -1
- package/dist/shared/settingsConsent.js +25 -0
- package/dist/shared/settingsConsent.js.map +1 -1
- package/docs/contracts/settings-classes.md +11 -0
- package/docs/guidelines/agent-infra/roadmap-progress-mechanics.md +2 -1
- package/package.json +1 -1
- package/src/agent-src/scripts/resume_probe.ts +154 -9
- package/src/agent-src/scripts/roadmap_gates.ts +210 -6
- package/src/agent-src/templates/scripts/telemetry/remote.ts +571 -0
- package/src/agent-src/templates/scripts/telemetry/settings.ts +222 -44
- package/src/config/estate-count-budget.json +120 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +32 -4
- package/src/config/gate-violation-baselines.json +63 -63
- package/src/config/gitignore-block.txt +5 -0
- package/src/config/hook-latency-budget.json +40 -1
- package/src/config/hook-token-budget.json +22 -0
- package/src/config/rule-stub-ceilings.json +11 -3
- package/src/scripts/_dispatch.bash +42 -1
- package/src/scripts/_lib/council_fallback_posture.ts +81 -0
- package/src/scripts/_lib/council_fallback_wiring.ts +126 -0
- package/src/scripts/_lib/council_settings_block.ts +152 -0
- package/src/scripts/_lib/headless_invocation.ts +285 -0
- package/src/scripts/_lib/host_launch.ts +72 -0
- package/src/scripts/_lib/map_to_object.ts +16 -0
- package/src/scripts/_lib/roadmap_checkboxes.ts +88 -0
- package/src/scripts/_lib/run_checkpoint.ts +409 -0
- package/src/scripts/_lib/session_register.ts +32 -1
- package/src/scripts/_lib/skill_catalogue_series.ts +91 -0
- package/src/scripts/_lib/stdin.ts +35 -1
- package/src/scripts/_lib/turn_end_refusals.ts +12 -4
- package/src/scripts/_lib/unattended_guard.ts +371 -0
- package/src/scripts/ai_council/config.ts +27 -1
- package/src/scripts/ai_council/events_log.ts +12 -1
- package/src/scripts/ai_council/fallback_config.ts +114 -0
- package/src/scripts/ai_council/mid_flight_fallback.ts +295 -0
- package/src/scripts/ai_council/orchestrator.ts +251 -292
- package/src/scripts/ai_council/py_parity.ts +139 -0
- package/src/scripts/ai_council/response_render.ts +99 -0
- package/src/scripts/ai_council/spend_gate.ts +123 -0
- package/src/scripts/ai_council/transport_resolver.ts +81 -13
- package/src/scripts/bench_hook_injection.ts +100 -6
- package/src/scripts/bench_hook_latency.ts +442 -16
- package/src/scripts/check_estate_count.ts +924 -0
- package/src/scripts/compile_hook_manifest.ts +67 -0
- package/src/scripts/council_cli.ts +100 -125
- package/src/scripts/decision_memo.ts +325 -0
- package/src/scripts/dispatch_r2_reviewer.ts +169 -15
- package/src/scripts/hook_manifest.json +1 -0
- package/src/scripts/hook_manifest.yaml +172 -25
- package/src/scripts/hooks/concern_registry.ts +6 -0
- package/src/scripts/hooks/dispatch_hook.ts +183 -10
- package/src/scripts/hooks/dispatch_issues.ts +26 -0
- package/src/scripts/hooks/end_review_nudge_hook.ts +97 -24
- package/src/scripts/hooks/hook_stdin.ts +111 -5
- package/src/scripts/hooks/injection_budget.ts +595 -0
- package/src/scripts/hooks/payload_stub.ts +446 -0
- package/src/scripts/hooks/run_continuation_hook.ts +627 -0
- package/src/scripts/hooks/session_eol_hook.ts +52 -0
- package/src/scripts/hooks/skill_route_hook.ts +172 -11
- package/src/scripts/hooks/telemetry_usage_hook.ts +339 -0
- package/src/scripts/hooks/tool_result_bytes_hook.ts +27 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +45 -7
- package/src/scripts/hooks_doctor.ts +16 -2
- package/src/scripts/install-hooks.sh +26 -0
- package/src/scripts/interruption_report.ts +330 -13
- package/src/scripts/lint_hook_manifest.ts +167 -0
- package/src/scripts/roadmap_progress_hook.ts +254 -46
- package/src/scripts/run_supervise.ts +623 -0
- package/src/scripts/session_register_hook.ts +108 -8
- package/src/scripts/sessions_cli.ts +18 -6
- package/src/scripts/telemetry_disclosure_hook.ts +244 -0
- package/src/server/io/yamlIO.ts +167 -3
- package/src/shared/settingsConsent.ts +49 -2
- 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
|
|
373
|
-
// so fixture dispatches
|
|
374
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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`);
|