@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
|
@@ -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
|
|
130
|
-
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
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:
|
|
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());
|