@matt82198/aesop 0.1.0-rc.1 → 0.2.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/CHANGELOG.md +101 -2
- package/README.md +66 -15
- package/bin/cli.js +164 -41
- package/daemons/run-watchdog.sh +16 -4
- package/daemons/selfheal.sh +231 -0
- package/docs/ANY-REPO.md +427 -0
- package/docs/CONTRIBUTING.md +72 -0
- package/docs/DEMO.md +334 -0
- package/docs/INSTALL.md +70 -1
- package/docs/QUICKSTART.md +80 -0
- package/docs/README.md +33 -3
- package/docs/TEAM-STATE.md +540 -0
- package/driver/CLAUDE.md +148 -0
- package/driver/README.md +383 -0
- package/driver/aesop.config.example.json +80 -0
- package/driver/agent_driver.py +355 -0
- package/driver/backend_config.py +253 -0
- package/driver/claude_code_driver.py +198 -0
- package/driver/codex_driver.py +626 -0
- package/driver/openai_compatible_driver.py +249 -0
- package/driver/openai_transport.py +146 -0
- package/driver/verification_policy.py +75 -0
- package/driver/wave_bridge.py +246 -0
- package/driver/wave_loop.py +1041 -0
- package/hooks/pre-push-policy.sh +109 -28
- package/mcp/server.mjs +320 -4
- package/package.json +23 -15
- package/skills/CLAUDE.md +132 -2
- package/skills/buildsystem/SKILL.md +330 -0
- package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
- package/skills/fleet/SKILL.md +113 -0
- package/skills/power/SKILL.md +246 -131
- package/state_store/__init__.py +2 -1
- package/state_store/api.py +19 -4
- package/state_store/coordination.py +209 -0
- package/state_store/identity.py +51 -0
- package/state_store/store.py +185 -73
- package/templates/wave-presets/data.json +65 -0
- package/templates/wave-presets/library.json +65 -0
- package/templates/wave-presets/saas.json +64 -0
- package/tools/audit_report.py +388 -0
- package/tools/bench_runner.py +100 -3
- package/tools/ci_merge_wait.py +256 -35
- package/tools/ci_workflow_lint.py +430 -0
- package/tools/claudemd_drift.py +394 -0
- package/tools/claudemd_lint.py +359 -0
- package/tools/common.py +39 -3
- package/tools/cost_ceiling.py +63 -31
- package/tools/cost_econ.py +480 -0
- package/tools/defect_escape.py +252 -0
- package/tools/doctor.js +1 -1
- package/tools/eod_sweep.py +58 -0
- package/tools/fleet.js +260 -0
- package/tools/fleet_ledger.py +209 -7
- package/tools/git_identity_check.py +315 -0
- package/tools/health-score.js +40 -0
- package/tools/health_score.py +361 -0
- package/tools/metrics_gate.py +13 -4
- package/tools/mutation_test.py +401 -0
- package/tools/portability_check.py +206 -0
- package/tools/reconcile.py +7 -4
- package/tools/secret_scan.py +137 -50
- package/tools/self_stats.py +20 -0
- package/tools/transcript_digest.py +380 -0
- package/tools/verify_activity_filter.py +437 -0
- package/tools/verify_agent_inspector.py +2 -0
- package/tools/verify_dash.py +2 -0
- package/tools/verify_dispatch_panel.py +301 -0
- package/tools/verify_failure_drilldown.py +188 -0
- package/tools/verify_prboard.py +2 -0
- package/tools/verify_scorecards.py +281 -0
- package/tools/verify_submit_encoding.py +2 -0
- package/tools/verify_ui_trio.py +409 -0
- package/tools/verify_wave_telemetry.py +268 -0
- package/tools/wave_backlog_analyzer.py +490 -0
- package/tools/wave_ledger_hook.py +150 -0
- package/tools/wave_preflight.py +512 -0
- package/tools/wave_resume.py +215 -0
- package/tools/wave_templates.py +215 -0
- package/ui/agents.py +68 -14
- package/ui/collectors.py +0 -55
- package/ui/config.py +7 -2
- package/ui/cost.py +231 -12
- package/ui/handler.py +183 -0
- package/ui/quality_scorecard.py +232 -0
- package/ui/wave_audit_tail.py +213 -0
- package/ui/wave_dispatch.py +280 -0
- package/ui/wave_failure.py +288 -0
- package/ui/wave_gantt.py +152 -0
- package/ui/wave_reasoning_tail.py +176 -0
- package/ui/wave_telemetry.py +377 -0
- package/ui/web/dist/assets/index-BGbgw2Nh.js +9 -0
- package/ui/web/dist/assets/index-DqZLgwNg.css +1 -0
- package/ui/web/dist/index.html +2 -2
- package/bin/CLAUDE.md +0 -76
- package/daemons/CLAUDE.md +0 -36
- package/dash/CLAUDE.md +0 -32
- package/docs/archive/README.md +0 -3
- package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
- package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
- package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
- package/docs/archive/spikes/tiered-cognition/README.md +0 -27
- package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
- package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
- package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
- package/hooks/CLAUDE.md +0 -89
- package/mcp/CLAUDE.md +0 -213
- package/monitor/CLAUDE.md +0 -40
- package/scan/CLAUDE.md +0 -30
- package/state_store/CLAUDE.md +0 -39
- package/tools/CLAUDE.md +0 -79
- package/ui/CLAUDE.md +0 -127
- package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
- package/ui/web/dist/assets/index-BdIlFieV.css +0 -1
|
@@ -1,673 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// force-model-policy.merged.mjs — STAGED CANDIDATE (wave 12). NOT ACTIVATED.
|
|
3
|
-
//
|
|
4
|
-
// This file is the wave-12 merge of two PreToolUse policies into ONE hook so
|
|
5
|
-
// that exactly one owner emits `updatedInput` on the `Agent|Task` matcher —
|
|
6
|
-
// resolving the wave-11 spike's top risk (FINDINGS.md risk 1: the live
|
|
7
|
-
// force-haiku-subagents.mjs and the spike's strip-tools-hook.mjs both rewrote
|
|
8
|
-
// the same dispatch with undefined merge order).
|
|
9
|
-
//
|
|
10
|
-
// It is a drop-in replacement candidate for:
|
|
11
|
-
// ~/.claude/hooks/force-haiku-subagents.mjs (the live model policy)
|
|
12
|
-
// merged with:
|
|
13
|
-
// docs/spikes/tiered-cognition/strip-tools-hook.mjs (the spike tier policy)
|
|
14
|
-
// Activation is a deliberate later step — see ACTIVATION.md in this directory.
|
|
15
|
-
//
|
|
16
|
-
// ─── Decision pipeline (one pass, one updatedInput) ─────────────────────────
|
|
17
|
-
//
|
|
18
|
-
// dispatch (Agent|Task)
|
|
19
|
-
// │
|
|
20
|
-
// ├─ [[ALLOW-NON-HAIKU]] in prompt → FULL bypass (model + tools), audited
|
|
21
|
-
// │ to state/MODEL-POLICY-ESCAPES.log (+ TIER log when the kept model
|
|
22
|
-
// │ is cognition-tier — migration grandfathering per DESIGN.md §2.4).
|
|
23
|
-
// ├─ subagent_type fork → pass (inherits parent, never rewritten)
|
|
24
|
-
// ├─ subagent_type aesop-cognition → pass (already shimmed by dispatcher)
|
|
25
|
-
// │
|
|
26
|
-
// ├─ 1. MODEL POLICY (unchanged from the live hook):
|
|
27
|
-
// │ • bash-pro → ALWAYS claude-sonnet-5 (hard user-ordered exception,
|
|
28
|
-
// │ wins over FORCE_ALL_HAIKU; ALSO exempt from tool stripping —
|
|
29
|
-
// │ its entire job is exec, a shimmed bash-pro is a contradiction).
|
|
30
|
-
// │ • catalog specialist (~/.claude/agents/*.md, non-haiku frontmatter)
|
|
31
|
-
// │ → its model (blank passes through to frontmatter; explicit
|
|
32
|
-
// │ opus/fable capped to claude-sonnet-5 — hands-on opus/fable is
|
|
33
|
-
// │ never a subagent; cognition opus/fable arrives via agent-def
|
|
34
|
-
// │ frontmatter and is shimmed below).
|
|
35
|
-
// │ • everything else → haiku. FORCE_ALL_HAIKU=1 → haiku for all
|
|
36
|
-
// │ (except bash-pro).
|
|
37
|
-
// │
|
|
38
|
-
// ├─ 2. TIER POLICY (Layer 1, from the spike), computed on the FINAL
|
|
39
|
-
// │ post-routing model — "model routing decides the tier; the same
|
|
40
|
-
// │ decision then strips or keeps tools" (DESIGN.md §2.1(4)):
|
|
41
|
-
// │ • tier haiku → pass with the model rewrite only.
|
|
42
|
-
// │ • tier cognition (sonnet/opus/fable) AND family in
|
|
43
|
-
// │ TIER_STRIP_SCOPE → rewrite subagent_type → 'aesop-cognition'
|
|
44
|
-
// │ shim (message-only tools via its frontmatter), prepend the
|
|
45
|
-
// │ [COGNITION CONTRACT v1] header naming the original role, and
|
|
46
|
-
// │ preserve the effective model explicitly (the shim def carries
|
|
47
|
-
// │ no model:).
|
|
48
|
-
// │ • [[ALLOW-TIER-TOOLS]] in prompt → skip the strip ONLY (model
|
|
49
|
-
// │ policy still applies), audited to state/TIER-POLICY-ESCAPES.log.
|
|
50
|
-
// │
|
|
51
|
-
// └─ 3. Emit ONE updatedInput carrying BOTH rewrites (or pass).
|
|
52
|
-
//
|
|
53
|
-
// I/O tool call (Read/Write/Edit/Bash/…) — LAYER 2 call-time backstop:
|
|
54
|
-
// Dormant unless TIER_ENFORCE=1 (staged OFF by default) AND the hook is
|
|
55
|
-
// wired to those matchers in settings.json (it is not, today). When live:
|
|
56
|
-
// transcript-sniffed cognition sessions are DENIED with a redirect-to-
|
|
57
|
-
// WORK-ORDER reason; haiku / escaped sessions pass. Fail-open on any
|
|
58
|
-
// unreadable transcript. Kept as the clearly-separable layer2Backstop().
|
|
59
|
-
//
|
|
60
|
-
// ─── Env knobs ───────────────────────────────────────────────────────────────
|
|
61
|
-
// FORCE_ALL_HAIKU=1 force every dispatch (incl. specialists) to haiku
|
|
62
|
-
// (bash-pro excepted) — unchanged from live hook.
|
|
63
|
-
// TIER_STRIP_SCOPE which cognition families get shimmed:
|
|
64
|
-
// 'opus-fable' (DEFAULT — FINDINGS.md Phase A: no
|
|
65
|
-
// specialist prompt loss), 'all' (Phase B: sonnet
|
|
66
|
-
// specialists too), 'off' (model policy only).
|
|
67
|
-
// TIER_ENFORCE=1 arm the Layer-2 call-time backstop (DEFAULT OFF).
|
|
68
|
-
// AESOP_ROOT state root for audit logs (default ~/aesop).
|
|
69
|
-
//
|
|
70
|
-
// Reliability: fail-open everywhere, stdin raced against a 2s timer, an
|
|
71
|
-
// escape-hatch use is NEVER silent (transcript reason + JSON-line audit).
|
|
72
|
-
// Self-test: `node force-model-policy.merged.mjs --self-test` (no stdin;
|
|
73
|
-
// builds fixtures in a temp dir; exit 0 on PASS / 1 on FAIL).
|
|
74
|
-
|
|
75
|
-
import fs from 'node:fs';
|
|
76
|
-
import os from 'node:os';
|
|
77
|
-
import path from 'node:path';
|
|
78
|
-
|
|
79
|
-
// ---------------------------------------------------------------- constants
|
|
80
|
-
|
|
81
|
-
export const ESCAPE_MODEL = '[[ALLOW-NON-HAIKU]]'; // full bypass (model + tools, grandfathered)
|
|
82
|
-
export const ESCAPE_TIER = '[[ALLOW-TIER-TOOLS]]'; // tool-strip bypass only
|
|
83
|
-
export const ESCAPE_TOKENS = [ESCAPE_TIER, ESCAPE_MODEL];
|
|
84
|
-
export const SHIM_AGENT = 'aesop-cognition';
|
|
85
|
-
export const MODEL_ESCAPE_LOG = 'MODEL-POLICY-ESCAPES.log';
|
|
86
|
-
export const TIER_ESCAPE_LOG = 'TIER-POLICY-ESCAPES.log';
|
|
87
|
-
export const SONNET_PIN = 'claude-sonnet-5';
|
|
88
|
-
const STDIN_TIMEOUT_MS = 2000;
|
|
89
|
-
|
|
90
|
-
// Cognition tiers KEEP exactly these (reason + message + dispatch):
|
|
91
|
-
export const KEEP_TOOLS = ['Agent', 'SendMessage', 'TaskStop', 'Monitor'];
|
|
92
|
-
|
|
93
|
-
// Cognition tiers are DENIED all I/O + exec + retrieval (Haiku's job).
|
|
94
|
-
// Doubles as the Layer-2 matcher set.
|
|
95
|
-
export const DENY_TOOLS = [
|
|
96
|
-
'Read', 'Write', 'Edit', 'MultiEdit', 'NotebookEdit',
|
|
97
|
-
'Bash', 'PowerShell', 'BashOutput', 'KillShell',
|
|
98
|
-
'Glob', 'Grep', 'WebFetch', 'WebSearch',
|
|
99
|
-
'Skill', 'Artifact', 'ToolSearch', 'EnterWorktree', 'ExitWorktree',
|
|
100
|
-
];
|
|
101
|
-
|
|
102
|
-
const DISPATCH_TOOLS = new Set(['Agent', 'Task']);
|
|
103
|
-
const DENY_SET = new Set(DENY_TOOLS);
|
|
104
|
-
|
|
105
|
-
// ------------------------------------------------------------------ helpers
|
|
106
|
-
|
|
107
|
-
function envOf(opts) { return (opts && opts.env) || process.env; }
|
|
108
|
-
|
|
109
|
-
/** State root, consistent with aesop's `${AESOP_ROOT:-$HOME/aesop}/state/`. */
|
|
110
|
-
function stateDir(opts) {
|
|
111
|
-
const root = (opts && opts.stateRoot) || envOf(opts).AESOP_ROOT || path.join(os.homedir(), 'aesop');
|
|
112
|
-
return path.join(root, 'state');
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** Best-effort JSON-line audit record; an unwritable log never blocks. */
|
|
116
|
-
function audit(logName, event, payload, extra, opts) {
|
|
117
|
-
try {
|
|
118
|
-
const dir = stateDir(opts);
|
|
119
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
120
|
-
const input = payload.tool_input || {};
|
|
121
|
-
const rec = {
|
|
122
|
-
ts: new Date().toISOString(),
|
|
123
|
-
event,
|
|
124
|
-
tool: payload.tool_name,
|
|
125
|
-
session_id: typeof payload.session_id === 'string' ? payload.session_id : null,
|
|
126
|
-
cwd: typeof payload.cwd === 'string' ? payload.cwd : null,
|
|
127
|
-
description: typeof input.description === 'string' ? input.description : null,
|
|
128
|
-
requested_model: typeof input.model === 'string' ? input.model : null,
|
|
129
|
-
prompt_head: typeof input.prompt === 'string' ? input.prompt.slice(0, 200) : null,
|
|
130
|
-
...extra,
|
|
131
|
-
};
|
|
132
|
-
fs.appendFileSync(path.join(dir, logName), JSON.stringify(rec) + '\n');
|
|
133
|
-
} catch { /* audit is best-effort; never block the harness */ }
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// --------------------------------------------------------------- tier logic
|
|
137
|
-
|
|
138
|
-
/** Model family: 'haiku' | 'sonnet' | 'opus' | 'fable' | null (unknown). */
|
|
139
|
-
export function modelFamily(model) {
|
|
140
|
-
const m = String(model || '').toLowerCase();
|
|
141
|
-
if (!m) return null;
|
|
142
|
-
if (m.includes('haiku')) return 'haiku';
|
|
143
|
-
if (m.includes('sonnet')) return 'sonnet';
|
|
144
|
-
if (m.includes('opus')) return 'opus';
|
|
145
|
-
if (m.includes('fable')) return 'fable';
|
|
146
|
-
return null;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/** Tier of a model string: 'haiku' | 'cognition' | null (no opinion). */
|
|
150
|
-
export function tierOf(model) {
|
|
151
|
-
const f = modelFamily(model);
|
|
152
|
-
if (!f) return null;
|
|
153
|
-
return f === 'haiku' ? 'haiku' : 'cognition';
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/** Look up an installed agent definition by type (basename or `name:` field).
|
|
157
|
-
* Returns { found, model } where model is the frontmatter `model:` string
|
|
158
|
-
* or null. Fail-open: unreadable dir => { found: false, model: null }. */
|
|
159
|
-
export function agentDefInfo(type, agentsDir) {
|
|
160
|
-
const out = { found: false, model: null };
|
|
161
|
-
if (!type) return out;
|
|
162
|
-
let files = [];
|
|
163
|
-
try { files = fs.readdirSync(agentsDir).filter(f => f.endsWith('.md')); } catch { return out; }
|
|
164
|
-
for (const f of files) {
|
|
165
|
-
let head = '';
|
|
166
|
-
try { head = fs.readFileSync(path.join(agentsDir, f), 'utf8').slice(0, 600); } catch { continue; }
|
|
167
|
-
const base = f.replace(/\.md$/, '').toLowerCase();
|
|
168
|
-
let match = base === type;
|
|
169
|
-
if (!match) {
|
|
170
|
-
const nm = head.match(/^name:\s*(.+)$/m);
|
|
171
|
-
match = !!nm && nm[1].trim().toLowerCase() === type;
|
|
172
|
-
}
|
|
173
|
-
if (match) {
|
|
174
|
-
out.found = true;
|
|
175
|
-
const mm = head.match(/^model:\s*(.+)$/m);
|
|
176
|
-
if (mm) out.model = mm[1].trim();
|
|
177
|
-
return out;
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
return out;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/** Which cognition families get shimmed. Default 'opus-fable' = FINDINGS.md
|
|
184
|
-
* Phase A (no sonnet-specialist prompt loss). 'all' = Phase B. 'off' = model
|
|
185
|
-
* policy only. */
|
|
186
|
-
export function stripScope(opts) {
|
|
187
|
-
const v = String(envOf(opts).TIER_STRIP_SCOPE || 'opus-fable').toLowerCase();
|
|
188
|
-
if (v === 'off' || v === 'none') return new Set();
|
|
189
|
-
if (v === 'all') return new Set(['sonnet', 'opus', 'fable']);
|
|
190
|
-
return new Set(['opus', 'fable']);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
export function contractHeader(originalType, family) {
|
|
194
|
-
return (
|
|
195
|
-
`[COGNITION CONTRACT v1] You are a COGNITION-tier agent` +
|
|
196
|
-
(originalType ? ` acting in the role of '${originalType}'` : '') +
|
|
197
|
-
` (model family: ${family}). You have NO file/exec/retrieval tools. Do not ` +
|
|
198
|
-
`attempt Read/Write/Edit/Bash/Glob/Grep — they are stripped. Produce your ` +
|
|
199
|
-
`technical output as a WORK-ORDER v1 (unified diff + command list + ` +
|
|
200
|
-
`verify steps; schema in docs/spikes/tiered-cognition/DESIGN.md) and ` +
|
|
201
|
-
`dispatch a Haiku executor agent to apply it, or return the work-order ` +
|
|
202
|
-
`to your caller. All facts you need must arrive via your prompt or via ` +
|
|
203
|
-
`Haiku courier briefs you dispatch.\n\n`
|
|
204
|
-
);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// ------------------------------------------------------- transcript sniffer
|
|
208
|
-
|
|
209
|
-
/** Read a Claude Code transcript (JSONL) → { tier, escaped }.
|
|
210
|
-
* tier: from the LAST assistant entry carrying message.model (robust to
|
|
211
|
-
* mid-session model switches). escaped: any user entry contains an escape
|
|
212
|
-
* token (a subagent's dispatch prompt is its first user message).
|
|
213
|
-
* Fail-open: unreadable/unparseable => { tier: null, escaped: false }. */
|
|
214
|
-
export function sniffTranscript(transcriptPath) {
|
|
215
|
-
const out = { tier: null, escaped: false };
|
|
216
|
-
let raw = '';
|
|
217
|
-
try { raw = fs.readFileSync(transcriptPath, 'utf8'); } catch { return out; }
|
|
218
|
-
for (const line of raw.split('\n')) {
|
|
219
|
-
if (!line.trim()) continue;
|
|
220
|
-
let j;
|
|
221
|
-
try { j = JSON.parse(line); } catch { continue; }
|
|
222
|
-
const msg = j && j.message;
|
|
223
|
-
if (!msg || typeof msg !== 'object') continue;
|
|
224
|
-
if ((j.type === 'assistant' || msg.role === 'assistant') && typeof msg.model === 'string') {
|
|
225
|
-
const t = tierOf(msg.model);
|
|
226
|
-
if (t) out.tier = t;
|
|
227
|
-
}
|
|
228
|
-
if (j.type === 'user' || msg.role === 'user') {
|
|
229
|
-
const text = typeof msg.content === 'string'
|
|
230
|
-
? msg.content
|
|
231
|
-
: Array.isArray(msg.content)
|
|
232
|
-
? msg.content.map(c => (c && typeof c.text === 'string') ? c.text : '').join('\n')
|
|
233
|
-
: '';
|
|
234
|
-
if (ESCAPE_TOKENS.some(tok => text.includes(tok))) out.escaped = true;
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
return out;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// -------------------------------------------------------------- core policy
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Pure decision function (testable). payload = parsed hook stdin JSON.
|
|
244
|
-
* opts = { agentsDir, stateRoot, env } — all injectable for tests.
|
|
245
|
-
* Returns { action, output } where output is the object to write to stdout
|
|
246
|
-
* (null = default allow, no opinion).
|
|
247
|
-
* action: 'pass' | 'route' | 'strip' | 'route+strip' | 'escape'
|
|
248
|
-
* | 'escape-tier' | 'allow' | 'deny'
|
|
249
|
-
*/
|
|
250
|
-
export function decide(payload, opts = {}) {
|
|
251
|
-
const pass = { action: 'pass', output: null };
|
|
252
|
-
if (!payload || typeof payload !== 'object') return pass;
|
|
253
|
-
const toolName = payload.tool_name;
|
|
254
|
-
if (DISPATCH_TOOLS.has(toolName)) return dispatchGuard(payload, opts);
|
|
255
|
-
if (DENY_SET.has(toolName)) return layer2Backstop(payload, opts);
|
|
256
|
-
return pass; // tool not governed
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// ---- LAYER 1 + MODEL POLICY: the single updatedInput owner ----------------
|
|
260
|
-
|
|
261
|
-
function dispatchGuard(payload, opts) {
|
|
262
|
-
const pass = { action: 'pass', output: null };
|
|
263
|
-
const input = payload.tool_input;
|
|
264
|
-
// Timeout / empty / malformed payload: fail-open, and NEVER emit updatedInput
|
|
265
|
-
// built from a missing tool_input (that would wipe the real dispatch input).
|
|
266
|
-
if (!input || typeof input !== 'object') return pass;
|
|
267
|
-
|
|
268
|
-
const model = String(input.model || '').toLowerCase();
|
|
269
|
-
const prompt = String(input.prompt || '');
|
|
270
|
-
const type = String(input.subagent_type || input.agentType || '').toLowerCase();
|
|
271
|
-
const agentsDir = opts.agentsDir || path.join(os.homedir(), '.claude', 'agents');
|
|
272
|
-
|
|
273
|
-
// FULL bypass: [[ALLOW-NON-HAIKU]] keeps model AND (grandfathered) tools.
|
|
274
|
-
// Never silent — audited to the model log, plus the tier log when the kept
|
|
275
|
-
// model is cognition-tier (DESIGN.md §2.4 migration grandfathering).
|
|
276
|
-
if (prompt.includes(ESCAPE_MODEL)) {
|
|
277
|
-
audit(MODEL_ESCAPE_LOG, 'model_policy_escape', payload, { via: ESCAPE_MODEL }, opts);
|
|
278
|
-
if (tierOf(model) === 'cognition') {
|
|
279
|
-
audit(TIER_ESCAPE_LOG, 'tier_policy_escape', payload,
|
|
280
|
-
{ via: `${ESCAPE_MODEL} (grandfathered: tools retained)` }, opts);
|
|
281
|
-
}
|
|
282
|
-
return {
|
|
283
|
-
action: 'escape',
|
|
284
|
-
output: {
|
|
285
|
-
systemMessage: `⛓ model+tier policy BYPASSED via ${ESCAPE_MODEL} — recorded in state/${MODEL_ESCAPE_LOG}`,
|
|
286
|
-
hookSpecificOutput: {
|
|
287
|
-
hookEventName: 'PreToolUse',
|
|
288
|
-
permissionDecision: 'allow',
|
|
289
|
-
permissionDecisionReason:
|
|
290
|
-
`Model policy BYPASSED via ${ESCAPE_MODEL} escape hatch: this ` +
|
|
291
|
-
`${payload.tool_name || 'Agent'} dispatch keeps model ` +
|
|
292
|
-
`"${typeof input.model === 'string' && input.model ? input.model : '(default)'}" ` +
|
|
293
|
-
`and its full toolset (grandfathered). Use recorded in state/${MODEL_ESCAPE_LOG}.`,
|
|
294
|
-
},
|
|
295
|
-
},
|
|
296
|
-
};
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
if (type === 'fork') return pass; // forks inherit parent model/context
|
|
300
|
-
if (type === SHIM_AGENT) return pass; // already cognition-shimmed by the dispatcher
|
|
301
|
-
|
|
302
|
-
// ---------------- 1. MODEL POLICY: resolve the final model ---------------
|
|
303
|
-
let finalModel = model; // '' = inherit / frontmatter pass-through
|
|
304
|
-
let modelChanged = false;
|
|
305
|
-
let modelWhy = null;
|
|
306
|
-
let stripExempt = false; // executor-class agents are never shimmed
|
|
307
|
-
let isSpecialist = false;
|
|
308
|
-
let defModel = null;
|
|
309
|
-
|
|
310
|
-
if (type === 'bash-pro') {
|
|
311
|
-
// Hard exception (user-ordered 2026-07-13): bash work degrades badly on
|
|
312
|
-
// haiku — always Sonnet 5, wins over FORCE_ALL_HAIKU. bash-pro exists to
|
|
313
|
-
// EXECUTE, so it is also exempt from the cognition shim.
|
|
314
|
-
stripExempt = true;
|
|
315
|
-
if (model !== SONNET_PIN) {
|
|
316
|
-
finalModel = SONNET_PIN;
|
|
317
|
-
modelChanged = true;
|
|
318
|
-
modelWhy = `bash-pro always runs Sonnet 5 (was: ${model || 'inherit'})`;
|
|
319
|
-
}
|
|
320
|
-
} else {
|
|
321
|
-
const forceAll = envOf(opts).FORCE_ALL_HAIKU === '1';
|
|
322
|
-
const def = agentDefInfo(type, agentsDir);
|
|
323
|
-
defModel = def.model;
|
|
324
|
-
const isHaikuDef = !!def.model && def.model.toLowerCase().includes('haiku');
|
|
325
|
-
isSpecialist = !forceAll && !!type && def.found && !isHaikuDef;
|
|
326
|
-
|
|
327
|
-
if (isSpecialist) {
|
|
328
|
-
const fam = modelFamily(model);
|
|
329
|
-
if (fam === 'opus' || fam === 'fable') {
|
|
330
|
-
// Hands-on opus/fable is never a subagent — cap explicit requests to
|
|
331
|
-
// Sonnet 5. (Cognition-tier opus/fable arrives via agent-def
|
|
332
|
-
// frontmatter and is shimmed message-only below.)
|
|
333
|
-
finalModel = SONNET_PIN;
|
|
334
|
-
modelChanged = true;
|
|
335
|
-
modelWhy = `specialist '${type}' capped to Sonnet 5 (was: ${model})`;
|
|
336
|
-
}
|
|
337
|
-
// blank / explicit sonnet / explicit haiku pass through unchanged
|
|
338
|
-
} else if (isHaikuDef && !model) {
|
|
339
|
-
// haiku-frontmatter agent, blank model: the definition already lands it
|
|
340
|
-
// on haiku — no rewrite needed.
|
|
341
|
-
} else if (!model.startsWith('haiku')) {
|
|
342
|
-
// Non-specialist (generic dispatch OR a specialist's own fan-out) → Haiku.
|
|
343
|
-
finalModel = 'haiku';
|
|
344
|
-
modelChanged = true;
|
|
345
|
-
modelWhy = `non-specialist dispatch (was: ${model || 'inherit'})`;
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
// ------------- 2. TIER POLICY: tier of the POST-routing model ------------
|
|
350
|
-
// Effective model: the explicit final model; else the agent-def frontmatter;
|
|
351
|
-
// a model-less specialist def is treated as sonnet (the live hook's model:
|
|
352
|
-
// claude-sonnet-5 assumption); everything else defaults haiku.
|
|
353
|
-
const effective = finalModel || defModel || (isSpecialist ? SONNET_PIN : '');
|
|
354
|
-
const tier = tierOf(effective) || 'haiku';
|
|
355
|
-
const family = modelFamily(effective) || 'haiku';
|
|
356
|
-
|
|
357
|
-
let strip = tier === 'cognition' && !stripExempt && stripScope(opts).has(family);
|
|
358
|
-
let tierEscaped = false;
|
|
359
|
-
if (strip && prompt.includes(ESCAPE_TIER)) {
|
|
360
|
-
// Tool-strip bypass ONLY — the model policy above still applies.
|
|
361
|
-
strip = false;
|
|
362
|
-
tierEscaped = true;
|
|
363
|
-
audit(TIER_ESCAPE_LOG, 'tier_policy_escape', payload, { via: ESCAPE_TIER }, opts);
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
// ------------------- 3. compose ONE updatedInput -------------------------
|
|
367
|
-
if (!modelChanged && !strip) {
|
|
368
|
-
if (tierEscaped) {
|
|
369
|
-
return {
|
|
370
|
-
action: 'escape-tier',
|
|
371
|
-
output: {
|
|
372
|
-
systemMessage: `⛓ tier strip BYPASSED via ${ESCAPE_TIER} — recorded in state/${TIER_ESCAPE_LOG}`,
|
|
373
|
-
hookSpecificOutput: {
|
|
374
|
-
hookEventName: 'PreToolUse',
|
|
375
|
-
permissionDecision: 'allow',
|
|
376
|
-
permissionDecisionReason:
|
|
377
|
-
`Tier policy BYPASSED via ${ESCAPE_TIER}: this cognition-tier ` +
|
|
378
|
-
`(${family}) dispatch keeps its full toolset. Use recorded in ` +
|
|
379
|
-
`state/${TIER_ESCAPE_LOG}.`,
|
|
380
|
-
},
|
|
381
|
-
},
|
|
382
|
-
};
|
|
383
|
-
}
|
|
384
|
-
return pass;
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
const updated = { ...input };
|
|
388
|
-
const notes = [];
|
|
389
|
-
if (modelChanged) {
|
|
390
|
-
updated.model = finalModel;
|
|
391
|
-
notes.push(`model → ${finalModel} (${modelWhy})`);
|
|
392
|
-
}
|
|
393
|
-
if (strip) {
|
|
394
|
-
updated.subagent_type = SHIM_AGENT;
|
|
395
|
-
updated.prompt = contractHeader(type, family) + prompt;
|
|
396
|
-
// The shim def carries no model: — keep the effective cognition model
|
|
397
|
-
// explicit so the rewrite never silently changes the tier.
|
|
398
|
-
if (!updated.model) updated.model = defModel || SONNET_PIN;
|
|
399
|
-
notes.push(`cognition tier (${family}) → tools stripped via '${SHIM_AGENT}' shim (kept: ${KEEP_TOOLS.join(', ')})`);
|
|
400
|
-
}
|
|
401
|
-
if (tierEscaped) notes.push(`tier strip BYPASSED via ${ESCAPE_TIER} (audited)`);
|
|
402
|
-
|
|
403
|
-
return {
|
|
404
|
-
action: strip ? (modelChanged ? 'route+strip' : 'strip') : 'route',
|
|
405
|
-
strippedTools: strip ? DENY_TOOLS : undefined,
|
|
406
|
-
keptTools: strip ? KEEP_TOOLS : undefined,
|
|
407
|
-
output: {
|
|
408
|
-
systemMessage: `⛓ subagent policy: ${notes.join('; ')}`,
|
|
409
|
-
suppressOutput: true,
|
|
410
|
-
hookSpecificOutput: {
|
|
411
|
-
hookEventName: 'PreToolUse',
|
|
412
|
-
permissionDecision: 'allow',
|
|
413
|
-
permissionDecisionReason:
|
|
414
|
-
'Merged policy: specialists=Sonnet, bash-pro=Sonnet, everything else=Haiku ' +
|
|
415
|
-
`(${ESCAPE_MODEL} to override); cognition tiers (scope: ` +
|
|
416
|
-
`${[...stripScope(opts)].join('/') || 'off'}) are message-only via the ` +
|
|
417
|
-
`'${SHIM_AGENT}' shim (${ESCAPE_TIER} to override).`,
|
|
418
|
-
updatedInput: updated,
|
|
419
|
-
},
|
|
420
|
-
},
|
|
421
|
-
};
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
// ---- LAYER 2: call-time backstop (clearly separable; staged OFF) ----------
|
|
425
|
-
// Only meaningful once (a) settings.json wires this hook to the DENY_TOOLS
|
|
426
|
-
// matchers AND (b) TIER_ENFORCE=1. Until both, every call passes untouched.
|
|
427
|
-
// See FINDINGS.md condition 2 (validate transcript sniffing on real
|
|
428
|
-
// sidechains) and risk 4 (main-thread carve-out) before arming.
|
|
429
|
-
|
|
430
|
-
export function layer2Backstop(payload, opts = {}) {
|
|
431
|
-
const pass = { action: 'pass', output: null };
|
|
432
|
-
if (envOf(opts).TIER_ENFORCE !== '1') return pass; // staged: default OFF
|
|
433
|
-
|
|
434
|
-
const t = payload.transcript_path;
|
|
435
|
-
if (!t || typeof t !== 'string') return pass; // fail-open
|
|
436
|
-
const { tier, escaped } = sniffTranscript(t);
|
|
437
|
-
if (tier === null) return pass; // unreadable/unknown transcript: no opinion
|
|
438
|
-
if (tier === 'haiku' || escaped) return { action: 'allow', output: null };
|
|
439
|
-
|
|
440
|
-
return {
|
|
441
|
-
action: 'deny',
|
|
442
|
-
output: {
|
|
443
|
-
hookSpecificOutput: {
|
|
444
|
-
hookEventName: 'PreToolUse',
|
|
445
|
-
permissionDecision: 'deny',
|
|
446
|
-
permissionDecisionReason:
|
|
447
|
-
`Tier policy: this session runs on a cognition-tier model, which never ` +
|
|
448
|
-
`touches files/exec directly. Do NOT retry ${payload.tool_name}. Instead emit a ` +
|
|
449
|
-
`WORK-ORDER v1 (unified diff / command list / verify steps — schema in ` +
|
|
450
|
-
`docs/spikes/tiered-cognition/DESIGN.md) and dispatch a Haiku executor ` +
|
|
451
|
-
`agent to apply it and report back an EXEC-BRIEF.`,
|
|
452
|
-
},
|
|
453
|
-
},
|
|
454
|
-
};
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
// ----------------------------------------------------------------- plumbing
|
|
458
|
-
|
|
459
|
-
/** Read stdin raced against a timer: a never-closing pipe must not hang
|
|
460
|
-
* Agent/Task dispatch. On timeout, resolve '' (fail-open, no rewrite). */
|
|
461
|
-
function readStdin(timeoutMs = STDIN_TIMEOUT_MS) {
|
|
462
|
-
return new Promise((resolve) => {
|
|
463
|
-
let settled = false;
|
|
464
|
-
const finish = (v) => { if (!settled) { settled = true; clearTimeout(timer); resolve(v); } };
|
|
465
|
-
const timer = setTimeout(() => finish(''), timeoutMs);
|
|
466
|
-
let data = '';
|
|
467
|
-
process.stdin.setEncoding('utf8');
|
|
468
|
-
process.stdin.on('data', (c) => { data += c; });
|
|
469
|
-
process.stdin.on('end', () => finish(data));
|
|
470
|
-
process.stdin.on('error', () => finish(''));
|
|
471
|
-
});
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
async function runAsHook() {
|
|
475
|
-
const raw = await readStdin();
|
|
476
|
-
let payload = null;
|
|
477
|
-
try { payload = JSON.parse(raw || '{}'); } catch { /* fail-open */ }
|
|
478
|
-
const { output } = decide(payload);
|
|
479
|
-
process.stdout.write(JSON.stringify(output || {
|
|
480
|
-
suppressOutput: true,
|
|
481
|
-
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow' },
|
|
482
|
-
}));
|
|
483
|
-
process.exit(0);
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
// ----------------------------------------------------------------- selftest
|
|
487
|
-
|
|
488
|
-
function selfTest() {
|
|
489
|
-
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mergedpolicy-'));
|
|
490
|
-
const agentsDir = path.join(tmp, 'agents');
|
|
491
|
-
fs.mkdirSync(agentsDir);
|
|
492
|
-
fs.writeFileSync(path.join(agentsDir, 'typescript-pro.md'),
|
|
493
|
-
'---\nname: typescript-pro\ndescription: test fixture\nmodel: claude-sonnet-5\n---\nbody\n');
|
|
494
|
-
fs.writeFileSync(path.join(agentsDir, 'hooktest-haiku.md'),
|
|
495
|
-
'---\nname: hooktest-haiku\ndescription: test fixture\nmodel: haiku\n---\nbody\n');
|
|
496
|
-
fs.writeFileSync(path.join(agentsDir, 'plan-architect.md'),
|
|
497
|
-
'---\nname: plan-architect\ndescription: test fixture\nmodel: claude-opus-4-5\n---\nbody\n');
|
|
498
|
-
fs.writeFileSync(path.join(agentsDir, 'mission-cognition.md'),
|
|
499
|
-
'---\nname: mission-cognition\ndescription: test fixture\nmodel: fable\n---\nbody\n');
|
|
500
|
-
fs.writeFileSync(path.join(agentsDir, 'bash-pro.md'),
|
|
501
|
-
'---\nname: bash-pro\ndescription: test fixture\nmodel: claude-sonnet-5\n---\nbody\n');
|
|
502
|
-
|
|
503
|
-
const mkTranscript = (name, lines) => {
|
|
504
|
-
const p = path.join(tmp, name);
|
|
505
|
-
fs.writeFileSync(p, lines.map(l => JSON.stringify(l)).join('\n') + '\n');
|
|
506
|
-
return p;
|
|
507
|
-
};
|
|
508
|
-
const sonnetTx = mkTranscript('sonnet.jsonl', [
|
|
509
|
-
{ type: 'user', message: { role: 'user', content: 'implement the parser' } },
|
|
510
|
-
{ type: 'assistant', message: { role: 'assistant', model: 'claude-sonnet-5', content: [] } },
|
|
511
|
-
]);
|
|
512
|
-
const haikuTx = mkTranscript('haiku.jsonl', [
|
|
513
|
-
{ type: 'user', message: { role: 'user', content: 'apply this work-order' } },
|
|
514
|
-
{ type: 'assistant', message: { role: 'assistant', model: 'claude-haiku-4-5', content: [] } },
|
|
515
|
-
]);
|
|
516
|
-
const escapedTx = mkTranscript('escaped.jsonl', [
|
|
517
|
-
{ type: 'user', message: { role: 'user', content: [{ type: 'text', text: '[[ALLOW-NON-HAIKU]] mission, hands-on' }] } },
|
|
518
|
-
{ type: 'assistant', message: { role: 'assistant', model: 'claude-fable-5', content: [] } },
|
|
519
|
-
]);
|
|
520
|
-
|
|
521
|
-
// Baseline opts: isolated env so ambient FORCE_ALL_HAIKU/TIER_* can't skew.
|
|
522
|
-
const base = { agentsDir, stateRoot: tmp, env: {} };
|
|
523
|
-
const withEnv = (env) => ({ ...base, env });
|
|
524
|
-
const dispatch = (input) => ({ tool_name: 'Agent', tool_input: input, session_id: 's1', cwd: tmp });
|
|
525
|
-
const call = (tool, tx) => ({ tool_name: tool, tool_input: { file_path: 'x' }, transcript_path: tx });
|
|
526
|
-
const ui = (r) => r.output && r.output.hookSpecificOutput && r.output.hookSpecificOutput.updatedInput;
|
|
527
|
-
|
|
528
|
-
let failures = 0;
|
|
529
|
-
const check = (name, cond, detail) => {
|
|
530
|
-
console.log(` ${cond ? 'PASS' : 'FAIL'} ${name}${cond ? '' : ' <-- ' + (detail || '')}`);
|
|
531
|
-
if (!cond) failures++;
|
|
532
|
-
};
|
|
533
|
-
|
|
534
|
-
console.log('MODEL POLICY (live-hook parity)');
|
|
535
|
-
// 1. generic/untyped dispatch → haiku
|
|
536
|
-
let r = decide(dispatch({ prompt: 'p', description: 'd' }), base);
|
|
537
|
-
check('untyped dispatch forced to haiku', r.action === 'route' && ui(r).model === 'haiku', JSON.stringify(ui(r)));
|
|
538
|
-
check(' no shim on a haiku-routed dispatch', ui(r).subagent_type === undefined && ui(r).prompt === 'p');
|
|
539
|
-
// 2. generic dispatch requesting cognition models → still haiku
|
|
540
|
-
r = decide(dispatch({ model: 'sonnet', prompt: 'p' }), base);
|
|
541
|
-
check('generic sonnet request forced to haiku', r.action === 'route' && ui(r).model === 'haiku');
|
|
542
|
-
r = decide(dispatch({ model: 'fable', prompt: 'p' }), base);
|
|
543
|
-
check('generic fable request forced to haiku', r.action === 'route' && ui(r).model === 'haiku');
|
|
544
|
-
// 3. explicit haiku passes
|
|
545
|
-
r = decide(dispatch({ model: 'haiku', prompt: 'p' }), base);
|
|
546
|
-
check('explicit haiku passes untouched', r.action === 'pass' && r.output === null, `action=${r.action}`);
|
|
547
|
-
// 4. bash-pro → ALWAYS sonnet-5, never shimmed
|
|
548
|
-
r = decide(dispatch({ prompt: 'p', subagent_type: 'bash-pro' }), base);
|
|
549
|
-
check('bash-pro pinned to claude-sonnet-5', r.action === 'route' && ui(r).model === SONNET_PIN, JSON.stringify(ui(r)));
|
|
550
|
-
r = decide(dispatch({ prompt: 'p', subagent_type: 'bash-pro' }), withEnv({ TIER_STRIP_SCOPE: 'all' }));
|
|
551
|
-
check('bash-pro NEVER shimmed (even scope=all)', r.action === 'route' && ui(r).model === SONNET_PIN && ui(r).subagent_type === 'bash-pro');
|
|
552
|
-
r = decide(dispatch({ model: SONNET_PIN, prompt: 'p', subagent_type: 'bash-pro' }), base);
|
|
553
|
-
check('bash-pro already sonnet-5 passes', r.action === 'pass', `action=${r.action}`);
|
|
554
|
-
r = decide(dispatch({ prompt: 'p', subagent_type: 'bash-pro' }), withEnv({ FORCE_ALL_HAIKU: '1' }));
|
|
555
|
-
check('bash-pro wins over FORCE_ALL_HAIKU', r.action === 'route' && ui(r).model === SONNET_PIN);
|
|
556
|
-
// 5. specialist → its model (blank passes through to frontmatter)
|
|
557
|
-
r = decide(dispatch({ prompt: 'p', subagent_type: 'typescript-pro' }), base);
|
|
558
|
-
check('sonnet specialist keeps its model (default scope)', r.action === 'pass', `action=${r.action}`);
|
|
559
|
-
r = decide(dispatch({ model: 'opus', prompt: 'p', subagent_type: 'typescript-pro' }), base);
|
|
560
|
-
check('specialist explicit opus capped to sonnet-5', r.action === 'route' && ui(r).model === SONNET_PIN);
|
|
561
|
-
check(' cap alone does not shim (default scope)', ui(r).subagent_type === 'typescript-pro', ui(r).subagent_type);
|
|
562
|
-
// 6. haiku-frontmatter agent untouched
|
|
563
|
-
r = decide(dispatch({ prompt: 'p', subagent_type: 'hooktest-haiku' }), base);
|
|
564
|
-
check('haiku-frontmatter agent passes', r.action === 'pass', `action=${r.action}`);
|
|
565
|
-
// 7. FORCE_ALL_HAIKU flattens specialists
|
|
566
|
-
r = decide(dispatch({ prompt: 'p', subagent_type: 'typescript-pro' }), withEnv({ FORCE_ALL_HAIKU: '1' }));
|
|
567
|
-
check('FORCE_ALL_HAIKU flattens specialist to haiku', r.action === 'route' && ui(r).model === 'haiku');
|
|
568
|
-
// 8. [[ALLOW-NON-HAIKU]] full bypass + audit
|
|
569
|
-
r = decide(dispatch({ model: 'opus', prompt: `${ESCAPE_MODEL} mission`, description: 'm' }), base);
|
|
570
|
-
check('ALLOW-NON-HAIKU bypasses (no rewrite)', r.action === 'escape' && !ui(r), `action=${r.action}`);
|
|
571
|
-
let logTxt = '';
|
|
572
|
-
try { logTxt = fs.readFileSync(path.join(tmp, 'state', MODEL_ESCAPE_LOG), 'utf8'); } catch { }
|
|
573
|
-
check(' audited to MODEL-POLICY-ESCAPES.log', logTxt.includes('model_policy_escape'));
|
|
574
|
-
try { logTxt = fs.readFileSync(path.join(tmp, 'state', TIER_ESCAPE_LOG), 'utf8'); } catch { logTxt = ''; }
|
|
575
|
-
check(' grandfathered tier escape audited too', logTxt.includes('grandfathered'));
|
|
576
|
-
// 9. fork untouched
|
|
577
|
-
r = decide(dispatch({ model: 'sonnet', prompt: 'p', subagent_type: 'fork' }), base);
|
|
578
|
-
check('fork dispatch passes untouched', r.action === 'pass', `action=${r.action}`);
|
|
579
|
-
// 10. malformed payloads fail open
|
|
580
|
-
check('malformed payload fails open', decide(null, base).action === 'pass');
|
|
581
|
-
check('missing tool_input fails open', decide({ tool_name: 'Agent' }, base).action === 'pass');
|
|
582
|
-
|
|
583
|
-
console.log('TIER POLICY (Layer 1, post-routing tier)');
|
|
584
|
-
// 11. opus-frontmatter def, blank model → shimmed, model preserved explicitly
|
|
585
|
-
r = decide(dispatch({ prompt: 'design it', subagent_type: 'plan-architect', description: 'd' }), base);
|
|
586
|
-
check('opus-frontmatter dispatch shimmed (default scope)', r.action === 'strip', `action=${r.action}`);
|
|
587
|
-
check(' rewritten to shim agent type', ui(r) && ui(r).subagent_type === SHIM_AGENT, JSON.stringify(ui(r)));
|
|
588
|
-
check(' contract header prepended', ui(r) && ui(r).prompt.startsWith('[COGNITION CONTRACT v1]'));
|
|
589
|
-
check(' original role named in header', ui(r) && ui(r).prompt.includes("'plan-architect'"));
|
|
590
|
-
check(' original prompt preserved', ui(r) && ui(r).prompt.endsWith('design it'));
|
|
591
|
-
check(' effective model kept explicit', ui(r) && ui(r).model === 'claude-opus-4-5', ui(r) && ui(r).model);
|
|
592
|
-
// 12. fable-frontmatter def → shimmed
|
|
593
|
-
r = decide(dispatch({ prompt: 'p', subagent_type: 'mission-cognition' }), base);
|
|
594
|
-
check('fable-frontmatter dispatch shimmed', r.action === 'strip' && ui(r).model === 'fable');
|
|
595
|
-
// 13. stripped/kept tool sets
|
|
596
|
-
check(' I/O+exec+retrieval all in stripped set',
|
|
597
|
-
['Read', 'Write', 'Edit', 'Bash', 'PowerShell', 'Glob', 'Grep', 'NotebookEdit', 'WebFetch', 'Skill']
|
|
598
|
-
.every(t => r.strippedTools.includes(t)));
|
|
599
|
-
check(' messaging/dispatch tools kept, disjoint',
|
|
600
|
-
['Agent', 'SendMessage', 'TaskStop', 'Monitor'].every(t => r.keptTools.includes(t)) &&
|
|
601
|
-
r.keptTools.every(t => !r.strippedTools.includes(t)));
|
|
602
|
-
// 14. sonnet specialists: untouched in Phase A, shimmed in Phase B (scope=all)
|
|
603
|
-
r = decide(dispatch({ prompt: 'p', subagent_type: 'typescript-pro' }), withEnv({ TIER_STRIP_SCOPE: 'all' }));
|
|
604
|
-
check('scope=all shims sonnet specialist', r.action === 'strip' && ui(r).subagent_type === SHIM_AGENT);
|
|
605
|
-
check(' frontmatter sonnet model kept explicit', ui(r).model === SONNET_PIN, ui(r).model);
|
|
606
|
-
// 15. scope=off disables stripping entirely
|
|
607
|
-
r = decide(dispatch({ prompt: 'p', subagent_type: 'plan-architect' }), withEnv({ TIER_STRIP_SCOPE: 'off' }));
|
|
608
|
-
check('scope=off leaves cognition dispatch untouched', r.action === 'pass', `action=${r.action}`);
|
|
609
|
-
// 16. [[ALLOW-TIER-TOOLS]] bypasses the strip only
|
|
610
|
-
r = decide(dispatch({ prompt: `${ESCAPE_TIER} hands-on`, subagent_type: 'plan-architect' }), base);
|
|
611
|
-
check('ALLOW-TIER-TOOLS keeps tools (no shim)', r.action === 'escape-tier' && !ui(r), `action=${r.action}`);
|
|
612
|
-
try { logTxt = fs.readFileSync(path.join(tmp, 'state', TIER_ESCAPE_LOG), 'utf8'); } catch { logTxt = ''; }
|
|
613
|
-
check(' tier escape audited', logTxt.includes(ESCAPE_TIER));
|
|
614
|
-
// 17. ALLOW-TIER-TOOLS does NOT bypass the model policy
|
|
615
|
-
r = decide(dispatch({ model: 'opus', prompt: `${ESCAPE_TIER} p`, subagent_type: 'typescript-pro' }),
|
|
616
|
-
withEnv({ TIER_STRIP_SCOPE: 'all' }));
|
|
617
|
-
check('ALLOW-TIER-TOOLS still gets model-capped', r.action === 'route' && ui(r).model === SONNET_PIN);
|
|
618
|
-
check(' but keeps its own agent type + prompt', ui(r).subagent_type === 'typescript-pro' && ui(r).prompt === `${ESCAPE_TIER} p`);
|
|
619
|
-
// 18. dispatch already typed to the shim is idempotent
|
|
620
|
-
r = decide(dispatch({ model: 'opus', prompt: 'p', subagent_type: SHIM_AGENT }), base);
|
|
621
|
-
check('dispatch typed aesop-cognition passes (no double-shim)', r.action === 'pass', `action=${r.action}`);
|
|
622
|
-
|
|
623
|
-
console.log('COMPOSITION — both policies in ONE updatedInput');
|
|
624
|
-
// 19. specialist + explicit fable + scope=all: model policy caps to sonnet-5
|
|
625
|
-
// AND tier policy shims — one updatedInput carries BOTH rewrites.
|
|
626
|
-
r = decide(dispatch({ model: 'fable', prompt: 'compose me', subagent_type: 'typescript-pro', description: 'keep-me' }),
|
|
627
|
-
withEnv({ TIER_STRIP_SCOPE: 'all' }));
|
|
628
|
-
check('composed action is route+strip', r.action === 'route+strip', `action=${r.action}`);
|
|
629
|
-
const u = ui(r);
|
|
630
|
-
check(' ONE updatedInput object', !!u && r.output.hookSpecificOutput.updatedInput === u);
|
|
631
|
-
check(' model policy present (fable→sonnet-5 cap)', u && u.model === SONNET_PIN, u && u.model);
|
|
632
|
-
check(' tier policy present (shim + contract header)',
|
|
633
|
-
u && u.subagent_type === SHIM_AGENT && u.prompt.startsWith('[COGNITION CONTRACT v1]') && u.prompt.endsWith('compose me'));
|
|
634
|
-
check(' untouched fields survive the merge', u && u.description === 'keep-me');
|
|
635
|
-
check(' systemMessage announces BOTH rewrites',
|
|
636
|
-
/model →/.test(r.output.systemMessage) && /shim/.test(r.output.systemMessage), r.output.systemMessage);
|
|
637
|
-
|
|
638
|
-
console.log('LAYER 2 — call-time backstop (TIER_ENFORCE-gated)');
|
|
639
|
-
// 20. default OFF: dormant even for a cognition transcript
|
|
640
|
-
r = decide(call('Write', sonnetTx), base);
|
|
641
|
-
check('TIER_ENFORCE unset: backstop dormant (pass)', r.action === 'pass', `action=${r.action}`);
|
|
642
|
-
const armed = withEnv({ TIER_ENFORCE: '1' });
|
|
643
|
-
// 21. armed: cognition session denied with work-order redirect
|
|
644
|
-
r = decide(call('Write', sonnetTx), armed);
|
|
645
|
-
check('armed: Write under sonnet session DENIED', r.action === 'deny', `action=${r.action}`);
|
|
646
|
-
check(' deny reason redirects to WORK-ORDER + Haiku executor',
|
|
647
|
-
r.action === 'deny' && /WORK-ORDER/.test(r.output.hookSpecificOutput.permissionDecisionReason) &&
|
|
648
|
-
r.output.hookSpecificOutput.permissionDecision === 'deny');
|
|
649
|
-
r = decide(call('Bash', sonnetTx), armed);
|
|
650
|
-
check('armed: Bash under sonnet session DENIED', r.action === 'deny', `action=${r.action}`);
|
|
651
|
-
// 22. armed: haiku substrate + escaped sessions allowed
|
|
652
|
-
r = decide(call('Write', haikuTx), armed);
|
|
653
|
-
check('armed: Write under haiku session allowed', r.action === 'allow', `action=${r.action}`);
|
|
654
|
-
r = decide(call('Edit', escapedTx), armed);
|
|
655
|
-
check('armed: escape-token session keeps I/O tools', r.action === 'allow', `action=${r.action}`);
|
|
656
|
-
// 23. armed: fail-open on missing transcript; ungoverned tools pass
|
|
657
|
-
r = decide(call('Write', path.join(tmp, 'nope.jsonl')), armed);
|
|
658
|
-
check('armed: missing transcript fails open', r.action === 'pass', `action=${r.action}`);
|
|
659
|
-
r = decide({ tool_name: 'SendMessage', tool_input: {}, transcript_path: sonnetTx }, armed);
|
|
660
|
-
check('ungoverned tool (SendMessage) passes', r.action === 'pass', `action=${r.action}`);
|
|
661
|
-
|
|
662
|
-
console.log(failures === 0 ? '\nSELF-TEST: ALL PASS' : `\nSELF-TEST: ${failures} FAILURE(S)`);
|
|
663
|
-
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { }
|
|
664
|
-
process.exit(failures === 0 ? 0 : 1);
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
// ----------------------------------------------------------------- dispatch
|
|
668
|
-
|
|
669
|
-
if (process.argv.includes('--self-test')) {
|
|
670
|
-
selfTest();
|
|
671
|
-
} else {
|
|
672
|
-
runAsHook();
|
|
673
|
-
}
|