@maestrofrontier/frontier 1.4.5 → 1.5.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 (50) hide show
  1. package/.agents/plugins/marketplace.json +21 -21
  2. package/.codex-plugin/plugin.json +29 -29
  3. package/.cursorrules +197 -194
  4. package/AGENTS.md +3 -3
  5. package/README.md +368 -368
  6. package/bin/maestro.cjs +75 -75
  7. package/commands/compress.md +36 -36
  8. package/commands/frontier.md +124 -124
  9. package/commands/terse.md +23 -23
  10. package/docs/codex.md +167 -167
  11. package/docs/orchestration.md +168 -168
  12. package/frontier/cli.cjs +279 -252
  13. package/frontier/config.cjs +468 -468
  14. package/frontier/dispatch.cjs +267 -255
  15. package/frontier/judge.cjs +92 -92
  16. package/frontier/run.cjs +201 -180
  17. package/frontier/schema.cjs +112 -112
  18. package/frontier/semaphore.cjs +49 -49
  19. package/frontier/synthesize.cjs +79 -79
  20. package/hooks/frontier-autorun.cjs +127 -120
  21. package/hooks/hooks.json +103 -103
  22. package/hooks/maestro-doctrine-guard.cjs +81 -81
  23. package/hooks/maestro-gate-reminder.cjs +22 -7
  24. package/hooks/maestro-gate-telemetry.cjs +79 -77
  25. package/hooks/maestro-phase-scope.cjs +118 -118
  26. package/hooks/maestro-statusline-sync.cjs +152 -152
  27. package/hooks/maestro-subagent-guard.cjs +148 -148
  28. package/hooks/maestro-terse-mode.cjs +189 -189
  29. package/hooks/maestro-toolbudget-advisory.cjs +127 -127
  30. package/integrations/README.md +111 -111
  31. package/integrations/cline/skills/frontier/SKILL.md +75 -75
  32. package/integrations/codex/prompts/frontier.md +70 -70
  33. package/integrations/codex/prompts/update.md +39 -39
  34. package/integrations/codex/skills/maestro-frontier/SKILL.md +122 -122
  35. package/integrations/codex/skills/maestro-settings/SKILL.md +55 -55
  36. package/integrations/codex/skills/maestro-terse/SKILL.md +58 -58
  37. package/integrations/codex/skills/maestro-update/SKILL.md +31 -31
  38. package/integrations/cursor/commands/frontier.md +63 -63
  39. package/integrations/cursor/commands/update.md +34 -34
  40. package/integrations/gemini/commands/frontier.toml +76 -76
  41. package/integrations/windsurf/workflows/frontier.md +70 -70
  42. package/package.json +58 -58
  43. package/scripts/install.cjs +1014 -1014
  44. package/settings/cli.cjs +140 -140
  45. package/settings/config.cjs +309 -309
  46. package/skills/maestro-frontier/SKILL.md +122 -122
  47. package/skills/maestro-settings/SKILL.md +55 -55
  48. package/skills/maestro-terse/SKILL.md +58 -58
  49. package/skills/maestro-update/SKILL.md +31 -31
  50. package/skills/terse/SKILL.md +74 -74
@@ -1,189 +1,189 @@
1
- #!/usr/bin/env node
2
- // Maestro terse-mode hook. One file, two events (dispatch on
3
- // hook_event_name):
4
- // - SessionStart: resolve the level (env > config > off), write the
5
- // flag file, inject the level-filtered ruleset from
6
- // skills/terse/SKILL.md (single source of truth) as
7
- // additionalContext.
8
- // - UserPromptSubmit: track level switches (/maestro:terse, /terse,
9
- // natural-language deactivation) in the flag file and emit a
10
- // one-line reminder every turn while active -- per-turn
11
- // reinforcement defeats style drift after context compression
12
- // (same pattern as maestro-gate-reminder).
13
- //
14
- // Level resolution: MAESTRO_TERSE_LEVEL env var, then terseLevel in
15
- // $XDG_CONFIG_HOME/maestro/config.json (~/.config/maestro fallback,
16
- // %APPDATA%\maestro on Windows), then 'off'. Off by default:
17
- // installing the plugin must not change anyone's output style.
18
- //
19
- // Flag I/O ported from Caveman (MIT): symlink-refusing, O_NOFOLLOW,
20
- // atomic temp+rename, 0600, 64-byte read cap, level whitelist. A
21
- // predictable flag path under ~/.claude is a symlink-attack target;
22
- // never write through one, never inject unvalidated bytes into model
23
- // context.
24
- //
25
- // .cjs so Node treats it as CommonJS regardless of any "type": "module"
26
- // package.json in a parent directory of the install location.
27
-
28
- const fs = require('fs');
29
- const os = require('os');
30
- const path = require('path');
31
-
32
- const LEVELS = ['lite', 'full', 'ultra'];
33
- const MAX_FLAG_BYTES = 64;
34
-
35
- const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
36
- const flagPath = path.join(claudeDir, '.maestro-terse');
37
-
38
- function configDir() {
39
- if (process.env.XDG_CONFIG_HOME) return path.join(process.env.XDG_CONFIG_HOME, 'maestro');
40
- if (process.platform === 'win32') {
41
- return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'maestro');
42
- }
43
- return path.join(os.homedir(), '.config', 'maestro');
44
- }
45
-
46
- function defaultLevel() {
47
- const env = String(process.env.MAESTRO_TERSE_LEVEL || '').toLowerCase();
48
- if (env === 'off' || LEVELS.includes(env)) return env;
49
- try {
50
- const cfg = JSON.parse(fs.readFileSync(path.join(configDir(), 'config.json'), 'utf8'));
51
- const v = String(cfg.terseLevel || '').toLowerCase();
52
- if (v === 'off' || LEVELS.includes(v)) return v;
53
- } catch {}
54
- return 'off';
55
- }
56
-
57
- function safeWriteFlag(level) {
58
- try {
59
- const dir = path.dirname(flagPath);
60
- fs.mkdirSync(dir, { recursive: true });
61
- try { if (fs.lstatSync(dir).isSymbolicLink()) return; } catch { return; }
62
- try {
63
- if (fs.lstatSync(flagPath).isSymbolicLink()) return;
64
- } catch (e) {
65
- if (e.code !== 'ENOENT') return;
66
- }
67
- const tempPath = path.join(dir, `.maestro-terse.${process.pid}.${Date.now()}`);
68
- const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
69
- const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW;
70
- let fd;
71
- try {
72
- if (O_NOFOLLOW === 0) { try { if (fs.lstatSync(tempPath).isSymbolicLink()) return; } catch {} }
73
- fd = fs.openSync(tempPath, flags, 0o600);
74
- fs.writeSync(fd, String(level));
75
- try { fs.fchmodSync(fd, 0o600); } catch {}
76
- } finally {
77
- if (fd !== undefined) fs.closeSync(fd);
78
- }
79
- fs.renameSync(tempPath, flagPath);
80
- } catch {}
81
- }
82
-
83
- function readFlag() {
84
- try {
85
- let st;
86
- try { st = fs.lstatSync(flagPath); } catch { return null; }
87
- if (st.isSymbolicLink() || !st.isFile()) return null;
88
- if (st.size > MAX_FLAG_BYTES) return null;
89
- const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
90
- let fd, out;
91
- try {
92
- if (O_NOFOLLOW === 0) { try { if (fs.lstatSync(flagPath).isSymbolicLink()) return null; } catch {} }
93
- fd = fs.openSync(flagPath, fs.constants.O_RDONLY | O_NOFOLLOW);
94
- const buf = Buffer.alloc(MAX_FLAG_BYTES);
95
- const n = fs.readSync(fd, buf, 0, MAX_FLAG_BYTES, 0);
96
- out = buf.slice(0, n).toString('utf8');
97
- } finally {
98
- if (fd !== undefined) fs.closeSync(fd);
99
- }
100
- const raw = out.trim().toLowerCase();
101
- return LEVELS.includes(raw) ? raw : null;
102
- } catch {
103
- return null;
104
- }
105
- }
106
-
107
- function removeFlag() {
108
- try { fs.unlinkSync(flagPath); } catch {}
109
- }
110
-
111
- function sessionStart() {
112
- const level = defaultLevel();
113
- if (level === 'off' || !LEVELS.includes(level)) { removeFlag(); return; }
114
- safeWriteFlag(level);
115
-
116
- let body = '';
117
- try {
118
- const skill = fs.readFileSync(path.join(__dirname, '..', 'skills', 'terse', 'SKILL.md'), 'utf8');
119
- // Strip frontmatter and maintainer HTML comments, then keep only
120
- // the active level's intensity row and example lines.
121
- body = skill
122
- .replace(/^---[\s\S]*?---\s*/, '')
123
- .replace(/<!--[\s\S]*?-->\s*/g, '')
124
- .split('\n')
125
- .filter(line => {
126
- const row = line.match(/^\|\s*\*\*(\S+?)\*\*\s*\|/);
127
- if (row) return row[1] === level;
128
- const ex = line.match(/^- (\S+?):\s/);
129
- if (ex) return ex[1] === level;
130
- return true;
131
- })
132
- .join('\n');
133
- } catch {
134
- body = 'Respond terse. All technical substance stay. Only fluff die.\n' +
135
- 'Drop articles/filler/pleasantries/hedging. Fragments OK. ' +
136
- 'Code/commits/PRs: write normal. Off: "stop terse" / "normal mode".';
137
- }
138
-
139
- process.stdout.write(JSON.stringify({
140
- hookSpecificOutput: {
141
- hookEventName: 'SessionStart',
142
- additionalContext: 'MAESTRO TERSE ACTIVE — level: ' + level + '\n\n' + body
143
- }
144
- }));
145
- }
146
-
147
- function promptSubmit(data) {
148
- const prompt = String(data.prompt || '').trim().toLowerCase();
149
-
150
- if (prompt.startsWith('/maestro:terse') || prompt.startsWith('/terse')) {
151
- const arg = (prompt.split(/\s+/)[1] || '').toLowerCase();
152
- if (arg === 'off') {
153
- removeFlag();
154
- } else if (LEVELS.includes(arg)) {
155
- safeWriteFlag(arg);
156
- } else {
157
- // Bare invocation: explicit opt-in. Use the configured default,
158
- // or 'full' when the default is off.
159
- const d = defaultLevel();
160
- safeWriteFlag(LEVELS.includes(d) ? d : 'full');
161
- }
162
- }
163
-
164
- if (/\b(stop|disable|deactivate|turn off)\b.*\bterse\b/.test(prompt) ||
165
- /\bterse\b.*\b(stop|disable|off)\b/.test(prompt) ||
166
- /\bnormal mode\b/.test(prompt)) {
167
- removeFlag();
168
- }
169
-
170
- const active = readFlag();
171
- if (active) {
172
- process.stdout.write(JSON.stringify({
173
- hookSpecificOutput: {
174
- hookEventName: 'UserPromptSubmit',
175
- additionalContext: 'MAESTRO TERSE ACTIVE (' + active + '). ' +
176
- 'Drop articles/filler/pleasantries/hedging. Fragments OK. ' +
177
- 'Code/commits/security: write normal.'
178
- }
179
- }));
180
- }
181
- }
182
-
183
- let data = {};
184
- try { data = JSON.parse(fs.readFileSync(0, 'utf8')); } catch { process.exit(0); }
185
-
186
- if (data.hook_event_name === 'SessionStart') sessionStart();
187
- else if (data.hook_event_name === 'UserPromptSubmit') promptSubmit(data);
188
-
189
- process.exit(0);
1
+ #!/usr/bin/env node
2
+ // Maestro terse-mode hook. One file, two events (dispatch on
3
+ // hook_event_name):
4
+ // - SessionStart: resolve the level (env > config > off), write the
5
+ // flag file, inject the level-filtered ruleset from
6
+ // skills/terse/SKILL.md (single source of truth) as
7
+ // additionalContext.
8
+ // - UserPromptSubmit: track level switches (/maestro:terse, /terse,
9
+ // natural-language deactivation) in the flag file and emit a
10
+ // one-line reminder every turn while active -- per-turn
11
+ // reinforcement defeats style drift after context compression
12
+ // (same pattern as maestro-gate-reminder).
13
+ //
14
+ // Level resolution: MAESTRO_TERSE_LEVEL env var, then terseLevel in
15
+ // $XDG_CONFIG_HOME/maestro/config.json (~/.config/maestro fallback,
16
+ // %APPDATA%\maestro on Windows), then 'off'. Off by default:
17
+ // installing the plugin must not change anyone's output style.
18
+ //
19
+ // Flag I/O ported from Caveman (MIT): symlink-refusing, O_NOFOLLOW,
20
+ // atomic temp+rename, 0600, 64-byte read cap, level whitelist. A
21
+ // predictable flag path under ~/.claude is a symlink-attack target;
22
+ // never write through one, never inject unvalidated bytes into model
23
+ // context.
24
+ //
25
+ // .cjs so Node treats it as CommonJS regardless of any "type": "module"
26
+ // package.json in a parent directory of the install location.
27
+
28
+ const fs = require('fs');
29
+ const os = require('os');
30
+ const path = require('path');
31
+
32
+ const LEVELS = ['lite', 'full', 'ultra'];
33
+ const MAX_FLAG_BYTES = 64;
34
+
35
+ const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
36
+ const flagPath = path.join(claudeDir, '.maestro-terse');
37
+
38
+ function configDir() {
39
+ if (process.env.XDG_CONFIG_HOME) return path.join(process.env.XDG_CONFIG_HOME, 'maestro');
40
+ if (process.platform === 'win32') {
41
+ return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'maestro');
42
+ }
43
+ return path.join(os.homedir(), '.config', 'maestro');
44
+ }
45
+
46
+ function defaultLevel() {
47
+ const env = String(process.env.MAESTRO_TERSE_LEVEL || '').toLowerCase();
48
+ if (env === 'off' || LEVELS.includes(env)) return env;
49
+ try {
50
+ const cfg = JSON.parse(fs.readFileSync(path.join(configDir(), 'config.json'), 'utf8'));
51
+ const v = String(cfg.terseLevel || '').toLowerCase();
52
+ if (v === 'off' || LEVELS.includes(v)) return v;
53
+ } catch {}
54
+ return 'off';
55
+ }
56
+
57
+ function safeWriteFlag(level) {
58
+ try {
59
+ const dir = path.dirname(flagPath);
60
+ fs.mkdirSync(dir, { recursive: true });
61
+ try { if (fs.lstatSync(dir).isSymbolicLink()) return; } catch { return; }
62
+ try {
63
+ if (fs.lstatSync(flagPath).isSymbolicLink()) return;
64
+ } catch (e) {
65
+ if (e.code !== 'ENOENT') return;
66
+ }
67
+ const tempPath = path.join(dir, `.maestro-terse.${process.pid}.${Date.now()}`);
68
+ const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
69
+ const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW;
70
+ let fd;
71
+ try {
72
+ if (O_NOFOLLOW === 0) { try { if (fs.lstatSync(tempPath).isSymbolicLink()) return; } catch {} }
73
+ fd = fs.openSync(tempPath, flags, 0o600);
74
+ fs.writeSync(fd, String(level));
75
+ try { fs.fchmodSync(fd, 0o600); } catch {}
76
+ } finally {
77
+ if (fd !== undefined) fs.closeSync(fd);
78
+ }
79
+ fs.renameSync(tempPath, flagPath);
80
+ } catch {}
81
+ }
82
+
83
+ function readFlag() {
84
+ try {
85
+ let st;
86
+ try { st = fs.lstatSync(flagPath); } catch { return null; }
87
+ if (st.isSymbolicLink() || !st.isFile()) return null;
88
+ if (st.size > MAX_FLAG_BYTES) return null;
89
+ const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
90
+ let fd, out;
91
+ try {
92
+ if (O_NOFOLLOW === 0) { try { if (fs.lstatSync(flagPath).isSymbolicLink()) return null; } catch {} }
93
+ fd = fs.openSync(flagPath, fs.constants.O_RDONLY | O_NOFOLLOW);
94
+ const buf = Buffer.alloc(MAX_FLAG_BYTES);
95
+ const n = fs.readSync(fd, buf, 0, MAX_FLAG_BYTES, 0);
96
+ out = buf.slice(0, n).toString('utf8');
97
+ } finally {
98
+ if (fd !== undefined) fs.closeSync(fd);
99
+ }
100
+ const raw = out.trim().toLowerCase();
101
+ return LEVELS.includes(raw) ? raw : null;
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ function removeFlag() {
108
+ try { fs.unlinkSync(flagPath); } catch {}
109
+ }
110
+
111
+ function sessionStart() {
112
+ const level = defaultLevel();
113
+ if (level === 'off' || !LEVELS.includes(level)) { removeFlag(); return; }
114
+ safeWriteFlag(level);
115
+
116
+ let body = '';
117
+ try {
118
+ const skill = fs.readFileSync(path.join(__dirname, '..', 'skills', 'terse', 'SKILL.md'), 'utf8');
119
+ // Strip frontmatter and maintainer HTML comments, then keep only
120
+ // the active level's intensity row and example lines.
121
+ body = skill
122
+ .replace(/^---[\s\S]*?---\s*/, '')
123
+ .replace(/<!--[\s\S]*?-->\s*/g, '')
124
+ .split('\n')
125
+ .filter(line => {
126
+ const row = line.match(/^\|\s*\*\*(\S+?)\*\*\s*\|/);
127
+ if (row) return row[1] === level;
128
+ const ex = line.match(/^- (\S+?):\s/);
129
+ if (ex) return ex[1] === level;
130
+ return true;
131
+ })
132
+ .join('\n');
133
+ } catch {
134
+ body = 'Respond terse. All technical substance stay. Only fluff die.\n' +
135
+ 'Drop articles/filler/pleasantries/hedging. Fragments OK. ' +
136
+ 'Code/commits/PRs: write normal. Off: "stop terse" / "normal mode".';
137
+ }
138
+
139
+ process.stdout.write(JSON.stringify({
140
+ hookSpecificOutput: {
141
+ hookEventName: 'SessionStart',
142
+ additionalContext: 'MAESTRO TERSE ACTIVE — level: ' + level + '\n\n' + body
143
+ }
144
+ }));
145
+ }
146
+
147
+ function promptSubmit(data) {
148
+ const prompt = String(data.prompt || '').trim().toLowerCase();
149
+
150
+ if (prompt.startsWith('/maestro:terse') || prompt.startsWith('/terse')) {
151
+ const arg = (prompt.split(/\s+/)[1] || '').toLowerCase();
152
+ if (arg === 'off') {
153
+ removeFlag();
154
+ } else if (LEVELS.includes(arg)) {
155
+ safeWriteFlag(arg);
156
+ } else {
157
+ // Bare invocation: explicit opt-in. Use the configured default,
158
+ // or 'full' when the default is off.
159
+ const d = defaultLevel();
160
+ safeWriteFlag(LEVELS.includes(d) ? d : 'full');
161
+ }
162
+ }
163
+
164
+ if (/\b(stop|disable|deactivate|turn off)\b.*\bterse\b/.test(prompt) ||
165
+ /\bterse\b.*\b(stop|disable|off)\b/.test(prompt) ||
166
+ /\bnormal mode\b/.test(prompt)) {
167
+ removeFlag();
168
+ }
169
+
170
+ const active = readFlag();
171
+ if (active) {
172
+ process.stdout.write(JSON.stringify({
173
+ hookSpecificOutput: {
174
+ hookEventName: 'UserPromptSubmit',
175
+ additionalContext: 'MAESTRO TERSE ACTIVE (' + active + '). ' +
176
+ 'Drop articles/filler/pleasantries/hedging. Fragments OK. ' +
177
+ 'Code/commits/security: write normal.'
178
+ }
179
+ }));
180
+ }
181
+ }
182
+
183
+ let data = {};
184
+ try { data = JSON.parse(fs.readFileSync(0, 'utf8')); } catch { process.exit(0); }
185
+
186
+ if (data.hook_event_name === 'SessionStart') sessionStart();
187
+ else if (data.hook_event_name === 'UserPromptSubmit') promptSubmit(data);
188
+
189
+ process.exit(0);
@@ -1,127 +1,127 @@
1
- #!/usr/bin/env node
2
- // Maestro PostToolUse tool-call budget advisory (Fable T1). Log-only,
3
- // zero prompt tokens, never blocks.
4
- //
5
- // Fable scales tool calls to task complexity (1 for single facts; 3-5
6
- // medium; 5-10 deep research). Maestro caps subagent tool budgets (S9)
7
- // but nothing watches the ORCHESTRATOR's own pre-edit exploration. This
8
- // hook measures how many exploration (non-edit) tool calls happened this
9
- // turn before the FIRST file edit; if that exceeds a budget it appends
10
- // one advisory row to a local log. It is a behavioural lever, evidence-
11
- // gated: log-only first, so a preregistered OFF/ON fixture can show the
12
- // signal separates before anyone promotes it to an enforcing warning.
13
- //
14
- // Design choices:
15
- // - Fires on PostToolUse for edit tools; evaluates once per turn at the
16
- // first-edit boundary (a per-turn marker file makes it idempotent).
17
- // - "Turn" = everything since the last genuine user prompt, located from
18
- // the transcript with the same heuristic as maestro-phase-scope.
19
- // - ZERO prompt tokens: never writes stdout / additionalContext, so it
20
- // adds nothing to context. It only appends a counts-only JSON row.
21
- // - NEVER blocks: PostToolUse cannot block and this emits no decision.
22
- // - Privacy: records counts + project folder basename only -- no
23
- // prompts, no file contents, no paths. No network, ever.
24
- //
25
- // Env:
26
- // - MAESTRO_TOOLBUDGET=0 disable entirely (default: active, log-only)
27
- // - MAESTRO_TOOLBUDGET_THRESHOLD exploration-call budget (default 20)
28
- // - MAESTRO_TOOLBUDGET_LOG override log path (default ~/.claude/maestro-toolbudget.jsonl)
29
- // - MAESTRO_TOOLBUDGET_MARKERDIR override per-turn marker dir (default OS tmp)
30
- //
31
- // Promotion path (NOT shipped, pending fixture evidence): a `warn` mode
32
- // that emits additionalContext at the first edit. Kept out until the log
33
- // shows the budget separates real over-exploration from normal work.
34
- //
35
- // Payload fields verified against code.claude.com/docs/en/hooks
36
- // (PostToolUse input: session_id, transcript_path, cwd, tool_name,
37
- // tool_input; PostToolUse output cannot block), 2026-06-16.
38
- //
39
- // .cjs so Node treats it as CommonJS regardless of any "type": "module"
40
- // package.json in a parent directory of the install location.
41
-
42
- 'use strict';
43
-
44
- const fs = require('fs');
45
- const os = require('os');
46
- const path = require('path');
47
- const crypto = require('crypto');
48
-
49
- if (process.env.MAESTRO_TOOLBUDGET === '0') process.exit(0);
50
-
51
- const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit']);
52
-
53
- let data = {};
54
- try { data = JSON.parse(fs.readFileSync(0, 'utf8')); } catch { process.exit(0); }
55
-
56
- // Only the first edit of a turn matters; ignore every non-edit tool.
57
- if (!EDIT_TOOLS.has(data.tool_name)) process.exit(0);
58
-
59
- // Counting exploration before the first edit needs the transcript.
60
- if (!data.transcript_path || !fs.existsSync(data.transcript_path)) process.exit(0);
61
-
62
- let lines = [];
63
- try {
64
- const buf = fs.readFileSync(data.transcript_path, 'utf8');
65
- lines = (buf.length > 4000000 ? buf.slice(-4000000) : buf).split(/\r?\n/);
66
- } catch { process.exit(0); }
67
-
68
- const parsed = lines.map(l => { try { return JSON.parse(l); } catch { return null; } });
69
-
70
- // Locate the last genuine user prompt (typed text, not a tool_result
71
- // carrier); everything after it is the current turn.
72
- let turnStart = 0;
73
- for (let i = 0; i < parsed.length; i++) {
74
- const e = parsed[i];
75
- if (!e || e.type !== 'user' || e.isMeta || !e.message) continue;
76
- const c = e.message.content;
77
- const genuine = typeof c === 'string'
78
- ? true
79
- : Array.isArray(c) && c.some(x => x && x.type === 'text') && !c.some(x => x && x.type === 'tool_result');
80
- if (genuine) turnStart = i;
81
- }
82
-
83
- // Evaluate at most once per turn, at the first-edit boundary.
84
- const markerDir = process.env.MAESTRO_TOOLBUDGET_MARKERDIR || os.tmpdir();
85
- const turnKey = crypto.createHash('sha1')
86
- .update(String(data.session_id || '') + ':' + turnStart + ':' + (lines[turnStart] || ''))
87
- .digest('hex').slice(0, 16);
88
- const marker = path.join(markerDir, 'maestro-toolbudget-' + turnKey);
89
- if (fs.existsSync(marker)) process.exit(0);
90
- try { fs.mkdirSync(markerDir, { recursive: true }); fs.writeFileSync(marker, '1'); } catch {}
91
-
92
- // Count exploration (non-edit) tool calls that ran before the first edit
93
- // this turn. priorEdit stops the count at the first edit, whether or not
94
- // the triggering edit is already in the transcript.
95
- let explore = 0;
96
- let priorEdit = false;
97
- for (let i = turnStart; i < parsed.length; i++) {
98
- const e = parsed[i];
99
- if (!e || e.type !== 'assistant' || !e.message || !Array.isArray(e.message.content)) continue;
100
- for (const item of e.message.content) {
101
- if (!item || item.type !== 'tool_use') continue;
102
- if (EDIT_TOOLS.has(item.name)) { priorEdit = true; break; }
103
- explore++;
104
- }
105
- if (priorEdit) break;
106
- }
107
-
108
- const threshold = parseInt(process.env.MAESTRO_TOOLBUDGET_THRESHOLD, 10) || 20;
109
- if (explore > threshold) {
110
- const row = {
111
- ts: new Date().toISOString(),
112
- session_id: data.session_id || null,
113
- kind: 'toolbudget-advisory',
114
- explore_calls: explore,
115
- threshold,
116
- first_edit_tool: data.tool_name,
117
- project: data.cwd ? path.basename(data.cwd) : null
118
- };
119
- const logPath = process.env.MAESTRO_TOOLBUDGET_LOG
120
- || path.join(os.homedir(), '.claude', 'maestro-toolbudget.jsonl');
121
- try {
122
- fs.mkdirSync(path.dirname(logPath), { recursive: true });
123
- fs.appendFileSync(logPath, JSON.stringify(row) + '\n');
124
- } catch { /* advisory is best-effort; never disrupt the session */ }
125
- }
126
-
127
- process.exit(0);
1
+ #!/usr/bin/env node
2
+ // Maestro PostToolUse tool-call budget advisory (Fable T1). Log-only,
3
+ // zero prompt tokens, never blocks.
4
+ //
5
+ // Fable scales tool calls to task complexity (1 for single facts; 3-5
6
+ // medium; 5-10 deep research). Maestro caps subagent tool budgets (S9)
7
+ // but nothing watches the ORCHESTRATOR's own pre-edit exploration. This
8
+ // hook measures how many exploration (non-edit) tool calls happened this
9
+ // turn before the FIRST file edit; if that exceeds a budget it appends
10
+ // one advisory row to a local log. It is a behavioural lever, evidence-
11
+ // gated: log-only first, so a preregistered OFF/ON fixture can show the
12
+ // signal separates before anyone promotes it to an enforcing warning.
13
+ //
14
+ // Design choices:
15
+ // - Fires on PostToolUse for edit tools; evaluates once per turn at the
16
+ // first-edit boundary (a per-turn marker file makes it idempotent).
17
+ // - "Turn" = everything since the last genuine user prompt, located from
18
+ // the transcript with the same heuristic as maestro-phase-scope.
19
+ // - ZERO prompt tokens: never writes stdout / additionalContext, so it
20
+ // adds nothing to context. It only appends a counts-only JSON row.
21
+ // - NEVER blocks: PostToolUse cannot block and this emits no decision.
22
+ // - Privacy: records counts + project folder basename only -- no
23
+ // prompts, no file contents, no paths. No network, ever.
24
+ //
25
+ // Env:
26
+ // - MAESTRO_TOOLBUDGET=0 disable entirely (default: active, log-only)
27
+ // - MAESTRO_TOOLBUDGET_THRESHOLD exploration-call budget (default 20)
28
+ // - MAESTRO_TOOLBUDGET_LOG override log path (default ~/.claude/maestro-toolbudget.jsonl)
29
+ // - MAESTRO_TOOLBUDGET_MARKERDIR override per-turn marker dir (default OS tmp)
30
+ //
31
+ // Promotion path (NOT shipped, pending fixture evidence): a `warn` mode
32
+ // that emits additionalContext at the first edit. Kept out until the log
33
+ // shows the budget separates real over-exploration from normal work.
34
+ //
35
+ // Payload fields verified against code.claude.com/docs/en/hooks
36
+ // (PostToolUse input: session_id, transcript_path, cwd, tool_name,
37
+ // tool_input; PostToolUse output cannot block), 2026-06-16.
38
+ //
39
+ // .cjs so Node treats it as CommonJS regardless of any "type": "module"
40
+ // package.json in a parent directory of the install location.
41
+
42
+ 'use strict';
43
+
44
+ const fs = require('fs');
45
+ const os = require('os');
46
+ const path = require('path');
47
+ const crypto = require('crypto');
48
+
49
+ if (process.env.MAESTRO_TOOLBUDGET === '0') process.exit(0);
50
+
51
+ const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit']);
52
+
53
+ let data = {};
54
+ try { data = JSON.parse(fs.readFileSync(0, 'utf8')); } catch { process.exit(0); }
55
+
56
+ // Only the first edit of a turn matters; ignore every non-edit tool.
57
+ if (!EDIT_TOOLS.has(data.tool_name)) process.exit(0);
58
+
59
+ // Counting exploration before the first edit needs the transcript.
60
+ if (!data.transcript_path || !fs.existsSync(data.transcript_path)) process.exit(0);
61
+
62
+ let lines = [];
63
+ try {
64
+ const buf = fs.readFileSync(data.transcript_path, 'utf8');
65
+ lines = (buf.length > 4000000 ? buf.slice(-4000000) : buf).split(/\r?\n/);
66
+ } catch { process.exit(0); }
67
+
68
+ const parsed = lines.map(l => { try { return JSON.parse(l); } catch { return null; } });
69
+
70
+ // Locate the last genuine user prompt (typed text, not a tool_result
71
+ // carrier); everything after it is the current turn.
72
+ let turnStart = 0;
73
+ for (let i = 0; i < parsed.length; i++) {
74
+ const e = parsed[i];
75
+ if (!e || e.type !== 'user' || e.isMeta || !e.message) continue;
76
+ const c = e.message.content;
77
+ const genuine = typeof c === 'string'
78
+ ? true
79
+ : Array.isArray(c) && c.some(x => x && x.type === 'text') && !c.some(x => x && x.type === 'tool_result');
80
+ if (genuine) turnStart = i;
81
+ }
82
+
83
+ // Evaluate at most once per turn, at the first-edit boundary.
84
+ const markerDir = process.env.MAESTRO_TOOLBUDGET_MARKERDIR || os.tmpdir();
85
+ const turnKey = crypto.createHash('sha1')
86
+ .update(String(data.session_id || '') + ':' + turnStart + ':' + (lines[turnStart] || ''))
87
+ .digest('hex').slice(0, 16);
88
+ const marker = path.join(markerDir, 'maestro-toolbudget-' + turnKey);
89
+ if (fs.existsSync(marker)) process.exit(0);
90
+ try { fs.mkdirSync(markerDir, { recursive: true }); fs.writeFileSync(marker, '1'); } catch {}
91
+
92
+ // Count exploration (non-edit) tool calls that ran before the first edit
93
+ // this turn. priorEdit stops the count at the first edit, whether or not
94
+ // the triggering edit is already in the transcript.
95
+ let explore = 0;
96
+ let priorEdit = false;
97
+ for (let i = turnStart; i < parsed.length; i++) {
98
+ const e = parsed[i];
99
+ if (!e || e.type !== 'assistant' || !e.message || !Array.isArray(e.message.content)) continue;
100
+ for (const item of e.message.content) {
101
+ if (!item || item.type !== 'tool_use') continue;
102
+ if (EDIT_TOOLS.has(item.name)) { priorEdit = true; break; }
103
+ explore++;
104
+ }
105
+ if (priorEdit) break;
106
+ }
107
+
108
+ const threshold = parseInt(process.env.MAESTRO_TOOLBUDGET_THRESHOLD, 10) || 20;
109
+ if (explore > threshold) {
110
+ const row = {
111
+ ts: new Date().toISOString(),
112
+ session_id: data.session_id || null,
113
+ kind: 'toolbudget-advisory',
114
+ explore_calls: explore,
115
+ threshold,
116
+ first_edit_tool: data.tool_name,
117
+ project: data.cwd ? path.basename(data.cwd) : null
118
+ };
119
+ const logPath = process.env.MAESTRO_TOOLBUDGET_LOG
120
+ || path.join(os.homedir(), '.claude', 'maestro-toolbudget.jsonl');
121
+ try {
122
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
123
+ fs.appendFileSync(logPath, JSON.stringify(row) + '\n');
124
+ } catch { /* advisory is best-effort; never disrupt the session */ }
125
+ }
126
+
127
+ process.exit(0);