@event4u/agent-config 12.0.0 → 13.0.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 +273 -0
- package/README.md +1 -1
- package/agents/templates/.ai-council.yml.example +46 -3
- package/dist/agent-src/commands/cost/report.md +23 -0
- package/dist/agent-src/contexts/execution/subagent-routing.md +148 -33
- package/dist/agent-src/contexts/model-recommendations.md +6 -4
- package/dist/agent-src/contexts/subagent-configuration.md +69 -7
- package/dist/agent-src/rules/autonomous-execution.md +1 -1
- package/dist/agent-src/rules/design-fidelity.md +1 -1
- package/dist/agent-src/rules/design-review-after-ui-write.md +10 -5
- package/dist/agent-src/rules/evaluator-independence.md +27 -5
- package/dist/agent-src/rules/git-history-discipline.md +5 -1
- package/dist/agent-src/rules/low-impact-corpus-privacy-floor.md +1 -0
- package/dist/agent-src/rules/missing-skill-recovery.md +89 -0
- package/dist/agent-src/rules/ui-audit-gate.md +9 -4
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
- package/dist/agent-src/scripts/roadmap_gates.ts +30 -2
- package/dist/agent-src/scripts/update_roadmap_progress.ts +47 -1
- package/dist/agent-src/skills/authz-review/SKILL.md +4 -0
- package/dist/agent-src/skills/design-system-capture/SKILL.md +4 -0
- package/dist/agent-src/skills/design-system-capture/references/snapshot-preference-order.md +77 -0
- package/dist/agent-src/skills/merge-conflicts/SKILL.md +4 -0
- package/dist/agent-src/skills/prompt-optimizer/SKILL.md +17 -1
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +11 -11
- package/dist/agent-src/skills/systematic-debugging/SKILL.md +4 -0
- package/dist/agent-src/skills/threat-modeling/SKILL.md +3 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +41 -3
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-NN4KW4HZ.js → chunk-3B72LSZL.js} +123 -2338
- package/dist/cli-delegate/{chunk-55WZN5VF.js → chunk-EVTY7ISR.js} +2 -2
- package/dist/cli-delegate/chunk-N7FEST6D.js +3895 -0
- package/dist/cli-delegate/{chunk-L565YFXH.js → chunk-XURJ4AQC.js} +566 -298
- package/dist/cli-delegate/cmd_conformance.js +4 -4
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +2 -2
- package/dist/cli-delegate/cmd_uninstall.js +2 -2
- package/dist/cli-delegate/cmd_validate.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +44 -23
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +6 -5
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +5 -4
- package/dist/hooks/dispatch.js +10625 -8541
- package/dist/install/install.mjs +754 -484
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1302 -167
- package/dist/router.json +1 -1
- package/dist/scripts/_lib/model_tier.js +9 -0
- package/dist/scripts/_lib/model_tier.js.map +1 -1
- package/dist/scripts/condense.js +50 -0
- package/dist/scripts/condense.js.map +1 -1
- package/dist/server/schemas/settings.js +2 -2
- package/dist/server/schemas/settings.js.map +1 -1
- package/docs/MIGRATION.md +20 -1
- package/docs/guidelines/design-fidelity-mechanics.md +21 -0
- package/docs/guidelines/design-handover-extraction.md +87 -0
- package/docs/guidelines/prompt-templates.md +24 -1
- package/package.json +1 -1
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
- package/src/agent-src/scripts/roadmap_gates.ts +30 -2
- package/src/agent-src/scripts/update_roadmap_progress.ts +47 -1
- package/src/config/evaluator-budgets.json +9 -8
- package/src/config/gate-coverage.yml +63 -11
- package/src/config/gate-violation-baselines.json +12 -0
- package/src/config/hook-token-budget.json +15 -2
- package/src/config/preamble-payload-budget.json +28 -0
- package/src/scripts/_dispatch.bash +10 -0
- package/src/scripts/_lib/bench_ab_complexity.ts +439 -0
- package/src/scripts/_lib/bench_ab_safety_tier.ts +142 -0
- package/src/scripts/_lib/bench_ab_scoring_v2.ts +28 -3
- package/src/scripts/_lib/bench_ab_search_adherence.ts +423 -0
- package/src/scripts/_lib/bench_ab_size_claim.ts +206 -0
- package/src/scripts/_lib/bench_ab_workspace.ts +111 -0
- package/src/scripts/_lib/git_common_dir.ts +173 -0
- package/src/scripts/_lib/model_tier.ts +9 -0
- package/src/scripts/_lib/release_highlights.ts +108 -7
- package/src/scripts/_lib/skill_catalogue.ts +1115 -0
- package/src/scripts/_lib/tier_budget_routing.ts +31 -331
- package/src/scripts/ai_council/clients.ts +197 -18
- package/src/scripts/ai_council/config.ts +16 -1
- package/src/scripts/ai_council/low_impact.ts +14 -1
- package/src/scripts/ai_council/orchestrator.ts +14 -1
- package/src/scripts/ai_council/transport_resolver.ts +32 -2
- package/src/scripts/bench_ab_v2_complexity.ts +252 -0
- package/src/scripts/bench_ab_v2_run.ts +3 -42
- package/src/scripts/bench_ab_v2_safety.ts +296 -0
- package/src/scripts/bench_ab_v2_search.ts +343 -0
- package/src/scripts/bench_ab_v2_stats.ts +191 -0
- package/src/scripts/build_archive_index.ts +383 -0
- package/src/scripts/capture_skill_catalogue.ts +279 -317
- package/src/scripts/check_branch_freshness.ts +7 -1
- package/src/scripts/check_release_published.ts +6 -2
- package/src/scripts/check_release_trunk_sync.ts +18 -2
- package/src/scripts/check_source_size_budget.ts +428 -0
- package/src/scripts/check_suppression_hygiene.ts +5 -1
- package/src/scripts/condense.ts +50 -1
- package/src/scripts/cost/backfill_rates.mjs +298 -0
- package/src/scripts/cost/budget.mjs +12 -32
- package/src/scripts/cost_summary.ts +25 -0
- package/src/scripts/dispatch_r2_reviewer.ts +15 -5
- package/src/scripts/evidence_report.ts +15 -4
- package/src/scripts/external_sources_denylist.json +1 -0
- package/src/scripts/hook_manifest.yaml +75 -15
- package/src/scripts/hooks/concern_registry.ts +4 -0
- package/src/scripts/hooks/orchestration_record_hook.ts +21 -0
- package/src/scripts/hooks/skill_route_hook.ts +226 -0
- package/src/scripts/hooks/tool_result_bytes_hook.ts +219 -0
- package/src/scripts/hooks/ui_route_nudge_hook.ts +6 -3
- package/src/scripts/hot_context_hook.ts +13 -1
- package/src/scripts/install.ts +129 -0
- package/src/scripts/janitor.ts +77 -2
- package/src/scripts/lint_design_antipattern_parity.ts +40 -6
- package/src/scripts/lint_harvest_provenance.ts +35 -1
- package/src/scripts/lint_plan_risk_register.ts +22 -9
- package/src/scripts/lint_roadmap_blockers.ts +109 -6
- package/src/scripts/lint_rule_skill_pack_reach.ts +43 -0
- package/src/scripts/lint_scheduled_deprecations.ts +738 -0
- package/src/scripts/lint_skill_router_head.ts +21 -0
- package/src/scripts/mcp_server/consumer_tool_catalog.json +26 -3
- package/src/scripts/mcp_server/tool_catalog_source.ts +6 -24
- package/src/scripts/mcp_server/tools.ts +87 -0
- package/src/scripts/migration_status.ts +17 -1
- package/src/scripts/probe_review_binding_drift.ts +731 -0
- package/src/scripts/release.ts +100 -6
- package/src/scripts/routing_doctor.ts +50 -13
- package/src/scripts/schemas/command.schema.json +3 -3
- package/src/scripts/schemas/skill.schema.json +20 -2
- package/src/scripts/schemas/subagent.schema.json +2 -2
- package/src/scripts/session_eol_report.ts +81 -0
- package/src/scripts/trigger_coverage.ts +224 -3
- package/src/scripts/workspace_doctor.ts +464 -0
- package/src/scripts/worktree_cleanup_check.ts +2 -1
- package/src/server/schemas/settings.ts +2 -2
- package/dist/cli-delegate/chunk-AYKQGIBG.js +0 -1594
- package/src/config/budget-routing.json +0 -7
|
@@ -57,12 +57,14 @@ import { main as codeGraphNudgeMain } from './code_graph_nudge_hook.js';
|
|
|
57
57
|
import { main as uiRouteNudgeMain } from './ui_route_nudge_hook.js';
|
|
58
58
|
import { main as orchestrationRecordMain } from './orchestration_record_hook.js';
|
|
59
59
|
import { main as delegationNudgeMain } from './delegation_nudge_hook.js';
|
|
60
|
+
import { main as skillRouteMain } from './skill_route_hook.js';
|
|
60
61
|
import { main as endReviewNudgeMain } from './end_review_nudge_hook.js';
|
|
61
62
|
import { main as turnEndGateMain } from './turn_end_gate_hook.js';
|
|
62
63
|
import { main as editShapeMain } from './edit_shape_hook.js';
|
|
63
64
|
import { main as rereadGuardMain } from './reread_guard_hook.js';
|
|
64
65
|
import { main as sessionEolMain } from './session_eol_hook.js';
|
|
65
66
|
import { main as subagentLedgerMain } from './subagent_ledger_hook.js';
|
|
67
|
+
import { main as toolResultBytesMain } from './tool_result_bytes_hook.js';
|
|
66
68
|
import { main as spawnGuardShadowMain } from './spawn_guard_shadow_hook.js';
|
|
67
69
|
|
|
68
70
|
/** A concern `main` — argv-taking or not; both shapes exist. */
|
|
@@ -103,11 +105,13 @@ export const CONCERN_REGISTRY: Readonly<Record<string, ConcernMain>> = {
|
|
|
103
105
|
'src/scripts/hooks/ui_route_nudge_hook.ts': uiRouteNudgeMain as ConcernMain,
|
|
104
106
|
'src/scripts/hooks/orchestration_record_hook.ts': orchestrationRecordMain as ConcernMain,
|
|
105
107
|
'src/scripts/hooks/delegation_nudge_hook.ts': delegationNudgeMain as ConcernMain,
|
|
108
|
+
'src/scripts/hooks/skill_route_hook.ts': skillRouteMain as ConcernMain,
|
|
106
109
|
'src/scripts/hooks/end_review_nudge_hook.ts': endReviewNudgeMain as ConcernMain,
|
|
107
110
|
'src/scripts/hooks/turn_end_gate_hook.ts': turnEndGateMain as ConcernMain,
|
|
108
111
|
'src/scripts/hooks/edit_shape_hook.ts': editShapeMain as ConcernMain,
|
|
109
112
|
'src/scripts/hooks/reread_guard_hook.ts': rereadGuardMain as ConcernMain,
|
|
110
113
|
'src/scripts/hooks/session_eol_hook.ts': sessionEolMain as ConcernMain,
|
|
111
114
|
'src/scripts/hooks/subagent_ledger_hook.ts': subagentLedgerMain as ConcernMain,
|
|
115
|
+
'src/scripts/hooks/tool_result_bytes_hook.ts': toolResultBytesMain as ConcernMain,
|
|
112
116
|
'src/scripts/hooks/spawn_guard_shadow_hook.ts': spawnGuardShadowMain as ConcernMain,
|
|
113
117
|
};
|
|
@@ -243,6 +243,27 @@ export function buildRecordInput(facts: DispatchFacts, ts: string, id: string):
|
|
|
243
243
|
|
|
244
244
|
if (!facts.isAsync) {
|
|
245
245
|
if (facts.resolvedModel) {
|
|
246
|
+
// road-to-inbox-harvest-2026-08-d-top-band-model-economy Step 1.1.
|
|
247
|
+
//
|
|
248
|
+
// The raw id goes into the field the schema already reserves for it
|
|
249
|
+
// ("the model id the provider reported SERVING"). Without it, a
|
|
250
|
+
// dispatch that ran on a band the three-tier vocabulary cannot
|
|
251
|
+
// express is byte-identical in the record to one where the host
|
|
252
|
+
// reported no model at all: `extractModelFamily` returns null for
|
|
253
|
+
// both, so `tiers` is omitted for both.
|
|
254
|
+
//
|
|
255
|
+
// That collapse is precisely what blocks ADR-035's reopen
|
|
256
|
+
// condition. "Did a vendor ship a band the three tiers cannot
|
|
257
|
+
// express" is answerable only if an unmapped id is distinguishable
|
|
258
|
+
// from an absent one, and recording the served id separates them:
|
|
259
|
+
// unmapped ⇒ `model_served` set with `tiers` absent; unreported ⇒
|
|
260
|
+
// both absent.
|
|
261
|
+
//
|
|
262
|
+
// Vendor-neutral by construction: the id is stored verbatim as an
|
|
263
|
+
// opaque host string. No band above `high` is named and no mapping
|
|
264
|
+
// is invented — naming a fourth band is the decision this
|
|
265
|
+
// measurement exists to inform, never one it may pre-empt.
|
|
266
|
+
input.model_served = facts.resolvedModel;
|
|
246
267
|
const family = extractModelFamily(facts.resolvedModel);
|
|
247
268
|
if (family) input.tiers = [family];
|
|
248
269
|
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Skill route — `user_prompt_submit` concern
|
|
4
|
+
* (road-to-inbox-harvest-2026-08-d-runtime-skill-routing Phase 2).
|
|
5
|
+
*
|
|
6
|
+
* THE DEFECT THIS CLOSES. A deterministic task→skill ranker has shipped in
|
|
7
|
+
* this tree for months and is bound to nothing: `score_skill_relevance.rank`
|
|
8
|
+
* and its `suggest_skill_for_task` wrapper have no production caller, and
|
|
9
|
+
* `hook_manifest.yaml` had no concern that reached either. A capability that
|
|
10
|
+
* ships and never runs is indistinguishable from one that was never built —
|
|
11
|
+
* the same shape `delegation-nudge` was created to end for `classifyTask`, and
|
|
12
|
+
* this concern is deliberately its twin rather than a new mechanism.
|
|
13
|
+
*
|
|
14
|
+
* POINTERS, NEVER BODIES. The injected line names at most three skill IDs and
|
|
15
|
+
* their scores. It does not carry descriptions, excerpts, or file contents, and
|
|
16
|
+
* it never instructs the agent to load anything. The reason is the risk this
|
|
17
|
+
* roadmap's own register ranks first: a pointer line the agent trusts is worse
|
|
18
|
+
* than no line when the ranking is poor, so what is injected is the cheapest
|
|
19
|
+
* thing that can still be acted on — and the cheapest thing to ignore.
|
|
20
|
+
*
|
|
21
|
+
* SILENCE IS THE DEFAULT, AND BOTH FLOORS ARE MEASURED, NOT GUESSED. `rank`
|
|
22
|
+
* already drops every zero-scoring skill, but on real prompts it almost always
|
|
23
|
+
* returns SOMETHING, so "non-empty" is not a usable trigger. Measured over the
|
|
24
|
+
* routing-matrix corpus (496 prompt lines, `tests/eval/routing-matrix/*.yaml`,
|
|
25
|
+
* 2026-08-16): top-score median 18, p75 28, p90 30, max 70, and only 4 prompts
|
|
26
|
+
* score nothing at all.
|
|
27
|
+
*
|
|
28
|
+
* TWO floors are needed, because the score alone cannot express the failure at
|
|
29
|
+
* the short end. `MIN_TOP_SCORE` gates confidence; `MIN_TASK_TERMS` gates the
|
|
30
|
+
* denominator that confidence is computed over — the scorer divides by the task
|
|
31
|
+
* term count, so a one-term prompt scores 70/100 against whatever shares that
|
|
32
|
+
* term. Together they fire on 9.1 % of the corpus. A score floor at the median
|
|
33
|
+
* with no term floor would fire on half of all turns AND still hand 70/100 to
|
|
34
|
+
* `"fix it"` — the per-turn-reminder shape this estate has already measured
|
|
35
|
+
* failing at a 24/29 miss rate. Each constant carries its own derivation below.
|
|
36
|
+
*
|
|
37
|
+
* COST, MEASURED RATHER THAN ASSUMED. Ranking re-reads the skill catalogue per
|
|
38
|
+
* prompt, uncached. Measured 2026-08-16 on this tree: **12.3 ms warm** for a
|
|
39
|
+
* prompt that reaches the ranker, against the slot's 250 ms p95 budget — about
|
|
40
|
+
* 5 % of it. A prompt below `MIN_TASK_TERMS` costs **0 ms**, because the term
|
|
41
|
+
* floor is checked BEFORE the catalogue read; that ordering is deliberate and
|
|
42
|
+
* is what keeps conversational turns free. No cache is added: a cache would
|
|
43
|
+
* need an invalidation story for a tree that changes under the session, and
|
|
44
|
+
* 12.3 ms does not buy one.
|
|
45
|
+
*
|
|
46
|
+
* NO ADOPTION THRESHOLD IS COMMITTED HERE. Whether the line changes behaviour
|
|
47
|
+
* is a question for data, not for this header: `skill_route_pointer_rate` is
|
|
48
|
+
* registered in `hook-token-budget.json` under the same owner/review discipline
|
|
49
|
+
* as its sibling advisories, with the same kill standard — a nudge whose
|
|
50
|
+
* verdicts are measurably ignored gets its trigger tightened or its line
|
|
51
|
+
* removed. The score floor above is a TRIGGER calibration and is measured; the
|
|
52
|
+
* adoption threshold is an OUTCOME claim and is deliberately absent.
|
|
53
|
+
*
|
|
54
|
+
* DELIVERY PATH — the verified one, copied rather than re-derived. A
|
|
55
|
+
* `user_prompt_submit` concern returning `{decision:"allow", context:…}` at
|
|
56
|
+
* exit 0 produces zero stdout: `host_semantics.emitFor` drops the field for
|
|
57
|
+
* severity `"allow"`. The only shape that survives to
|
|
58
|
+
* `hookSpecificOutput.additionalContext` is `{decision:"warn", reason,
|
|
59
|
+
* additional_context}` at exit 2, which `emitFor` reduces to a real process
|
|
60
|
+
* exit of 0 — it never blocks the turn. `delegation_nudge_hook.ts` carries the
|
|
61
|
+
* full proof of that mechanism and this concern follows it exactly.
|
|
62
|
+
*
|
|
63
|
+
* PLATFORM SCOPE — `claude` only, for the same reason its twin gives:
|
|
64
|
+
* `host_semantics.VERIFIED_PLATFORMS` covers only `claude`, `cowork`'s
|
|
65
|
+
* trampoline discards dispatcher stdout unconditionally, and the other
|
|
66
|
+
* trampolines were never inspected for that property. Extending an exit-2
|
|
67
|
+
* delivery to an unverified propagation path is the speculative mapping
|
|
68
|
+
* `host_semantics.ts` exists to prevent.
|
|
69
|
+
*/
|
|
70
|
+
import fs from "node:fs";
|
|
71
|
+
import path from "node:path";
|
|
72
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
73
|
+
|
|
74
|
+
import { readHookStdin } from "./hook_stdin.js";
|
|
75
|
+
import { isSyntheticPrompt } from "../_lib/prompt_shape.js";
|
|
76
|
+
import { resolveSkillsRoot } from "../_lib/skill_catalogue.js";
|
|
77
|
+
import { _tokenize, rank, type RankRow } from "../skill_tools/score_skill_relevance.js";
|
|
78
|
+
|
|
79
|
+
const EXIT_ALLOW = 0;
|
|
80
|
+
/** See the header: reduced to a real exit of 0 by `emitFor`'s warn branch. */
|
|
81
|
+
const EXIT_WARN = 2;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Minimum top-1 ranker score before this concern speaks.
|
|
85
|
+
*
|
|
86
|
+
* **31, not the measured p90 of 30, and the extra point is load-bearing.** The
|
|
87
|
+
* scorer is `overlap * 70 + personaHit * 30`, so a skill whose PERSONA slug
|
|
88
|
+
* appears in the prompt scores exactly 30 with zero keyword overlap. A floor of
|
|
89
|
+
* 30 with a `>=` test therefore admits a pure persona coincidence as if it were
|
|
90
|
+
* a ranked match. 31 is the smallest floor strictly above that value; it moves
|
|
91
|
+
* the corpus fire rate from 13.9 % to 9.1 %.
|
|
92
|
+
*/
|
|
93
|
+
export const MIN_TOP_SCORE = 31;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Minimum distinct task terms before the score is trusted at all.
|
|
97
|
+
*
|
|
98
|
+
* The scorer divides by `|task_terms|`, so the score is inversely proportional
|
|
99
|
+
* to prompt length and degenerates at the short end: `"fix it"` tokenizes to
|
|
100
|
+
* ONE term and hands 70/100 to whichever skills happen to share it, which on
|
|
101
|
+
* this catalogue means the alphabetically first three. `MIN_TOP_SCORE` cannot
|
|
102
|
+
* fix that — the number is not too low, the denominator is too small.
|
|
103
|
+
*
|
|
104
|
+
* **3 is the routing-matrix corpus's own minimum**, so this floor excludes
|
|
105
|
+
* ZERO of the 496 prompt lines the score calibration rests on while removing
|
|
106
|
+
* every degenerate short prompt measured (`"fix it"` 1, `"mach das"` 2,
|
|
107
|
+
* `"was denkst du dazu"` 2, `"weiter"` 1). A floor of 4 would have cost 10 real
|
|
108
|
+
* corpus prompts for one tenth of a percentage point.
|
|
109
|
+
*/
|
|
110
|
+
export const MIN_TASK_TERMS = 3;
|
|
111
|
+
|
|
112
|
+
/** Pointers per line. Three is the ranker's own default `--top`. */
|
|
113
|
+
export const TOP_K = 3;
|
|
114
|
+
|
|
115
|
+
type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
|
|
116
|
+
type JsonObject = { [k: string]: JsonValue };
|
|
117
|
+
|
|
118
|
+
function _isObject(v: unknown): v is JsonObject {
|
|
119
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Rank a prompt and return the pointers worth injecting, or `[]` for silence.
|
|
124
|
+
*
|
|
125
|
+
* Total by construction: a missing root, an unreadable catalogue, or a ranker
|
|
126
|
+
* error all resolve to `[]`. A routing advisory that can crash a turn is worse
|
|
127
|
+
* than one that says nothing.
|
|
128
|
+
*/
|
|
129
|
+
export function routePointers(prompt: string, skillsDir: string | null): RankRow[] {
|
|
130
|
+
if (skillsDir === null) return [];
|
|
131
|
+
// Denominator floor BEFORE the catalogue read: a prompt too short to score
|
|
132
|
+
// meaningfully cannot produce a pointer worth 337 file reads either.
|
|
133
|
+
try {
|
|
134
|
+
if (_tokenize(prompt).size < MIN_TASK_TERMS) return [];
|
|
135
|
+
} catch {
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
let rows: RankRow[];
|
|
139
|
+
try {
|
|
140
|
+
rows = rank(prompt, skillsDir);
|
|
141
|
+
} catch {
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
const top = rows[0];
|
|
145
|
+
if (top === undefined || top[1] < MIN_TOP_SCORE) return [];
|
|
146
|
+
return rows.slice(0, TOP_K);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The injected line. Names the floor it cleared, so a reader can tell a
|
|
151
|
+
* confident pointer from a lucky one without opening this file.
|
|
152
|
+
*/
|
|
153
|
+
export function buildRouteLine(rows: readonly RankRow[]): string {
|
|
154
|
+
const pointers = rows.map(([name, score]) => `${name} (${score})`).join(", ");
|
|
155
|
+
return (
|
|
156
|
+
`<skill-route>ranked skill pointers for this prompt: ${pointers}. ` +
|
|
157
|
+
`Deterministic keyword ranker, top-1 at or above ${MIN_TOP_SCORE}/100 — ` +
|
|
158
|
+
`pointers only, no bodies loaded. Open one if it fits; ignore the line if it does not.</skill-route>`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function _extractPrompt(payload: JsonObject): string {
|
|
163
|
+
for (const key of ["prompt", "userPrompt", "user_prompt", "message", "text"]) {
|
|
164
|
+
const v = payload[key];
|
|
165
|
+
if (typeof v === "string" && v.trim()) return v;
|
|
166
|
+
}
|
|
167
|
+
return "";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function _workspaceRoot(envelope: JsonObject): string {
|
|
171
|
+
const v = envelope["workspace_root"];
|
|
172
|
+
return typeof v === "string" && v ? v : process.cwd();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function main(): number {
|
|
176
|
+
let envelope: JsonValue = {};
|
|
177
|
+
try {
|
|
178
|
+
const raw = readHookStdin();
|
|
179
|
+
envelope = raw.trim() ? (JSON.parse(raw) as JsonValue) : {};
|
|
180
|
+
} catch {
|
|
181
|
+
return EXIT_ALLOW; // malformed envelope — never block
|
|
182
|
+
}
|
|
183
|
+
const env: JsonObject = _isObject(envelope) ? envelope : {};
|
|
184
|
+
|
|
185
|
+
const event = env["event"];
|
|
186
|
+
const slot = typeof event === "string" && event !== "" ? event : "user_prompt_submit";
|
|
187
|
+
if (slot !== "user_prompt_submit") return EXIT_ALLOW;
|
|
188
|
+
|
|
189
|
+
const payload = _isObject(env["payload"]) ? (env["payload"] as JsonObject) : env;
|
|
190
|
+
const prompt = _extractPrompt(payload);
|
|
191
|
+
if (!prompt) return EXIT_ALLOW;
|
|
192
|
+
if (isSyntheticPrompt(prompt)) return EXIT_ALLOW; // harness turn, not a task
|
|
193
|
+
|
|
194
|
+
const rows = routePointers(prompt, resolveSkillsRoot(_workspaceRoot(env)));
|
|
195
|
+
if (rows.length === 0) return EXIT_ALLOW; // silence is the default
|
|
196
|
+
|
|
197
|
+
process.stdout.write(
|
|
198
|
+
`${JSON.stringify({
|
|
199
|
+
decision: "warn",
|
|
200
|
+
reason: `skill-route: ${rows.length} pointer(s), top ${rows[0]![1]}/100`,
|
|
201
|
+
additional_context: buildRouteLine(rows),
|
|
202
|
+
})}\n`,
|
|
203
|
+
);
|
|
204
|
+
return EXIT_WARN;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Bundle-safety: never auto-run when inlined into an esbuild bundle, where
|
|
208
|
+
// every module shares the bundle's `import.meta.url`.
|
|
209
|
+
declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
|
|
210
|
+
function _isCliEntry(): boolean {
|
|
211
|
+
if (typeof __AGENT_CONFIG_BUNDLE__ !== "undefined" && __AGENT_CONFIG_BUNDLE__) return false;
|
|
212
|
+
if (process.argv[1] === undefined) return false;
|
|
213
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
214
|
+
if (import.meta.url === argvUrl) return true;
|
|
215
|
+
try {
|
|
216
|
+
const here = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
217
|
+
const argv = fs.realpathSync(path.resolve(process.argv[1]));
|
|
218
|
+
return here === argv;
|
|
219
|
+
} catch {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (_isCliEntry()) {
|
|
225
|
+
process.exit(main());
|
|
226
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Tool-result byte counter — capture only, no behaviour change
|
|
4
|
+
* (road-to-inbox-harvest-2026-08-d-context-ledger Phase 1, Step 1.1).
|
|
5
|
+
*
|
|
6
|
+
* Why this exists. `hook-token-budget.json` registers the gap in its own
|
|
7
|
+
* words: *"bytes-into-context from tool RESULTS is not instrumented
|
|
8
|
+
* anywhere (the census covers hook payloads only)"*. The injection census
|
|
9
|
+
* measures what HOOKS put into context — a few kilobytes per turn under a
|
|
10
|
+
* per-slot cap. It does not measure what TOOLS put into context, which is
|
|
11
|
+
* the larger number by any plausible margin and the one nobody can cite.
|
|
12
|
+
* Every claim about context cost in this tree is therefore made against a
|
|
13
|
+
* denominator that excludes its own dominant term.
|
|
14
|
+
*
|
|
15
|
+
* This concern is the instrument and ONLY the instrument. It appends; it
|
|
16
|
+
* never decides, never advises, and emits nothing to the model. The
|
|
17
|
+
* threshold question — when is a tool result too large — is deliberately
|
|
18
|
+
* NOT answered here: the distribution has never been observed, and a
|
|
19
|
+
* threshold set before the distribution is a number invented rather than
|
|
20
|
+
* measured. That ordering is the house rule this repository has paid to
|
|
21
|
+
* learn more than once.
|
|
22
|
+
*
|
|
23
|
+
* ── What is recorded, and what is refused ──────────────────────────────
|
|
24
|
+
*
|
|
25
|
+
* PRIVACY BY CONSTRUCTION — never widen this file to record content.
|
|
26
|
+
*
|
|
27
|
+
* The record type has exactly three fields and NONE of them can hold free
|
|
28
|
+
* text: a timestamp, a tool NAME (an id-shaped enum — `Read`, `Bash`,
|
|
29
|
+
* `Grep` — the same single host string `orchestration_record_hook` already
|
|
30
|
+
* records), and a BYTE COUNT. A tool result routinely carries file
|
|
31
|
+
* contents, command output, secrets in an env dump, and customer data from
|
|
32
|
+
* an API response. None of it reaches this file's output, because the
|
|
33
|
+
* output has no field able to carry it. A record shape that cannot hold a
|
|
34
|
+
* secret has no scrubber that can fail — the same construction
|
|
35
|
+
* `domain-safety-pii` § Surface 2 prescribes for logs and the reason the
|
|
36
|
+
* sibling `subagent-ledger` refuses `last_assistant_message` in any form.
|
|
37
|
+
*
|
|
38
|
+
* The result is measured, never read: `_resultBytes` computes a length and
|
|
39
|
+
* discards the value in the same expression.
|
|
40
|
+
*
|
|
41
|
+
* ── Always exit 0 ─────────────────────────────────────────────────────
|
|
42
|
+
*
|
|
43
|
+
* A `warn` (exit 2) is read as a hard BLOCK on this host, and this concern
|
|
44
|
+
* has nothing to say to the model. Every path returns EXIT_ALLOW —
|
|
45
|
+
* malformed envelope, unparseable payload, unwritable disk. An instrument
|
|
46
|
+
* that can fail a turn is a mechanism, and this is not one yet.
|
|
47
|
+
*
|
|
48
|
+
* ── Absence is recorded, not inferred ─────────────────────────────────
|
|
49
|
+
*
|
|
50
|
+
* A payload carrying no readable result writes a line with `bytes: null`
|
|
51
|
+
* and `measurable: false` rather than writing nothing. The two cases are
|
|
52
|
+
* otherwise byte-identical in the output — "this host does not carry tool
|
|
53
|
+
* results in its PostToolUse payload" and "no tools ran" would produce the
|
|
54
|
+
* same empty file, and the first is a finding about the instrument's own
|
|
55
|
+
* coverage that the baseline has to be able to see. This is the
|
|
56
|
+
* instrument-goes-quiet failure the sibling ledger's round-1 review fixed
|
|
57
|
+
* one layer down, applied here before it can be shipped.
|
|
58
|
+
*/
|
|
59
|
+
import * as fs from 'node:fs';
|
|
60
|
+
import * as path from 'node:path';
|
|
61
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
62
|
+
|
|
63
|
+
import { is_replay_mode } from './state_io.js';
|
|
64
|
+
import { readHookStdin } from './hook_stdin.js';
|
|
65
|
+
|
|
66
|
+
const EXIT_ALLOW = 0;
|
|
67
|
+
|
|
68
|
+
type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
|
|
69
|
+
type JsonObject = { [k: string]: JsonValue };
|
|
70
|
+
|
|
71
|
+
function isObject(v: unknown): v is JsonObject {
|
|
72
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Census file, relative to the consumer repo root. Sibling of
|
|
77
|
+
* `injection-census.jsonl` in the same directory and the same
|
|
78
|
+
* `{ts, …, bytes}` line shape — one family, two units: that file counts
|
|
79
|
+
* bytes this suite injects, this one counts bytes the host's tools do.
|
|
80
|
+
* Gitignored via `/agents/runtime/`.
|
|
81
|
+
*/
|
|
82
|
+
export const CENSUS_FILE = path.join('agents', 'runtime', 'state', 'tool-result-census.jsonl');
|
|
83
|
+
|
|
84
|
+
/** The payload keys a host may use for a tool's result, widest first. */
|
|
85
|
+
const RESULT_KEYS = ['tool_response', 'toolResponse', 'tool_result', 'toolUseResult'] as const;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* UTF-8 byte length of a tool result, or `null` when the payload carries
|
|
89
|
+
* none under any known key.
|
|
90
|
+
*
|
|
91
|
+
* A result is an object on most tools and a bare string on some (`Bash`
|
|
92
|
+
* returns its stdout directly — see the sibling hook's own test corpus), so
|
|
93
|
+
* both shapes are measured. The sibling's `extractToolResult` returns
|
|
94
|
+
* `JsonObject | null` and would report a bare-string result as absent,
|
|
95
|
+
* which is correct for its purpose (it reads named usage fields) and wrong
|
|
96
|
+
* for this one: a 400 KB command output is exactly the case this counter
|
|
97
|
+
* exists to see, and it is precisely the shape that extractor drops.
|
|
98
|
+
*
|
|
99
|
+
* Bytes, not characters: a multibyte result costs what it costs on the
|
|
100
|
+
* wire, and `String.length` would under-report it.
|
|
101
|
+
*/
|
|
102
|
+
export function _resultBytes(payload: JsonObject): number | null {
|
|
103
|
+
for (const key of RESULT_KEYS) {
|
|
104
|
+
const v = payload[key];
|
|
105
|
+
if (v === undefined || v === null) continue;
|
|
106
|
+
if (typeof v === 'string') return Buffer.byteLength(v, 'utf8');
|
|
107
|
+
try {
|
|
108
|
+
return Buffer.byteLength(JSON.stringify(v), 'utf8');
|
|
109
|
+
} catch {
|
|
110
|
+
// Circular or otherwise unserialisable — unmeasurable, not zero.
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The tool's name, read from the same positions the sibling hook reads. */
|
|
118
|
+
export function _toolName(payload: JsonObject, envelope: JsonObject): string | null {
|
|
119
|
+
const candidates = [
|
|
120
|
+
payload['tool_name'],
|
|
121
|
+
payload['toolName'],
|
|
122
|
+
payload['tool'],
|
|
123
|
+
envelope['tool_name'],
|
|
124
|
+
envelope['tool'],
|
|
125
|
+
];
|
|
126
|
+
for (const v of candidates) {
|
|
127
|
+
if (typeof v === 'string' && v) return v;
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function unwrapPayload(envelope: JsonObject): JsonObject {
|
|
133
|
+
const inner = envelope['payload'];
|
|
134
|
+
return isObject(inner) ? inner : envelope;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** `${ts.slice(0,7)}` is NOT used here — the census is one flat file, as its sibling is. */
|
|
138
|
+
function censusFile(consumerRoot: string): string {
|
|
139
|
+
return path.join(consumerRoot, CENSUS_FILE);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface CensusLine {
|
|
143
|
+
ts: string;
|
|
144
|
+
tool: string | null;
|
|
145
|
+
bytes: number | null;
|
|
146
|
+
/** `false` ⇒ the payload carried no readable result. See the header. */
|
|
147
|
+
measurable: boolean;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function _appendCensusLine(consumerRoot: string, line: CensusLine): void {
|
|
151
|
+
if (is_replay_mode()) return;
|
|
152
|
+
const file = censusFile(consumerRoot);
|
|
153
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
154
|
+
fs.appendFileSync(file, `${JSON.stringify(line)}\n`, 'utf8');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Process an ALREADY-PARSED dispatcher envelope. Always returns EXIT_ALLOW. */
|
|
158
|
+
export function processEnvelope(envelope: JsonValue, consumerRoot: string): number {
|
|
159
|
+
try {
|
|
160
|
+
if (!isObject(envelope)) return EXIT_ALLOW;
|
|
161
|
+
if (envelope['event'] !== 'post_tool_use') return EXIT_ALLOW;
|
|
162
|
+
|
|
163
|
+
const payload = unwrapPayload(envelope);
|
|
164
|
+
const bytes = _resultBytes(payload);
|
|
165
|
+
|
|
166
|
+
_appendCensusLine(consumerRoot, {
|
|
167
|
+
ts: new Date().toISOString(),
|
|
168
|
+
tool: _toolName(payload, envelope),
|
|
169
|
+
bytes,
|
|
170
|
+
measurable: bytes !== null,
|
|
171
|
+
});
|
|
172
|
+
} catch {
|
|
173
|
+
// Malformed payload, unwritable disk, anything — never disturb the run.
|
|
174
|
+
return EXIT_ALLOW;
|
|
175
|
+
}
|
|
176
|
+
return EXIT_ALLOW;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Resolve the consumer repo root, same precedence as the sibling ledger. */
|
|
180
|
+
export function resolveConsumerRoot(envelope: JsonValue): string {
|
|
181
|
+
if (isObject(envelope)) {
|
|
182
|
+
for (const key of ['workspace_root', 'project_root']) {
|
|
183
|
+
const v = envelope[key];
|
|
184
|
+
if (typeof v === 'string' && v) return v;
|
|
185
|
+
}
|
|
186
|
+
const cwd = unwrapPayload(envelope)['cwd'];
|
|
187
|
+
if (typeof cwd === 'string' && cwd) return cwd;
|
|
188
|
+
}
|
|
189
|
+
return process.cwd();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function main(): number {
|
|
193
|
+
const raw = readHookStdin();
|
|
194
|
+
let envelope: JsonValue = {};
|
|
195
|
+
try {
|
|
196
|
+
envelope = raw.trim() ? (JSON.parse(raw) as JsonValue) : {};
|
|
197
|
+
} catch {
|
|
198
|
+
return EXIT_ALLOW;
|
|
199
|
+
}
|
|
200
|
+
return processEnvelope(envelope, resolveConsumerRoot(envelope));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Bundle-safety: never auto-run when inlined into an esbuild bundle, where
|
|
204
|
+
// every module shares the bundle's `import.meta.url`.
|
|
205
|
+
declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
|
|
206
|
+
function _isCliEntry(): boolean {
|
|
207
|
+
if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
if (process.argv[1] === undefined) return false;
|
|
211
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
212
|
+
if (import.meta.url === argvUrl) return true;
|
|
213
|
+
try {
|
|
214
|
+
return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(path.resolve(process.argv[1]));
|
|
215
|
+
} catch {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (_isCliEntry()) process.exit(main());
|
|
@@ -30,9 +30,12 @@
|
|
|
30
30
|
* degradation valve `design_slop_hook` carries, for the same reason: an agent
|
|
31
31
|
* that has decided to proceed must not be re-prompted into a loop.
|
|
32
32
|
*
|
|
33
|
-
* HOST BOUNDARY, STATED RATHER THAN IMPLIED. `pre_tool_use`
|
|
34
|
-
* hosts. Everywhere else
|
|
35
|
-
*
|
|
33
|
+
* HOST BOUNDARY, STATED RATHER THAN IMPLIED. `pre_tool_use` is BOUND on three
|
|
34
|
+
* hosts. Everywhere else the obligation stays model-carried, exactly as the two
|
|
35
|
+
* rules already declare. Corrected 2026-08-17: this comment said the concern has
|
|
36
|
+
* "nowhere to bind" elsewhere, which the manifest's own `native_event_aliases`
|
|
37
|
+
* table refutes for cursor, cline and gemini — unbound, not unbindable. See
|
|
38
|
+
* `docs/contracts/hook-architecture-v1.md` § Which hosts carry pre_tool_use. Run
|
|
36
39
|
* `agent-config hooks:status` for the host you are actually on.
|
|
37
40
|
*
|
|
38
41
|
* Default-OFF: no-ops unless `hooks.ui_route_nudge.enabled: true`.
|
|
@@ -379,7 +379,19 @@ export function main(): number {
|
|
|
379
379
|
if (_is_replay_mode()) {
|
|
380
380
|
return 0; // replay fixtures: read-only, no state mutation
|
|
381
381
|
}
|
|
382
|
-
|
|
382
|
+
// `pre_compact` joins the write set (road-to-inbox-harvest-2026-08-d-
|
|
383
|
+
// context-ledger Step 2.1). It is the only slot that fires WHILE state
|
|
384
|
+
// is being destroyed, and until now it carried no writer at all: the
|
|
385
|
+
// cache a post-compaction `session_start` restores was written at the
|
|
386
|
+
// last `stop`, so everything the session did since then was lost with
|
|
387
|
+
// the compaction. Writing here makes the restore reflect the moment
|
|
388
|
+
// before the loss instead of the moment before the last turn.
|
|
389
|
+
//
|
|
390
|
+
// Cheap and idempotent by construction: `_write_hot_context` is a
|
|
391
|
+
// deterministic rebuild from the history file, so an extra fire costs
|
|
392
|
+
// one rebuild and can never produce a worse cache than the older one it
|
|
393
|
+
// replaces. It emits nothing — the restore side is unchanged.
|
|
394
|
+
if (event === 'stop' || event === 'session_end' || event === 'pre_compact') {
|
|
383
395
|
_write_hot_context(root);
|
|
384
396
|
} else if (event === 'session_start') {
|
|
385
397
|
const source = String(payload.source ?? '');
|