vibecoding-devtools 1.0.2 → 1.1.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/README.md +6 -5
- package/hooks/maestri-protocol.mjs +4 -3
- package/installer/cli.mjs +8 -0
- package/installer/codex-maestri.rules +13 -0
- package/installer/env-list.mjs +70 -0
- package/installer/execute.mjs +27 -4
- package/installer/json-merge.mjs +4 -4
- package/installer/plan.mjs +31 -1
- package/installer/team-checks.mjs +86 -0
- package/installer/team.mjs +260 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +3 -1
- package/rules/maestri-usage.md +2 -0
- package/skills/maestri-agent-cli/SKILL.md +4 -2
- package/skills/maestri-agent-cli/references/antigravity.md +24 -0
- package/skills/maestri-agent-cli/references/codex.md +16 -0
- package/skills/maestri-protocol/SKILL.md +12 -7
- package/team/README.md +139 -0
- package/team/notes/project-context/acceptance-criteria.md +6 -0
- package/team/notes/project-context/code-standards.md +8 -0
- package/team/notes/project-context/contracts-integrations.md +10 -0
- package/team/notes/project-context/decisions.md +7 -0
- package/team/notes/project-context/design-system.md +8 -0
- package/team/notes/project-context/ops-runbook.md +10 -0
- package/team/notes/project-context/requirements.md +23 -0
- package/team/notes/project-context/risks-backlog.md +6 -0
- package/team/notes/team-roster.md +67 -0
- package/team/partitura/vibecoding-devtools.maestripartitura +4908 -0
- package/team/roles/_team-protocol.md +12 -0
- package/team/roles/appsec-auditor.md +45 -0
- package/team/roles/backend-architect.md +44 -0
- package/team/roles/backend-engineer.md +44 -0
- package/team/roles/code-reviewer.md +43 -0
- package/team/roles/devops-engineer.md +41 -0
- package/team/roles/frontend-engineer.md +45 -0
- package/team/roles/qa-engineer.md +41 -0
- package/team/roles/research-analyst.md +34 -0
- package/team/roles/task-runner.md +26 -0
- package/team/roles/team-maestro.md +80 -0
- package/team/roles/ux-ui-designer.md +49 -0
- package/team/roles/visual-asset-designer.md +45 -0
- package/team/roles/visual-qa-inspector.md +36 -0
- package/team/settings/task-runner-limited.json +55 -0
- package/team/team.json +59 -0
package/README.md
CHANGED
|
@@ -10,12 +10,12 @@ instalados das fontes oficiais.
|
|
|
10
10
|
## Instalação
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
npx vibecoding-devtools@1.0
|
|
14
|
-
npx vibecoding-devtools@1.0
|
|
15
|
-
npx vibecoding-devtools@1.0
|
|
13
|
+
npx vibecoding-devtools@1.1.0 install # instala pelo painel
|
|
14
|
+
npx vibecoding-devtools@1.1.0 update # reutiliza as escolhas salvas
|
|
15
|
+
npx vibecoding-devtools@1.1.0 uninstall # remove só o que o kit instalou
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
Alternativa pelo GitHub: `npx github:Or4cu1o/vibecoding-devtools#v1.0
|
|
18
|
+
Alternativa pelo GitHub: `npx github:Or4cu1o/vibecoding-devtools#v1.1.0 install` (aceita os mesmos comandos).
|
|
19
19
|
|
|
20
20
|
Requer Node 22+ e git. O instalador detecta Claude Code, Codex e Antigravity, mostra o plano e só grava depois da
|
|
21
21
|
confirmação. Use `--dry-run` para apenas conferir o plano. Detalhes no [guia do instalador](docs/installer.md).
|
|
@@ -28,7 +28,8 @@ confirmação. Use `--dry-run` para apenas conferir o plano. Detalhes no [guia d
|
|
|
28
28
|
| [`skills/`](skills/) | triagem de risco, revisão de segurança e operação do Maestri |
|
|
29
29
|
| [`templates/`](templates/) | `AGENTS.md` e `CLAUDE.md` para projetos, e `.coderabbit.yaml` |
|
|
30
30
|
| [Hooks](hooks/README.md) | `secret-scan`, `maestri-protocol` e `full-suite` implementados |
|
|
31
|
-
| [`
|
|
31
|
+
| [`team/`](team/README.md) | equipe de 13 agentes para o Maestri (roles, notas, partitura `vibecoding-devtools`), instalada com `npx vibecoding-devtools team` |
|
|
32
|
+
| [`docs/`](docs/) | instalação, compatibilidade, Maestri em máquina remota e justificativas das regras |
|
|
32
33
|
|
|
33
34
|
O `secret-scan` verifica segredos com gitleaks antes de commits. O `maestri-protocol` valida mensagens e exige o pacote
|
|
34
35
|
Maestri. O `full-suite` aplica um bloqueio contornável a suítes completas em repositórios com CI; para uma execução
|
|
@@ -6,9 +6,10 @@ const reason = 'Maestri message must use a valid protocol header or end with the
|
|
|
6
6
|
export function validMessage(message) {
|
|
7
7
|
const text = message.replace(/\r\n/g, '\n');
|
|
8
8
|
if (text.startsWith('~')) return header.test(text.split('\n')[0]);
|
|
9
|
-
// Match the skill footer at the end, including its report-back instruction.
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
// Match the skill footer at the end, including its report-back instruction. Its quotes may be double or single,
|
|
10
|
+
// each pair matching, so a prompt already wrapped in double quotes can carry the footer unescaped.
|
|
11
|
+
const footer = text.match(/(?:^|\n)ref: ([^\n]+)\nWhen done, load the maestri-protocol skill and report back with:\nmaestri ask (["'])((?:(?!\2)[^\n])+)\2 (["'])~ ([^|\n]+?) \| STATUS \| ([^|\n]+?) \| one-line summary\4\nSTATUS is one of DONE, PENDING, BLOCKED, ERROR, ACK\. Details go on the following lines\.\s*$/);
|
|
12
|
+
return Boolean(footer && footer[1] === footer[6]);
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
function messages(words) {
|
package/installer/cli.mjs
CHANGED
|
@@ -10,9 +10,12 @@ import { readState, hashContent } from './state.mjs';
|
|
|
10
10
|
import { parseFlags, choicesFromFlags, panel, confirmExecution, Cancellation } from './panel.mjs';
|
|
11
11
|
import * as thirdPartyApi from './third-party.mjs';
|
|
12
12
|
import { jsonMergeSituation } from './json-merge.mjs';
|
|
13
|
+
import { envListSituation } from './env-list.mjs';
|
|
13
14
|
import { CODEX_HOOK_APPROVAL } from './hooks.mjs';
|
|
15
|
+
import { teamMain } from './team.mjs';
|
|
14
16
|
|
|
15
17
|
export const HELP = `Usage: vibecoding-devtools [install|update|detect|uninstall] [flags]
|
|
18
|
+
vibecoding-devtools team [--roles-only] [--update-roles] [--dry-run] (Maestri team; see team --help)
|
|
16
19
|
Default command: install. Without a TTY: use --yes to apply or --dry-run to preview.
|
|
17
20
|
Update reuses saved install choices; explicit flags override them. Run install first if no choices are saved.
|
|
18
21
|
Update removes unchanged kit-owned items absent from the new plan; edited and third-party items are retained.
|
|
@@ -76,6 +79,10 @@ export function summarizePlan(plan, { home, force = false } = {}) {
|
|
|
76
79
|
const previous = state.items.find((i) => i.type === action.type && i.destination === action.destination);
|
|
77
80
|
return { ...action, situation: jsonMergeSituation(current, action, previous) };
|
|
78
81
|
}
|
|
82
|
+
if (action.type === 'env-list') {
|
|
83
|
+
const previous = state.items.find((i) => i.type === action.type && i.destination === action.destination);
|
|
84
|
+
return { ...action, situation: envListSituation(current, action, previous) };
|
|
85
|
+
}
|
|
79
86
|
let desired;
|
|
80
87
|
if (action.type === 'block') {
|
|
81
88
|
const text = current?.toString('utf8') ?? '';
|
|
@@ -148,6 +155,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|
|
148
155
|
isTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY), out = console.log, err = console.error,
|
|
149
156
|
detect = detectAll, choose = panel, confirm = confirmExecution, prepareThirdParty: prepare, thirdParty = thirdPartyApi,
|
|
150
157
|
planBuilder = buildPlan, apply = execute, remove = uninstall } = options;
|
|
158
|
+
if (argv[0] === 'team') return teamMain(argv.slice(1), { kit, out, err, home });
|
|
151
159
|
let flags;
|
|
152
160
|
let warnings = [];
|
|
153
161
|
let errorCode = 2;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# vibecoding-devtools: Maestri commands Codex agents run outside the sandbox without a human prompt.
|
|
2
|
+
# Installed globally as ~/.codex/rules/vibecoding-devtools-maestri.rules when the Maestri pack is selected.
|
|
3
|
+
prefix_rule(pattern = ["maestri", "ask"], decision = "allow")
|
|
4
|
+
prefix_rule(pattern = ["maestri", "check"], decision = "allow")
|
|
5
|
+
prefix_rule(pattern = ["maestri", "list"], decision = "allow")
|
|
6
|
+
prefix_rule(pattern = ["maestri", "recall"], decision = "allow")
|
|
7
|
+
prefix_rule(pattern = ["maestri", "floor", "status"], decision = "allow")
|
|
8
|
+
prefix_rule(pattern = ["maestri", "note", "read"], decision = "allow")
|
|
9
|
+
prefix_rule(pattern = ["maestri", "note", "edit"], decision = "allow")
|
|
10
|
+
prefix_rule(pattern = ["maestri", "portal"], decision = "allow")
|
|
11
|
+
prefix_rule(pattern = ["maestri", "portal", "close"], decision = "forbidden")
|
|
12
|
+
prefix_rule(pattern = ["maestri", "note", "delete"], decision = "forbidden")
|
|
13
|
+
prefix_rule(pattern = ["maestri", "dismiss"], decision = "forbidden")
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Kit values inside one comma-separated string of a JSON settings file's `env` object, such as
|
|
2
|
+
// GATEGUARD_EXEMPT_GLOBS in ~/.claude/settings.json. The rest of the file stays byte for byte; the value keeps the
|
|
3
|
+
// user's entries and spacing (the string is written back with JSON escaping, so escapes such as \u002f decode).
|
|
4
|
+
import { isUtf8 } from 'node:buffer';
|
|
5
|
+
import { append, parse, property, removeChild } from './json-merge.mjs';
|
|
6
|
+
|
|
7
|
+
function validate(action) {
|
|
8
|
+
if (typeof action.key !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(action.key) || !Array.isArray(action.values)
|
|
9
|
+
|| !action.values.length || action.values.some((v) => typeof v !== 'string' || !v.trim() || v.includes(','))) {
|
|
10
|
+
throw new Error('Invalid env list action');
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function locate(text, key) {
|
|
15
|
+
const root = parse(text);
|
|
16
|
+
const env = property(root, 'env');
|
|
17
|
+
if (env && env.node.kind !== 'object') throw new Error('env must be a JSON object');
|
|
18
|
+
const entry = env && property(env.node, key);
|
|
19
|
+
if (entry && typeof entry.node.value !== 'string') throw new Error(`env.${key} must be a string`);
|
|
20
|
+
return { root, env, entry };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// `record` is the state item of an earlier install: its values, the ones the user already had (`preexisting`, never
|
|
24
|
+
// removed) and the containers the kit created. Install adds the missing values and drops values an earlier kit
|
|
25
|
+
// version added and this one no longer lists; remove drops the kit's values.
|
|
26
|
+
export function envListMerge(current, action, record = null, { remove = false } = {}) {
|
|
27
|
+
validate(action);
|
|
28
|
+
if (current !== null && !isUtf8(current)) throw new Error('Settings file is not valid UTF-8');
|
|
29
|
+
const text = current === null ? '{}\n' : current.toString('utf8');
|
|
30
|
+
const { root, env, entry } = locate(text, action.key);
|
|
31
|
+
// Raw segments keep the user's spacing; values compare trimmed.
|
|
32
|
+
const parts = entry ? entry.node.value.split(',') : [];
|
|
33
|
+
const list = parts.map((v) => v.trim()).filter(Boolean);
|
|
34
|
+
// A value the user had before the kit first listed it stays theirs, including values a later kit version adds.
|
|
35
|
+
const preexisting = [...new Set([...(record?.preexisting ?? []),
|
|
36
|
+
...action.values.filter((v) => list.includes(v) && !(record?.values ?? []).includes(v))])];
|
|
37
|
+
const createdPaths = record?.createdPaths ?? [...(entry ? [] : [['env', action.key]]), ...(env ? [] : [['env']])];
|
|
38
|
+
const wanted = remove ? [] : action.values;
|
|
39
|
+
const dropped = new Set([...(record?.values ?? []), ...(remove ? action.values : [])]
|
|
40
|
+
.filter((v) => !preexisting.includes(v) && !wanted.includes(v)));
|
|
41
|
+
const added = wanted.filter((v) => !list.includes(v));
|
|
42
|
+
const result = { preexisting, createdPaths };
|
|
43
|
+
if (!added.length && !list.some((v) => dropped.has(v))) return { ...result, same: true, content: current };
|
|
44
|
+
|
|
45
|
+
const kept = parts.filter((v) => !dropped.has(v.trim()));
|
|
46
|
+
const next = [...(kept.some((v) => v.trim()) ? kept : []), ...added];
|
|
47
|
+
const created = (keys) => createdPaths.some((p) => p.join('\0') === keys.join('\0'));
|
|
48
|
+
const joined = next.join(',');
|
|
49
|
+
let out;
|
|
50
|
+
if (next.length && entry) out = text.slice(0, entry.node.start) + JSON.stringify(joined) + text.slice(entry.node.end);
|
|
51
|
+
else if (next.length) {
|
|
52
|
+
out = env ? append(text, env.node, action.key, joined) : append(text, root, 'env', { [action.key]: joined });
|
|
53
|
+
} else if (created(['env', action.key])) {
|
|
54
|
+
out = removeChild(text, env.node, entry);
|
|
55
|
+
const rest = parse(out);
|
|
56
|
+
const after = property(rest, 'env');
|
|
57
|
+
if (created(['env']) && !after.node.children.length) out = removeChild(out, rest, after);
|
|
58
|
+
} else out = text.slice(0, entry.node.start) + '""' + text.slice(entry.node.end);
|
|
59
|
+
return { ...result, same: false, content: Buffer.from(out) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Same bytes with another value list in the state is an update: the state must stop owning values the kit dropped.
|
|
63
|
+
export const envListStale = (record, action) => JSON.stringify(record?.values) !== JSON.stringify(action.values);
|
|
64
|
+
|
|
65
|
+
export function envListSituation(current, action, previous) {
|
|
66
|
+
try {
|
|
67
|
+
if (envListMerge(current, action, previous).same && !(previous && envListStale(previous, action))) return 'same';
|
|
68
|
+
return previous ? 'update' : 'new';
|
|
69
|
+
} catch { return 'conflict'; }
|
|
70
|
+
}
|
package/installer/execute.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
5
5
|
import { isUtf8 } from 'node:buffer';
|
|
6
6
|
import { readState, saveState, hashContent, writeAtomic, withLock, kitVersion } from './state.mjs';
|
|
7
7
|
import { mergeJson, jsonMergeSnapshot } from './json-merge.mjs';
|
|
8
|
+
import { envListMerge, envListStale } from './env-list.mjs';
|
|
8
9
|
|
|
9
10
|
export const START = '<!-- vibecoding-devtools:start -->';
|
|
10
11
|
export const END = '<!-- vibecoding-devtools:end -->';
|
|
@@ -17,6 +18,7 @@ const marker = (item) => item.type === 'block' ? item.marker ?? START : null;
|
|
|
17
18
|
const identity = (item) => item.type === 'command'
|
|
18
19
|
? JSON.stringify([item.type, item.cmd, item.args, item.cwd, item.cli, item.owner])
|
|
19
20
|
: JSON.stringify([item.type, path.resolve(item.destination), marker(item)]);
|
|
21
|
+
const emptyObject = (bytes) => Object.keys(JSON.parse(bytes.toString('utf8').replace(/^\uFEFF/, ''))).length === 0;
|
|
20
22
|
const confirmed = ({ pending, operation, previous, hashBefore, diskHash, ...item }) => item;
|
|
21
23
|
|
|
22
24
|
function checkPath(destination) {
|
|
@@ -206,8 +208,8 @@ export function execute(plan, { dryRun = false, force = false, home = os.homedir
|
|
|
206
208
|
});
|
|
207
209
|
return;
|
|
208
210
|
}
|
|
209
|
-
if (!['copy', 'write', 'block', 'json-merge'].includes(action.type)) throw new Error(`Unknown type: ${action.type}`);
|
|
210
|
-
if (!['copy', 'json-merge'].includes(action.type) && typeof action.content !== 'string'
|
|
211
|
+
if (!['copy', 'write', 'block', 'json-merge', 'env-list'].includes(action.type)) throw new Error(`Unknown type: ${action.type}`);
|
|
212
|
+
if (!['copy', 'json-merge', 'env-list'].includes(action.type) && typeof action.content !== 'string'
|
|
211
213
|
&& !(action.type === 'write' && Buffer.isBuffer(action.content))) throw new Error('Action requires text content');
|
|
212
214
|
const destination = path.resolve(action.destination);
|
|
213
215
|
checkPath(destination);
|
|
@@ -248,6 +250,18 @@ export function execute(plan, { dryRun = false, force = false, home = os.homedir
|
|
|
248
250
|
extra = { hash: merged.after.hash, format: action.format, scriptPaths: action.scriptPaths,
|
|
249
251
|
scriptReferences: action.scriptReferences,
|
|
250
252
|
created: previous?.created ?? !st, createdPaths: previous?.createdPaths ?? merged.createdPaths };
|
|
253
|
+
} else if (action.type === 'env-list') {
|
|
254
|
+
let merged;
|
|
255
|
+
try { merged = envListMerge(current, action, previous); }
|
|
256
|
+
catch (e) { conflict(action, e.message); return; }
|
|
257
|
+
if (merged.same && !(previous && envListStale(previous, action))) {
|
|
258
|
+
r.actions.push({ ...action, status: 'same' });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
// Same bytes still record the current values, so uninstall never takes a value the kit no longer lists.
|
|
262
|
+
content = merged.same ? current : merged.content;
|
|
263
|
+
extra = { key: action.key, values: action.values, preexisting: merged.preexisting,
|
|
264
|
+
created: previous?.created ?? !st, createdPaths: merged.createdPaths };
|
|
251
265
|
} else if (action.type === 'block') {
|
|
252
266
|
const text = current ? utf8(current) : '';
|
|
253
267
|
if (text === null) { conflict(action, 'File is not valid UTF-8'); return; }
|
|
@@ -329,7 +343,9 @@ function runtimeRegistration(item, context) {
|
|
|
329
343
|
|
|
330
344
|
function removeItems(context, r, items, home) {
|
|
331
345
|
const dryRun = r.dryRun;
|
|
332
|
-
|
|
346
|
+
// Env lists share settings.json with hook registrations; removing them first lets the item that created the file
|
|
347
|
+
// delete it once it is empty.
|
|
348
|
+
const isRegistration = (item) => ['block', 'json-merge', 'env-list'].includes(item.type);
|
|
333
349
|
const order = [...items].reverse().sort((a, b) => Number(isRegistration(b)) - Number(isRegistration(a)));
|
|
334
350
|
const { virtualFiles } = context;
|
|
335
351
|
for (const item of order) {
|
|
@@ -360,7 +376,14 @@ function removeItems(context, r, items, home) {
|
|
|
360
376
|
if (!snapshot.count) { context.forget(item); r.actions.push({ ...item, status: 'missing' }); continue; }
|
|
361
377
|
if (snapshot.hash !== item.hash) { r.warnings.push({ item, reason: 'Modified JSON entries; retained' }); continue; }
|
|
362
378
|
remaining = mergeJson(current, item, { remove: true }).content;
|
|
363
|
-
if (item.created &&
|
|
379
|
+
if (item.created && emptyObject(remaining)) remaining = null;
|
|
380
|
+
} else if (item.type === 'env-list') {
|
|
381
|
+
let merged;
|
|
382
|
+
try { merged = envListMerge(current, item, item, { remove: true }); }
|
|
383
|
+
catch (e) { r.warnings.push({ item, reason: `${e.message}; retained` }); continue; }
|
|
384
|
+
if (merged.same) { context.forget(item); r.actions.push({ ...item, status: 'missing' }); continue; }
|
|
385
|
+
remaining = merged.content;
|
|
386
|
+
if (item.created && emptyObject(remaining)) remaining = null;
|
|
364
387
|
} else if (item.type === 'block') {
|
|
365
388
|
const text = utf8(current);
|
|
366
389
|
if (text === null) { r.warnings.push({ item, reason: 'File is not valid UTF-8; retained' }); continue; }
|
package/installer/json-merge.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { hashContent } from './state.mjs';
|
|
4
4
|
|
|
5
5
|
// Keep source ranges so edits never reserialize unrelated settings or hooks.
|
|
6
|
-
function parse(text) {
|
|
6
|
+
export function parse(text) {
|
|
7
7
|
JSON.parse(text.replace(/^\uFEFF/, ''));
|
|
8
8
|
let offset = text.charCodeAt(0) === 0xFEFF ? 1 : 0;
|
|
9
9
|
const whitespace = () => { while (/\s/.test(text[offset] ?? '') && offset < text.length) offset++; };
|
|
@@ -53,7 +53,7 @@ function parse(text) {
|
|
|
53
53
|
return root;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
const property = (node, key) => node.children.find((child) => child.key === key);
|
|
56
|
+
export const property = (node, key) => node.children.find((child) => child.key === key);
|
|
57
57
|
const normalizePath = (file) => file.replace(/\\/g, '/');
|
|
58
58
|
function ownsHook(hook, action) {
|
|
59
59
|
if (!hook || typeof hook !== 'object') return false;
|
|
@@ -115,7 +115,7 @@ export function jsonMergeSnapshot(current, action) {
|
|
|
115
115
|
return { hash: hashContent(JSON.stringify(entries.map((entry) => entry.fingerprint))), count: entries.length };
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
function removeChild(text, parent, entry) {
|
|
118
|
+
export function removeChild(text, parent, entry) {
|
|
119
119
|
const index = parent.children.indexOf(entry);
|
|
120
120
|
const next = parent.children[index + 1];
|
|
121
121
|
const previous = parent.children[index - 1];
|
|
@@ -150,7 +150,7 @@ function removeEmptyContainers(text, paths) {
|
|
|
150
150
|
return text;
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
-
function append(text, parent, key, value) {
|
|
153
|
+
export function append(text, parent, key, value) {
|
|
154
154
|
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
155
155
|
const unit = text.match(/\n([ \t]+)"/)?.[1] ?? ' ';
|
|
156
156
|
const lineStart = text.lastIndexOf('\n', parent.start) + 1;
|
package/installer/plan.mjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// Read-only planning performs no writes, network calls, execution or real-home access.
|
|
2
2
|
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { isUtf8 } from "node:buffer";
|
|
5
6
|
import { readState, hashContent } from "./state.mjs";
|
|
6
7
|
import { convertAntigravityRule, readFrontmatter, buildCodexBlock } from "./convert.mjs";
|
|
7
8
|
import { hookActions } from './hooks.mjs';
|
|
8
9
|
import { jsonMergeSituation } from './json-merge.mjs';
|
|
10
|
+
import { envListSituation } from './env-list.mjs';
|
|
9
11
|
|
|
10
12
|
const CLIS = ["claude", "codex", "agy"];
|
|
11
13
|
const uniqueList = (items = []) => [...new Set(items)].sort();
|
|
@@ -84,6 +86,7 @@ function situationOf(action, items) {
|
|
|
84
86
|
let previous = items.find((item) => item.type === action.type && samePath(item.destination, action.destination));
|
|
85
87
|
if (previous?.pending && previous.hashBefore === hashContent(current)) previous = previous.previous;
|
|
86
88
|
if (action.type === 'json-merge') return jsonMergeSituation(current, action, previous);
|
|
89
|
+
if (action.type === 'env-list') return envListSituation(current, action, previous);
|
|
87
90
|
if (action.type === "block") {
|
|
88
91
|
if (!isUtf8(current)) return "conflict";
|
|
89
92
|
const block = extractBlock(current.toString("utf8"));
|
|
@@ -135,11 +138,25 @@ export function reconcileUpdate(plan, { home }) {
|
|
|
135
138
|
item.type === 'block' ? item.marker ?? START : null]);
|
|
136
139
|
const wanted = new Set(actions.filter((a) => a.destination).map(key));
|
|
137
140
|
const removals = state.items.filter((item) => item.owner === 'kit'
|
|
138
|
-
&& ['copy', 'write', 'block', 'json-merge'].includes(item.type) && !wanted.has(key(item)))
|
|
141
|
+
&& ['copy', 'write', 'block', 'json-merge', 'env-list'].includes(item.type) && !wanted.has(key(item)))
|
|
139
142
|
.map((item) => ({ ...item, operation: 'remove', situation: 'remove' }));
|
|
140
143
|
return Object.assign([...removals, ...actions], { warnings: plan.warnings, preparation: plan.preparation });
|
|
141
144
|
}
|
|
142
145
|
|
|
146
|
+
// ECC's GateGuard asks for facts before the first edit of each file. These trees have no import value and agents
|
|
147
|
+
// write to them all the time: Claude Code's temp directory (scratchpad), its auto memory and the Maestri role folders.
|
|
148
|
+
// The temp directory follows CLAUDE_CODE_TMPDIR as Claude Code documents it: <dir>/claude-<uid> on Unix,
|
|
149
|
+
// <dir>/claude on Windows, with /tmp or the system temp directory as <dir>.
|
|
150
|
+
export function gateguardExemptions({ home, maestri, env = process.env, platform = process.platform,
|
|
151
|
+
uid = process.getuid?.() }) {
|
|
152
|
+
const slash = (file) => file.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
153
|
+
const windows = platform === "win32";
|
|
154
|
+
const base = slash(env.CLAUDE_CODE_TMPDIR || (windows ? os.tmpdir() : "/tmp"));
|
|
155
|
+
const temp = windows ? `${base}/claude` : uid == null ? null : `${base}/claude-${uid}`;
|
|
156
|
+
return [...(temp ? [`${temp}/**`] : []), `${slash(home)}/.claude/projects/*/memory/**`,
|
|
157
|
+
...(maestri ? ["**/.maestri/**"] : [])];
|
|
158
|
+
}
|
|
159
|
+
|
|
143
160
|
export function buildPlan(choices, detection, paths) {
|
|
144
161
|
const { home, kit, ecc, project } = paths;
|
|
145
162
|
if (!home || !kit) throw new Error("paths.home and paths.kit are required");
|
|
@@ -206,6 +223,12 @@ export function buildPlan(choices, detection, paths) {
|
|
|
206
223
|
add("copy", "kit", cli, path.join(kit, "skills", skill.name),
|
|
207
224
|
path.join(destinationSkills, skill.name), null, `Kit skill: ${skill.name}`);
|
|
208
225
|
}
|
|
226
|
+
// Agents message each other through the maestri CLI, which runs outside Codex's sandbox. The rules go to the
|
|
227
|
+
// user layer in either scope so every workspace's agents get them.
|
|
228
|
+
if (cli === "codex" && choices.maestri) {
|
|
229
|
+
add("copy", "kit", cli, path.join(kit, "installer", "codex-maestri.rules"),
|
|
230
|
+
path.join(home, ".codex", "rules", "vibecoding-devtools-maestri.rules"), null, "Maestri command rules for Codex");
|
|
231
|
+
}
|
|
209
232
|
}
|
|
210
233
|
if (eccClis.includes(cli)) {
|
|
211
234
|
const packages = uniqueList(["common", ...(cli === "claude" ? ["web"] : []),
|
|
@@ -249,6 +272,13 @@ export function buildPlan(choices, detection, paths) {
|
|
|
249
272
|
}
|
|
250
273
|
}
|
|
251
274
|
actions.push(...hookActions(choices, { home, kit, project }));
|
|
275
|
+
// Any ECC on Claude: the ECC plugin brings GateGuard even with the kit's ECC hooks off. The paths are this machine's,
|
|
276
|
+
// so they go to the user settings in either scope, never to a project's file.
|
|
277
|
+
if (eccClis.includes("claude")) {
|
|
278
|
+
actions.push({ type: "env-list", owner: "kit", cli: "claude", destination: path.join(home, ".claude", "settings.json"),
|
|
279
|
+
key: "GATEGUARD_EXEMPT_GLOBS", values: gateguardExemptions({ home, maestri: choices.maestri }),
|
|
280
|
+
description: "GateGuard exemptions for scratch, memory and Maestri files" });
|
|
281
|
+
}
|
|
252
282
|
const plan = actions.map((action) => ({ ...action, situation: situationOf(action, state.items) }));
|
|
253
283
|
if (warnings.length) plan.warnings = warnings;
|
|
254
284
|
return plan;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Read-only prerequisites of the team install: the CLIs, the models the launch commands name, and ECC in Claude Code.
|
|
2
|
+
// Everything that touches the machine goes through `tools`, so tests inject fakes instead of running real CLIs.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { findIn, pathDirectories } from './detect.mjs';
|
|
6
|
+
|
|
7
|
+
const binaryOf = (command) => command.trim().split(/\s+/)[0];
|
|
8
|
+
const modelOf = (command) => command.match(/(?:^|\s)(?:--model|-m)[ =](\S+)/)?.[1];
|
|
9
|
+
|
|
10
|
+
// The commands the install launches: presets, and agents that carry their own command instead of a preset.
|
|
11
|
+
const launchCommands = (manifest) => [
|
|
12
|
+
...manifest.presets.map((p) => ({ label: `preset "${p.name}"`, command: p.command })),
|
|
13
|
+
...manifest.agents.filter((a) => a.command).map((a) => ({ label: `agent "${a.name}"`, command: a.command })),
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
// Model ids a CLI lists: `codex debug models` prints JSON with `slug`; `agy models` prints `<id>\t<name>` lines.
|
|
17
|
+
const MODEL_LISTS = {
|
|
18
|
+
codex: { source: 'codex debug models', read: (tools) => JSON.parse(tools.codexModels()).models.map((m) => m.slug) },
|
|
19
|
+
agy: { source: 'agy models', read: (tools) => tools.agyModels().split('\n').filter((l) => l.includes('\t'))
|
|
20
|
+
.map((l) => l.split(/\s+/)[0]) },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const attempt = (what, fn) => {
|
|
24
|
+
try {
|
|
25
|
+
return { value: fn() };
|
|
26
|
+
} catch (e) {
|
|
27
|
+
return { problem: `could not run ${what}: ${(e.stderr?.toString().trim() || e.message).split('\n')[0]}` };
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Missing items, all together; an empty list means the run may start. `rolesOnly` creates roles and launches nothing,
|
|
32
|
+
// so it needs only the maestri binary.
|
|
33
|
+
export function missingPrerequisites(manifest, tools, { maestri = 'maestri', rolesOnly = false } = {}) {
|
|
34
|
+
const launches = rolesOnly ? [] : launchCommands(manifest);
|
|
35
|
+
const bins = [...new Set([maestri, ...launches.map((l) => binaryOf(l.command))])];
|
|
36
|
+
const absent = new Set(bins.filter((bin) => !tools.onPath(bin)));
|
|
37
|
+
const problems = [...absent].map((bin) => `${bin} is not on PATH`);
|
|
38
|
+
|
|
39
|
+
const catalogs = new Map();
|
|
40
|
+
for (const { label, command } of launches) {
|
|
41
|
+
const bin = binaryOf(command);
|
|
42
|
+
const model = modelOf(command);
|
|
43
|
+
if (absent.has(bin) || !model || !MODEL_LISTS[bin]) continue;
|
|
44
|
+
if (!catalogs.has(bin)) {
|
|
45
|
+
const { source, read } = MODEL_LISTS[bin];
|
|
46
|
+
catalogs.set(bin, attempt(source, () => new Set(read(tools))));
|
|
47
|
+
}
|
|
48
|
+
const { value, problem } = catalogs.get(bin);
|
|
49
|
+
if (problem) {
|
|
50
|
+
if (!problems.includes(problem)) problems.push(problem);
|
|
51
|
+
} else if (!value.has(model)) {
|
|
52
|
+
problems.push(`${label}: model "${model}" is not in ${MODEL_LISTS[bin].source}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (bins.includes('claude') && !absent.has('claude')) {
|
|
57
|
+
const { value, problem } = attempt('claude plugin list', () => tools.claudePlugins());
|
|
58
|
+
if (problem) problems.push(problem);
|
|
59
|
+
else if (!/\becc@/.test(value)) {
|
|
60
|
+
problems.push('ECC is not installed in Claude Code (claude plugin list shows no ecc@): '
|
|
61
|
+
+ 'the Team Maestro role uses /ecc:plan');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return problems;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// A path as given, or a name found on PATH (PATHEXT on Windows).
|
|
68
|
+
function onPath(bin, env, platform) {
|
|
69
|
+
if (/[\\/]/.test(bin)) return fs.existsSync(bin);
|
|
70
|
+
try {
|
|
71
|
+
return Boolean(findIn(bin, pathDirectories(env), { env, platform }));
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const BIG_OUTPUT = 64 * 1024 * 1024;
|
|
78
|
+
const runCli = (bin, args) => execFileSync(bin, args,
|
|
79
|
+
{ encoding: 'utf8', maxBuffer: BIG_OUTPUT, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
80
|
+
|
|
81
|
+
export const realTools = ({ env = process.env, platform = process.platform } = {}) => ({
|
|
82
|
+
onPath: (bin) => onPath(bin, env, platform),
|
|
83
|
+
codexModels: () => runCli('codex', ['debug', 'models']),
|
|
84
|
+
agyModels: () => runCli('agy', ['models']),
|
|
85
|
+
claudePlugins: () => runCli('claude', ['plugin', 'list']),
|
|
86
|
+
});
|