@event4u/agent-config 9.29.0 → 9.30.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 +108 -7
- package/README.md +1 -0
- package/dist/agent-src/commands/analyze/inbox.md +22 -1
- package/dist/agent-src/commands/explain-run.md +31 -1
- package/dist/agent-src/commands/team/delegate.md +1 -0
- package/dist/agent-src/contexts/execution/orchestration-telemetry.md +4 -1
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +17 -0
- package/dist/agent-src/contexts/execution/subagent-response-contract.md +11 -1
- package/dist/agent-src/rules/preservation-guard.md +9 -0
- package/dist/agent-src/scripts/roadmap_gates.ts +312 -0
- package/dist/agent-src/scripts/update_roadmap_progress.ts +72 -4
- package/dist/agent-src/skills/subagent-orchestration/SKILL.md +10 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +2 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-OJM3MBJM.js → chunk-BTJNRUX3.js} +21 -34
- package/dist/cli-delegate/{chunk-MMXA5GY7.js → chunk-DDUNNFYX.js} +14 -0
- package/dist/cli-delegate/{chunk-NZ5N2XQL.js → chunk-EQY4FLJB.js} +1 -1
- package/dist/cli-delegate/{chunk-RCIA67XN.js → chunk-IYTGIG2Y.js} +44 -1
- package/dist/cli-delegate/chunk-MMJL4TIX.js +18 -0
- package/dist/cli-delegate/chunk-O3CO5BFB.js +161 -0
- package/dist/cli-delegate/{chunk-REKCMYOU.js → chunk-PFBV6XRT.js} +36 -20
- package/dist/cli-delegate/chunk-RVGBH4XK.js +142 -0
- package/dist/cli-delegate/cmd_conformance.js +2798 -95
- package/dist/cli-delegate/cmd_doctor.js +2 -1
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +86 -6
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_route_audit.js +2 -1
- package/dist/cli-delegate/cmd_session_recycle.js +177 -0
- package/dist/cli-delegate/cmd_settings_get.js +1 -1
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +2 -2
- 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 +7 -7
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.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 +1904 -575
- package/dist/install/install.mjs +14 -0
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +3760 -726
- package/dist/server/schemas/settings.js +6 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/dist/ui/assets/index-B5qGYOJo.js +26 -0
- package/dist/ui/assets/index-B5qGYOJo.js.map +1 -0
- package/dist/ui/index.html +1 -1
- package/docs/contracts/settings-classes.md +21 -5
- package/package.json +1 -1
- package/src/agent-src/scripts/roadmap_gates.ts +312 -0
- package/src/agent-src/scripts/update_roadmap_progress.ts +72 -4
- package/src/config/agent-settings.template.yml +36 -0
- package/src/config/dispatch-economy-metrics.json +61 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/hook-token-budget.json +91 -0
- package/src/config/recycle-threshold-budget.json +12 -0
- package/src/config/rule-enforcement-baseline.json +86 -0
- package/src/scripts/_cli/cmd_conformance.ts +17 -2
- package/src/scripts/_cli/cmd_session_recycle.ts +226 -0
- package/src/scripts/_dispatch.bash +23 -0
- package/src/scripts/_lib/judgment_ladder.ts +254 -2
- package/src/scripts/_lib/orchestration_record.ts +41 -10
- package/src/scripts/_lib/recycle_envelope_paths.ts +35 -0
- package/src/scripts/_lib/self_repair.ts +194 -0
- package/src/scripts/_lib/session_eol.ts +230 -0
- package/src/scripts/_lib/session_role.ts +42 -0
- package/src/scripts/_lib/subagent_capsule.ts +159 -0
- package/src/scripts/_lib/subagent_response.ts +75 -0
- package/src/scripts/ai_council/clients.ts +50 -1
- package/src/scripts/ask_transport.ts +221 -0
- package/src/scripts/bench_hook_injection.ts +234 -0
- package/src/scripts/check_adr_frontmatter.ts +84 -40
- package/src/scripts/check_branch_freshness.ts +1 -0
- package/src/scripts/check_corpus_staleness.ts +18 -4
- package/src/scripts/check_detector_corpus.ts +17 -0
- package/src/scripts/check_gitignore_freshness.ts +42 -8
- package/src/scripts/check_hook_bundle_freshness.ts +20 -0
- package/src/scripts/check_kernel_prefix_stability.ts +1 -0
- package/src/scripts/check_standing_rule_delivery.ts +73 -32
- package/src/scripts/check_static_layer_stability.ts +154 -0
- package/src/scripts/check_suppression_hygiene.ts +48 -0
- package/src/scripts/dispatch_economy_report.ts +448 -0
- package/src/scripts/dispatch_r2_reviewer.ts +15 -2
- package/src/scripts/explain_run.ts +206 -4
- package/src/scripts/generate_settings_reference.ts +1 -0
- package/src/scripts/handoff_context_hook.ts +112 -4
- package/src/scripts/hook_manifest.yaml +117 -10
- package/src/scripts/hooks/concern_registry.ts +8 -0
- package/src/scripts/hooks/delegation_nudge_hook.ts +40 -3
- package/src/scripts/hooks/dispatch_hook.ts +39 -2
- package/src/scripts/hooks/edit_shape_hook.ts +303 -0
- package/src/scripts/hooks/orchestration_record_hook.ts +19 -1
- package/src/scripts/hooks/reread_guard_hook.ts +314 -0
- package/src/scripts/hooks/rtk_wrap_hook.ts +133 -14
- package/src/scripts/hooks/session_eol_hook.ts +268 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +507 -0
- package/src/scripts/install-hooks.sh +1 -1
- package/src/scripts/language_mirror_hook.ts +89 -0
- package/src/scripts/lint_decision_impact.ts +14 -1
- package/src/scripts/lint_hook_manifest.ts +82 -0
- package/src/scripts/lint_readme_serial_comma.ts +1 -0
- package/src/scripts/lint_rule_enforcement_declaration.ts +347 -0
- package/src/scripts/measure_turn_end_gate.ts +194 -0
- package/src/scripts/orchestration_record.ts +4 -1
- package/src/scripts/rebuild_hook_bundle.ts +1 -0
- package/src/scripts/report_conformance_funnel.ts +284 -0
- package/src/scripts/report_imperative_density.ts +1 -0
- package/src/scripts/routing_doctor.ts +97 -0
- package/src/scripts/self_repair_cli.ts +51 -3
- package/src/scripts/session_eol_report.ts +261 -0
- package/src/server/schemas/settings.ts +14 -0
- package/src/templates/compact-instructions.md +40 -0
- package/dist/ui/assets/index-JDAF00bC.js +0 -26
- package/dist/ui/assets/index-JDAF00bC.js.map +0 -1
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Rung-0.5 ask transport — one prompt, one answer, no harness, no session
|
|
4
|
+
* (road-to-token-economy-dispatch Phase 4.1).
|
|
5
|
+
*
|
|
6
|
+
* The judgment ladder's gap this closes: rung 0 routes mechanical slices to
|
|
7
|
+
* deterministic scripts; rung 1 jumps straight to a full subagent spawn
|
|
8
|
+
* whose measured median cold start is ~251k tokens (dispatch_floor,
|
|
9
|
+
* 2026-08-10). A single bounded QUESTION — a classification, a small
|
|
10
|
+
* verification, a one-file semantic lookup — needs neither a session nor
|
|
11
|
+
* tools: one completion over the reconciled council transport chain
|
|
12
|
+
* (cli → api → ∅) answers it at roughly two orders of magnitude below the
|
|
13
|
+
* spawn floor.
|
|
14
|
+
*
|
|
15
|
+
* Hard caps, per the roadmap's anti-loop discipline (Risk 2 — "rung 0.5
|
|
16
|
+
* becomes a hidden agent loop"):
|
|
17
|
+
* - ONE completion. No retry beyond the transport chain's own cli→api
|
|
18
|
+
* fallback semantics inside the client.
|
|
19
|
+
* - NO tool use, NO session, NO follow-up turn.
|
|
20
|
+
* - Honest ∅: council disabled / no member / transport failure → null
|
|
21
|
+
* (CLI exit 3), never a fabricated answer and never an escalation this
|
|
22
|
+
* module performs itself. Escalating to a spawn is the CALLER's
|
|
23
|
+
* decision and is recorded as such (`escalated` on the caller's line).
|
|
24
|
+
*
|
|
25
|
+
* Telemetry: every completed ask appends ONE orchestration_record line
|
|
26
|
+
* (route_taken: "ask", spawn_count: 0, dispatch_tokens measured from the
|
|
27
|
+
* provider response) — the substitution/adoption metrics registered in
|
|
28
|
+
* `src/config/dispatch-economy-metrics.json` read these lines.
|
|
29
|
+
*
|
|
30
|
+
* Reuses, never re-implements: `council_cli.load_settings` +
|
|
31
|
+
* `build_members` (member construction, auth, transport resolution,
|
|
32
|
+
* cli-first billing classification — all unchanged), `CouncilResponse`
|
|
33
|
+
* accounting, `_lib/orchestration_record` validation.
|
|
34
|
+
*
|
|
35
|
+
* Usage:
|
|
36
|
+
* ./scripts-run src/scripts/ask_transport "is X true in file Y?" \
|
|
37
|
+
* [--system "<system prompt>"] [--member <name>] [--format text|json] \
|
|
38
|
+
* [--no-record] [--dir <audit-dir>]
|
|
39
|
+
*
|
|
40
|
+
* Exit codes: 0 answer produced · 2 usage error · 3 honest-∅ (no transport).
|
|
41
|
+
*/
|
|
42
|
+
import * as crypto from 'node:crypto';
|
|
43
|
+
import * as fs from 'node:fs';
|
|
44
|
+
import * as path from 'node:path';
|
|
45
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
46
|
+
|
|
47
|
+
import type { ExternalAIClient } from './ai_council/clients.js';
|
|
48
|
+
import { buildOrchestrationLine } from './_lib/orchestration_record.js';
|
|
49
|
+
import { DEFAULT_DIR } from './orchestration_record.js';
|
|
50
|
+
|
|
51
|
+
export interface AskResult {
|
|
52
|
+
answer: string;
|
|
53
|
+
member: string;
|
|
54
|
+
model: string;
|
|
55
|
+
input_tokens: number;
|
|
56
|
+
output_tokens: number;
|
|
57
|
+
latency_ms: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface AskOptions {
|
|
61
|
+
/** Injected members (tests). Default: council_cli.load_settings + build_members. */
|
|
62
|
+
members?: ExternalAIClient[];
|
|
63
|
+
/** Pick a specific member by provider/name; default: first configured. */
|
|
64
|
+
member?: string;
|
|
65
|
+
systemPrompt?: string;
|
|
66
|
+
/** Telemetry append target; null disables recording (tests / --no-record). */
|
|
67
|
+
auditDir?: string | null;
|
|
68
|
+
/** Injected clock/id (tests). */
|
|
69
|
+
now?: () => Date;
|
|
70
|
+
id?: () => string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const DEFAULT_SYSTEM =
|
|
74
|
+
'You are a single-completion answer service. Answer the question directly, ' +
|
|
75
|
+
'concisely, and honestly. If the question cannot be answered without tools, ' +
|
|
76
|
+
'files, or more context, reply exactly: INSUFFICIENT_CONTEXT — one line on ' +
|
|
77
|
+
'what is missing. Never invent facts.';
|
|
78
|
+
|
|
79
|
+
/** Lazily import the council CLI surface so tests with injected members never load it. */
|
|
80
|
+
async function defaultMembers(): Promise<ExternalAIClient[]> {
|
|
81
|
+
const mod = await import('./council_cli.js');
|
|
82
|
+
const settings = mod.load_settings();
|
|
83
|
+
return mod.build_members(settings);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function recordAskLine(auditDir: string, result: AskResult | null, member: string, ts: string, id: string): void {
|
|
87
|
+
const { line, errors } = buildOrchestrationLine({
|
|
88
|
+
spawn_count: 0,
|
|
89
|
+
token_delta: 0,
|
|
90
|
+
token_delta_provenance: 'estimated',
|
|
91
|
+
route_taken: 'ask',
|
|
92
|
+
verify_mode: 'none',
|
|
93
|
+
dispatch_outcome: result === null ? 'BLOCKED' : 'DONE',
|
|
94
|
+
dispatch_tokens: result === null ? null : result.input_tokens + result.output_tokens,
|
|
95
|
+
wall_clock_ms: result === null ? 0 : Math.max(0, Math.round(result.latency_ms)),
|
|
96
|
+
agent_combo: [member || 'none'],
|
|
97
|
+
origin: 'dispatch-economy-2026',
|
|
98
|
+
ts,
|
|
99
|
+
id,
|
|
100
|
+
});
|
|
101
|
+
if (errors.length || line === null) return; // telemetry must never fail the ask
|
|
102
|
+
try {
|
|
103
|
+
const file = path.join(auditDir, `${ts.slice(0, 7)}.jsonl`);
|
|
104
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
105
|
+
fs.appendFileSync(file, `${JSON.stringify(line)}\n`, 'utf8');
|
|
106
|
+
} catch {
|
|
107
|
+
/* fail-open — accounting never blocks the answer */
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* One bounded ask. Returns the answer or `null` (honest ∅ — council
|
|
113
|
+
* disabled, no member resolves, transport failure, or provider error).
|
|
114
|
+
*/
|
|
115
|
+
export async function askOnce(prompt: string, opts: AskOptions = {}): Promise<AskResult | null> {
|
|
116
|
+
const trimmed = prompt.trim();
|
|
117
|
+
if (!trimmed) return null;
|
|
118
|
+
|
|
119
|
+
let members: ExternalAIClient[];
|
|
120
|
+
try {
|
|
121
|
+
members = opts.members ?? (await defaultMembers());
|
|
122
|
+
} catch {
|
|
123
|
+
return _finish(null, 'none', opts); // disabled / unreadable config → ∅
|
|
124
|
+
}
|
|
125
|
+
const memberName = (m: ExternalAIClient): string => {
|
|
126
|
+
const c = m as { name?: string; provider?: string };
|
|
127
|
+
return c.name || c.provider || 'unknown';
|
|
128
|
+
};
|
|
129
|
+
const pick =
|
|
130
|
+
opts.member !== undefined ? members.find((m) => memberName(m) === opts.member) : members[0];
|
|
131
|
+
if (pick === undefined) {
|
|
132
|
+
return _finish(null, opts.member ?? 'none', opts);
|
|
133
|
+
}
|
|
134
|
+
const provider = memberName(pick);
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const response = pick.ask(opts.systemPrompt ?? DEFAULT_SYSTEM, trimmed);
|
|
138
|
+
const r = response instanceof Promise ? await response : response;
|
|
139
|
+
if (r === null || r === undefined || (r.error !== null && r.error !== undefined && r.error !== '')) {
|
|
140
|
+
return _finish(null, provider, opts);
|
|
141
|
+
}
|
|
142
|
+
const text = (r.text ?? '').trim();
|
|
143
|
+
if (!text) return _finish(null, provider, opts);
|
|
144
|
+
return _finish(
|
|
145
|
+
{
|
|
146
|
+
answer: text,
|
|
147
|
+
member: provider,
|
|
148
|
+
model: r.model ?? 'unknown',
|
|
149
|
+
input_tokens: r.input_tokens ?? 0,
|
|
150
|
+
output_tokens: r.output_tokens ?? 0,
|
|
151
|
+
latency_ms: r.latency_ms ?? 0,
|
|
152
|
+
},
|
|
153
|
+
provider,
|
|
154
|
+
opts,
|
|
155
|
+
);
|
|
156
|
+
} catch {
|
|
157
|
+
return _finish(null, provider, opts); // ONE completion — no retry here
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function _finish(result: AskResult | null, member: string, opts: AskOptions): AskResult | null {
|
|
162
|
+
const auditDir = opts.auditDir === undefined ? path.join(process.cwd(), DEFAULT_DIR) : opts.auditDir;
|
|
163
|
+
if (auditDir !== null) {
|
|
164
|
+
const ts = (opts.now ? opts.now() : new Date()).toISOString();
|
|
165
|
+
const id = opts.id ? opts.id() : crypto.randomUUID();
|
|
166
|
+
recordAskLine(auditDir, result, member, ts, id);
|
|
167
|
+
}
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function main(argv: readonly string[]): Promise<number> {
|
|
172
|
+
let prompt = '';
|
|
173
|
+
let system: string | undefined;
|
|
174
|
+
let member: string | undefined;
|
|
175
|
+
let format: 'text' | 'json' = 'text';
|
|
176
|
+
let record = true;
|
|
177
|
+
let dir: string | undefined;
|
|
178
|
+
for (let i = 0; i < argv.length; i++) {
|
|
179
|
+
const a = argv[i] ?? '';
|
|
180
|
+
if (a === '--system') system = argv[++i];
|
|
181
|
+
else if (a === '--member') member = argv[++i];
|
|
182
|
+
else if (a === '--format') format = argv[++i] === 'json' ? 'json' : 'text';
|
|
183
|
+
else if (a === '--no-record') record = false;
|
|
184
|
+
else if (a === '--dir') dir = argv[++i];
|
|
185
|
+
else if (!a.startsWith('--')) prompt = prompt ? `${prompt} ${a}` : a;
|
|
186
|
+
}
|
|
187
|
+
if (!prompt.trim()) {
|
|
188
|
+
process.stderr.write('ask_transport: a question argument is required\n');
|
|
189
|
+
return 2;
|
|
190
|
+
}
|
|
191
|
+
const opts: AskOptions = {
|
|
192
|
+
...(system !== undefined ? { systemPrompt: system } : {}),
|
|
193
|
+
...(member !== undefined ? { member } : {}),
|
|
194
|
+
auditDir: record ? (dir ?? path.join(process.cwd(), DEFAULT_DIR)) : null,
|
|
195
|
+
};
|
|
196
|
+
const result = await askOnce(prompt, opts);
|
|
197
|
+
if (result === null) {
|
|
198
|
+
process.stderr.write('ask_transport: ∅ — no transport resolved or the single completion failed (no retry by design)\n');
|
|
199
|
+
return 3;
|
|
200
|
+
}
|
|
201
|
+
process.stdout.write(
|
|
202
|
+
format === 'json' ? JSON.stringify(result, null, 2) + '\n' : result.answer + '\n',
|
|
203
|
+
);
|
|
204
|
+
return 0;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
|
|
208
|
+
function _isCliEntry(): boolean {
|
|
209
|
+
if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) return false;
|
|
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()) {
|
|
220
|
+
main(process.argv.slice(2)).then((rc) => process.exit(rc));
|
|
221
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Hook-injection byte bench + CI gate (road-to-token-economy-cache Phase 3.2)
|
|
4
|
+
* — the latency harness's twin for the INJECTION side.
|
|
5
|
+
*
|
|
6
|
+
* Runs every concern bound on the `claude` platform against the committed
|
|
7
|
+
* fixture envelope of each slot it is bound to (tests/fixtures/hooks/
|
|
8
|
+
* <event>.json), in-process via CONCERN_REGISTRY with the hook-stdin
|
|
9
|
+
* override, under AGENT_CONFIG_REPLAY=1 (state writes skipped). Measures the
|
|
10
|
+
* bytes of the stdout payload fields a concern injects — `reason` +
|
|
11
|
+
* `additional_context` + `context` — and gates them against
|
|
12
|
+
* `src/config/hook-token-budget.json`:
|
|
13
|
+
*
|
|
14
|
+
* - a concern whose emission exceeds its row (or the default cap) → RED
|
|
15
|
+
* - an EMITTING concern with no explicit row above the default is fine;
|
|
16
|
+
* the default cap IS its row — but a breach names the missing row so
|
|
17
|
+
* the fix is a registered decision, not a silent default bump
|
|
18
|
+
* - per-slot SUM over all bound concerns > the slot cap → RED
|
|
19
|
+
*
|
|
20
|
+
* HONEST SCOPE, stated up front: concerns are conditional-silence by design —
|
|
21
|
+
* a generic fixture triggers few of them, so most measure 0 bytes here. The
|
|
22
|
+
* gate therefore enforces the cap on whatever DOES fire under the committed
|
|
23
|
+
* fixtures (deterministic, CI-reproducible), while the census mode
|
|
24
|
+
* (`--record`) measures REAL sessions: it appends counts-only lines
|
|
25
|
+
* ({ts, slot, concern, bytes}) to agents/runtime/state/injection-census.jsonl
|
|
26
|
+
* — the repeat-injection-census blocker's instrument. Claiming the fixture
|
|
27
|
+
* numbers are the live distribution would be the coverage inflation this
|
|
28
|
+
* repo's gate discipline refuses; the two modes exist because neither
|
|
29
|
+
* substitutes for the other.
|
|
30
|
+
*
|
|
31
|
+
* Class A: in-process, no daemon, no network. Exit 0 green · 1 breach ·
|
|
32
|
+
* 2 unusable invocation. Dead-scope guarded: zero concerns run = RED.
|
|
33
|
+
*
|
|
34
|
+
* Usage:
|
|
35
|
+
* ./scripts-run src/scripts/bench_hook_injection [--format text|json]
|
|
36
|
+
* [--record] [--platform claude]
|
|
37
|
+
*/
|
|
38
|
+
import * as fs from 'node:fs';
|
|
39
|
+
import * as path from 'node:path';
|
|
40
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
41
|
+
|
|
42
|
+
import { CONCERN_REGISTRY } from './hooks/concern_registry.js';
|
|
43
|
+
import { setHookStdinOverride, clearHookStdinOverride } from './hooks/hook_stdin.js';
|
|
44
|
+
import { _load_yaml, _resolve_concerns, EVENT_VOCABULARY, type JsonObject } from './hooks/dispatch_hook.js';
|
|
45
|
+
|
|
46
|
+
declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
|
|
47
|
+
const _IN_BUNDLE = typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__;
|
|
48
|
+
const REPO_ROOT = path.resolve(
|
|
49
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
50
|
+
...(_IN_BUNDLE ? ['..'] : ['..', '..']),
|
|
51
|
+
);
|
|
52
|
+
const MANIFEST = path.join(REPO_ROOT, 'src', 'scripts', 'hook_manifest.yaml');
|
|
53
|
+
const BUDGET = path.join(REPO_ROOT, 'src', 'config', 'hook-token-budget.json');
|
|
54
|
+
const FIXTURES = path.join(REPO_ROOT, 'tests', 'fixtures', 'hooks');
|
|
55
|
+
const CENSUS_FILE = path.join('agents', 'runtime', 'state', 'injection-census.jsonl');
|
|
56
|
+
|
|
57
|
+
export interface Measurement {
|
|
58
|
+
slot: string;
|
|
59
|
+
concern: string;
|
|
60
|
+
bytes: number;
|
|
61
|
+
cap: number;
|
|
62
|
+
breach: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface Budget {
|
|
66
|
+
default_cap_bytes: number;
|
|
67
|
+
per_concern_caps_bytes: Record<string, number | string>;
|
|
68
|
+
per_slot_sum_caps_bytes: Record<string, number | string>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function capFor(budget: Budget, concern: string): number {
|
|
72
|
+
const v = budget.per_concern_caps_bytes[concern];
|
|
73
|
+
return typeof v === 'number' ? v : budget.default_cap_bytes;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Run one registered concern in-process with the fixture envelope; return payload bytes. */
|
|
77
|
+
export function runConcernOnce(
|
|
78
|
+
script: string,
|
|
79
|
+
args: readonly string[],
|
|
80
|
+
envelope: JsonObject,
|
|
81
|
+
): number {
|
|
82
|
+
const main = CONCERN_REGISTRY[script];
|
|
83
|
+
if (main === undefined) return -1; // not in registry — parity tests own that failure
|
|
84
|
+
let out = '';
|
|
85
|
+
const prevWrite = process.stdout.write;
|
|
86
|
+
const prevErr = process.stderr.write;
|
|
87
|
+
const prevArgv = process.argv;
|
|
88
|
+
const prevReplay = process.env['AGENT_CONFIG_REPLAY'];
|
|
89
|
+
setHookStdinOverride(JSON.stringify(envelope));
|
|
90
|
+
process.env['AGENT_CONFIG_REPLAY'] = '1';
|
|
91
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
92
|
+
process.stdout.write = ((chunk: any, enc?: any, cb?: any): boolean => {
|
|
93
|
+
out += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf-8');
|
|
94
|
+
const callback = typeof enc === 'function' ? enc : cb;
|
|
95
|
+
if (typeof callback === 'function') callback();
|
|
96
|
+
return true;
|
|
97
|
+
}) as typeof process.stdout.write;
|
|
98
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
99
|
+
process.stderr.write = ((chunk: any, enc?: any, cb?: any): boolean => {
|
|
100
|
+
const callback = typeof enc === 'function' ? enc : cb;
|
|
101
|
+
if (typeof callback === 'function') callback();
|
|
102
|
+
return true;
|
|
103
|
+
}) as typeof process.stderr.write;
|
|
104
|
+
try {
|
|
105
|
+
process.argv = [prevArgv[0] as string, script, ...args, '--platform', 'claude'];
|
|
106
|
+
main([...args, '--platform', 'claude']);
|
|
107
|
+
} catch {
|
|
108
|
+
/* a crashing concern injects nothing — latency/parity suites own crashes */
|
|
109
|
+
} finally {
|
|
110
|
+
process.stdout.write = prevWrite;
|
|
111
|
+
process.stderr.write = prevErr;
|
|
112
|
+
process.argv = prevArgv;
|
|
113
|
+
if (prevReplay === undefined) delete process.env['AGENT_CONFIG_REPLAY'];
|
|
114
|
+
else process.env['AGENT_CONFIG_REPLAY'] = prevReplay;
|
|
115
|
+
clearHookStdinOverride();
|
|
116
|
+
}
|
|
117
|
+
const text = out.trim();
|
|
118
|
+
if (!text) return 0;
|
|
119
|
+
try {
|
|
120
|
+
const parsed = JSON.parse(text) as Record<string, unknown>;
|
|
121
|
+
let bytes = 0;
|
|
122
|
+
for (const key of ['reason', 'additional_context', 'context']) {
|
|
123
|
+
const v = parsed[key];
|
|
124
|
+
if (typeof v === 'string') bytes += Buffer.byteLength(v, 'utf-8');
|
|
125
|
+
}
|
|
126
|
+
return bytes;
|
|
127
|
+
} catch {
|
|
128
|
+
return Buffer.byteLength(text, 'utf-8'); // non-JSON stdout counts whole
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function bench(opts: { platform?: string; budgetPath?: string } = {}): { measurements: Measurement[]; slotSums: Record<string, { bytes: number; cap: number; breach: boolean }>; errors: string[] } {
|
|
133
|
+
const platform = opts.platform ?? 'claude';
|
|
134
|
+
const manifest = _load_yaml(MANIFEST);
|
|
135
|
+
const budget = JSON.parse(fs.readFileSync(opts.budgetPath ?? BUDGET, 'utf8')) as Budget;
|
|
136
|
+
const measurements: Measurement[] = [];
|
|
137
|
+
const slotSums: Record<string, { bytes: number; cap: number; breach: boolean }> = {};
|
|
138
|
+
const errors: string[] = [];
|
|
139
|
+
|
|
140
|
+
for (const event of EVENT_VOCABULARY) {
|
|
141
|
+
const fixtureFile = path.join(FIXTURES, `${event}.json`);
|
|
142
|
+
if (!fs.existsSync(fixtureFile)) continue;
|
|
143
|
+
const envelope = JSON.parse(fs.readFileSync(fixtureFile, 'utf8')) as JsonObject;
|
|
144
|
+
const concerns = _resolve_concerns(manifest, platform, event);
|
|
145
|
+
let sum = 0;
|
|
146
|
+
for (const concern of concerns) {
|
|
147
|
+
const bytes = runConcernOnce(String(concern['script']), Array.isArray(concern['args']) ? concern['args'].map(String) : [], envelope);
|
|
148
|
+
if (bytes < 0) {
|
|
149
|
+
errors.push(`${event}/${String(concern['name'])}: not in CONCERN_REGISTRY`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const cap = capFor(budget, String(concern['name']));
|
|
153
|
+
const breach = bytes > cap;
|
|
154
|
+
measurements.push({ slot: event, concern: String(concern['name']), bytes, cap, breach });
|
|
155
|
+
sum += bytes;
|
|
156
|
+
}
|
|
157
|
+
const slotCapRaw = budget.per_slot_sum_caps_bytes[event];
|
|
158
|
+
const slotCap = typeof slotCapRaw === 'number' ? slotCapRaw : Number.MAX_SAFE_INTEGER;
|
|
159
|
+
slotSums[event] = { bytes: sum, cap: slotCap, breach: sum > slotCap };
|
|
160
|
+
}
|
|
161
|
+
return { measurements, slotSums, errors };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function main(argv: readonly string[]): number {
|
|
165
|
+
let format: 'text' | 'json' = 'text';
|
|
166
|
+
let record = false;
|
|
167
|
+
let platform = 'claude';
|
|
168
|
+
for (let i = 0; i < argv.length; i++) {
|
|
169
|
+
const a = argv[i];
|
|
170
|
+
if (a === '--format') format = argv[++i] === 'json' ? 'json' : 'text';
|
|
171
|
+
else if (a === '--record') record = true;
|
|
172
|
+
else if (a === '--platform') platform = argv[++i] ?? platform;
|
|
173
|
+
}
|
|
174
|
+
let result: ReturnType<typeof bench>;
|
|
175
|
+
try {
|
|
176
|
+
result = bench({ platform });
|
|
177
|
+
} catch (err) {
|
|
178
|
+
process.stderr.write(`bench_hook_injection: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
179
|
+
return 2;
|
|
180
|
+
}
|
|
181
|
+
// Dead-scope guard: a bench that ran zero concerns proves nothing (a
|
|
182
|
+
// gate that scans nothing exits green is the recorded failure class).
|
|
183
|
+
if (result.measurements.length === 0) {
|
|
184
|
+
process.stderr.write('bench_hook_injection: DEAD SCOPE — zero concerns ran; fixtures or manifest unreadable\n');
|
|
185
|
+
return 1;
|
|
186
|
+
}
|
|
187
|
+
if (record) {
|
|
188
|
+
try {
|
|
189
|
+
const file = path.join(process.cwd(), CENSUS_FILE);
|
|
190
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
191
|
+
const ts = new Date().toISOString();
|
|
192
|
+
for (const m of result.measurements) {
|
|
193
|
+
if (m.bytes > 0) {
|
|
194
|
+
fs.appendFileSync(file, JSON.stringify({ ts, slot: m.slot, concern: m.concern, bytes: m.bytes }) + '\n');
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
} catch {
|
|
198
|
+
/* census is observability — never fails the bench */
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const breaches = result.measurements.filter((m) => m.breach);
|
|
202
|
+
const slotBreaches = Object.entries(result.slotSums).filter(([, s]) => s.breach);
|
|
203
|
+
if (format === 'json') {
|
|
204
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
205
|
+
} else {
|
|
206
|
+
const emitting = result.measurements.filter((m) => m.bytes > 0);
|
|
207
|
+
process.stdout.write(`bench_hook_injection: ${result.measurements.length} concern-slot pairs · ${emitting.length} emitted under the committed fixtures\n`);
|
|
208
|
+
for (const m of emitting) {
|
|
209
|
+
process.stdout.write(` ${m.slot.padEnd(20)} ${m.concern.padEnd(24)} ${String(m.bytes).padStart(6)} B (cap ${m.cap})${m.breach ? ' ❌ BREACH' : ''}\n`);
|
|
210
|
+
}
|
|
211
|
+
for (const [slot, s] of Object.entries(result.slotSums)) {
|
|
212
|
+
if (s.bytes > 0) process.stdout.write(` slot-sum ${slot.padEnd(20)} ${String(s.bytes).padStart(6)} B (cap ${s.cap === Number.MAX_SAFE_INTEGER ? '—' : s.cap})${s.breach ? ' ❌ BREACH' : ''}\n`);
|
|
213
|
+
}
|
|
214
|
+
for (const e of result.errors) process.stdout.write(` warn: ${e}\n`);
|
|
215
|
+
}
|
|
216
|
+
if (breaches.length > 0 || slotBreaches.length > 0) {
|
|
217
|
+
process.stderr.write(`bench_hook_injection: ${breaches.length} concern breach(es), ${slotBreaches.length} slot-sum breach(es) — the budget row is the decision surface (src/config/hook-token-budget.json)\n`);
|
|
218
|
+
return 1;
|
|
219
|
+
}
|
|
220
|
+
return 0;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function _isCliEntry(): boolean {
|
|
224
|
+
if (_IN_BUNDLE) return false;
|
|
225
|
+
if (process.argv[1] === undefined) return false;
|
|
226
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
227
|
+
if (import.meta.url === argvUrl) return true;
|
|
228
|
+
try {
|
|
229
|
+
return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(path.resolve(process.argv[1]));
|
|
230
|
+
} catch {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (_isCliEntry()) process.exit(main(process.argv.slice(2)));
|
|
@@ -35,6 +35,7 @@ import * as fs from 'node:fs';
|
|
|
35
35
|
import * as path from 'node:path';
|
|
36
36
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
37
37
|
|
|
38
|
+
import { GateLedger } from './_lib/gate_ledger.js';
|
|
38
39
|
import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
|
|
39
40
|
|
|
40
41
|
const _HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -101,48 +102,81 @@ export function trigger_is_meaningful(value: string): boolean {
|
|
|
101
102
|
return true;
|
|
102
103
|
}
|
|
103
104
|
|
|
104
|
-
|
|
105
|
+
/** Every frontmatter finding for one ADR file. Pure — no I/O, no enumeration. */
|
|
106
|
+
export function check_one(rel: string, text: string): AdrFinding[] {
|
|
107
|
+
const findings: AdrFinding[] = [];
|
|
108
|
+
const fm = parse_frontmatter(text);
|
|
109
|
+
|
|
110
|
+
if (fm === null) {
|
|
111
|
+
findings.push({ file: rel, level: 'error', message: 'no YAML frontmatter block' });
|
|
112
|
+
return findings;
|
|
113
|
+
}
|
|
114
|
+
for (const req of REQUIRED) {
|
|
115
|
+
if (!fm[req]) findings.push({ file: rel, level: 'error', message: `missing \`${req}\`` });
|
|
116
|
+
}
|
|
117
|
+
if (fm['status'] && !ALLOWED_STATUS.has(fm['status'])) {
|
|
118
|
+
findings.push({
|
|
119
|
+
file: rel,
|
|
120
|
+
level: 'error',
|
|
121
|
+
message: `status \`${fm['status']}\` is not one of: ${[...ALLOWED_STATUS].sort().join(', ')}`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const date = fm['date'] ?? '';
|
|
126
|
+
const grandfathered = date !== '' && date < REVIEW_TRIGGER_SINCE;
|
|
127
|
+
const trigger = fm['review_trigger'];
|
|
128
|
+
|
|
129
|
+
if (!trigger) {
|
|
130
|
+
findings.push({
|
|
131
|
+
file: rel,
|
|
132
|
+
level: grandfathered ? 'warn' : 'error',
|
|
133
|
+
message: grandfathered
|
|
134
|
+
? `no \`review_trigger\` (grandfathered — dated ${date}, before ${REVIEW_TRIGGER_SINCE})`
|
|
135
|
+
: `missing \`review_trigger\` — name the CONDITION that would reopen this decision`,
|
|
136
|
+
});
|
|
137
|
+
} else if (!trigger_is_meaningful(trigger)) {
|
|
138
|
+
findings.push({
|
|
139
|
+
file: rel,
|
|
140
|
+
level: grandfathered ? 'warn' : 'error',
|
|
141
|
+
message: `\`review_trigger\` is a cadence, not a condition: "${trigger}". A calendar review is ignored; an event fires.`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return findings;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Check every ADR in `dir`, optionally recording per-file completeness.
|
|
149
|
+
*
|
|
150
|
+
* The candidate set is every `*.md` in the directory, not the `ADR-*.md` subset:
|
|
151
|
+
* the name filter used to be a bare `continue`, so a decision record saved as
|
|
152
|
+
* `adr-131-foo.md` (lower case) or `131-foo.md` left the corpus without a trace
|
|
153
|
+
* and the gate still printed a green line over the files it did read. It is now
|
|
154
|
+
* an out-of-scope outcome with a reason, and it prints.
|
|
155
|
+
*
|
|
156
|
+
* A `warn`-level finding resolves as `complete`: warnings are allowed by this
|
|
157
|
+
* gate's exit-code contract, so `fail` is reserved for what actually reds it.
|
|
158
|
+
*/
|
|
159
|
+
export function check(dir: string = ADR_DIR, ledger?: GateLedger): AdrFinding[] {
|
|
105
160
|
const findings: AdrFinding[] = [];
|
|
106
161
|
if (!fs.existsSync(dir)) return findings;
|
|
107
162
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const rel = path.relative(REPO_ROOT, path.join(dir, name));
|
|
111
|
-
const fm = parse_frontmatter(fs.readFileSync(path.join(dir, name), 'utf-8'));
|
|
163
|
+
const candidates = fs.readdirSync(dir).filter((n) => n.endsWith('.md')).sort();
|
|
164
|
+
ledger?.plan(candidates);
|
|
112
165
|
|
|
113
|
-
|
|
114
|
-
|
|
166
|
+
for (const name of candidates) {
|
|
167
|
+
if (!/^ADR-.*\.md$/.test(name)) {
|
|
168
|
+
ledger?.outOfScope(name, 'not_applicable_kind');
|
|
115
169
|
continue;
|
|
116
170
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (fm['status'] && !ALLOWED_STATUS.has(fm['status'])) {
|
|
121
|
-
findings.push({
|
|
122
|
-
file: rel,
|
|
123
|
-
level: 'error',
|
|
124
|
-
message: `status \`${fm['status']}\` is not one of: ${[...ALLOWED_STATUS].sort().join(', ')}`,
|
|
125
|
-
});
|
|
126
|
-
}
|
|
171
|
+
const rel = path.relative(REPO_ROOT, path.join(dir, name));
|
|
172
|
+
const own = check_one(rel, fs.readFileSync(path.join(dir, name), 'utf-8'));
|
|
173
|
+
findings.push(...own);
|
|
127
174
|
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
findings.push({
|
|
134
|
-
file: rel,
|
|
135
|
-
level: grandfathered ? 'warn' : 'error',
|
|
136
|
-
message: grandfathered
|
|
137
|
-
? `no \`review_trigger\` (grandfathered — dated ${date}, before ${REVIEW_TRIGGER_SINCE})`
|
|
138
|
-
: `missing \`review_trigger\` — name the CONDITION that would reopen this decision`,
|
|
139
|
-
});
|
|
140
|
-
} else if (!trigger_is_meaningful(trigger)) {
|
|
141
|
-
findings.push({
|
|
142
|
-
file: rel,
|
|
143
|
-
level: grandfathered ? 'warn' : 'error',
|
|
144
|
-
message: `\`review_trigger\` is a cadence, not a condition: "${trigger}". A calendar review is ignored; an event fires.`,
|
|
145
|
-
});
|
|
175
|
+
const errors = own.filter((f) => f.level === 'error').length;
|
|
176
|
+
if (errors > 0) {
|
|
177
|
+
ledger?.fail(name, `${String(errors)} frontmatter error(s)`);
|
|
178
|
+
} else {
|
|
179
|
+
ledger?.complete(name);
|
|
146
180
|
}
|
|
147
181
|
}
|
|
148
182
|
return findings;
|
|
@@ -150,10 +184,17 @@ export function check(dir: string = ADR_DIR): AdrFinding[] {
|
|
|
150
184
|
|
|
151
185
|
function main(argv: string[]): number {
|
|
152
186
|
const as_json = argv.includes('--json');
|
|
153
|
-
const
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
187
|
+
const ledger = new GateLedger('check_adr_frontmatter');
|
|
188
|
+
const findings = check(ADR_DIR, ledger);
|
|
189
|
+
// Finalized here, printed later by `report()` (which re-finalizes — the call
|
|
190
|
+
// is pure): the denominator is needed for the scope assertion below, which
|
|
191
|
+
// runs before any output. It is the ledger's own per-file accounting — the
|
|
192
|
+
// ADRs that actually reached a checked outcome — where it used to be a SECOND
|
|
193
|
+
// `readdirSync` of the same directory, independent of the loop that did the
|
|
194
|
+
// work, so the printed count could not disagree with the scan even when the
|
|
195
|
+
// scan had skipped files.
|
|
196
|
+
const tally = ledger.finalize();
|
|
197
|
+
const total = tally.completed + tally.failed;
|
|
157
198
|
|
|
158
199
|
// `check()` returns findings, never a count, so a moved `docs/decisions/`
|
|
159
200
|
// yields an empty list — reported as "0 ADR(s) · 0 error(s)" and green.
|
|
@@ -174,7 +215,9 @@ function main(argv: string[]): number {
|
|
|
174
215
|
}
|
|
175
216
|
|
|
176
217
|
if (as_json) {
|
|
177
|
-
|
|
218
|
+
// The tally rides in the payload rather than on stdout: `ledger.report()`
|
|
219
|
+
// would print beside the JSON and break every parser of this mode.
|
|
220
|
+
process.stdout.write(JSON.stringify({ findings, ledger: tally }, null, 2) + '\n');
|
|
178
221
|
return 0;
|
|
179
222
|
}
|
|
180
223
|
|
|
@@ -185,6 +228,7 @@ function main(argv: string[]): number {
|
|
|
185
228
|
`ADR frontmatter: ${total} ADR(s) · ${errors.length} error(s) · ` +
|
|
186
229
|
`${warns.length} grandfathered without a revisit condition\n`,
|
|
187
230
|
);
|
|
231
|
+
ledger.report();
|
|
188
232
|
for (const e of errors) process.stderr.write(` ❌ ${e.file}: ${e.message}\n`);
|
|
189
233
|
|
|
190
234
|
if (errors.length > 0) {
|
|
@@ -41,6 +41,7 @@ import { fileURLToPath } from "node:url";
|
|
|
41
41
|
import { runGateCli, runSelfTest, type SelfTestCase } from "./_lib/gate_self_test.js";
|
|
42
42
|
import { reportScanned } from "./_lib/scan_scope.js";
|
|
43
43
|
|
|
44
|
+
// ledger-exempt: single remote-ref probe — the entire scope is ONE ls-remote answer (0 or 1 refs) resolved to one aggregate ancestor verdict, and every empty path already publishes its reason via reportScanned allowEmpty; there is no per-target collection to account.
|
|
44
45
|
const DEFAULT_BASE = "main";
|
|
45
46
|
const SELF_TEST_MIN_CASES = 3;
|
|
46
47
|
const SELF_TEST_MIN_REJECT = 1;
|