@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
@@ -54,6 +54,8 @@ import {
54
54
  scanEolSlice,
55
55
  type EolCounters,
56
56
  } from '../_lib/session_eol.js';
57
+ import { buildCheckpoint, writeCheckpoint } from '../_lib/run_checkpoint.js';
58
+ import { read_claimed_slug } from '../session_register_hook.js';
57
59
  import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
58
60
  import { readHookStdin } from './hook_stdin.js';
59
61
  import { atomic_write_json, is_replay_mode } from './state_io.js';
@@ -107,6 +109,23 @@ export function deriveSessionKey(envelope: JsonObject, payload: JsonObject): str
107
109
  );
108
110
  }
109
111
 
112
+ /**
113
+ * The RAW session id, not the hashed key.
114
+ *
115
+ * `read_claimed_slug` keys the per-session claim file off the id the host
116
+ * exported, so handing it `deriveSessionKey`'s hash would look up a file that
117
+ * cannot exist and silently report "no contract" for every claimed run.
118
+ * Falls back to the envelope, which is where the dispatcher puts it, and
119
+ * deliberately does NOT fall back to the transcript path: that is a filename,
120
+ * not an identity, and `sessions:claim` never wrote a claim under one.
121
+ */
122
+ export function payloadSessionId(payload: JsonObject, envelope: JsonObject): string {
123
+ return (
124
+ str(payload['session_id'] as JsonValue | undefined) ||
125
+ str(envelope['session_id'] as JsonValue | undefined)
126
+ ).trim();
127
+ }
128
+
110
129
  /** Load prior per-session state; any read/shape error reads as "fresh session". */
111
130
  export function readState(file: string): SessionEolState {
112
131
  try {
@@ -337,6 +356,39 @@ export function main(): number {
337
356
 
338
357
  writeContextFill(workspaceRoot, tokens, threshold, now);
339
358
 
359
+ // ── UOTL Phase 6.1 — the deterministic half of the handoff ───────
360
+ //
361
+ // Above the threshold AND inside a running contract, leave a derived
362
+ // checkpoint behind. Two properties the advisory alone cannot give:
363
+ //
364
+ // · It costs no judgement. Every field is recomputed from the
365
+ // roadmap on disk, so a dying session produces it correctly even
366
+ // when it has no context left to summarise anything.
367
+ // · It is re-verifiable. A resumed run recomputes the same fields
368
+ // and can name WHICH claim went stale rather than trusting the
369
+ // record, which is the deliberate departure from resuming by
370
+ // bookkeeping (Phase 3.2).
371
+ //
372
+ // "Inside a running contract" is the same carrier `run-continuation`
373
+ // uses — a `sessions:claim` for this session — and no second one is
374
+ // invented. Outside a contract this is silent: a checkpoint for a
375
+ // conversational session names work nobody is executing.
376
+ //
377
+ // Best-effort throughout. A checkpoint is a recovery aid, and a
378
+ // recovery aid that can fail a Stop is a liability.
379
+ const checkpointRunId = payloadSessionId(payload, envelope);
380
+ if (shouldAdvise && checkpointRunId !== '') {
381
+ try {
382
+ const slug = read_claimed_slug(workspaceRoot, checkpointRunId);
383
+ if (slug !== null) {
384
+ const cp = buildCheckpoint(workspaceRoot, eolSessionKey(checkpointRunId), slug);
385
+ if (cp !== null) writeCheckpoint(workspaceRoot, cp);
386
+ }
387
+ } catch {
388
+ // never block the Stop path
389
+ }
390
+ }
391
+
340
392
  if (shouldAdvise && threshold !== null && tokens !== null) {
341
393
  process.stdout.write(
342
394
  `${JSON.stringify({
@@ -43,6 +43,66 @@
43
43
  * need an invalidation story for a tree that changes under the session, and
44
44
  * 12.3 ms does not buy one.
45
45
  *
46
+ * HOST-HONEST, AND FAIL-OPEN BY CONSTRUCTION (road-to-catalogue-host-fit Phase
47
+ * 3). The ranker reads the on-disk tree; the host does not necessarily deliver
48
+ * it. A skill the host truncated is still rankable and still pointable, and the
49
+ * pointer then names a skill whose description the model never received — worse
50
+ * than silence, because a pointer reads as a delivered capability. That is the
51
+ * roadmap's D-4, and it is MEASURED rather than assumed: 16 of 16 bare entries
52
+ * in the 2026-08-12 claude observation are still in this ranker's catalogue
53
+ * (`capture_skill_catalogue --pointable-bare`), among them `design-review`,
54
+ * `design-intelligence` and `fe-design` — skills this estate's own rules route
55
+ * to. So the pointer set is filtered against the latest per-entry observation
56
+ * for the CURRENT host.
57
+ *
58
+ * The filter only ever narrows on a positive reading, and every uncertain state
59
+ * resolves to no filtering at all: no observation log, no record for this host,
60
+ * a host that publishes no per-entry list, a malformed line, an unknown host, or
61
+ * any throw. `knownBareNames` returns `null` for each of them and
62
+ * `filterKnownBare` treats `null` as a pass-through, so behaviour is
63
+ * byte-identical to the unfiltered line. A filter that quietly hides skills on
64
+ * missing data is worse than the divergence it treats — the roadmap's own words,
65
+ * and the reason fail-open is a construction here rather than a convention.
66
+ *
67
+ * IT COSTS ZERO ON THE PATH THE HEADER ABOVE PROMISES IS FREE, AND 0.015 ms ON
68
+ * THE ONE THAT PAYS. The log read is passed in as a thunk, not a value, and
69
+ * `routePointers` calls it only after the term floor AND the rank both pass. A
70
+ * prompt below `MIN_TASK_TERMS` therefore still reads no files at all, which is
71
+ * the claim the 0 ms paragraph makes and an eagerly-read log would have quietly
72
+ * falsified.
73
+ *
74
+ * The FIRING path was measured rather than assumed, because R2 finding 10 is
75
+ * right that the paragraph above describes a path this branch changed. Measured
76
+ * 2026-08-18 on this tree, same method as the 12.3 ms figure: `knownBareForHost`
77
+ * alone is **0.015 ms median / 0.022 ms p95** (n=2000, warm) against a ranked
78
+ * pass of 8.3 ms median — three orders of magnitude below the ranker it rides
79
+ * on, and under 0.01 % of the slot's 250 ms p95 budget. The log is seven lines;
80
+ * a cache would need an invalidation story and 0.015 ms does not buy one, which
81
+ * is the same reasoning the ranker's own no-cache decision records.
82
+ *
83
+ * Note what the existing bench does NOT cover, so nobody reads it as coverage:
84
+ * `bench_hook_latency`'s synthetic payload carries neither `prompt` nor
85
+ * `platform`, so this concern returns before the ranker on every bench
86
+ * iteration. The figures above come from a direct probe, not from that harness.
87
+ *
88
+ * THE FLOOR APPLIES TO WHAT IS DELIVERED, NOT TO WHAT WAS RANKED. Filtering
89
+ * happens BEFORE `MIN_TOP_SCORE` and before the `TOP_K` slice, so the confidence
90
+ * question is asked of the best pointer the model can actually use. One rule, in
91
+ * one order: rank → drop the undeliverable → apply the floor → take the top
92
+ * three.
93
+ *
94
+ * That ordering makes silence a real outcome, and the number says so rather than
95
+ * the prose guessing: on `"review the authorization policy and tenant scope for
96
+ * this endpoint"` the ranker returns `authz-review` at 47 and the next entry at
97
+ * 23, against a floor of 31. Suppress the top-1 and NOTHING clears the floor —
98
+ * the line goes silent instead of naming a 23/100 pointer because the 47 one was
99
+ * undeliverable. That is the intended reading of a floor calibrated for
100
+ * confidence, and promoting a sub-floor pointer to fill the gap would be the
101
+ * "advisory worse than silence" failure this file's risk paragraph ranks first.
102
+ * `suppressed` is what distinguishes that silence from an unranked prompt, and
103
+ * `tests/hooks/skill_route_hook.test.ts` pins both cases against the real
104
+ * corpus rather than a constructed one.
105
+ *
46
106
  * NO ADOPTION THRESHOLD IS COMMITTED HERE. Whether the line changes behaviour
47
107
  * is a question for data, not for this header: `skill_route_pointer_rate` is
48
108
  * registered in `hook-token-budget.json` under the same owner/review discipline
@@ -51,6 +111,13 @@
51
111
  * removed. The score floor above is a TRIGGER calibration and is measured; the
52
112
  * adoption threshold is an OUTCOME claim and is deliberately absent.
53
113
  *
114
+ * `skill_route_bare_suppression_rate` is registered beside it under the same
115
+ * owner, review date and kill discipline, and inherits the same refusal: how
116
+ * often the filter fires is a number to collect, not one to promise. Its
117
+ * numerator is carried in the warn `reason` below — `N suppressed` — because no
118
+ * dedicated counter exists, and that gap is registered as a gap rather than
119
+ * implied to be automatic.
120
+ *
54
121
  * DELIVERY PATH — the verified one, copied rather than re-derived. A
55
122
  * `user_prompt_submit` concern returning `{decision:"allow", context:…}` at
56
123
  * exit 0 produces zero stdout: `host_semantics.emitFor` drops the field for
@@ -73,7 +140,8 @@ import { fileURLToPath, pathToFileURL } from "node:url";
73
140
 
74
141
  import { readHookStdin } from "./hook_stdin.js";
75
142
  import { isSyntheticPrompt } from "../_lib/prompt_shape.js";
76
- import { resolveSkillsRoot } from "../_lib/skill_catalogue.js";
143
+ import { OBSERVATION_LOG, readObservationLog, resolveSkillsRoot } from "../_lib/skill_catalogue.js";
144
+ import { knownBareNames } from "../_lib/skill_catalogue_series.js";
77
145
  import { _tokenize, rank, type RankRow } from "../skill_tools/score_skill_relevance.js";
78
146
 
79
147
  const EXIT_ALLOW = 0;
@@ -119,31 +187,115 @@ function _isObject(v: unknown): v is JsonObject {
119
187
  return typeof v === "object" && v !== null && !Array.isArray(v);
120
188
  }
121
189
 
190
+ /**
191
+ * Names this host is known to have delivered bare, or `null` for no filtering.
192
+ *
193
+ * Every failure mode collapses to `null` — an unknown host, a missing log, an
194
+ * unreadable one, a host that enumerates nothing, a throw from anywhere in the
195
+ * read. The three-way distinction `knownBareNames` draws is preserved: an empty
196
+ * set means *measured clean*, `null` means *never measured*, and only the second
197
+ * one is produced here by accident.
198
+ */
199
+ export function knownBareForHost(workspaceRoot: string, host: string | null): Set<string> | null {
200
+ if (host === null || host === "") return null;
201
+ try {
202
+ return knownBareNames(readObservationLog(path.join(workspaceRoot, OBSERVATION_LOG)), host);
203
+ } catch {
204
+ return null;
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Drop the ranked rows this host is known to have delivered bare.
210
+ *
211
+ * `null` is a pass-through and returns the input order untouched, which is the
212
+ * fail-open half of the header's contract and what makes an absent observation
213
+ * byte-identical to today.
214
+ */
215
+ export function filterKnownBare(
216
+ rows: readonly RankRow[],
217
+ bare: Set<string> | null,
218
+ ): RankRow[] {
219
+ if (bare === null || bare.size === 0) return [...rows];
220
+ return rows.filter(([name]) => !bare.has(name));
221
+ }
222
+
223
+ /** What one routing pass decided, including what it refused to name. */
224
+ export interface RouteDecision {
225
+ rows: RankRow[];
226
+ /**
227
+ * Would-be POINTERS dropped as known-bare — the suppression metric's numerator.
228
+ *
229
+ * Scoped to the pointer window (`TOP_K` of the unfiltered ranking), not to the
230
+ * whole ranked list. R2 finding 1: `rank` returns every non-zero-scoring skill
231
+ * and this catalogue has hundreds, so counting drops across all of them would
232
+ * let a bare name at rank 40 — never pointable, never a loss — bump the count
233
+ * on almost every fire. The registered metric defines this as skills the
234
+ * ranker wanted to POINT AT, and its upper falsifier would otherwise fire on a
235
+ * perfectly healthy join.
236
+ */
237
+ suppressed: number;
238
+ }
239
+
122
240
  /**
123
241
  * Rank a prompt and return the pointers worth injecting, or `[]` for silence.
124
242
  *
125
243
  * Total by construction: a missing root, an unreadable catalogue, or a ranker
126
244
  * error all resolve to `[]`. A routing advisory that can crash a turn is worse
127
245
  * than one that says nothing.
246
+ *
247
+ * `bareProvider` is a THUNK, deliberately — see the header's zero-cost
248
+ * paragraph. It is called only once both the term floor and the rank have
249
+ * passed, so the short-prompt path still touches no files. Omitting it disables
250
+ * filtering entirely, which is what keeps every caller that predates the filter
251
+ * behaviourally unchanged.
128
252
  */
129
- export function routePointers(prompt: string, skillsDir: string | null): RankRow[] {
130
- if (skillsDir === null) return [];
253
+ export function routeDecision(
254
+ prompt: string,
255
+ skillsDir: string | null,
256
+ bareProvider?: () => Set<string> | null,
257
+ ): RouteDecision {
258
+ const silent: RouteDecision = { rows: [], suppressed: 0 };
259
+ if (skillsDir === null) return silent;
131
260
  // Denominator floor BEFORE the catalogue read: a prompt too short to score
132
261
  // meaningfully cannot produce a pointer worth 337 file reads either.
133
262
  try {
134
- if (_tokenize(prompt).size < MIN_TASK_TERMS) return [];
263
+ if (_tokenize(prompt).size < MIN_TASK_TERMS) return silent;
135
264
  } catch {
136
- return [];
265
+ return silent;
137
266
  }
138
267
  let rows: RankRow[];
139
268
  try {
140
269
  rows = rank(prompt, skillsDir);
141
270
  } catch {
142
- return [];
271
+ return silent;
272
+ }
273
+ let bare: Set<string> | null = null;
274
+ if (bareProvider !== undefined) {
275
+ try {
276
+ bare = bareProvider();
277
+ } catch {
278
+ bare = null; // fail open, never silent-narrow
279
+ }
143
280
  }
144
- const top = rows[0];
145
- if (top === undefined || top[1] < MIN_TOP_SCORE) return [];
146
- return rows.slice(0, TOP_K);
281
+ const deliverable = filterKnownBare(rows, bare);
282
+ // The pointer WINDOW, not the ranked list — see `RouteDecision.suppressed`.
283
+ const suppressed =
284
+ bare === null ? 0 : rows.slice(0, TOP_K).filter(([name]) => bare.has(name)).length;
285
+ // The floor asks its question of the best DELIVERABLE pointer, not of a
286
+ // pointer the model cannot use. See the header's one-rule-one-order note.
287
+ const top = deliverable[0];
288
+ if (top === undefined || top[1] < MIN_TOP_SCORE) return { rows: [], suppressed };
289
+ return { rows: deliverable.slice(0, TOP_K), suppressed };
290
+ }
291
+
292
+ /** The rows alone. Kept because most callers and fixtures want only these. */
293
+ export function routePointers(
294
+ prompt: string,
295
+ skillsDir: string | null,
296
+ bareProvider?: () => Set<string> | null,
297
+ ): RankRow[] {
298
+ return routeDecision(prompt, skillsDir, bareProvider).rows;
147
299
  }
148
300
 
149
301
  /**
@@ -191,13 +343,22 @@ export function main(): number {
191
343
  if (!prompt) return EXIT_ALLOW;
192
344
  if (isSyntheticPrompt(prompt)) return EXIT_ALLOW; // harness turn, not a task
193
345
 
194
- const rows = routePointers(prompt, resolveSkillsRoot(_workspaceRoot(env)));
346
+ const root = _workspaceRoot(env);
347
+ const platform = env["platform"];
348
+ const host = typeof platform === "string" && platform ? platform : null;
349
+ const { rows, suppressed } = routeDecision(prompt, resolveSkillsRoot(root), () =>
350
+ knownBareForHost(root, host),
351
+ );
195
352
  if (rows.length === 0) return EXIT_ALLOW; // silence is the default
196
353
 
197
354
  process.stdout.write(
198
355
  `${JSON.stringify({
199
356
  decision: "warn",
200
- reason: `skill-route: ${rows.length} pointer(s), top ${rows[0]![1]}/100`,
357
+ reason:
358
+ `skill-route: ${rows.length} pointer(s), top ${rows[0]![1]}/100` +
359
+ // The suppression metric's numerator. Named only when non-zero, so the
360
+ // common line keeps its shape and its registered byte budget.
361
+ (suppressed > 0 ? `, ${suppressed} suppressed as host-bare` : ""),
201
362
  additional_context: buildRouteLine(rows),
202
363
  })}\n`,
203
364
  );
@@ -0,0 +1,339 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Deterministic PostToolUse Class-A usage capture
4
+ * (road-to-org-telemetry Phase 1, step 1.2).
5
+ *
6
+ * Why a hook rather than the existing collector. `skill_usage_collect.ts`
7
+ * scans the maintainer's own transcript directory and derives its project
8
+ * slug from this repository, so a consumer session is invisible to it by
9
+ * construction — the roadmap's Context table records that as still true
10
+ * after 479 commits. Phase 0's third spike then measured the size of the
11
+ * gap on the set the collector DOES read: **0 of 89** invocations detected,
12
+ * against 163 of 164 across every worktree slug. The zero in the usage
13
+ * report is an instrumentation artefact, not an adoption measurement.
14
+ *
15
+ * Phase 0's first spike settled the event this concern binds to:
16
+ * `post_tool_use` with `tool_name === "Skill"`, the skill name at
17
+ * `tool_input.skill` — present in 164 of 164 real invocations across 14,171
18
+ * live records. The transcript-scan fallback the roadmap pre-registered does
19
+ * not fire.
20
+ *
21
+ * DEFAULT-OFF, AND THE SWITCH IS NOT `enabled`. `read_remote_settings`
22
+ * activates only when `enabled` is true AND the org pack supplied an
23
+ * endpoint, an org id, and a salt. An external clone of this repository
24
+ * carries the key names and no values, so copying the tree cannot reach the
25
+ * write path. When inactive this concern performs zero telemetry file
26
+ * operations — no log write, no directory creation — and zero network calls.
27
+ * (It does read `.agent-settings.yml`, once per process and memoised: the
28
+ * settings file is how it learns it is off, the same as the artefact-
29
+ * engagement surface it mirrors.)
30
+ *
31
+ * NO OUTBOUND CALL EXISTS HERE, DELIBERATELY. Transport is Phase 2 and is
32
+ * blocked on `sink-choice`; org-wide enablement is Phase 3 and is blocked on
33
+ * `dpo-signoff`. This concern appends to a local JSONL file and stops.
34
+ *
35
+ * Exit code is ALWAYS 0 — malformed envelope, unreadable settings, failed
36
+ * write, anything. A hook `warn` (exit 2) is read as a hard BLOCK on this
37
+ * host (recorded trap), and a telemetry concern has nothing to say to the
38
+ * model, so it never warns either.
39
+ */
40
+ import * as os from 'node:os';
41
+ import * as path from 'node:path';
42
+ import { fileURLToPath, pathToFileURL } from 'node:url';
43
+ import * as fs from 'node:fs';
44
+
45
+ import {
46
+ read_remote_settings,
47
+ type RemoteTelemetrySettings,
48
+ } from '../../agent-src/templates/scripts/telemetry/settings.js';
49
+ import {
50
+ append_class_a_record,
51
+ build_class_a_record,
52
+ profile_from_legacy_tier,
53
+ } from '../../agent-src/templates/scripts/telemetry/remote.js';
54
+ import { is_replay_mode } from './state_io.js';
55
+ import { readHookStdin } from './hook_stdin.js';
56
+
57
+ const EXIT_ALLOW = 0;
58
+
59
+ /** The tool name Phase 0 spike 1 confirmed for a skill invocation. */
60
+ export const SKILL_TOOL_NAME = 'Skill';
61
+
62
+ const SETTINGS_FILENAME = '.agent-settings.yml';
63
+
64
+ type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
65
+ type JsonObject = { [k: string]: JsonValue };
66
+
67
+ function isObject(v: unknown): v is JsonObject {
68
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
69
+ }
70
+
71
+ /** Unwrap the dispatcher envelope down to the platform-native payload. */
72
+ export function unwrapPayload(envelope: JsonObject): JsonObject {
73
+ const inner = envelope['payload'];
74
+ return isObject(inner) ? inner : envelope;
75
+ }
76
+
77
+ /** Best-effort read of the tool name across host key variants. */
78
+ export function extractToolName(payload: JsonObject): string | null {
79
+ const v = payload['tool_name'] ?? payload['toolName'] ?? payload['tool'];
80
+ return typeof v === 'string' && v ? v : null;
81
+ }
82
+
83
+ /**
84
+ * The invoked skill's name, off `tool_input.skill`.
85
+ *
86
+ * Spike 1 observed this key on 164 of 164 real invocations; the camelCase
87
+ * variant is accepted for the same reason `extractToolName` accepts one —
88
+ * host key casing has varied across versions in this tree before.
89
+ */
90
+ export function extractSkillName(payload: JsonObject): string | null {
91
+ const input = payload['tool_input'] ?? payload['toolInput'];
92
+ if (!isObject(input)) return null;
93
+ const v = input['skill'] ?? input['skill_name'] ?? input['name'];
94
+ return typeof v === 'string' && v ? v : null;
95
+ }
96
+
97
+ /** Host id off the dispatcher envelope (`claude`, `augment`, …). */
98
+ export function extractHost(envelope: JsonObject): string | null {
99
+ const v = envelope['platform'] ?? envelope['host'];
100
+ return typeof v === 'string' && v ? v : null;
101
+ }
102
+
103
+ /**
104
+ * The host session id. Hashed before it is recorded, never stored raw —
105
+ * it is a high-entropy opaque token that `check_secret_leak` reads as a
106
+ * candidate credential.
107
+ */
108
+ export function extractSessionId(envelope: JsonObject, payload: JsonObject): string {
109
+ for (const source of [envelope, payload]) {
110
+ const v = source['session_id'] ?? source['sessionId'];
111
+ if (typeof v === 'string' && v) return v;
112
+ }
113
+ return '';
114
+ }
115
+
116
+ /**
117
+ * Read one top-level scalar out of the settings text.
118
+ *
119
+ * Deliberately a raw line scan rather than a YAML parse: this runs on the
120
+ * post-tool hot path, both values wanted here are top-level scalars from
121
+ * closed enums, and `build_class_a_record` rejects anything outside those
122
+ * enums — so a mis-parse produces a dropped field, never a wrong one.
123
+ *
124
+ * Accepts a trailing inline comment, which this file's own settings
125
+ * template uses heavily (`discipline_profile: essential # ~3.3x kernel
126
+ * tokens`); rejecting it would have silently reported "not declared" for a
127
+ * perfectly ordinary line.
128
+ */
129
+ function _scalar(settingsText: string, key: string): string | null {
130
+ const re = new RegExp(`^${key}:[ \\t]*([^#\\r\\n]+?)[ \\t]*(?:#.*)?$`, 'u');
131
+ for (const line of settingsText.split('\n')) {
132
+ const m = re.exec(line);
133
+ if (m && m[1]) {
134
+ const value = m[1].trim().replace(/^["']|["']$/gu, '');
135
+ if (!value) return null;
136
+ // The shipped template carries a placeholder until `install`
137
+ // fills it in; that is "not yet resolved", not a value.
138
+ if (value.startsWith('__')) return null;
139
+ return value;
140
+ }
141
+ }
142
+ return null;
143
+ }
144
+
145
+ /**
146
+ * The DECLARED discipline profile — the knob that actually decides which
147
+ * rule surfaces load.
148
+ *
149
+ * The first version of this function read `rule_loading_tier`, and that was
150
+ * wrong: the settings template calls that key a "legacy knob — superseded by
151
+ * discipline_profile above", and `resolve_discipline_profile` confirms the
152
+ * precedence — explicit `discipline_profile` wins outright and the legacy
153
+ * key is consulted only in its absence. So a default install would have had
154
+ * a knob recorded that does not decide the thing the field implies.
155
+ *
156
+ * The fallback mirrors the resolver's own legacy mapping, which needs no
157
+ * model id and is therefore safe to reproduce here. What is NOT reproduced
158
+ * is the resolver's both-absent default (`essential`): recording it would
159
+ * claim a declaration the install never made. Absent stays `null`, and a
160
+ * reader applies the documented default themselves.
161
+ */
162
+ export function extractDisciplineProfile(settingsText: string): string | null {
163
+ const explicit = _scalar(settingsText, 'discipline_profile');
164
+ if (explicit !== null) return explicit;
165
+ return profile_from_legacy_tier(_scalar(settingsText, 'rule_loading_tier'));
166
+ }
167
+
168
+ /**
169
+ * Installed package version, from the env the installer sets. Never
170
+ * fabricated: an install that did not report one records `null`.
171
+ */
172
+ export function extractPackageVersion(env: NodeJS.ProcessEnv): string | null {
173
+ const v = env['AGENT_CONFIG_PACKAGE_VERSION'];
174
+ return typeof v === 'string' && v.trim() ? v.trim() : null;
175
+ }
176
+
177
+ interface SettingsRead {
178
+ settings: RemoteTelemetrySettings;
179
+ text: string;
180
+ /**
181
+ * The directory the settings file was found in — the project root, and
182
+ * the base a relative `output.path` resolves against. NOT the session
183
+ * cwd: a session started in a subdirectory would otherwise scatter one
184
+ * log file per directory it happened to start in.
185
+ */
186
+ root: string;
187
+ }
188
+
189
+ /**
190
+ * Read + memoise the settings for one process. The in-process dispatcher
191
+ * runs every concern in one `node`, so without this the file would be read
192
+ * once per concern per tool call.
193
+ */
194
+ const _settingsCache = new Map<string, SettingsRead>();
195
+
196
+ /**
197
+ * Walk up from `start` to the first directory holding a settings file.
198
+ *
199
+ * A host may report a session `cwd` inside a subdirectory of the project,
200
+ * and reading only that directory made the concern silently inactive on a
201
+ * legitimately enabled install — a failure with no message, which is the
202
+ * worst shape for a default-off surface. Bounded by the filesystem root, so
203
+ * it terminates on every path; no hit returns `start` unchanged, and the
204
+ * settings read then fails closed exactly as before.
205
+ */
206
+ export function resolveSettingsPath(start: string): string {
207
+ let dir = path.resolve(start);
208
+ for (;;) {
209
+ const candidate = path.join(dir, SETTINGS_FILENAME);
210
+ try {
211
+ if (fs.statSync(candidate).isFile()) return candidate;
212
+ } catch {
213
+ // Not here — keep walking.
214
+ }
215
+ const parent = path.dirname(dir);
216
+ if (parent === dir) return path.join(path.resolve(start), SETTINGS_FILENAME);
217
+ dir = parent;
218
+ }
219
+ }
220
+
221
+ export function readSettingsFor(consumerRoot: string): SettingsRead {
222
+ const p = resolveSettingsPath(consumerRoot);
223
+ const cached = _settingsCache.get(p);
224
+ if (cached !== undefined) return cached;
225
+
226
+ let text = '';
227
+ try {
228
+ text = fs.readFileSync(p, 'utf-8');
229
+ } catch {
230
+ text = '';
231
+ }
232
+ const read: SettingsRead = { settings: read_remote_settings(p), text, root: path.dirname(p) };
233
+ _settingsCache.set(p, read);
234
+ return read;
235
+ }
236
+
237
+ /** Test seam — the cache is process-global and tests write real files. */
238
+ export function _resetSettingsCache(): void {
239
+ _settingsCache.clear();
240
+ }
241
+
242
+ function processEnvelope(envelope: JsonValue, consumer_root: string): number {
243
+ try {
244
+ if (!isObject(envelope)) return EXIT_ALLOW;
245
+
246
+ const payload = unwrapPayload(envelope);
247
+ if (extractToolName(payload) !== SKILL_TOOL_NAME) return EXIT_ALLOW;
248
+
249
+ const { settings, text, root } = readSettingsFor(consumer_root);
250
+ if (!settings.active) return EXIT_ALLOW;
251
+ if (is_replay_mode()) return EXIT_ALLOW;
252
+
253
+ const skill = extractSkillName(payload);
254
+ if (skill === null) return EXIT_ALLOW;
255
+
256
+ const record = build_class_a_record({
257
+ skill,
258
+ host: extractHost(envelope),
259
+ org_id: settings.org_id,
260
+ salt: settings.salt,
261
+ hostname: os.hostname(),
262
+ username: os.userInfo().username,
263
+ session_id: extractSessionId(envelope, payload),
264
+ package_version: extractPackageVersion(process.env),
265
+ discipline_profile: extractDisciplineProfile(text),
266
+ });
267
+
268
+ // Relative to the PROJECT root (the directory holding the settings
269
+ // file), never to the session cwd — otherwise a session started in a
270
+ // subdirectory writes its own stray log there.
271
+ const logPath = path.isAbsolute(settings.log_path)
272
+ ? settings.log_path
273
+ : path.join(root, settings.log_path);
274
+ // The growth budget travels from the settings the install actually
275
+ // declared, not from the appender's own defaults — a retention key
276
+ // the write path never reads would be a setting that decorates
277
+ // rather than decides.
278
+ append_class_a_record(logPath, record, {
279
+ max_age_days: settings.retention_max_age_days,
280
+ max_bytes: settings.retention_max_bytes,
281
+ });
282
+ } catch {
283
+ // Malformed payload, unreadable disk, a rejected id — never block
284
+ // the tool call, and never write a degraded record.
285
+ return EXIT_ALLOW;
286
+ }
287
+ return EXIT_ALLOW;
288
+ }
289
+
290
+ export function run(stdin_text: string, options: { consumer_root: string }): number {
291
+ let envelope: JsonValue;
292
+ try {
293
+ const raw = stdin_text.trim();
294
+ if (!raw) return EXIT_ALLOW;
295
+ envelope = JSON.parse(raw) as JsonValue;
296
+ } catch {
297
+ return EXIT_ALLOW;
298
+ }
299
+ return processEnvelope(envelope, options.consumer_root);
300
+ }
301
+
302
+ export function _resolveRoot(envelope: JsonValue): string {
303
+ if (isObject(envelope)) {
304
+ const cwd = envelope['cwd'];
305
+ if (typeof cwd === 'string' && cwd) return cwd;
306
+ const pr = envelope['workspace_root'] ?? envelope['project_root'];
307
+ if (typeof pr === 'string' && pr) return pr;
308
+ }
309
+ return process.cwd();
310
+ }
311
+
312
+ export function main(): number {
313
+ const raw = readHookStdin();
314
+ let envelope: JsonValue = {};
315
+ try {
316
+ envelope = raw.trim() ? (JSON.parse(raw) as JsonValue) : {};
317
+ } catch {
318
+ return EXIT_ALLOW;
319
+ }
320
+ return processEnvelope(envelope, _resolveRoot(envelope));
321
+ }
322
+
323
+ // Bundle-safety: never auto-run when inlined into an esbuild bundle, where
324
+ // every module shares the bundle's `import.meta.url` (see cmd_migrate.ts).
325
+ declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
326
+ function _isCliEntry(): boolean {
327
+ if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) {
328
+ return false;
329
+ }
330
+ if (process.argv[1] === undefined) return false;
331
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
332
+ if (import.meta.url === argvUrl) return true;
333
+ try {
334
+ return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(path.resolve(process.argv[1]));
335
+ } catch {
336
+ return false;
337
+ }
338
+ }
339
+ if (_isCliEntry()) process.exit(main());