@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.
Files changed (114) hide show
  1. package/CHANGELOG.md +101 -2
  2. package/README.md +66 -15
  3. package/bin/cli.js +164 -41
  4. package/daemons/run-watchdog.sh +16 -4
  5. package/daemons/selfheal.sh +231 -0
  6. package/docs/ANY-REPO.md +427 -0
  7. package/docs/CONTRIBUTING.md +72 -0
  8. package/docs/DEMO.md +334 -0
  9. package/docs/INSTALL.md +70 -1
  10. package/docs/QUICKSTART.md +80 -0
  11. package/docs/README.md +33 -3
  12. package/docs/TEAM-STATE.md +540 -0
  13. package/driver/CLAUDE.md +148 -0
  14. package/driver/README.md +383 -0
  15. package/driver/aesop.config.example.json +80 -0
  16. package/driver/agent_driver.py +355 -0
  17. package/driver/backend_config.py +253 -0
  18. package/driver/claude_code_driver.py +198 -0
  19. package/driver/codex_driver.py +626 -0
  20. package/driver/openai_compatible_driver.py +249 -0
  21. package/driver/openai_transport.py +146 -0
  22. package/driver/verification_policy.py +75 -0
  23. package/driver/wave_bridge.py +246 -0
  24. package/driver/wave_loop.py +1041 -0
  25. package/hooks/pre-push-policy.sh +109 -28
  26. package/mcp/server.mjs +320 -4
  27. package/package.json +23 -15
  28. package/skills/CLAUDE.md +132 -2
  29. package/skills/buildsystem/SKILL.md +330 -0
  30. package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
  31. package/skills/fleet/SKILL.md +113 -0
  32. package/skills/power/SKILL.md +246 -131
  33. package/state_store/__init__.py +2 -1
  34. package/state_store/api.py +19 -4
  35. package/state_store/coordination.py +209 -0
  36. package/state_store/identity.py +51 -0
  37. package/state_store/store.py +185 -73
  38. package/templates/wave-presets/data.json +65 -0
  39. package/templates/wave-presets/library.json +65 -0
  40. package/templates/wave-presets/saas.json +64 -0
  41. package/tools/audit_report.py +388 -0
  42. package/tools/bench_runner.py +100 -3
  43. package/tools/ci_merge_wait.py +256 -35
  44. package/tools/ci_workflow_lint.py +430 -0
  45. package/tools/claudemd_drift.py +394 -0
  46. package/tools/claudemd_lint.py +359 -0
  47. package/tools/common.py +39 -3
  48. package/tools/cost_ceiling.py +63 -31
  49. package/tools/cost_econ.py +480 -0
  50. package/tools/defect_escape.py +252 -0
  51. package/tools/doctor.js +1 -1
  52. package/tools/eod_sweep.py +58 -0
  53. package/tools/fleet.js +260 -0
  54. package/tools/fleet_ledger.py +209 -7
  55. package/tools/git_identity_check.py +315 -0
  56. package/tools/health-score.js +40 -0
  57. package/tools/health_score.py +361 -0
  58. package/tools/metrics_gate.py +13 -4
  59. package/tools/mutation_test.py +401 -0
  60. package/tools/portability_check.py +206 -0
  61. package/tools/reconcile.py +7 -4
  62. package/tools/secret_scan.py +137 -50
  63. package/tools/self_stats.py +20 -0
  64. package/tools/transcript_digest.py +380 -0
  65. package/tools/verify_activity_filter.py +437 -0
  66. package/tools/verify_agent_inspector.py +2 -0
  67. package/tools/verify_dash.py +2 -0
  68. package/tools/verify_dispatch_panel.py +301 -0
  69. package/tools/verify_failure_drilldown.py +188 -0
  70. package/tools/verify_prboard.py +2 -0
  71. package/tools/verify_scorecards.py +281 -0
  72. package/tools/verify_submit_encoding.py +2 -0
  73. package/tools/verify_ui_trio.py +409 -0
  74. package/tools/verify_wave_telemetry.py +268 -0
  75. package/tools/wave_backlog_analyzer.py +490 -0
  76. package/tools/wave_ledger_hook.py +150 -0
  77. package/tools/wave_preflight.py +512 -0
  78. package/tools/wave_resume.py +215 -0
  79. package/tools/wave_templates.py +215 -0
  80. package/ui/agents.py +68 -14
  81. package/ui/collectors.py +0 -55
  82. package/ui/config.py +7 -2
  83. package/ui/cost.py +231 -12
  84. package/ui/handler.py +183 -0
  85. package/ui/quality_scorecard.py +232 -0
  86. package/ui/wave_audit_tail.py +213 -0
  87. package/ui/wave_dispatch.py +280 -0
  88. package/ui/wave_failure.py +288 -0
  89. package/ui/wave_gantt.py +152 -0
  90. package/ui/wave_reasoning_tail.py +176 -0
  91. package/ui/wave_telemetry.py +377 -0
  92. package/ui/web/dist/assets/index-BGbgw2Nh.js +9 -0
  93. package/ui/web/dist/assets/index-DqZLgwNg.css +1 -0
  94. package/ui/web/dist/index.html +2 -2
  95. package/bin/CLAUDE.md +0 -76
  96. package/daemons/CLAUDE.md +0 -36
  97. package/dash/CLAUDE.md +0 -32
  98. package/docs/archive/README.md +0 -3
  99. package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
  100. package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
  101. package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
  102. package/docs/archive/spikes/tiered-cognition/README.md +0 -27
  103. package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
  104. package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
  105. package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
  106. package/hooks/CLAUDE.md +0 -89
  107. package/mcp/CLAUDE.md +0 -213
  108. package/monitor/CLAUDE.md +0 -40
  109. package/scan/CLAUDE.md +0 -30
  110. package/state_store/CLAUDE.md +0 -39
  111. package/tools/CLAUDE.md +0 -79
  112. package/ui/CLAUDE.md +0 -127
  113. package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
  114. package/ui/web/dist/assets/index-BdIlFieV.css +0 -1
@@ -1,434 +0,0 @@
1
- #!/usr/bin/env node
2
- // strip-tools-hook.mjs — SPIKE PROTOTYPE (wave 11, NOT wired into live settings).
3
- //
4
- // PreToolUse hook enforcing the tiered cognition/execution architecture:
5
- // Haiku is the ONLY tier that reads/writes/executes; Sonnet/Opus/Fable are
6
- // pure cognition (reason + message + dispatch). Two enforcement layers in
7
- // one script (selected by payload.tool_name):
8
- //
9
- // LAYER 1 — dispatch guard (matcher "Agent|Task"):
10
- // A subagent dispatch resolving to a cognition-tier model (sonnet/opus/
11
- // fable) is rewritten to the `aesop-cognition` shim agent type, whose
12
- // definition frontmatter allowlists ONLY cognition tools
13
- // (Agent, SendMessage, TaskStop, Monitor). The original requested type is
14
- // preserved in a COGNITION CONTRACT header prepended to the prompt.
15
- // NOTE: the Agent tool input schema has NO `tools` field
16
- // (additionalProperties: false), so per-dispatch tool lists CANNOT be set
17
- // via updatedInput — the agent-definition `tools:` frontmatter is the only
18
- // supported stripping mechanism today. Hence the shim rewrite.
19
- //
20
- // LAYER 2 — call-time backstop (matcher on the denied tools below):
21
- // When an I/O/exec tool call fires inside a session whose transcript shows
22
- // a non-haiku model, the call is DENIED with a reason that redirects the
23
- // agent to emit a WORK-ORDER v1 and dispatch a Haiku executor instead
24
- // (see DESIGN.md §3). Haiku sessions pass untouched.
25
- //
26
- // Escape hatch: [[ALLOW-TIER-TOOLS]] in the dispatch prompt keeps the agent's
27
- // tools ([[ALLOW-NON-HAIKU]] is honored too during migration — today's
28
- // escalations are hands-on missions). Never silent: every use is announced via
29
- // permissionDecisionReason and appended as a JSON line to
30
- // ${AESOP_ROOT:-~/aesop}/state/TIER-POLICY-ESCAPES.log.
31
- //
32
- // Reliability: fail-open everywhere (malformed payload / missing transcript /
33
- // unreadable agents dir => no opinion, pass). stdin raced against a 2s timer.
34
- // See FINDINGS.md for the fail-open-vs-fail-closed discussion.
35
- //
36
- // Self-test: `node strip-tools-hook.mjs --self-test` (no stdin needed; builds
37
- // its own fixtures in a temp dir, exits 0 on PASS / 1 on FAIL).
38
-
39
- import fs from 'node:fs';
40
- import os from 'node:os';
41
- import path from 'node:path';
42
-
43
- // ---------------------------------------------------------------- constants
44
-
45
- export const ESCAPE_TOKENS = ['[[ALLOW-TIER-TOOLS]]', '[[ALLOW-NON-HAIKU]]'];
46
- export const SHIM_AGENT = 'aesop-cognition';
47
- export const ESCAPE_LOG_NAME = 'TIER-POLICY-ESCAPES.log';
48
- const STDIN_TIMEOUT_MS = 2000;
49
-
50
- // Cognition tiers KEEP exactly these (reason + message + dispatch):
51
- export const KEEP_TOOLS = ['Agent', 'SendMessage', 'TaskStop', 'Monitor'];
52
-
53
- // Cognition tiers are DENIED all I/O + exec + retrieval (Haiku's job):
54
- export const DENY_TOOLS = [
55
- 'Read', 'Write', 'Edit', 'MultiEdit', 'NotebookEdit',
56
- 'Bash', 'PowerShell', 'BashOutput', 'KillShell',
57
- 'Glob', 'Grep', 'WebFetch', 'WebSearch',
58
- 'Skill', 'Artifact', 'ToolSearch', 'EnterWorktree', 'ExitWorktree',
59
- ];
60
-
61
- const DISPATCH_TOOLS = new Set(['Agent', 'Task']);
62
- const DENY_SET = new Set(DENY_TOOLS);
63
-
64
- // --------------------------------------------------------------- tier logic
65
-
66
- /** Map a model string to a tier: 'haiku' | 'cognition' | null (unknown). */
67
- export function tierOf(model) {
68
- const m = String(model || '').toLowerCase();
69
- if (!m) return null;
70
- if (m.includes('haiku')) return 'haiku';
71
- if (m.includes('sonnet') || m.includes('opus') || m.includes('fable')) return 'cognition';
72
- return null; // unrecognized model string — no opinion
73
- }
74
-
75
- /** Resolve tier from an installed agent definition's `model:` frontmatter
76
- * (matches basename or `name:` field). Returns tier or null. */
77
- export function tierFromAgentDef(type, agentsDir) {
78
- if (!type) return null;
79
- let files = [];
80
- try { files = fs.readdirSync(agentsDir).filter(f => f.endsWith('.md')); } catch { return null; }
81
- for (const f of files) {
82
- let head = '';
83
- try { head = fs.readFileSync(path.join(agentsDir, f), 'utf8').slice(0, 600); } catch { continue; }
84
- const base = f.replace(/\.md$/, '').toLowerCase();
85
- let match = base === type;
86
- if (!match) {
87
- const nm = head.match(/^name:\s*(.+)$/m);
88
- match = !!nm && nm[1].trim().toLowerCase() === type;
89
- }
90
- if (match) {
91
- const mm = head.match(/^model:\s*(.+)$/m);
92
- return mm ? tierOf(mm[1].trim()) : null;
93
- }
94
- }
95
- return null;
96
- }
97
-
98
- // -------------------------------------------------------------- audit trail
99
-
100
- function stateDir(opts) {
101
- const root = (opts && opts.stateRoot) || process.env.AESOP_ROOT || path.join(os.homedir(), 'aesop');
102
- return path.join(root, 'state');
103
- }
104
-
105
- /** Best-effort JSON-line audit record for escape-hatch use; never blocks. */
106
- function logEscapeUse(payload, opts) {
107
- try {
108
- const dir = stateDir(opts);
109
- fs.mkdirSync(dir, { recursive: true });
110
- const input = payload.tool_input || {};
111
- const rec = {
112
- ts: new Date().toISOString(),
113
- event: 'tier_policy_escape',
114
- tool: payload.tool_name,
115
- session_id: typeof payload.session_id === 'string' ? payload.session_id : null,
116
- cwd: typeof payload.cwd === 'string' ? payload.cwd : null,
117
- description: typeof input.description === 'string' ? input.description : null,
118
- requested_model: typeof input.model === 'string' ? input.model : null,
119
- prompt_head: typeof input.prompt === 'string' ? input.prompt.slice(0, 200) : null,
120
- };
121
- fs.appendFileSync(path.join(dir, ESCAPE_LOG_NAME), JSON.stringify(rec) + '\n');
122
- } catch { /* audit is best-effort */ }
123
- }
124
-
125
- // ------------------------------------------------------- transcript sniffer
126
-
127
- /** Read a Claude Code transcript (JSONL) and return { tier, escaped }.
128
- * tier: from the LAST assistant entry carrying message.model.
129
- * escaped: true if any user entry contains an escape token (the subagent's
130
- * dispatch prompt is the first user message of its transcript).
131
- * Fail-open: unreadable/unparseable => { tier: null, escaped: false }. */
132
- export function sniffTranscript(transcriptPath) {
133
- const out = { tier: null, escaped: false };
134
- let raw = '';
135
- try { raw = fs.readFileSync(transcriptPath, 'utf8'); } catch { return out; }
136
- for (const line of raw.split('\n')) {
137
- if (!line.trim()) continue;
138
- let j;
139
- try { j = JSON.parse(line); } catch { continue; }
140
- const msg = j && j.message;
141
- if (!msg || typeof msg !== 'object') continue;
142
- if ((j.type === 'assistant' || msg.role === 'assistant') && typeof msg.model === 'string') {
143
- const t = tierOf(msg.model);
144
- if (t) out.tier = t; // keep the LAST seen — model can change mid-session
145
- }
146
- if (j.type === 'user' || msg.role === 'user') {
147
- const text = typeof msg.content === 'string'
148
- ? msg.content
149
- : Array.isArray(msg.content)
150
- ? msg.content.map(c => (c && typeof c.text === 'string') ? c.text : '').join('\n')
151
- : '';
152
- if (ESCAPE_TOKENS.some(tok => text.includes(tok))) out.escaped = true;
153
- }
154
- }
155
- return out;
156
- }
157
-
158
- // ------------------------------------------------------------ contract text
159
-
160
- export function contractHeader(originalType, tier) {
161
- return (
162
- `[COGNITION CONTRACT v1] You are a COGNITION-tier agent` +
163
- (originalType ? ` acting in the role of '${originalType}'` : '') +
164
- ` (tier: ${tier}). You have NO file/exec/retrieval tools. Do not attempt ` +
165
- `Read/Write/Edit/Bash/Glob/Grep — they are stripped. Produce your ` +
166
- `technical output as a WORK-ORDER v1 (unified diff + command list + ` +
167
- `verify steps; schema in docs/spikes/tiered-cognition/DESIGN.md) and ` +
168
- `dispatch a Haiku executor agent to apply it, or return the work-order ` +
169
- `to your caller. All facts you need must arrive via your prompt or via ` +
170
- `Haiku courier briefs you dispatch.\n\n`
171
- );
172
- }
173
-
174
- // -------------------------------------------------------------- core policy
175
-
176
- /**
177
- * Pure decision function (testable). payload = parsed hook stdin JSON.
178
- * opts = { agentsDir, stateRoot } (injectable for tests).
179
- * Returns { action, output } where output is the object to write to stdout
180
- * (or null for "no opinion").
181
- * action: 'pass' | 'strip' | 'escape' | 'deny' | 'allow'
182
- */
183
- export function decide(payload, opts = {}) {
184
- const pass = { action: 'pass', output: null };
185
- if (!payload || typeof payload !== 'object') return pass;
186
- const toolName = payload.tool_name;
187
-
188
- // ---- LAYER 1: dispatch guard --------------------------------------
189
- if (DISPATCH_TOOLS.has(toolName)) {
190
- const input = payload.tool_input;
191
- if (!input || typeof input !== 'object') return pass; // fail-open
192
- const prompt = String(input.prompt || '');
193
- const type = String(input.subagent_type || input.agentType || '').toLowerCase();
194
- if (type === 'fork') return pass; // forks inherit parent context/model
195
-
196
- const agentsDir = opts.agentsDir || path.join(os.homedir(), '.claude', 'agents');
197
- // Tier resolution order: explicit model > agent-def frontmatter > default
198
- // haiku (the model-policy hook lands every other dispatch on haiku).
199
- const tier = tierOf(input.model) || tierFromAgentDef(type, agentsDir) || 'haiku';
200
- if (tier === 'haiku') return pass;
201
-
202
- if (ESCAPE_TOKENS.some(tok => prompt.includes(tok))) {
203
- logEscapeUse(payload, opts);
204
- return {
205
- action: 'escape',
206
- output: {
207
- systemMessage: `⛓ tier policy BYPASSED via escape token — recorded in state/${ESCAPE_LOG_NAME}`,
208
- hookSpecificOutput: {
209
- hookEventName: 'PreToolUse',
210
- permissionDecision: 'allow',
211
- permissionDecisionReason:
212
- `Tier policy BYPASSED via escape token: this ${toolName} dispatch ` +
213
- `keeps its full toolset on model "${input.model || '(default)'}". ` +
214
- `Use recorded in state/${ESCAPE_LOG_NAME}.`,
215
- },
216
- },
217
- };
218
- }
219
-
220
- return {
221
- action: 'strip',
222
- strippedTools: DENY_TOOLS,
223
- keptTools: KEEP_TOOLS,
224
- output: {
225
- systemMessage:
226
- `⛓ cognition-tier dispatch (${tier}) → tools stripped via '${SHIM_AGENT}' shim ` +
227
- `(kept: ${KEEP_TOOLS.join(', ')})`,
228
- suppressOutput: true,
229
- hookSpecificOutput: {
230
- hookEventName: 'PreToolUse',
231
- permissionDecision: 'allow',
232
- permissionDecisionReason:
233
- 'Tier policy: cognition tiers (sonnet/opus/fable) are message-only; ' +
234
- 'Haiku executors perform all I/O ([[ALLOW-TIER-TOOLS]] to override).',
235
- updatedInput: {
236
- ...input,
237
- subagent_type: SHIM_AGENT,
238
- prompt: contractHeader(type, tier) + prompt,
239
- },
240
- },
241
- },
242
- };
243
- }
244
-
245
- // ---- LAYER 2: call-time backstop ----------------------------------
246
- if (DENY_SET.has(toolName)) {
247
- const t = payload.transcript_path;
248
- if (!t || typeof t !== 'string') return pass; // fail-open
249
- const { tier, escaped } = sniffTranscript(t);
250
- if (tier === null) return pass; // unreadable/unknown transcript: fail-open, no opinion
251
- if (tier === 'haiku' || escaped) {
252
- return { action: 'allow', output: null }; // haiku substrate / escaped session
253
- }
254
- return {
255
- action: 'deny',
256
- output: {
257
- hookSpecificOutput: {
258
- hookEventName: 'PreToolUse',
259
- permissionDecision: 'deny',
260
- permissionDecisionReason:
261
- `Tier policy: this session runs on a cognition-tier model, which never ` +
262
- `touches files/exec directly. Do NOT retry ${toolName}. Instead emit a ` +
263
- `WORK-ORDER v1 (unified diff / command list / verify steps — schema in ` +
264
- `docs/spikes/tiered-cognition/DESIGN.md) and dispatch a Haiku executor ` +
265
- `agent to apply it and report back an EXEC-BRIEF.`,
266
- },
267
- },
268
- };
269
- }
270
-
271
- return pass; // tool not governed
272
- }
273
-
274
- // ----------------------------------------------------------------- plumbing
275
-
276
- function readStdin(timeoutMs = STDIN_TIMEOUT_MS) {
277
- return new Promise((resolve) => {
278
- let settled = false;
279
- const finish = (v) => { if (!settled) { settled = true; clearTimeout(timer); resolve(v); } };
280
- const timer = setTimeout(() => finish(''), timeoutMs);
281
- let data = '';
282
- process.stdin.setEncoding('utf8');
283
- process.stdin.on('data', (c) => { data += c; });
284
- process.stdin.on('end', () => finish(data));
285
- process.stdin.on('error', () => finish(''));
286
- });
287
- }
288
-
289
- async function runAsHook() {
290
- const raw = await readStdin();
291
- let payload = null;
292
- try { payload = JSON.parse(raw); } catch { /* fail-open */ }
293
- const { output } = decide(payload);
294
- if (output) process.stdout.write(JSON.stringify(output) + '\n');
295
- process.exit(0);
296
- }
297
-
298
- // ----------------------------------------------------------------- selftest
299
-
300
- function selfTest() {
301
- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'tiercog-'));
302
- const agentsDir = path.join(tmp, 'agents');
303
- fs.mkdirSync(agentsDir);
304
- fs.writeFileSync(path.join(agentsDir, 'typescript-pro.md'),
305
- '---\nname: typescript-pro\ndescription: test fixture\nmodel: claude-sonnet-5\n---\nbody\n');
306
- fs.writeFileSync(path.join(agentsDir, 'hooktest-haiku.md'),
307
- '---\nname: hooktest-haiku\ndescription: test fixture\nmodel: haiku\n---\nbody\n');
308
-
309
- const mkTranscript = (name, lines) => {
310
- const p = path.join(tmp, name);
311
- fs.writeFileSync(p, lines.map(l => JSON.stringify(l)).join('\n') + '\n');
312
- return p;
313
- };
314
- const sonnetTx = mkTranscript('sonnet.jsonl', [
315
- { type: 'user', message: { role: 'user', content: 'implement the parser' } },
316
- { type: 'assistant', message: { role: 'assistant', model: 'claude-sonnet-5', content: [] } },
317
- ]);
318
- const haikuTx = mkTranscript('haiku.jsonl', [
319
- { type: 'user', message: { role: 'user', content: 'apply this work-order' } },
320
- { type: 'assistant', message: { role: 'assistant', model: 'claude-haiku-4-5', content: [] } },
321
- ]);
322
- const escapedTx = mkTranscript('escaped.jsonl', [
323
- { type: 'user', message: { role: 'user', content: [{ type: 'text', text: '[[ALLOW-NON-HAIKU]] mission spike, hands-on' }] } },
324
- { type: 'assistant', message: { role: 'assistant', model: 'claude-fable-5', content: [] } },
325
- ]);
326
-
327
- const opts = { agentsDir, stateRoot: tmp };
328
- const dispatch = (input) => ({ tool_name: 'Agent', tool_input: input, session_id: 's1', cwd: tmp });
329
- const call = (tool, tx) => ({ tool_name: tool, tool_input: { file_path: 'x' }, transcript_path: tx });
330
-
331
- let failures = 0;
332
- const check = (name, cond, detail) => {
333
- console.log(` ${cond ? 'PASS' : 'FAIL'} ${name}${cond ? '' : ' <-- ' + (detail || '')}`);
334
- if (!cond) failures++;
335
- };
336
-
337
- console.log('LAYER 1 — dispatch guard');
338
- // 1. sonnet dispatch -> stripped via shim
339
- let r = decide(dispatch({ model: 'sonnet', prompt: 'design the schema', description: 'd' }), opts);
340
- check('sonnet dispatch is stripped', r.action === 'strip', `action=${r.action}`);
341
- const ui = r.output && r.output.hookSpecificOutput.updatedInput;
342
- check(' rewritten to shim agent type', !!ui && ui.subagent_type === SHIM_AGENT, JSON.stringify(ui));
343
- check(' contract header prepended', !!ui && ui.prompt.startsWith('[COGNITION CONTRACT v1]'));
344
- check(' original prompt preserved', !!ui && ui.prompt.endsWith('design the schema'));
345
- check(' all I/O+exec tools in stripped set',
346
- ['Read', 'Write', 'Edit', 'Bash', 'PowerShell', 'Glob', 'Grep', 'NotebookEdit', 'WebFetch']
347
- .every(t => r.strippedTools.includes(t)));
348
- check(' messaging/dispatch tools kept',
349
- ['Agent', 'SendMessage'].every(t => r.keptTools.includes(t)) &&
350
- r.keptTools.every(t => !r.strippedTools.includes(t)));
351
-
352
- // 2. opus + fable dispatches -> stripped
353
- r = decide(dispatch({ model: 'opus', prompt: 'p' }), opts);
354
- check('opus dispatch is stripped', r.action === 'strip', `action=${r.action}`);
355
- r = decide(dispatch({ model: 'fable', prompt: 'p' }), opts);
356
- check('fable dispatch is stripped', r.action === 'strip', `action=${r.action}`);
357
-
358
- // 3. haiku dispatch -> untouched
359
- r = decide(dispatch({ model: 'haiku', prompt: 'apply the patch' }), opts);
360
- check('haiku dispatch passes untouched', r.action === 'pass' && r.output === null, `action=${r.action}`);
361
-
362
- // 4. blank model, generic type -> defaults haiku (model-policy hook lands it there) -> untouched
363
- r = decide(dispatch({ prompt: 'p', subagent_type: 'general-purpose' }), opts);
364
- check('blank-model generic dispatch passes (defaults haiku)', r.action === 'pass', `action=${r.action}`);
365
-
366
- // 5. blank model, specialist type with sonnet frontmatter -> stripped
367
- r = decide(dispatch({ prompt: 'p', subagent_type: 'typescript-pro' }), opts);
368
- check('blank-model sonnet specialist is stripped', r.action === 'strip', `action=${r.action}`);
369
-
370
- // 6. blank model, haiku-frontmatter agent -> untouched
371
- r = decide(dispatch({ prompt: 'p', subagent_type: 'hooktest-haiku' }), opts);
372
- check('haiku-frontmatter agent passes', r.action === 'pass', `action=${r.action}`);
373
-
374
- // 7. escape-token dispatch -> allowed + audited
375
- r = decide(dispatch({ model: 'sonnet', prompt: '[[ALLOW-TIER-TOOLS]] hands-on mission' }), opts);
376
- check('escape-token dispatch keeps tools', r.action === 'escape', `action=${r.action}`);
377
- check(' no updatedInput on escape', !r.output.hookSpecificOutput.updatedInput);
378
- const logPath = path.join(tmp, 'state', ESCAPE_LOG_NAME);
379
- let audited = false;
380
- try { audited = fs.readFileSync(logPath, 'utf8').includes('tier_policy_escape'); } catch { }
381
- check(' escape use audited to state log', audited, logPath);
382
-
383
- // 8. legacy [[ALLOW-NON-HAIKU]] also escapes (migration grandfathering)
384
- r = decide(dispatch({ model: 'opus', prompt: '[[ALLOW-NON-HAIKU]] mission' }), opts);
385
- check('legacy ALLOW-NON-HAIKU token also escapes', r.action === 'escape', `action=${r.action}`);
386
-
387
- // 9. fork passes untouched
388
- r = decide(dispatch({ model: 'sonnet', prompt: 'p', subagent_type: 'fork' }), opts);
389
- check('fork dispatch passes untouched', r.action === 'pass', `action=${r.action}`);
390
-
391
- // 10. malformed payloads fail open
392
- check('malformed payload fails open', decide(null, opts).action === 'pass');
393
- check('missing tool_input fails open', decide({ tool_name: 'Agent' }, opts).action === 'pass');
394
-
395
- console.log('LAYER 2 — call-time backstop');
396
- // 11. Write/Bash under a sonnet transcript -> denied with redirect reason
397
- r = decide(call('Write', sonnetTx), opts);
398
- check('Write under sonnet session is DENIED', r.action === 'deny', `action=${r.action}`);
399
- check(' deny reason redirects to WORK-ORDER + Haiku executor',
400
- r.action === 'deny' && /WORK-ORDER/.test(r.output.hookSpecificOutput.permissionDecisionReason) &&
401
- r.output.hookSpecificOutput.permissionDecision === 'deny');
402
- r = decide(call('Bash', sonnetTx), opts);
403
- check('Bash under sonnet session is DENIED', r.action === 'deny', `action=${r.action}`);
404
-
405
- // 12. same tools under a haiku transcript -> allowed
406
- r = decide(call('Write', haikuTx), opts);
407
- check('Write under haiku session is allowed', r.action === 'allow', `action=${r.action}`);
408
- r = decide(call('Bash', haikuTx), opts);
409
- check('Bash under haiku session is allowed', r.action === 'allow', `action=${r.action}`);
410
-
411
- // 13. escaped fable session (this spike!) keeps its tools
412
- r = decide(call('Edit', escapedTx), opts);
413
- check('escape-token session keeps I/O tools', r.action === 'allow', `action=${r.action}`);
414
-
415
- // 14. missing/unreadable transcript fails open
416
- r = decide(call('Write', path.join(tmp, 'nope.jsonl')), opts);
417
- check('missing transcript fails open (pass)', r.action === 'pass', `action=${r.action}`);
418
-
419
- // 15. ungoverned tool passes
420
- r = decide({ tool_name: 'SendMessage', tool_input: {}, transcript_path: sonnetTx }, opts);
421
- check('ungoverned tool (SendMessage) passes', r.action === 'pass', `action=${r.action}`);
422
-
423
- console.log(failures === 0 ? '\nSELF-TEST: ALL PASS' : `\nSELF-TEST: ${failures} FAILURE(S)`);
424
- try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { }
425
- process.exit(failures === 0 ? 0 : 1);
426
- }
427
-
428
- // ----------------------------------------------------------------- dispatch
429
-
430
- if (process.argv.includes('--self-test')) {
431
- selfTest();
432
- } else {
433
- runAsHook();
434
- }
package/hooks/CLAUDE.md DELETED
@@ -1,89 +0,0 @@
1
- # hooks/ — Installable org-policy git pre-push enforcement
2
-
3
- **Purpose**: Ship executable git hooks that gate pushes with organization security policies (branch protection, secret scanning).
4
-
5
- ## Hook: pre-push-policy.sh
6
-
7
- Runs on `git push` via `.git/hooks/pre-push` symlink or copy.
8
-
9
- **Checks & Exit Contract**:
10
- 1. `check_branch_policy()` — blocks direct pushes to main/master; exit 1 on violation
11
- 2. `check_secret_scan()` — runs `tools/secret_scan.py --staged`; exit 1 on failure
12
- 3. Both trigger `log_block()` to append audit record before exit
13
-
14
- **Audit-Ledger Contract**:
15
- - Path: `${AESOP_ROOT:-$HOME/aesop}/state/SECURITY-AUDIT.log` (append-only, git-ignored)
16
- - Format: JSON-lines (one record per line)
17
- - Schema: `{"ts":"2025-07-12T14:32:01Z","repo":"aesop","event":"push_blocked","reason":"secret_scan_failure","user":"alice"}`
18
- - All string values must be json_escaped (backslash → `\\`, quote → `\"`)
19
-
20
- **Self-Test Convention**:
21
- - `bash hooks/pre-push-policy.sh --test` runs the self-test suite, including:
22
- 1. Branch policy blocks main/master
23
- 2. Branch policy allows feature/* branches
24
- 3. Audit log JSON format is valid
25
- 4. JSON escaping handles special chars (quotes, backslashes)
26
- 5. stdin handling (git pre-push pipe) doesn't crash hook
27
- - Exit 0 = all pass; exit 1 = any fail
28
-
29
- **Installation**:
30
- - See `docs/HOOK-INSTALL.md` for symlink (Linux/macOS/Git Bash) and copy (Windows) methods
31
- - Test with `bash hooks/pre-push-policy.sh --test` before org distribution
32
-
33
- ## Hook: pre-commit-waveguard.sh
34
-
35
- Prevents accidental commits to the PRIMARY aesop tree during a wave cycle. Runs on `git commit` via `.git/hooks/pre-commit`.
36
-
37
- **Purpose**: During orchestrated waves, the orchestrator sets a marker file (`state/.wave-in-flight`) in the PRIMARY tree only. Sibling worktrees do not inherit this marker (separate working trees), so fleet agents commit freely in worktrees while stray commits to the primary tree are rejected.
38
-
39
- **Mechanism**:
40
- 1. **Marker Contract**: Orchestrator writes `state/.wave-in-flight` in the PRIMARY tree before dispatching wave work. The marker is git-ignored, so sibling worktrees checked out during the wave do NOT carry it.
41
- 2. **Pre-Commit Check**: Hook resolves the marker relative to the CURRENT working tree via `git rev-parse --show-toplevel` (NOT a hardcoded `$HOME/aesop` — that resolved to the primary tree from every worktree and blocked the whole fleet mid-wave, the wave-24 incident). Primary tree (has marker during a wave) → exit 1 (reject); sibling worktree (no marker) → exit 0 (allow).
42
- 3. **Override**: User or orchestrator may delete `state/.wave-in-flight` to manually allow commits to primary tree.
43
-
44
- **Exit Contract**:
45
- - Exit 0: Marker absent, commit allowed (normal operation)
46
- - Exit 1: Marker present, commit blocked with clear error message
47
-
48
- **Error Message**:
49
- ```
50
- Error: Wave in flight. Commit from a sibling worktree, or clear <marker_path> to override.
51
- ```
52
-
53
- **Installation**:
54
- - Run `bash hooks/install-waveguard.sh` to idempotently install into `.git/hooks/pre-commit`.
55
- - If a pre-commit hook already exists, installer backs it up (`.git/hooks/pre-commit.waveguard-backup`) and wraps both (waveguard first, then existing hook if present).
56
-
57
- **Idempotency**:
58
- - Installer checks if hook already calls waveguard; skips if already installed.
59
- - Safe to re-run multiple times.
60
-
61
- ## Hook: hooks/claude/force-model-policy.mjs
62
-
63
- Claude Code hook enforcing subagent Haiku dispatch (cost optimization).
64
-
65
- **Trigger**: On skill invocation or task delegation from main orchestrator thread. Examines Claude API request and enforces model constraint.
66
-
67
- **Policy**:
68
- - **Main orchestrator** (Fable/Opus on primary): no override (uses native model)
69
- - **Subagent dispatch** (fleet workers): **enforce Haiku** (claude-haiku-4-5-*). Exit 1 on non-Haiku model request.
70
- - **Specialists** (typed dispatches): Pin model to Haiku in the dispatch call; hook validates + blocks if violated.
71
-
72
- **Logging**:
73
- - On policy violation: log to `state/SECURITY-ALERTS.log` with timestamp, model-name, worker-id, and reason.
74
- - No alerts on compliant requests.
75
-
76
- **Self-Test**:
77
- - `node hooks/claude/force-model-policy.mjs --test` validates:
78
- 1. Haiku model allowed on subagents
79
- 2. Non-Haiku (e.g., Opus) blocked on subagents
80
- 3. Orchestrator not subject to policy
81
- 4. JSON logging format is valid
82
- - Exit 0 = all pass; exit 1 = any fail
83
-
84
- ## Invariants
85
-
86
- - POSIX sh compatible, CRLF-safe (no line continuations)
87
- - Tolerate git pre-push stdin (ref list) + optional args without choking
88
- - Fail-open only for missing optional tooling (secret_scan.py absent → allow); fail-closed for policy checks
89
- - Use `AESOP_ROOT` env var or `$HOME/aesop` fallback; no hardcoded machine paths/usernames