@tavaressan/vetor 0.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 +42 -0
- package/bin/vetor.js +6 -0
- package/lib/banner.js +35 -0
- package/lib/commands/install.js +71 -0
- package/lib/commands/status.js +59 -0
- package/lib/commands/uninstall.js +119 -0
- package/lib/commands/update.js +63 -0
- package/lib/installer/command-exists.js +30 -0
- package/lib/installer/cursor-hooks.js +181 -0
- package/lib/installer/detector.js +79 -0
- package/lib/installer/manifest.js +76 -0
- package/lib/installer/prompts.js +97 -0
- package/lib/installer/writer.js +382 -0
- package/lib/router.js +50 -0
- package/package.json +39 -0
- package/templates/.gitkeep +0 -0
- package/templates/agents/code-review/agent.json +27 -0
- package/templates/agents/code-review/codex.toml +37 -0
- package/templates/agents/code-review.md +99 -0
- package/templates/agents/issue-worker/agent.json +33 -0
- package/templates/agents/issue-worker/codex.toml +57 -0
- package/templates/agents/issue-worker.md +112 -0
- package/templates/hooks/hooks-codex.json +48 -0
- package/templates/hooks/hooks.json +62 -0
- package/templates/opencode/agent/code-review.md +73 -0
- package/templates/opencode/agent/issue-coordinator.md +521 -0
- package/templates/opencode/agent/issue-worker.md +64 -0
- package/templates/opencode/mcp.jsonc +39 -0
- package/templates/opencode/plugin/vetor.ts +207 -0
- package/templates/opencode/scripts/agent-registration_test.ts +92 -0
- package/templates/opencode/scripts/check-edit.ts +147 -0
- package/templates/opencode/scripts/ensure-external-directory-permission.ts +110 -0
- package/templates/opencode/scripts/ensure-external-directory-permission_test.ts +142 -0
- package/templates/opencode/scripts/lib/guard.ts +45 -0
- package/templates/opencode/scripts/lib/model-health.ts +133 -0
- package/templates/opencode/scripts/lib/model-health_test.ts +181 -0
- package/templates/opencode/scripts/lib/project.ts +240 -0
- package/templates/opencode/scripts/lib/project_test.ts +45 -0
- package/templates/opencode/scripts/lib/status.ts +69 -0
- package/templates/opencode/scripts/lib/worktree.ts +41 -0
- package/templates/opencode/scripts/model-health.ts +50 -0
- package/templates/opencode/scripts/model-health_test.ts +80 -0
- package/templates/opencode/scripts/resolve-model.ts +112 -0
- package/templates/opencode/scripts/resolve-model_test.ts +185 -0
- package/templates/opencode/scripts/safety-check.ts +203 -0
- package/templates/opencode/scripts/vetor-checks.sh +217 -0
- package/templates/opencode/scripts/vetor-status.sh +99 -0
- package/templates/skills/architecture-review/SKILL.md +187 -0
- package/templates/skills/backlog-ideator/SKILL.md +277 -0
- package/templates/skills/design/SKILL.md +468 -0
- package/templates/skills/design/examples/design-contract-example.md +46 -0
- package/templates/skills/design/examples/prototype-handoff-example.md +142 -0
- package/templates/skills/fix-loop-agent/SKILL.md +255 -0
- package/templates/skills/guardian/SKILL.md +343 -0
- package/templates/skills/issue-coordinator/SKILL.md +596 -0
- package/templates/skills/retro/SKILL.md +156 -0
- package/templates/skills/shared/references/agent-status.template.md +68 -0
- package/templates/skills/shared/references/codebase-design-vocabulary.md +54 -0
- package/templates/skills/shared/references/conflict-resolution.md +94 -0
- package/templates/skills/shared/references/delegate-to-runtime.md +239 -0
- package/templates/skills/shared/references/design-vocabulary.md +508 -0
- package/templates/skills/shared/references/evidence-state.md +365 -0
- package/templates/skills/shared/references/frontend-design-enforcement.md +33 -0
- package/templates/skills/shared/references/grilling-conventions.md +64 -0
- package/templates/skills/shared/references/knowledge-provider-contract.md +150 -0
- package/templates/skills/shared/references/mcp-availability.md +104 -0
- package/templates/skills/shared/references/module-test-map.template.md +72 -0
- package/templates/skills/shared/references/planning-conventions.md +97 -0
- package/templates/skills/shared/references/project-conventions.md +63 -0
- package/templates/skills/shared/references/tdd-conventions.md +81 -0
- package/templates/skills/shared/references/touched-files-cache.md +30 -0
- package/templates/skills/spec/SKILL.md +524 -0
- package/templates/skills/spec-validate/SKILL.md +195 -0
- package/templates/skills/spec-validate/references/traceability.md +169 -0
- package/templates/skills/stack-practices/SKILL.md +151 -0
- package/templates/skills/vetor/SKILL.md +174 -0
- package/templates/skills/worktree-create/SKILL.md +142 -0
- package/templates/skills/worktree-ship/SKILL.md +394 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Teste de ponta-a-ponta do CLI (issue #84): com uma entrada `degraded` sintética em
|
|
2
|
+
// model-health.json para o modelo preferencial, confirma que a escolha cai para o próximo
|
|
3
|
+
// modelo/provedor saudável da lista de fallback — e que, com todos degraded, o script sai com
|
|
4
|
+
// código 1 (o issue-coordinator interpreta isso como "mantenha o grupo QUEUED").
|
|
5
|
+
|
|
6
|
+
import { assertEquals, assertStringIncludes } from "@std/assert";
|
|
7
|
+
|
|
8
|
+
const SCRIPT = new URL("./resolve-model.ts", import.meta.url).pathname;
|
|
9
|
+
|
|
10
|
+
async function git(args: string[], cwd: string): Promise<void> {
|
|
11
|
+
await new Deno.Command("git", { args, cwd, stdout: "null", stderr: "null" }).output();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function makeRepo(): Promise<string> {
|
|
15
|
+
const dir = await Deno.makeTempDir();
|
|
16
|
+
await git(["init", "-q", "-b", "main"], dir);
|
|
17
|
+
await git(["config", "user.email", "test@example.com"], dir);
|
|
18
|
+
await git(["config", "user.name", "Test"], dir);
|
|
19
|
+
await git(["commit", "-q", "--allow-empty", "-m", "init"], dir);
|
|
20
|
+
return dir;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function writeModelHealth(repo: string, content: unknown): Promise<void> {
|
|
24
|
+
const dir = `${repo}/.claude/vetor/status`;
|
|
25
|
+
await Deno.mkdir(dir, { recursive: true });
|
|
26
|
+
await Deno.writeTextFile(`${dir}/model-health.json`, JSON.stringify(content));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function runCli(
|
|
30
|
+
input: unknown,
|
|
31
|
+
): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
32
|
+
const command = new Deno.Command("deno", {
|
|
33
|
+
args: ["run", "-A", SCRIPT],
|
|
34
|
+
stdin: "piped",
|
|
35
|
+
stdout: "piped",
|
|
36
|
+
stderr: "piped",
|
|
37
|
+
});
|
|
38
|
+
const child = command.spawn();
|
|
39
|
+
const writer = child.stdin.getWriter();
|
|
40
|
+
await writer.write(new TextEncoder().encode(JSON.stringify(input)));
|
|
41
|
+
await writer.close();
|
|
42
|
+
const out = await child.output();
|
|
43
|
+
return {
|
|
44
|
+
code: out.code,
|
|
45
|
+
stdout: new TextDecoder().decode(out.stdout).trim(),
|
|
46
|
+
stderr: new TextDecoder().decode(out.stderr).trim(),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
Deno.test("resolve-model CLI - sem model-health.json escolhe o primeiro da lista", async () => {
|
|
51
|
+
const repo = await makeRepo();
|
|
52
|
+
const result = await runCli({
|
|
53
|
+
fallback: ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-4-5"],
|
|
54
|
+
cwd: repo,
|
|
55
|
+
});
|
|
56
|
+
assertEquals(result.code, 0);
|
|
57
|
+
assertEquals(result.stdout, "anthropic/claude-haiku-4-5");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
Deno.test("resolve-model CLI - preferencial degraded (sintético) cai para o próximo", async () => {
|
|
61
|
+
const repo = await makeRepo();
|
|
62
|
+
await writeModelHealth(repo, {
|
|
63
|
+
"anthropic/claude-haiku-4-5": {
|
|
64
|
+
status: "degraded",
|
|
65
|
+
until: Date.now() + 60_000,
|
|
66
|
+
lastError: "HTTP 429",
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const result = await runCli({
|
|
71
|
+
fallback: ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-4-5"],
|
|
72
|
+
cwd: repo,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
assertEquals(result.code, 0);
|
|
76
|
+
assertEquals(result.stdout, "anthropic/claude-sonnet-4-5");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
Deno.test("resolve-model CLI - todos degraded sai com código 1 e sem stdout", async () => {
|
|
80
|
+
const repo = await makeRepo();
|
|
81
|
+
const until = Date.now() + 60_000;
|
|
82
|
+
await writeModelHealth(repo, {
|
|
83
|
+
"anthropic/claude-haiku-4-5": { status: "degraded", until, lastError: "HTTP 429" },
|
|
84
|
+
"anthropic/claude-sonnet-4-5": { status: "degraded", until, lastError: "HTTP 429" },
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const result = await runCli({
|
|
88
|
+
fallback: ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-4-5"],
|
|
89
|
+
cwd: repo,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
assertEquals(result.code, 1);
|
|
93
|
+
assertEquals(result.stdout, "");
|
|
94
|
+
assertStringIncludes(result.stderr, "degraded");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Issue #307: no Git Bash do Windows, `$(pwd)` produz um path POSIX-style (`/c/Users/...`), não
|
|
98
|
+
// nativo (`C:\Users\...`). `resolveWorktree()` roda `git` com esse cwd via `Deno.Command`, que no
|
|
99
|
+
// Windows falha ao spawnar (`Failed to spawn ... No such cwd`) para paths desse formato — cai
|
|
100
|
+
// silenciosamente no `DEFAULT_MODEL_FALLBACK` embutido em vez do `config.json` real.
|
|
101
|
+
function toPosixPath(nativePath: string): string {
|
|
102
|
+
return nativePath.replace(/^([A-Za-z]):\\/, (_, d) => `/${d.toLowerCase()}/`).replaceAll(
|
|
103
|
+
"\\",
|
|
104
|
+
"/",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
Deno.test({
|
|
109
|
+
name: "resolve-model CLI - resolve config.json real mesmo com cwd POSIX-style (Git Bash Windows)",
|
|
110
|
+
ignore: Deno.build.os !== "windows",
|
|
111
|
+
fn: async () => {
|
|
112
|
+
const repo = await makeRepo();
|
|
113
|
+
await Deno.mkdir(`${repo}/.claude/vetor`, { recursive: true });
|
|
114
|
+
await Deno.writeTextFile(
|
|
115
|
+
`${repo}/.claude/vetor/config.json`,
|
|
116
|
+
JSON.stringify({
|
|
117
|
+
modelFallback: { simple: ["openai/gpt-5-mini", "anthropic/claude-haiku-4-5"] },
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const result = await runCli({ tier: "simple", cwd: toPosixPath(repo) });
|
|
122
|
+
|
|
123
|
+
assertEquals(result.code, 0);
|
|
124
|
+
assertEquals(result.stdout, "openai/gpt-5-mini");
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
Deno.test("resolve-model CLI - usa tier de config.json quando fallback não é passado", async () => {
|
|
129
|
+
const repo = await makeRepo();
|
|
130
|
+
await Deno.mkdir(`${repo}/.claude/vetor`, { recursive: true });
|
|
131
|
+
await Deno.writeTextFile(
|
|
132
|
+
`${repo}/.claude/vetor/config.json`,
|
|
133
|
+
JSON.stringify({
|
|
134
|
+
modelFallback: { simple: ["openai/gpt-5-mini", "anthropic/claude-haiku-4-5"] },
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const result = await runCli({ tier: "simple", cwd: repo });
|
|
139
|
+
|
|
140
|
+
assertEquals(result.code, 0);
|
|
141
|
+
assertEquals(result.stdout, "openai/gpt-5-mini");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Issue #312: o default embutido anterior assumia provider `anthropic` direto
|
|
145
|
+
// (`anthropic/claude-haiku-4-5`), que falha com "Unexpected server error" do opencode real em
|
|
146
|
+
// qualquer ambiente configurado só com outro provider (ex.: OpenRouter) — sem heurística confiável
|
|
147
|
+
// para detectar o provider certo, a escolha (opção b da issue) é falhar cedo com mensagem acionável
|
|
148
|
+
// em vez de devolver um modelo que pode não existir no ambiente do usuário.
|
|
149
|
+
//
|
|
150
|
+
// Código de saída 2, não 1: "não configurado" é um erro de configuração permanente, diferente de
|
|
151
|
+
// "todos degraded" (transitório, código 1, que o coordinator interpreta como "mantenha QUEUED e
|
|
152
|
+
// tente de novo"). Se "não configurado" também saísse com 1, o coordinator ficaria retentando um
|
|
153
|
+
// grupo indefinidamente sem nunca mostrar a mensagem acionável — pior que o erro genérico que
|
|
154
|
+
// substituiu.
|
|
155
|
+
Deno.test(
|
|
156
|
+
"resolve-model CLI - sem config.json e sem fallback explícito falha cedo (código 2) com mensagem acionável",
|
|
157
|
+
async () => {
|
|
158
|
+
const repo = await makeRepo();
|
|
159
|
+
|
|
160
|
+
const result = await runCli({ tier: "simple", cwd: repo });
|
|
161
|
+
|
|
162
|
+
assertEquals(result.code, 2);
|
|
163
|
+
assertEquals(result.stdout, "");
|
|
164
|
+
assertStringIncludes(result.stderr, "modelFallback.simple");
|
|
165
|
+
assertStringIncludes(result.stderr, "config.json");
|
|
166
|
+
},
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
Deno.test(
|
|
170
|
+
"resolve-model CLI - config.json presente mas sem modelFallback para o tier falha cedo (código 2)",
|
|
171
|
+
async () => {
|
|
172
|
+
const repo = await makeRepo();
|
|
173
|
+
await Deno.mkdir(`${repo}/.claude/vetor`, { recursive: true });
|
|
174
|
+
await Deno.writeTextFile(
|
|
175
|
+
`${repo}/.claude/vetor/config.json`,
|
|
176
|
+
JSON.stringify({ runtime: "deno" }),
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const result = await runCli({ tier: "complex", cwd: repo });
|
|
180
|
+
|
|
181
|
+
assertEquals(result.code, 2);
|
|
182
|
+
assertEquals(result.stdout, "");
|
|
183
|
+
assertStringIncludes(result.stderr, "modelFallback.complex");
|
|
184
|
+
},
|
|
185
|
+
);
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Safety gate do Vetor para o Claude Code (hook PreToolUse, matcher Bash|Edit|Write).
|
|
2
|
+
//
|
|
3
|
+
// Substitui scripts/cc-safety-hook.sh, que dependia de `python3` no PATH — ausente no
|
|
4
|
+
// Windows, onde o interpretador costuma se chamar `python`. Sob `set -euo pipefail`
|
|
5
|
+
// aquele hook morria em silêncio e o gate não era aplicado.
|
|
6
|
+
//
|
|
7
|
+
// Contrato do Claude Code: recebe o evento em JSON no stdin; exit 2 BLOQUEIA a chamada.
|
|
8
|
+
// Espelha a política de scripts/safety-check.sh, que segue servindo ao Antigravity —
|
|
9
|
+
// exceto pelo guard de escrita, que o Antigravity não tem como aplicar (lá o matcher só
|
|
10
|
+
// cobre run_command).
|
|
11
|
+
//
|
|
12
|
+
// Três políticas:
|
|
13
|
+
// Worktree — dentro de um worktree, o cwd precisa estar em .claude/worktrees/ da raiz
|
|
14
|
+
// e continuar registrado em `git worktree list` (não pode ser stale).
|
|
15
|
+
// Bash — não empurrar para branch protegida; não fazer push/PR de worker não-GREEN.
|
|
16
|
+
// Edit/Write — dentro de um worktree, não escrever fora dele; um agente vetor:issue-worker
|
|
17
|
+
// nunca deveria escrever com cwd resolvendo para a raiz do projeto (fora de
|
|
18
|
+
// qualquer worktree linkado) — se acontecer, é sinal de cwd mal resolvido pelo
|
|
19
|
+
// harness, não uma escrita legítima (ver issue #57).
|
|
20
|
+
// Binding — segunda camada, independente do guard acima: com múltiplos workers em
|
|
21
|
+
// paralelo (ver issue #63), o cwd recebido no payload pode contaminar entre
|
|
22
|
+
// subagentes — o cwd resolve para um worktree real, só que de OUTRO worker
|
|
23
|
+
// ativo na mesma sessão. `isLinked` sozinho não pega esse caso. Aqui
|
|
24
|
+
// correlacionamos `agent_id` (estável por instância de subagente, diferente
|
|
25
|
+
// de `agent_type`) com o worktree resolvido na primeira chamada; uma mudança
|
|
26
|
+
// de worktree para o mesmo agent_id é bloqueada.
|
|
27
|
+
|
|
28
|
+
import { isWriteAllowed } from "./lib/guard.ts";
|
|
29
|
+
import { run } from "./lib/project.ts";
|
|
30
|
+
import {
|
|
31
|
+
agentBindingPath,
|
|
32
|
+
readStatus,
|
|
33
|
+
resolveWorktree,
|
|
34
|
+
statusFilePath,
|
|
35
|
+
type WorktreeInfo,
|
|
36
|
+
} from "./lib/status.ts";
|
|
37
|
+
import { evaluateFreshness } from "./lib/worktree.ts";
|
|
38
|
+
|
|
39
|
+
const PROTECTED_BRANCHES = ["main", "master", "production"];
|
|
40
|
+
|
|
41
|
+
interface HookInput {
|
|
42
|
+
tool_name?: string;
|
|
43
|
+
tool_input?: { command?: string; file_path?: string };
|
|
44
|
+
cwd?: string;
|
|
45
|
+
/** Presente quando o hook dispara dentro de um subagente (ex.: "vetor:issue-worker"). */
|
|
46
|
+
agent_type?: string;
|
|
47
|
+
/** Identificador único da instância do subagente — estável entre chamadas, ao contrário de agent_type. */
|
|
48
|
+
agent_id?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function blocked(message: string): never {
|
|
52
|
+
console.error(message);
|
|
53
|
+
Deno.exit(2);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Extrai a branch de destino de um `git push [flags] [remote] <branch>[:<remote-branch>]`. */
|
|
57
|
+
function pushDestination(command: string): string | null {
|
|
58
|
+
const push = command.match(/git push[^&|;]*/)?.[0];
|
|
59
|
+
if (!push) return null;
|
|
60
|
+
|
|
61
|
+
const last = push.trim().split(/\s+/).pop();
|
|
62
|
+
if (!last || last.startsWith("-")) return null;
|
|
63
|
+
return last.split(":")[0];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Só se aplica dentro de um worktree linkado (`wt.isLinked`): a raiz do repositório
|
|
68
|
+
* principal não tem essa restrição, senão o próprio uso legítimo do hook lá quebraria.
|
|
69
|
+
*/
|
|
70
|
+
async function checkFreshness(wt: WorktreeInfo): Promise<void> {
|
|
71
|
+
const list = await run("git", ["worktree", "list", "--porcelain"], wt.root);
|
|
72
|
+
const message = evaluateFreshness(wt.toplevel, wt.root, list.stdout);
|
|
73
|
+
if (message) blocked(message);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function checkBash(command: string, wt: WorktreeInfo | null): void {
|
|
77
|
+
const dest = pushDestination(command);
|
|
78
|
+
if (dest && PROTECTED_BRANCHES.includes(dest)) {
|
|
79
|
+
blocked(
|
|
80
|
+
"ERROR: Push to protected branches (main, master, production) is prohibited by Vetor Safety Hook.",
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!/git push|gh pr (create|ready|merge)/.test(command)) return;
|
|
85
|
+
if (!wt?.isLinked) return;
|
|
86
|
+
|
|
87
|
+
const status = readStatus(statusFilePath(wt.root, wt.branch));
|
|
88
|
+
// Sem status file, a branch não pertence ao fluxo do coordinator: não interfere.
|
|
89
|
+
if (status === null) return;
|
|
90
|
+
|
|
91
|
+
if (status !== "GREEN") {
|
|
92
|
+
blocked(
|
|
93
|
+
`ERROR: worker não-GREEN (Status: ${
|
|
94
|
+
status || "desconhecido"
|
|
95
|
+
}) — push/PR bloqueado pelo Vetor Safety Hook.\n` +
|
|
96
|
+
"Registre BLOCKED_WAITING no status file se precisar de intervenção; o worktree-ship faz a entrega após GREEN.",
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Segunda camada de defesa contra cwd contaminado (issue #63): sem `agent_id` não há como
|
|
103
|
+
* correlacionar, então não se aplica — não é regressão, é a mesma cobertura de antes.
|
|
104
|
+
* Na primeira chamada de um `agent_id`, grava o worktree resolvido; em chamadas seguintes,
|
|
105
|
+
* uma mudança de worktree para o MESMO agent_id indica cwd contaminado — bloqueia.
|
|
106
|
+
*/
|
|
107
|
+
function checkAgentBinding(root: string, agentId: string | undefined, toplevel: string): void {
|
|
108
|
+
if (!agentId) return;
|
|
109
|
+
|
|
110
|
+
const path = agentBindingPath(root, agentId);
|
|
111
|
+
let bound: string | null = null;
|
|
112
|
+
try {
|
|
113
|
+
bound = Deno.readTextFileSync(path).trim();
|
|
114
|
+
} catch {
|
|
115
|
+
// Primeira chamada deste agent_id: nada gravado ainda.
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (bound && bound !== toplevel) {
|
|
119
|
+
blocked(
|
|
120
|
+
`ERROR: cwd contaminado detectado pelo Vetor Safety Hook (issue #63): agent_id ${agentId} ` +
|
|
121
|
+
`já estava vinculado ao worktree ${bound}, mas este evento resolveu para ${toplevel}.\n` +
|
|
122
|
+
"Isso indica cwd entregue incorretamente pelo harness durante execução paralela de " +
|
|
123
|
+
"subagentes — a escrita foi bloqueada para não vazar entre workers.",
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!bound) {
|
|
128
|
+
try {
|
|
129
|
+
Deno.mkdirSync(`${root}/.claude/vetor/status/.agent-cwd`, { recursive: true });
|
|
130
|
+
Deno.writeTextFileSync(path, `${toplevel}\n`);
|
|
131
|
+
} catch {
|
|
132
|
+
// Sem conseguir gravar o vínculo, a checagem principal (isWriteAllowed) já cobre
|
|
133
|
+
// a política de escrita — a ausência do vínculo só reduz a segunda camada.
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function checkWrite(
|
|
139
|
+
filePath: string,
|
|
140
|
+
cwd: string,
|
|
141
|
+
agentType?: string,
|
|
142
|
+
agentId?: string,
|
|
143
|
+
): Promise<void> {
|
|
144
|
+
const wt = await resolveWorktree(cwd);
|
|
145
|
+
|
|
146
|
+
if (!wt?.isLinked) {
|
|
147
|
+
// Um vetor:issue-worker deveria estar sempre dentro do seu worktree isolado. cwd
|
|
148
|
+
// resolvendo para fora de um worktree linkado (ex.: a raiz do projeto) indica cwd
|
|
149
|
+
// incorreto entregue pelo harness — deixar passar contamina a raiz compartilhada por
|
|
150
|
+
// todos os workers em paralelo (reprodução real: issue #57).
|
|
151
|
+
if (agentType === "vetor:issue-worker") {
|
|
152
|
+
blocked(
|
|
153
|
+
`ERROR: vetor:issue-worker escrevendo com cwd fora de um worktree linkado: ${filePath}\n` +
|
|
154
|
+
`cwd resolvido: ${cwd}${wt ? ` (branch: ${wt.branch})` : ""}. Um issue-worker só ` +
|
|
155
|
+
"deveria escrever dentro do seu próprio worktree — bloqueado pelo Vetor Safety Hook.",
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
checkAgentBinding(wt.root, agentId, wt.toplevel);
|
|
162
|
+
|
|
163
|
+
if (!isWriteAllowed(filePath, wt.toplevel, wt.root)) {
|
|
164
|
+
blocked(
|
|
165
|
+
`ERROR: escrita fora do worktree bloqueada pelo Vetor Safety Hook: ${filePath}\n` +
|
|
166
|
+
`O worker só escreve dentro de ${wt.toplevel} (e no seu status file). Editar a raiz ` +
|
|
167
|
+
"contamina os demais workers em paralelo.",
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function main() {
|
|
173
|
+
const raw = new TextDecoder().decode(await new Response(Deno.stdin.readable).arrayBuffer());
|
|
174
|
+
|
|
175
|
+
let input: HookInput;
|
|
176
|
+
try {
|
|
177
|
+
input = JSON.parse(raw);
|
|
178
|
+
} catch {
|
|
179
|
+
// Entrada ilegível não deve travar a sessão do usuário.
|
|
180
|
+
Deno.exit(0);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const cwd = input.cwd ?? Deno.cwd();
|
|
184
|
+
const wt = await resolveWorktree(cwd);
|
|
185
|
+
|
|
186
|
+
// A checagem de frescor só faz sentido dentro de um worktree linkado — na raiz do
|
|
187
|
+
// repositório principal (isLinked === false) o hook segue liberando normalmente.
|
|
188
|
+
if (wt?.isLinked) {
|
|
189
|
+
await checkFreshness(wt);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (input.tool_name === "Edit" || input.tool_name === "Write") {
|
|
193
|
+
const filePath = input.tool_input?.file_path;
|
|
194
|
+
if (filePath) await checkWrite(filePath, cwd, input.agent_type, input.agent_id);
|
|
195
|
+
Deno.exit(0);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const command = input.tool_input?.command;
|
|
199
|
+
if (command) checkBash(command, wt);
|
|
200
|
+
Deno.exit(0);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
await main();
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Checagens determinísticas compartilhadas pelas skills do Vetor.
|
|
3
|
+
#
|
|
4
|
+
# Uso: vetor-checks.sh <subcomando> [args]
|
|
5
|
+
# default-branch imprime a branch default do repositório (nunca assume master)
|
|
6
|
+
# in-worktree exit 0 se o cwd é um worktree linkado; exit 1 se é o root
|
|
7
|
+
# migrations exit 1 se há versões de migration duplicadas (convenção Flyway)
|
|
8
|
+
# debug-scan <base-branch> exit 1 se o diff vs. a base contém padrões de debug/teste exclusivo
|
|
9
|
+
# validate-issue-ref <valor> exit 1 se valor não for inteiro positivo; exit 0 caso contrário
|
|
10
|
+
# safe-remove-worktree <path> remove o worktree somente se não houver worktree filho ativo
|
|
11
|
+
# sync-root tenta retornar o repositório principal para a branch default de forma segura
|
|
12
|
+
# worktree-audit lista worktrees linkados (exceto o root) com idade/tamanho/uncommitted
|
|
13
|
+
# find-orphan-status [dir] lista status files sem worktree correspondente (default: .claude/vetor/status)
|
|
14
|
+
# archive-orphan-status <path> move um status file órfão para <dir>/archive/
|
|
15
|
+
#
|
|
16
|
+
# Exit codes: 0 = passou; 1 = checagem falhou (a skill deve parar e mostrar a saída); 2 = uso incorreto.
|
|
17
|
+
|
|
18
|
+
set -uo pipefail
|
|
19
|
+
|
|
20
|
+
cmd="${1:-}"
|
|
21
|
+
|
|
22
|
+
case "$cmd" in
|
|
23
|
+
default-branch)
|
|
24
|
+
DEFAULT_BRANCH=$(git symbolic-ref --quiet refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@')
|
|
25
|
+
[ -z "$DEFAULT_BRANCH" ] && DEFAULT_BRANCH=$(git remote show origin 2>/dev/null | sed -n '/HEAD branch/s/.*: //p')
|
|
26
|
+
[ -z "$DEFAULT_BRANCH" ] && DEFAULT_BRANCH=master
|
|
27
|
+
echo "$DEFAULT_BRANCH"
|
|
28
|
+
;;
|
|
29
|
+
|
|
30
|
+
in-worktree)
|
|
31
|
+
# Contrato estável para uso EXTERNO ao plugin (issue #129): hooks/scripts de projetos
|
|
32
|
+
# consumidores podem chamar `vetor-checks.sh in-worktree` diretamente para decidir se o
|
|
33
|
+
# cwd é um worktree linkado (exit 0) ou o repositório principal (exit 1), sem stdout.
|
|
34
|
+
# Não reimplemente essa comparação com `pwd` vs `git worktree list` — os formatos de path
|
|
35
|
+
# nunca coincidem no Windows/Git Bash, o que faz a comparação ingênua concluir "worktree"
|
|
36
|
+
# mesmo estando no root.
|
|
37
|
+
git_dir=$(git rev-parse --git-dir 2>/dev/null) || { echo "não é um repositório git" >&2; exit 1; }
|
|
38
|
+
common_dir=$(git rev-parse --git-common-dir 2>/dev/null)
|
|
39
|
+
if [ "$(cd "$git_dir" && pwd)" = "$(cd "$common_dir" && pwd)" ]; then
|
|
40
|
+
echo "cwd é o root do repositório, não um worktree linkado" >&2
|
|
41
|
+
exit 1
|
|
42
|
+
fi
|
|
43
|
+
;;
|
|
44
|
+
|
|
45
|
+
migrations)
|
|
46
|
+
# Colisão semântica invisível ao git: dois arquivos com a mesma versão (V13__a.sql, V13__b.sql).
|
|
47
|
+
dups=$(git ls-files '*/db/migration/V*__*.sql' \
|
|
48
|
+
| sed -E 's#.*/V([0-9]+)__.*#\1#' | sort | uniq -d)
|
|
49
|
+
if [ -n "$dups" ]; then
|
|
50
|
+
echo "FALHA: colisão de versão de migration — versões duplicadas:" >&2
|
|
51
|
+
for v in $dups; do
|
|
52
|
+
echo " V$v:" >&2
|
|
53
|
+
git ls-files "*/db/migration/V${v}__*.sql" | sed 's/^/ /' >&2
|
|
54
|
+
done
|
|
55
|
+
echo "Renumere a migration deste worker para a próxima versão livre antes de prosseguir." >&2
|
|
56
|
+
exit 1
|
|
57
|
+
fi
|
|
58
|
+
;;
|
|
59
|
+
|
|
60
|
+
debug-scan)
|
|
61
|
+
base="${2:?uso: vetor-checks.sh debug-scan <base-branch>}"
|
|
62
|
+
# Grep only added lines from diff (lines starting with +), not the entire file content.
|
|
63
|
+
# The caller MUST pass origin/$DEFAULT_BRANCH (not local $DEFAULT_BRANCH) to avoid
|
|
64
|
+
# Stale branch references in worktrees (issue #70).
|
|
65
|
+
# Exclude script/test files that define or test the regex to avoid false positives (issue #108, #109)
|
|
66
|
+
hits=$(git diff "$base" -U0 -- '*.ts' '*.sh' '*.js' '*.tsx' '*.jsx' \
|
|
67
|
+
':!scripts/vetor-checks.sh' ':!scripts/tests/vetor-checks_test.ts' ':!skills/**/*.md' \
|
|
68
|
+
':!.opencode/scripts/vetor-checks.sh' ':!.opencode/skills/**/*.md' \
|
|
69
|
+
':!opencode/scripts/vetor-checks.sh' ':!opencode/skills/**/*.md' 2>/dev/null \
|
|
70
|
+
| grep -E '^\+' | grep -vE '^\+\+\+' \
|
|
71
|
+
| grep -nE 'console\.log|var_dump|fit\(|fdescribe\(|it\.only' 2>/dev/null || true)
|
|
72
|
+
if [ -n "$hits" ]; then
|
|
73
|
+
echo "FALHA: padrões de debug/teste exclusivo no diff (remova antes do push):" >&2
|
|
74
|
+
echo "$hits" >&2
|
|
75
|
+
exit 1
|
|
76
|
+
fi
|
|
77
|
+
;;
|
|
78
|
+
|
|
79
|
+
validate-issue-ref)
|
|
80
|
+
valor="${2:?uso: vetor-checks.sh validate-issue-ref <valor>}"
|
|
81
|
+
if ! [[ "$valor" =~ ^[1-9][0-9]*$ ]]; then
|
|
82
|
+
echo "ERRO: issue# deve ser um inteiro positivo, recebido \"$valor\"" >&2
|
|
83
|
+
exit 1
|
|
84
|
+
fi
|
|
85
|
+
;;
|
|
86
|
+
|
|
87
|
+
safe-remove-worktree)
|
|
88
|
+
target="${2:?uso: vetor-checks.sh safe-remove-worktree <path>}"
|
|
89
|
+
target=$(cd "$target" && pwd -P) || {
|
|
90
|
+
echo "ERRO: worktree para cleanup não encontrado: $target" >&2
|
|
91
|
+
exit 1
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
children=()
|
|
95
|
+
while IFS= read -r line; do
|
|
96
|
+
case "$line" in
|
|
97
|
+
"worktree "*)
|
|
98
|
+
candidate="${line#worktree }"
|
|
99
|
+
candidate=$(cd "$candidate" 2>/dev/null && pwd -P) || continue
|
|
100
|
+
case "$candidate" in
|
|
101
|
+
"$target"/*) children+=("$candidate") ;;
|
|
102
|
+
esac
|
|
103
|
+
;;
|
|
104
|
+
esac
|
|
105
|
+
done < <(git worktree list --porcelain)
|
|
106
|
+
|
|
107
|
+
if [ "${#children[@]}" -gt 0 ]; then
|
|
108
|
+
echo "FALHA: cleanup bloqueado; o worktree $target contém worktree(s) ativo(s):" >&2
|
|
109
|
+
printf ' %s\n' "${children[@]}" >&2
|
|
110
|
+
echo "Remova ou realoque os worktrees filhos antes de remover o pai." >&2
|
|
111
|
+
exit 1
|
|
112
|
+
fi
|
|
113
|
+
|
|
114
|
+
git worktree remove "$target"
|
|
115
|
+
;;
|
|
116
|
+
|
|
117
|
+
sync-root)
|
|
118
|
+
# Tenta retornar a raiz do repo para a branch default se a branch atual estiver limpa
|
|
119
|
+
# e sem commits locais pendentes vs remote (ou sem remote tracker caso já deletada).
|
|
120
|
+
ROOT=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null | xargs dirname)
|
|
121
|
+
[ -z "$ROOT" ] && exit 0
|
|
122
|
+
cd "$ROOT" || exit 0
|
|
123
|
+
|
|
124
|
+
DEFAULT_BRANCH=$("$0" default-branch)
|
|
125
|
+
current=$(git branch --show-current 2>/dev/null)
|
|
126
|
+
[ -z "$current" ] || [ "$current" = "$DEFAULT_BRANCH" ] && exit 0
|
|
127
|
+
|
|
128
|
+
# 1. Verifica se tem uncommitted changes
|
|
129
|
+
if ! git diff-index --quiet HEAD --; then
|
|
130
|
+
echo "AVISO: root tem mudanças pendentes na branch $current. Não mudando para $DEFAULT_BRANCH." >&2
|
|
131
|
+
exit 0
|
|
132
|
+
fi
|
|
133
|
+
|
|
134
|
+
# 2. Verifica se a branch tem commits que não estão na default
|
|
135
|
+
if ! git merge-base --is-ancestor HEAD "origin/$DEFAULT_BRANCH" 2>/dev/null; then
|
|
136
|
+
echo "AVISO: root está na branch $current que possui commits não integrados em origin/$DEFAULT_BRANCH. Não mudando para $DEFAULT_BRANCH." >&2
|
|
137
|
+
exit 0
|
|
138
|
+
fi
|
|
139
|
+
|
|
140
|
+
git checkout "$DEFAULT_BRANCH" >/dev/null 2>&1
|
|
141
|
+
git pull origin "$DEFAULT_BRANCH" >/dev/null 2>&1
|
|
142
|
+
echo "Root sincronizado com $DEFAULT_BRANCH (branch anterior: $current estava limpa e mesclada)."
|
|
143
|
+
;;
|
|
144
|
+
|
|
145
|
+
worktree-audit)
|
|
146
|
+
# Emite uma linha por worktree linkado (exclui o root) no formato:
|
|
147
|
+
# <path>|<branch>|<age_days>|<size_kb>|<uncommitted:yes/no>
|
|
148
|
+
# "age_days" é medido a partir do timestamp do último commit do worktree (proxy de
|
|
149
|
+
# staleness — evita depender de mtime de diretório, que muda a qualquer escrita).
|
|
150
|
+
main_worktree=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null | xargs dirname)
|
|
151
|
+
now_ts=$(date +%s)
|
|
152
|
+
path=""
|
|
153
|
+
branch=""
|
|
154
|
+
while IFS= read -r line; do
|
|
155
|
+
case "$line" in
|
|
156
|
+
"worktree "*)
|
|
157
|
+
path="${line#worktree }"
|
|
158
|
+
branch=""
|
|
159
|
+
;;
|
|
160
|
+
"branch refs/heads/"*)
|
|
161
|
+
branch="${line#branch refs/heads/}"
|
|
162
|
+
;;
|
|
163
|
+
"")
|
|
164
|
+
if [ -n "$path" ] && [ "$path" != "$main_worktree" ] && [ -d "$path" ]; then
|
|
165
|
+
last_commit_ts=$(git -C "$path" log -1 --format=%ct 2>/dev/null || echo "$now_ts")
|
|
166
|
+
age_days=$(( (now_ts - last_commit_ts) / 86400 ))
|
|
167
|
+
size_kb=$(du -sk "$path" 2>/dev/null | cut -f1)
|
|
168
|
+
if [ -n "$(git -C "$path" status --porcelain 2>/dev/null)" ]; then
|
|
169
|
+
uncommitted="yes"
|
|
170
|
+
else
|
|
171
|
+
uncommitted="no"
|
|
172
|
+
fi
|
|
173
|
+
echo "${path}|${branch:-detached}|${age_days}|${size_kb:-0}|${uncommitted}"
|
|
174
|
+
fi
|
|
175
|
+
path=""
|
|
176
|
+
branch=""
|
|
177
|
+
;;
|
|
178
|
+
esac
|
|
179
|
+
done < <(git worktree list --porcelain; echo "")
|
|
180
|
+
;;
|
|
181
|
+
|
|
182
|
+
find-orphan-status)
|
|
183
|
+
# Lista status files (`.claude/vetor/status/*.md`) cujo worktree correspondente não
|
|
184
|
+
# existe mais em `git worktree list`. Report-only — não move nem apaga nada.
|
|
185
|
+
status_dir="${2:-.claude/vetor/status}"
|
|
186
|
+
[ -d "$status_dir" ] || exit 0
|
|
187
|
+
active=$(git worktree list --porcelain | sed -n 's#^branch refs/heads/##p' | tr '/' '-')
|
|
188
|
+
for f in "$status_dir"/*.md; do
|
|
189
|
+
[ -e "$f" ] || continue
|
|
190
|
+
name=$(basename "$f" .md)
|
|
191
|
+
if ! printf '%s\n' "$active" | grep -qx "$name"; then
|
|
192
|
+
echo "$f"
|
|
193
|
+
fi
|
|
194
|
+
done
|
|
195
|
+
;;
|
|
196
|
+
|
|
197
|
+
archive-orphan-status)
|
|
198
|
+
# Move um status file órfão para <dir>/archive/ (não apaga — recolhimento reversível).
|
|
199
|
+
target="${2:?uso: vetor-checks.sh archive-orphan-status <path-do-status-file>}"
|
|
200
|
+
[ -f "$target" ] || {
|
|
201
|
+
echo "ERRO: status file não encontrado: $target" >&2
|
|
202
|
+
exit 1
|
|
203
|
+
}
|
|
204
|
+
status_dir=$(dirname "$target")
|
|
205
|
+
archive_dir="$status_dir/archive"
|
|
206
|
+
mkdir -p "$archive_dir"
|
|
207
|
+
mv "$target" "$archive_dir/"
|
|
208
|
+
echo "Arquivado: $archive_dir/$(basename "$target")"
|
|
209
|
+
;;
|
|
210
|
+
|
|
211
|
+
*)
|
|
212
|
+
echo "uso: vetor-checks.sh <default-branch|in-worktree|migrations|debug-scan <base-branch>|validate-issue-ref <valor>|safe-remove-worktree <path>|sync-root|worktree-audit|find-orphan-status [dir]|archive-orphan-status <path>>" >&2
|
|
213
|
+
exit 2
|
|
214
|
+
;;
|
|
215
|
+
esac
|
|
216
|
+
|
|
217
|
+
exit 0
|