@maestrofrontier/frontier 1.4.5 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/plugins/marketplace.json +21 -21
- package/.codex-plugin/plugin.json +29 -29
- package/.cursorrules +197 -194
- package/AGENTS.md +3 -3
- package/README.md +368 -368
- package/bin/maestro.cjs +75 -75
- package/commands/compress.md +36 -36
- package/commands/frontier.md +124 -124
- package/commands/terse.md +23 -23
- package/docs/codex.md +167 -167
- package/docs/orchestration.md +168 -168
- package/frontier/cli.cjs +279 -252
- package/frontier/config.cjs +468 -468
- package/frontier/dispatch.cjs +267 -255
- package/frontier/judge.cjs +92 -92
- package/frontier/progress.cjs +138 -0
- package/frontier/run.cjs +201 -180
- package/frontier/schema.cjs +112 -112
- package/frontier/semaphore.cjs +49 -49
- package/frontier/synthesize.cjs +79 -79
- package/hooks/frontier-autorun.cjs +135 -120
- package/hooks/hooks.json +103 -103
- package/hooks/maestro-doctrine-guard.cjs +81 -81
- package/hooks/maestro-gate-reminder.cjs +22 -7
- package/hooks/maestro-gate-telemetry.cjs +79 -77
- package/hooks/maestro-phase-scope.cjs +118 -118
- package/hooks/maestro-statusline-sync.cjs +152 -152
- package/hooks/maestro-subagent-guard.cjs +148 -148
- package/hooks/maestro-terse-mode.cjs +189 -189
- package/hooks/maestro-toolbudget-advisory.cjs +127 -127
- package/integrations/README.md +111 -111
- package/integrations/cline/skills/frontier/SKILL.md +75 -75
- package/integrations/codex/prompts/frontier.md +70 -70
- package/integrations/codex/prompts/update.md +39 -39
- package/integrations/codex/skills/maestro-frontier/SKILL.md +122 -122
- package/integrations/codex/skills/maestro-settings/SKILL.md +55 -55
- package/integrations/codex/skills/maestro-terse/SKILL.md +58 -58
- package/integrations/codex/skills/maestro-update/SKILL.md +31 -31
- package/integrations/cursor/commands/frontier.md +63 -63
- package/integrations/cursor/commands/update.md +34 -34
- package/integrations/gemini/commands/frontier.toml +76 -76
- package/integrations/windsurf/workflows/frontier.md +70 -70
- package/package.json +59 -58
- package/scripts/install.cjs +1014 -1014
- package/settings/cli.cjs +140 -140
- package/settings/config.cjs +309 -309
- package/skills/maestro-frontier/SKILL.md +122 -122
- package/skills/maestro-settings/SKILL.md +55 -55
- package/skills/maestro-terse/SKILL.md +58 -58
- package/skills/maestro-update/SKILL.md +31 -31
- package/skills/terse/SKILL.md +74 -74
|
@@ -1,118 +1,118 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Maestro PostToolUse phase-scope guard. Enforces AGENTS.md S7.1
|
|
3
|
-
// structurally: max 5 files modified per phase. Counts distinct files
|
|
4
|
-
// touched by Edit/Write/NotebookEdit -- plus Bash mutations whose
|
|
5
|
-
// target path is statically extractable (redirects, sed -i, tee, mv,
|
|
6
|
-
// cp, rm, mkdir, touch) -- since the last real user prompt (one turn
|
|
7
|
-
// ~ one phase for interactive work) and warns once per turn when the
|
|
8
|
-
// count exceeds MAESTRO_PHASE_FILE_CAP (default 5). Targets with
|
|
9
|
-
// shell expansion ($, backticks, globs) are skipped: a missed count
|
|
10
|
-
// is cheaper than a false warning. git commit / npm install mutate
|
|
11
|
-
// but name no file; they are out of scope for a file counter.
|
|
12
|
-
//
|
|
13
|
-
// Wire with matcher "Edit|Write|NotebookEdit|Bash" so it only runs on
|
|
14
|
-
// file-modifying tools. Soft warning via additionalContext -- never
|
|
15
|
-
// blocks; the agent decides whether the scope is justified (e.g. a
|
|
16
|
-
// user-approved bulk rename). Degrades silently on missing payload
|
|
17
|
-
// fields. Payload fields verified against code.claude.com/docs/en/hooks
|
|
18
|
-
// (PostToolUse input: tool_name, tool_input, transcript_path;
|
|
19
|
-
// output: hookSpecificOutput.additionalContext), 2026-06-10.
|
|
20
|
-
//
|
|
21
|
-
// .cjs so Node treats it as CommonJS regardless of any "type": "module"
|
|
22
|
-
// package.json in a parent directory of the install location.
|
|
23
|
-
//
|
|
24
|
-
// Install: see README "Claude Code: Phase-Scope Guard".
|
|
25
|
-
|
|
26
|
-
const fs = require('fs');
|
|
27
|
-
|
|
28
|
-
const MUTATORS = new Set(['Edit', 'Write', 'NotebookEdit']);
|
|
29
|
-
const MARKER = 'Maestro phase-scope guard:';
|
|
30
|
-
|
|
31
|
-
// Best-effort file targets of a Bash command's mutating constructs.
|
|
32
|
-
function bashTargets(cmd) {
|
|
33
|
-
const files = [];
|
|
34
|
-
if (typeof cmd !== 'string') return files;
|
|
35
|
-
const strip = t => t.replace(/^["']+|["']+$/g, '');
|
|
36
|
-
const ok = t => t && !t.startsWith('-') && !/[$`*?{}()\[\]<>]/.test(t) && t !== '/dev/null' && !/^nul$/i.test(t);
|
|
37
|
-
let m;
|
|
38
|
-
const redir = /(?<![-=<>])>{1,2}\s*([^\s;&|<>]+)/g;
|
|
39
|
-
while ((m = redir.exec(cmd))) { const t = strip(m[1]); if (ok(t)) files.push(t); }
|
|
40
|
-
for (let seg of cmd.split(/(?:&&|\|\||[;|\n])/)) {
|
|
41
|
-
seg = seg.replace(/(?<![-=<>])>{1,2}\s*[^\s;&|<>]+/g, ' ');
|
|
42
|
-
const toks = seg.trim().split(/\s+/).filter(Boolean);
|
|
43
|
-
while (toks.length && (toks[0] === 'sudo' || /^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[0]))) toks.shift();
|
|
44
|
-
const name = toks[0];
|
|
45
|
-
if (!name) continue;
|
|
46
|
-
const args = toks.slice(1).filter(a => !a.startsWith('-')).map(strip).filter(ok);
|
|
47
|
-
if ((name === 'mv' || name === 'cp') && args.length >= 2) files.push(args[args.length - 1]);
|
|
48
|
-
else if (name === 'rm' || name === 'mkdir' || name === 'touch' || name === 'tee') files.push(...args);
|
|
49
|
-
else if (name === 'sed' && /(^|\s)-i/.test(seg) && args.length) files.push(args[args.length - 1]);
|
|
50
|
-
}
|
|
51
|
-
return files;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
let data = {};
|
|
55
|
-
try { data = JSON.parse(fs.readFileSync(0, 'utf8')); } catch { process.exit(0); }
|
|
56
|
-
|
|
57
|
-
let lines = [];
|
|
58
|
-
if (data.transcript_path && fs.existsSync(data.transcript_path)) {
|
|
59
|
-
try {
|
|
60
|
-
const buf = fs.readFileSync(data.transcript_path, 'utf8');
|
|
61
|
-
lines = (buf.length > 4000000 ? buf.slice(-4000000) : buf).split(/\r?\n/);
|
|
62
|
-
} catch {}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Locate the last genuine user prompt (typed text, not a tool_result
|
|
66
|
-
// carrier). Everything after it is the current turn.
|
|
67
|
-
let turnStart = 0;
|
|
68
|
-
const parsed = lines.map(l => { try { return JSON.parse(l); } catch { return null; } });
|
|
69
|
-
for (let i = 0; i < parsed.length; i++) {
|
|
70
|
-
const e = parsed[i];
|
|
71
|
-
if (!e || e.type !== 'user' || e.isMeta || !e.message) continue;
|
|
72
|
-
const c = e.message.content;
|
|
73
|
-
const genuine = typeof c === 'string'
|
|
74
|
-
? true
|
|
75
|
-
: Array.isArray(c) && c.some(x => x && x.type === 'text') && !c.some(x => x && x.type === 'tool_result');
|
|
76
|
-
if (genuine) turnStart = i;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Fire once per turn.
|
|
80
|
-
for (let i = turnStart; i < lines.length; i++) {
|
|
81
|
-
if (lines[i].includes(MARKER)) process.exit(0);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const files = new Set();
|
|
85
|
-
for (let i = turnStart; i < parsed.length; i++) {
|
|
86
|
-
const e = parsed[i];
|
|
87
|
-
if (!e || e.type !== 'assistant' || !e.message || !Array.isArray(e.message.content)) continue;
|
|
88
|
-
for (const item of e.message.content) {
|
|
89
|
-
if (!item || item.type !== 'tool_use' || !item.input) continue;
|
|
90
|
-
if (MUTATORS.has(item.name)) {
|
|
91
|
-
const p = item.input.file_path || item.input.notebook_path;
|
|
92
|
-
if (p) files.add(p);
|
|
93
|
-
} else if (item.name === 'Bash') {
|
|
94
|
-
for (const t of bashTargets(item.input.command)) files.add(t);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
// The triggering call may not be in the transcript yet.
|
|
99
|
-
if (data.tool_input) {
|
|
100
|
-
if (MUTATORS.has(data.tool_name)) {
|
|
101
|
-
const p = data.tool_input.file_path || data.tool_input.notebook_path;
|
|
102
|
-
if (p) files.add(p);
|
|
103
|
-
} else if (data.tool_name === 'Bash') {
|
|
104
|
-
for (const t of bashTargets(data.tool_input.command)) files.add(t);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const cap = parseInt(process.env.MAESTRO_PHASE_FILE_CAP, 10) || 5;
|
|
109
|
-
if (files.size > cap) {
|
|
110
|
-
process.stdout.write(JSON.stringify({
|
|
111
|
-
hookSpecificOutput: {
|
|
112
|
-
hookEventName: 'PostToolUse',
|
|
113
|
-
additionalContext: `${MARKER} ${files.size} distinct files modified this turn exceeds the max-${cap}-files-per-phase rule (AGENTS.md S7.1). Complete and verify the current phase before expanding scope, or split the remaining work into a follow-up phase. If the user explicitly approved a wider batch (e.g. a bulk rename), proceed.`
|
|
114
|
-
}
|
|
115
|
-
}));
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
process.exit(0);
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Maestro PostToolUse phase-scope guard. Enforces AGENTS.md S7.1
|
|
3
|
+
// structurally: max 5 files modified per phase. Counts distinct files
|
|
4
|
+
// touched by Edit/Write/NotebookEdit -- plus Bash mutations whose
|
|
5
|
+
// target path is statically extractable (redirects, sed -i, tee, mv,
|
|
6
|
+
// cp, rm, mkdir, touch) -- since the last real user prompt (one turn
|
|
7
|
+
// ~ one phase for interactive work) and warns once per turn when the
|
|
8
|
+
// count exceeds MAESTRO_PHASE_FILE_CAP (default 5). Targets with
|
|
9
|
+
// shell expansion ($, backticks, globs) are skipped: a missed count
|
|
10
|
+
// is cheaper than a false warning. git commit / npm install mutate
|
|
11
|
+
// but name no file; they are out of scope for a file counter.
|
|
12
|
+
//
|
|
13
|
+
// Wire with matcher "Edit|Write|NotebookEdit|Bash" so it only runs on
|
|
14
|
+
// file-modifying tools. Soft warning via additionalContext -- never
|
|
15
|
+
// blocks; the agent decides whether the scope is justified (e.g. a
|
|
16
|
+
// user-approved bulk rename). Degrades silently on missing payload
|
|
17
|
+
// fields. Payload fields verified against code.claude.com/docs/en/hooks
|
|
18
|
+
// (PostToolUse input: tool_name, tool_input, transcript_path;
|
|
19
|
+
// output: hookSpecificOutput.additionalContext), 2026-06-10.
|
|
20
|
+
//
|
|
21
|
+
// .cjs so Node treats it as CommonJS regardless of any "type": "module"
|
|
22
|
+
// package.json in a parent directory of the install location.
|
|
23
|
+
//
|
|
24
|
+
// Install: see README "Claude Code: Phase-Scope Guard".
|
|
25
|
+
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
|
|
28
|
+
const MUTATORS = new Set(['Edit', 'Write', 'NotebookEdit']);
|
|
29
|
+
const MARKER = 'Maestro phase-scope guard:';
|
|
30
|
+
|
|
31
|
+
// Best-effort file targets of a Bash command's mutating constructs.
|
|
32
|
+
function bashTargets(cmd) {
|
|
33
|
+
const files = [];
|
|
34
|
+
if (typeof cmd !== 'string') return files;
|
|
35
|
+
const strip = t => t.replace(/^["']+|["']+$/g, '');
|
|
36
|
+
const ok = t => t && !t.startsWith('-') && !/[$`*?{}()\[\]<>]/.test(t) && t !== '/dev/null' && !/^nul$/i.test(t);
|
|
37
|
+
let m;
|
|
38
|
+
const redir = /(?<![-=<>])>{1,2}\s*([^\s;&|<>]+)/g;
|
|
39
|
+
while ((m = redir.exec(cmd))) { const t = strip(m[1]); if (ok(t)) files.push(t); }
|
|
40
|
+
for (let seg of cmd.split(/(?:&&|\|\||[;|\n])/)) {
|
|
41
|
+
seg = seg.replace(/(?<![-=<>])>{1,2}\s*[^\s;&|<>]+/g, ' ');
|
|
42
|
+
const toks = seg.trim().split(/\s+/).filter(Boolean);
|
|
43
|
+
while (toks.length && (toks[0] === 'sudo' || /^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[0]))) toks.shift();
|
|
44
|
+
const name = toks[0];
|
|
45
|
+
if (!name) continue;
|
|
46
|
+
const args = toks.slice(1).filter(a => !a.startsWith('-')).map(strip).filter(ok);
|
|
47
|
+
if ((name === 'mv' || name === 'cp') && args.length >= 2) files.push(args[args.length - 1]);
|
|
48
|
+
else if (name === 'rm' || name === 'mkdir' || name === 'touch' || name === 'tee') files.push(...args);
|
|
49
|
+
else if (name === 'sed' && /(^|\s)-i/.test(seg) && args.length) files.push(args[args.length - 1]);
|
|
50
|
+
}
|
|
51
|
+
return files;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let data = {};
|
|
55
|
+
try { data = JSON.parse(fs.readFileSync(0, 'utf8')); } catch { process.exit(0); }
|
|
56
|
+
|
|
57
|
+
let lines = [];
|
|
58
|
+
if (data.transcript_path && fs.existsSync(data.transcript_path)) {
|
|
59
|
+
try {
|
|
60
|
+
const buf = fs.readFileSync(data.transcript_path, 'utf8');
|
|
61
|
+
lines = (buf.length > 4000000 ? buf.slice(-4000000) : buf).split(/\r?\n/);
|
|
62
|
+
} catch {}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Locate the last genuine user prompt (typed text, not a tool_result
|
|
66
|
+
// carrier). Everything after it is the current turn.
|
|
67
|
+
let turnStart = 0;
|
|
68
|
+
const parsed = lines.map(l => { try { return JSON.parse(l); } catch { return null; } });
|
|
69
|
+
for (let i = 0; i < parsed.length; i++) {
|
|
70
|
+
const e = parsed[i];
|
|
71
|
+
if (!e || e.type !== 'user' || e.isMeta || !e.message) continue;
|
|
72
|
+
const c = e.message.content;
|
|
73
|
+
const genuine = typeof c === 'string'
|
|
74
|
+
? true
|
|
75
|
+
: Array.isArray(c) && c.some(x => x && x.type === 'text') && !c.some(x => x && x.type === 'tool_result');
|
|
76
|
+
if (genuine) turnStart = i;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Fire once per turn.
|
|
80
|
+
for (let i = turnStart; i < lines.length; i++) {
|
|
81
|
+
if (lines[i].includes(MARKER)) process.exit(0);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const files = new Set();
|
|
85
|
+
for (let i = turnStart; i < parsed.length; i++) {
|
|
86
|
+
const e = parsed[i];
|
|
87
|
+
if (!e || e.type !== 'assistant' || !e.message || !Array.isArray(e.message.content)) continue;
|
|
88
|
+
for (const item of e.message.content) {
|
|
89
|
+
if (!item || item.type !== 'tool_use' || !item.input) continue;
|
|
90
|
+
if (MUTATORS.has(item.name)) {
|
|
91
|
+
const p = item.input.file_path || item.input.notebook_path;
|
|
92
|
+
if (p) files.add(p);
|
|
93
|
+
} else if (item.name === 'Bash') {
|
|
94
|
+
for (const t of bashTargets(item.input.command)) files.add(t);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// The triggering call may not be in the transcript yet.
|
|
99
|
+
if (data.tool_input) {
|
|
100
|
+
if (MUTATORS.has(data.tool_name)) {
|
|
101
|
+
const p = data.tool_input.file_path || data.tool_input.notebook_path;
|
|
102
|
+
if (p) files.add(p);
|
|
103
|
+
} else if (data.tool_name === 'Bash') {
|
|
104
|
+
for (const t of bashTargets(data.tool_input.command)) files.add(t);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const cap = parseInt(process.env.MAESTRO_PHASE_FILE_CAP, 10) || 5;
|
|
109
|
+
if (files.size > cap) {
|
|
110
|
+
process.stdout.write(JSON.stringify({
|
|
111
|
+
hookSpecificOutput: {
|
|
112
|
+
hookEventName: 'PostToolUse',
|
|
113
|
+
additionalContext: `${MARKER} ${files.size} distinct files modified this turn exceeds the max-${cap}-files-per-phase rule (AGENTS.md S7.1). Complete and verify the current phase before expanding scope, or split the remaining work into a follow-up phase. If the user explicitly approved a wider batch (e.g. a bulk rename), proceed.`
|
|
114
|
+
}
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
process.exit(0);
|
|
@@ -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);
|