@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,152 +1,152 @@
1
- #!/usr/bin/env node
2
- // Maestro status-line sync hook (SessionStart).
3
- //
4
- // Problem it solves: the context-bar status line is a STANDALONE copy the
5
- // user installs once (curl into ~/.claude/statusline/, per docs/context-bar.md)
6
- // and wires into ~/.claude/settings.json. A Claude Code plugin cannot edit
7
- // settings.json or copy that file at install time, so when the plugin updates,
8
- // the wired copy goes stale -- the user keeps seeing the old render (e.g. the
9
- // "1.00M" token format) while the plugin already ships the fix. This hook
10
- // closes that gap: on every session start it refreshes the wired copy from the
11
- // plugin's shipped version.
12
- //
13
- // Refresh-if-present ONLY. It never creates the file: an absent context-bar.sh
14
- // means the user never opted into the status line, and the plugin must not
15
- // change anyone's status line uninvited (same opt-in rule as terse mode). It
16
- // only overwrites a file that already exists and whose content differs.
17
- //
18
- // Source of truth: ${CLAUDE_PLUGIN_ROOT}/statusline/ (the installed plugin),
19
- // falling back to this hook's own ../statusline when the env var is unset.
20
- //
21
- // Targets: the canonical ~/.claude/statusline/ (the documented install dir,
22
- // and what the vibe-ads-style ad wrappers chain to), plus the dir resolved
23
- // from settings.json statusLine.command when that resolves to a real
24
- // context-bar script. Deduped.
25
- //
26
- // Hardened I/O (symlink refusal, O_NOFOLLOW, atomic temp+rename, size cap)
27
- // mirrors hooks/maestro-terse-mode.cjs. The destination lives at a predictable
28
- // path under ~/.claude -- a symlink-attack target -- so never write through a
29
- // link. The source is trusted shipped plugin content.
30
- //
31
- // Always silent, never throws, exit 0: a maintenance hook must never break or
32
- // clutter a session. It writes at most twice (.sh, .ps1) and only right after
33
- // an update changed the shipped script.
34
- //
35
- // .cjs so Node treats it as CommonJS regardless of a parent package.json.
36
-
37
- 'use strict';
38
-
39
- const fs = require('fs');
40
- const os = require('os');
41
- const path = require('path');
42
-
43
- const SCRIPTS = ['context-bar.sh', 'context-bar.ps1'];
44
- const MAX_SCRIPT_BYTES = 1 << 16; // 64 KB cap; the scripts are ~6 KB
45
-
46
- const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
47
-
48
- function sourceDir() {
49
- const root = process.env.CLAUDE_PLUGIN_ROOT;
50
- if (root) return path.join(root, 'statusline');
51
- return path.join(__dirname, '..', 'statusline');
52
- }
53
-
54
- // Read a regular file, refusing symlinks and oversized files. Returns a Buffer
55
- // or null. Buffer (not utf8) so a byte-exact compare/copy survives any encoding.
56
- function safeReadFile(p) {
57
- try {
58
- let st;
59
- try { st = fs.lstatSync(p); } catch { return null; }
60
- if (st.isSymbolicLink() || !st.isFile()) return null;
61
- if (st.size > MAX_SCRIPT_BYTES) return null;
62
- const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
63
- let fd;
64
- try {
65
- if (O_NOFOLLOW === 0) { try { if (fs.lstatSync(p).isSymbolicLink()) return null; } catch {} }
66
- fd = fs.openSync(p, fs.constants.O_RDONLY | O_NOFOLLOW);
67
- const buf = Buffer.alloc(st.size);
68
- const n = fs.readSync(fd, buf, 0, st.size, 0);
69
- return buf.slice(0, n);
70
- } finally {
71
- if (fd !== undefined) fs.closeSync(fd);
72
- }
73
- } catch {
74
- return null;
75
- }
76
- }
77
-
78
- // Atomic overwrite via temp+rename, refusing to write through a symlinked dir
79
- // or destination. mode sets the final permission bits.
80
- function safeWriteFile(dest, buf, mode) {
81
- try {
82
- const dir = path.dirname(dest);
83
- try { if (fs.lstatSync(dir).isSymbolicLink()) return false; } catch { return false; }
84
- try {
85
- if (fs.lstatSync(dest).isSymbolicLink()) return false;
86
- } catch (e) {
87
- if (e.code !== 'ENOENT') return false;
88
- }
89
- const tempPath = path.join(dir, '.' + path.basename(dest) + '.' + process.pid + '.' + Date.now() + '.tmp');
90
- const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
91
- const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW;
92
- let fd;
93
- try {
94
- if (O_NOFOLLOW === 0) { try { if (fs.lstatSync(tempPath).isSymbolicLink()) return false; } catch {} }
95
- fd = fs.openSync(tempPath, flags, mode);
96
- fs.writeSync(fd, buf, 0, buf.length, 0);
97
- try { fs.fchmodSync(fd, mode); } catch {}
98
- } finally {
99
- if (fd !== undefined) fs.closeSync(fd);
100
- }
101
- fs.renameSync(tempPath, dest);
102
- return true;
103
- } catch {
104
- return false;
105
- }
106
- }
107
-
108
- // The dir Claude Code is actually pointed at, when settings.json resolves to a
109
- // real context-bar script. Reused from settings/config.cjs so there is one
110
- // resolver. Best-effort: any failure just drops this candidate.
111
- function resolvedTargetDir() {
112
- try {
113
- const cfg = require('../settings/config.cjs');
114
- const r = cfg.resolveStatuslineDir();
115
- if (r && r.scriptOk && r.dir) return r.dir;
116
- } catch {}
117
- return null;
118
- }
119
-
120
- function syncDir(dir, srcDir) {
121
- for (const name of SCRIPTS) {
122
- const src = safeReadFile(path.join(srcDir, name));
123
- if (!src) continue;
124
- const destPath = path.join(dir, name);
125
- // Refresh-if-present: skip if the user never installed this script here.
126
- let dst;
127
- try {
128
- const st = fs.lstatSync(destPath);
129
- if (st.isSymbolicLink() || !st.isFile()) continue;
130
- dst = safeReadFile(destPath);
131
- } catch {
132
- continue; // ENOENT -> not installed here -> do not create
133
- }
134
- if (dst && dst.equals(src)) continue; // already current
135
- const mode = name.endsWith('.sh') ? 0o755 : 0o644;
136
- safeWriteFile(destPath, src, mode);
137
- }
138
- }
139
-
140
- function run() {
141
- const srcDir = sourceDir();
142
- const dirs = new Set([path.join(claudeDir, 'statusline')]);
143
- const resolved = resolvedTargetDir();
144
- if (resolved) dirs.add(resolved);
145
- for (const dir of dirs) syncDir(dir, srcDir);
146
- }
147
-
148
- // Drain stdin (the harness pipes the SessionStart payload) but ignore it; we
149
- // act unconditionally on session start. Garbage stdin must not throw.
150
- try { fs.readFileSync(0, 'utf8'); } catch {}
151
- try { run(); } catch {}
152
- process.exit(0);
1
+ #!/usr/bin/env node
2
+ // Maestro status-line sync hook (SessionStart).
3
+ //
4
+ // Problem it solves: the context-bar status line is a STANDALONE copy the
5
+ // user installs once (curl into ~/.claude/statusline/, per docs/context-bar.md)
6
+ // and wires into ~/.claude/settings.json. A Claude Code plugin cannot edit
7
+ // settings.json or copy that file at install time, so when the plugin updates,
8
+ // the wired copy goes stale -- the user keeps seeing the old render (e.g. the
9
+ // "1.00M" token format) while the plugin already ships the fix. This hook
10
+ // closes that gap: on every session start it refreshes the wired copy from the
11
+ // plugin's shipped version.
12
+ //
13
+ // Refresh-if-present ONLY. It never creates the file: an absent context-bar.sh
14
+ // means the user never opted into the status line, and the plugin must not
15
+ // change anyone's status line uninvited (same opt-in rule as terse mode). It
16
+ // only overwrites a file that already exists and whose content differs.
17
+ //
18
+ // Source of truth: ${CLAUDE_PLUGIN_ROOT}/statusline/ (the installed plugin),
19
+ // falling back to this hook's own ../statusline when the env var is unset.
20
+ //
21
+ // Targets: the canonical ~/.claude/statusline/ (the documented install dir,
22
+ // and what the vibe-ads-style ad wrappers chain to), plus the dir resolved
23
+ // from settings.json statusLine.command when that resolves to a real
24
+ // context-bar script. Deduped.
25
+ //
26
+ // Hardened I/O (symlink refusal, O_NOFOLLOW, atomic temp+rename, size cap)
27
+ // mirrors hooks/maestro-terse-mode.cjs. The destination lives at a predictable
28
+ // path under ~/.claude -- a symlink-attack target -- so never write through a
29
+ // link. The source is trusted shipped plugin content.
30
+ //
31
+ // Always silent, never throws, exit 0: a maintenance hook must never break or
32
+ // clutter a session. It writes at most twice (.sh, .ps1) and only right after
33
+ // an update changed the shipped script.
34
+ //
35
+ // .cjs so Node treats it as CommonJS regardless of a parent package.json.
36
+
37
+ 'use strict';
38
+
39
+ const fs = require('fs');
40
+ const os = require('os');
41
+ const path = require('path');
42
+
43
+ const SCRIPTS = ['context-bar.sh', 'context-bar.ps1'];
44
+ const MAX_SCRIPT_BYTES = 1 << 16; // 64 KB cap; the scripts are ~6 KB
45
+
46
+ const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
47
+
48
+ function sourceDir() {
49
+ const root = process.env.CLAUDE_PLUGIN_ROOT;
50
+ if (root) return path.join(root, 'statusline');
51
+ return path.join(__dirname, '..', 'statusline');
52
+ }
53
+
54
+ // Read a regular file, refusing symlinks and oversized files. Returns a Buffer
55
+ // or null. Buffer (not utf8) so a byte-exact compare/copy survives any encoding.
56
+ function safeReadFile(p) {
57
+ try {
58
+ let st;
59
+ try { st = fs.lstatSync(p); } catch { return null; }
60
+ if (st.isSymbolicLink() || !st.isFile()) return null;
61
+ if (st.size > MAX_SCRIPT_BYTES) return null;
62
+ const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
63
+ let fd;
64
+ try {
65
+ if (O_NOFOLLOW === 0) { try { if (fs.lstatSync(p).isSymbolicLink()) return null; } catch {} }
66
+ fd = fs.openSync(p, fs.constants.O_RDONLY | O_NOFOLLOW);
67
+ const buf = Buffer.alloc(st.size);
68
+ const n = fs.readSync(fd, buf, 0, st.size, 0);
69
+ return buf.slice(0, n);
70
+ } finally {
71
+ if (fd !== undefined) fs.closeSync(fd);
72
+ }
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ // Atomic overwrite via temp+rename, refusing to write through a symlinked dir
79
+ // or destination. mode sets the final permission bits.
80
+ function safeWriteFile(dest, buf, mode) {
81
+ try {
82
+ const dir = path.dirname(dest);
83
+ try { if (fs.lstatSync(dir).isSymbolicLink()) return false; } catch { return false; }
84
+ try {
85
+ if (fs.lstatSync(dest).isSymbolicLink()) return false;
86
+ } catch (e) {
87
+ if (e.code !== 'ENOENT') return false;
88
+ }
89
+ const tempPath = path.join(dir, '.' + path.basename(dest) + '.' + process.pid + '.' + Date.now() + '.tmp');
90
+ const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
91
+ const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW;
92
+ let fd;
93
+ try {
94
+ if (O_NOFOLLOW === 0) { try { if (fs.lstatSync(tempPath).isSymbolicLink()) return false; } catch {} }
95
+ fd = fs.openSync(tempPath, flags, mode);
96
+ fs.writeSync(fd, buf, 0, buf.length, 0);
97
+ try { fs.fchmodSync(fd, mode); } catch {}
98
+ } finally {
99
+ if (fd !== undefined) fs.closeSync(fd);
100
+ }
101
+ fs.renameSync(tempPath, dest);
102
+ return true;
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ // The dir Claude Code is actually pointed at, when settings.json resolves to a
109
+ // real context-bar script. Reused from settings/config.cjs so there is one
110
+ // resolver. Best-effort: any failure just drops this candidate.
111
+ function resolvedTargetDir() {
112
+ try {
113
+ const cfg = require('../settings/config.cjs');
114
+ const r = cfg.resolveStatuslineDir();
115
+ if (r && r.scriptOk && r.dir) return r.dir;
116
+ } catch {}
117
+ return null;
118
+ }
119
+
120
+ function syncDir(dir, srcDir) {
121
+ for (const name of SCRIPTS) {
122
+ const src = safeReadFile(path.join(srcDir, name));
123
+ if (!src) continue;
124
+ const destPath = path.join(dir, name);
125
+ // Refresh-if-present: skip if the user never installed this script here.
126
+ let dst;
127
+ try {
128
+ const st = fs.lstatSync(destPath);
129
+ if (st.isSymbolicLink() || !st.isFile()) continue;
130
+ dst = safeReadFile(destPath);
131
+ } catch {
132
+ continue; // ENOENT -> not installed here -> do not create
133
+ }
134
+ if (dst && dst.equals(src)) continue; // already current
135
+ const mode = name.endsWith('.sh') ? 0o755 : 0o644;
136
+ safeWriteFile(destPath, src, mode);
137
+ }
138
+ }
139
+
140
+ function run() {
141
+ const srcDir = sourceDir();
142
+ const dirs = new Set([path.join(claudeDir, 'statusline')]);
143
+ const resolved = resolvedTargetDir();
144
+ if (resolved) dirs.add(resolved);
145
+ for (const dir of dirs) syncDir(dir, srcDir);
146
+ }
147
+
148
+ // Drain stdin (the harness pipes the SessionStart payload) but ignore it; we
149
+ // act unconditionally on session start. Garbage stdin must not throw.
150
+ try { fs.readFileSync(0, 'utf8'); } catch {}
151
+ try { run(); } catch {}
152
+ process.exit(0);
@@ -1,148 +1,148 @@
1
- #!/usr/bin/env node
2
- // Maestro SubagentStop guard. Enforces AGENTS.md S7.3 structurally.
3
- // - Warn on stop with orphaned background_tasks (all agents)
4
- // - Warn if a file-modifying agent ran no type-check/lint/test
5
- // - Warn if a file-modifying agent's final text carries none of the
6
- // S7.3 status tokens (VERIFIED / PENDING_REVIEW / UNVERIFIED / FAIL)
7
- //
8
- // Read-only agents (Explore, Plan, or any agent with no file mutation
9
- // in its transcript) are exempt from the verification warning:
10
- // research and audit agents have nothing to verify, and a warning on
11
- // stop extends the agent's turn so its reply to the warning would
12
- // displace the final report the orchestrator is waiting for.
13
- //
14
- // Fires at most once per agent: decision:block re-prompts the
15
- // agent, which stops again and re-triggers this hook. Without the
16
- // once-guard the warning loops and pushes the real report out of the
17
- // final message entirely. The guard is a marker file in the temp dir
18
- // keyed by the agent transcript path -- the block reason is injected
19
- // into the conversation, NOT written to the transcript file, so
20
- // grepping the transcript for our own warning never matches
21
- // (observed live 2026-06-10). The transcript check is kept as a
22
- // secondary guard for harness versions that do persist it.
23
- //
24
- // Feedback channel: {"decision":"block","reason":...} -- the only
25
- // SubagentStop output the harness honors (additionalContext is not
26
- // supported on this event). Blocks the stop exactly once; the agent
27
- // is told to restate its final report so the orchestrator still
28
- // receives it.
29
- //
30
- // .cjs so Node treats it as CommonJS regardless of any "type": "module"
31
- // package.json in a parent directory of the install location.
32
- //
33
- // Install: see README "Claude Code: Verification Hook".
34
-
35
- const fs = require('fs');
36
- const os = require('os');
37
- const path = require('path');
38
- const crypto = require('crypto');
39
-
40
- let data = {};
41
- try { data = JSON.parse(fs.readFileSync(0, 'utf8')); } catch { process.exit(0); }
42
-
43
- // Prefer the subagent's own transcript (agent_transcript_path, since
44
- // Claude Code 2.0.42) over the session transcript.
45
- const txPath = data.agent_transcript_path || data.transcript_path;
46
- let txText = '';
47
- if (txPath && fs.existsSync(txPath)) {
48
- try {
49
- const buf = fs.readFileSync(txPath, 'utf8');
50
- txText = buf.length > 2000000 ? buf.slice(-2000000) : buf;
51
- } catch {}
52
- }
53
-
54
- // Fire once per agent: marker file keyed by transcript path (or
55
- // session id). MAESTRO_GUARD_STATE_DIR overrides the marker dir for
56
- // tests. Transcript check kept as a secondary guard.
57
- const guardKey = data.agent_transcript_path || data.transcript_path || data.session_id || '';
58
- const stateDir = process.env.MAESTRO_GUARD_STATE_DIR || os.tmpdir();
59
- const marker = guardKey
60
- ? path.join(stateDir, 'maestro-guard-' + crypto.createHash('sha1').update(String(guardKey)).digest('hex').slice(0, 16))
61
- : null;
62
- if (marker && fs.existsSync(marker)) process.exit(0);
63
- if (txText.includes('Maestro guard:')) process.exit(0);
64
-
65
- const warnings = [];
66
-
67
- // background_tasks in the SubagentStop payload is machine-wide (all
68
- // sessions), not scoped to this agent (observed live 2026-06-10: a
69
- // fixture-builder agent was warned about unrelated sessions' tasks).
70
- // Only warn when the agent's own transcript shows it spawned
71
- // background work; agents that spawned nothing are exempt.
72
- const spawnRe = /"run_in_background"\s*:\s*true|"name"\s*:\s*"TaskCreate"/;
73
- const bg = Array.isArray(data.background_tasks) ? data.background_tasks : [];
74
- const active = bg.filter(t => t && (t.status === 'running' || t.status === 'pending' || t.status === 'active'));
75
- if (active.length && spawnRe.test(txText)) {
76
- warnings.push(`${active.length} background task(s) still active. Wait or stop before declaring complete (AGENTS.md S7.3).`);
77
- }
78
-
79
- // Read-only exemption: known read-only agent types (agent_type, since
80
- // Claude Code 2.1.69), or no file-mutating activity in the transcript.
81
- // Mutation = Edit/Write/NotebookEdit tool calls, plus Bash mutations:
82
- // redirects, sed -i, tee/mv/cp/rm/mkdir/touch, git commit/apply,
83
- // migrations, package installs. Bash patterns are tested against the
84
- // parsed command strings only, never raw transcript text -- arrows
85
- // (->, =>) and redirect-ish chars in prose must not flip a research
86
- // agent into the writer path (a false nag eats its final report).
87
- const READ_ONLY_TYPES = new Set(['explore', 'plan']);
88
- const agentType = String(data.agent_type || '').toLowerCase();
89
- const toolMutRe = /"name"\s*:\s*"(Edit|Write|NotebookEdit)"/;
90
- const bashMutRe = /(?<![-=<>])>{1,2}\s*[^\s&|<>]|(^|[\s;&|(])(sed\s+(-\S+\s+)*-i|tee\s|mv\s|cp\s|rm\s|mkdir\s|touch\s|git\s+(commit|apply)\b|apply_migration|(npm|pnpm|yarn)\s+(i|install|add)\b)/;
91
- let bashMutation = false;
92
- for (const line of txText.split(/\r?\n/)) {
93
- let obj;
94
- try { obj = JSON.parse(line); } catch { continue; }
95
- if (!obj || obj.type !== 'assistant' || !obj.message || !Array.isArray(obj.message.content)) continue;
96
- for (const c of obj.message.content) {
97
- if (c && c.type === 'tool_use' && c.name === 'Bash' && c.input &&
98
- typeof c.input.command === 'string' && bashMutRe.test(c.input.command)) {
99
- bashMutation = true;
100
- break;
101
- }
102
- }
103
- if (bashMutation) break;
104
- }
105
- const readOnly = READ_ONLY_TYPES.has(agentType) || (txText !== '' && !toolMutRe.test(txText) && !bashMutation);
106
-
107
- const verifyRe = /(tsc\s+--noEmit|eslint|pytest|jest|vitest|\bgo\s+test\b|\bcargo\s+test\b|npm\s+(?:run\s+)?test|pnpm\s+test|yarn\s+test|ruff\s+check|mypy|prettier\s+--check|biome\s+check)/i;
108
- if (!readOnly && txText && !verifyRe.test(txText)) {
109
- warnings.push('No type-check/lint/test detected after file modifications. Verify before complete, or state "no checker configured" (AGENTS.md S7.3).');
110
- }
111
-
112
- // S7.3 status vocabulary: a file-modifying agent's final text must
113
- // carry one of the four status tokens. Case-sensitive on purpose --
114
- // the doctrine tokens are uppercase, and lowercase "fail"/"verified"
115
- // in prose are not status declarations.
116
- const statusRe = /\b(VERIFIED|PENDING_REVIEW|UNVERIFIED|FAIL)\b/;
117
- if (!readOnly && txText) {
118
- let finalText = '';
119
- for (const line of txText.split(/\r?\n/)) {
120
- let obj;
121
- try { obj = JSON.parse(line); } catch { continue; }
122
- if (obj && obj.type === 'assistant' && obj.message && Array.isArray(obj.message.content)) {
123
- const t = obj.message.content
124
- .filter(c => c && c.type === 'text' && typeof c.text === 'string')
125
- .map(c => c.text).join('\n');
126
- if (t) finalText = t;
127
- }
128
- }
129
- if (finalText && !statusRe.test(finalText)) {
130
- warnings.push('Final report carries no status token. State exactly one of VERIFIED / PENDING_REVIEW / UNVERIFIED / FAIL, with the named gap if not VERIFIED (AGENTS.md S7.3).');
131
- }
132
- }
133
-
134
- if (warnings.length) {
135
- if (marker) { try { fs.writeFileSync(marker, String(Date.now())); } catch {} }
136
- // SubagentStop supports only decision:block + reason as a feedback
137
- // channel (additionalContext is not honored on this event). Blocking
138
- // the stop feeds the reason back to the subagent, which addresses it
139
- // and stops again; the marker file above keeps that second stop silent.
140
- const payload = {
141
- decision: 'block',
142
- reason: 'Maestro guard:\n- ' + warnings.join('\n- ') +
143
- '\nAfter addressing this, restate your complete final report. Only your last message is returned to the orchestrator.'
144
- };
145
- process.stdout.write(JSON.stringify(payload));
146
- }
147
-
148
- process.exit(0);
1
+ #!/usr/bin/env node
2
+ // Maestro SubagentStop guard. Enforces AGENTS.md S7.3 structurally.
3
+ // - Warn on stop with orphaned background_tasks (all agents)
4
+ // - Warn if a file-modifying agent ran no type-check/lint/test
5
+ // - Warn if a file-modifying agent's final text carries none of the
6
+ // S7.3 status tokens (VERIFIED / PENDING_REVIEW / UNVERIFIED / FAIL)
7
+ //
8
+ // Read-only agents (Explore, Plan, or any agent with no file mutation
9
+ // in its transcript) are exempt from the verification warning:
10
+ // research and audit agents have nothing to verify, and a warning on
11
+ // stop extends the agent's turn so its reply to the warning would
12
+ // displace the final report the orchestrator is waiting for.
13
+ //
14
+ // Fires at most once per agent: decision:block re-prompts the
15
+ // agent, which stops again and re-triggers this hook. Without the
16
+ // once-guard the warning loops and pushes the real report out of the
17
+ // final message entirely. The guard is a marker file in the temp dir
18
+ // keyed by the agent transcript path -- the block reason is injected
19
+ // into the conversation, NOT written to the transcript file, so
20
+ // grepping the transcript for our own warning never matches
21
+ // (observed live 2026-06-10). The transcript check is kept as a
22
+ // secondary guard for harness versions that do persist it.
23
+ //
24
+ // Feedback channel: {"decision":"block","reason":...} -- the only
25
+ // SubagentStop output the harness honors (additionalContext is not
26
+ // supported on this event). Blocks the stop exactly once; the agent
27
+ // is told to restate its final report so the orchestrator still
28
+ // receives it.
29
+ //
30
+ // .cjs so Node treats it as CommonJS regardless of any "type": "module"
31
+ // package.json in a parent directory of the install location.
32
+ //
33
+ // Install: see README "Claude Code: Verification Hook".
34
+
35
+ const fs = require('fs');
36
+ const os = require('os');
37
+ const path = require('path');
38
+ const crypto = require('crypto');
39
+
40
+ let data = {};
41
+ try { data = JSON.parse(fs.readFileSync(0, 'utf8')); } catch { process.exit(0); }
42
+
43
+ // Prefer the subagent's own transcript (agent_transcript_path, since
44
+ // Claude Code 2.0.42) over the session transcript.
45
+ const txPath = data.agent_transcript_path || data.transcript_path;
46
+ let txText = '';
47
+ if (txPath && fs.existsSync(txPath)) {
48
+ try {
49
+ const buf = fs.readFileSync(txPath, 'utf8');
50
+ txText = buf.length > 2000000 ? buf.slice(-2000000) : buf;
51
+ } catch {}
52
+ }
53
+
54
+ // Fire once per agent: marker file keyed by transcript path (or
55
+ // session id). MAESTRO_GUARD_STATE_DIR overrides the marker dir for
56
+ // tests. Transcript check kept as a secondary guard.
57
+ const guardKey = data.agent_transcript_path || data.transcript_path || data.session_id || '';
58
+ const stateDir = process.env.MAESTRO_GUARD_STATE_DIR || os.tmpdir();
59
+ const marker = guardKey
60
+ ? path.join(stateDir, 'maestro-guard-' + crypto.createHash('sha1').update(String(guardKey)).digest('hex').slice(0, 16))
61
+ : null;
62
+ if (marker && fs.existsSync(marker)) process.exit(0);
63
+ if (txText.includes('Maestro guard:')) process.exit(0);
64
+
65
+ const warnings = [];
66
+
67
+ // background_tasks in the SubagentStop payload is machine-wide (all
68
+ // sessions), not scoped to this agent (observed live 2026-06-10: a
69
+ // fixture-builder agent was warned about unrelated sessions' tasks).
70
+ // Only warn when the agent's own transcript shows it spawned
71
+ // background work; agents that spawned nothing are exempt.
72
+ const spawnRe = /"run_in_background"\s*:\s*true|"name"\s*:\s*"TaskCreate"/;
73
+ const bg = Array.isArray(data.background_tasks) ? data.background_tasks : [];
74
+ const active = bg.filter(t => t && (t.status === 'running' || t.status === 'pending' || t.status === 'active'));
75
+ if (active.length && spawnRe.test(txText)) {
76
+ warnings.push(`${active.length} background task(s) still active. Wait or stop before declaring complete (AGENTS.md S7.3).`);
77
+ }
78
+
79
+ // Read-only exemption: known read-only agent types (agent_type, since
80
+ // Claude Code 2.1.69), or no file-mutating activity in the transcript.
81
+ // Mutation = Edit/Write/NotebookEdit tool calls, plus Bash mutations:
82
+ // redirects, sed -i, tee/mv/cp/rm/mkdir/touch, git commit/apply,
83
+ // migrations, package installs. Bash patterns are tested against the
84
+ // parsed command strings only, never raw transcript text -- arrows
85
+ // (->, =>) and redirect-ish chars in prose must not flip a research
86
+ // agent into the writer path (a false nag eats its final report).
87
+ const READ_ONLY_TYPES = new Set(['explore', 'plan']);
88
+ const agentType = String(data.agent_type || '').toLowerCase();
89
+ const toolMutRe = /"name"\s*:\s*"(Edit|Write|NotebookEdit)"/;
90
+ const bashMutRe = /(?<![-=<>])>{1,2}\s*[^\s&|<>]|(^|[\s;&|(])(sed\s+(-\S+\s+)*-i|tee\s|mv\s|cp\s|rm\s|mkdir\s|touch\s|git\s+(commit|apply)\b|apply_migration|(npm|pnpm|yarn)\s+(i|install|add)\b)/;
91
+ let bashMutation = false;
92
+ for (const line of txText.split(/\r?\n/)) {
93
+ let obj;
94
+ try { obj = JSON.parse(line); } catch { continue; }
95
+ if (!obj || obj.type !== 'assistant' || !obj.message || !Array.isArray(obj.message.content)) continue;
96
+ for (const c of obj.message.content) {
97
+ if (c && c.type === 'tool_use' && c.name === 'Bash' && c.input &&
98
+ typeof c.input.command === 'string' && bashMutRe.test(c.input.command)) {
99
+ bashMutation = true;
100
+ break;
101
+ }
102
+ }
103
+ if (bashMutation) break;
104
+ }
105
+ const readOnly = READ_ONLY_TYPES.has(agentType) || (txText !== '' && !toolMutRe.test(txText) && !bashMutation);
106
+
107
+ const verifyRe = /(tsc\s+--noEmit|eslint|pytest|jest|vitest|\bgo\s+test\b|\bcargo\s+test\b|npm\s+(?:run\s+)?test|pnpm\s+test|yarn\s+test|ruff\s+check|mypy|prettier\s+--check|biome\s+check)/i;
108
+ if (!readOnly && txText && !verifyRe.test(txText)) {
109
+ warnings.push('No type-check/lint/test detected after file modifications. Verify before complete, or state "no checker configured" (AGENTS.md S7.3).');
110
+ }
111
+
112
+ // S7.3 status vocabulary: a file-modifying agent's final text must
113
+ // carry one of the four status tokens. Case-sensitive on purpose --
114
+ // the doctrine tokens are uppercase, and lowercase "fail"/"verified"
115
+ // in prose are not status declarations.
116
+ const statusRe = /\b(VERIFIED|PENDING_REVIEW|UNVERIFIED|FAIL)\b/;
117
+ if (!readOnly && txText) {
118
+ let finalText = '';
119
+ for (const line of txText.split(/\r?\n/)) {
120
+ let obj;
121
+ try { obj = JSON.parse(line); } catch { continue; }
122
+ if (obj && obj.type === 'assistant' && obj.message && Array.isArray(obj.message.content)) {
123
+ const t = obj.message.content
124
+ .filter(c => c && c.type === 'text' && typeof c.text === 'string')
125
+ .map(c => c.text).join('\n');
126
+ if (t) finalText = t;
127
+ }
128
+ }
129
+ if (finalText && !statusRe.test(finalText)) {
130
+ warnings.push('Final report carries no status token. State exactly one of VERIFIED / PENDING_REVIEW / UNVERIFIED / FAIL, with the named gap if not VERIFIED (AGENTS.md S7.3).');
131
+ }
132
+ }
133
+
134
+ if (warnings.length) {
135
+ if (marker) { try { fs.writeFileSync(marker, String(Date.now())); } catch {} }
136
+ // SubagentStop supports only decision:block + reason as a feedback
137
+ // channel (additionalContext is not honored on this event). Blocking
138
+ // the stop feeds the reason back to the subagent, which addresses it
139
+ // and stops again; the marker file above keeps that second stop silent.
140
+ const payload = {
141
+ decision: 'block',
142
+ reason: 'Maestro guard:\n- ' + warnings.join('\n- ') +
143
+ '\nAfter addressing this, restate your complete final report. Only your last message is returned to the orchestrator.'
144
+ };
145
+ process.stdout.write(JSON.stringify(payload));
146
+ }
147
+
148
+ process.exit(0);