@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,240 @@
|
|
|
1
|
+
// Detecção de runtime, gerenciador de pacotes e comando de teste de um projeto.
|
|
2
|
+
// Compartilhado por detect-project.ts e prepare-worktree.ts.
|
|
3
|
+
|
|
4
|
+
export type Runtime =
|
|
5
|
+
| "deno"
|
|
6
|
+
| "node"
|
|
7
|
+
| "rust"
|
|
8
|
+
| "go"
|
|
9
|
+
| "python"
|
|
10
|
+
| "gradle"
|
|
11
|
+
| "unknown";
|
|
12
|
+
|
|
13
|
+
export interface ProjectInfo {
|
|
14
|
+
runtime: Runtime;
|
|
15
|
+
packageManager: string | null;
|
|
16
|
+
testCommand: string;
|
|
17
|
+
/** Deno com package.json precisa de `deno install`; Deno puro resolve tudo pelo cache global. */
|
|
18
|
+
needsInstall: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function exists(path: string): boolean {
|
|
22
|
+
try {
|
|
23
|
+
Deno.statSync(path);
|
|
24
|
+
return true;
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** JSON.parse rejeita BOM, comum em arquivos gerados no Windows. */
|
|
31
|
+
export function readJson(path: string): unknown {
|
|
32
|
+
return JSON.parse(Deno.readTextFileSync(path).replace(/^/, ""));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Issue #307: no Git Bash do Windows, `$(pwd)` produz um path POSIX-style (`/c/Users/...`), não
|
|
37
|
+
* nativo (`C:\Users\...`). `Deno.Command`/`Deno.statSync` no Windows não resolvem esse formato —
|
|
38
|
+
* `git rev-parse` falha ao spawnar (`No such cwd`) e `Deno.statSync` trata `/c/...` como relativo à
|
|
39
|
+
* raiz da unidade atual, nunca encontrando o arquivo real. Normaliza só no Windows (no Linux/macOS
|
|
40
|
+
* `/c/Users/x` já é um path absoluto legítimo, nunca deve ser reescrito).
|
|
41
|
+
*/
|
|
42
|
+
export function normalizeCwd(cwd: string): string {
|
|
43
|
+
if (Deno.build.os !== "windows") return cwd;
|
|
44
|
+
const match = cwd.match(/^\/([a-zA-Z])\/(.*)$/);
|
|
45
|
+
if (!match) return cwd;
|
|
46
|
+
const [, drive, rest] = match;
|
|
47
|
+
return `${drive.toUpperCase()}:/${rest}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Lê o campo `tasks` de um deno.json para escolher entre `deno task test` e `deno test -A`. */
|
|
51
|
+
function hasDenoTestTask(dir: string): boolean {
|
|
52
|
+
for (const name of ["deno.json", "deno.jsonc"]) {
|
|
53
|
+
const path = `${dir}/${name}`;
|
|
54
|
+
if (!exists(path)) continue;
|
|
55
|
+
try {
|
|
56
|
+
// jsonc com comentários falha aqui; nesse caso caímos no default (deno test -A).
|
|
57
|
+
const parsed = readJson(path) as { tasks?: { test?: unknown } };
|
|
58
|
+
return typeof parsed?.tasks?.test === "string";
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function detectNodePackageManager(dir: string): string {
|
|
67
|
+
if (exists(`${dir}/bun.lockb`) || exists(`${dir}/bun.lock`)) return "bun";
|
|
68
|
+
if (exists(`${dir}/pnpm-lock.yaml`)) return "pnpm";
|
|
69
|
+
if (exists(`${dir}/yarn.lock`)) return "yarn";
|
|
70
|
+
return "npm";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function detectProject(dir: string): ProjectInfo {
|
|
74
|
+
const hasDeno = exists(`${dir}/deno.json`) || exists(`${dir}/deno.jsonc`);
|
|
75
|
+
const hasPackageJson = exists(`${dir}/package.json`);
|
|
76
|
+
|
|
77
|
+
if (hasDeno) {
|
|
78
|
+
return {
|
|
79
|
+
runtime: "deno",
|
|
80
|
+
packageManager: "deno",
|
|
81
|
+
testCommand: hasDenoTestTask(dir) ? "deno task test" : "deno test -A",
|
|
82
|
+
// Sem package.json o nodeModulesDir default é "none": as deps vêm do cache
|
|
83
|
+
// global $DENO_DIR, que já é compartilhado por todos os worktrees.
|
|
84
|
+
needsInstall: hasPackageJson,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (hasPackageJson) {
|
|
89
|
+
const pm = detectNodePackageManager(dir);
|
|
90
|
+
return {
|
|
91
|
+
runtime: "node",
|
|
92
|
+
packageManager: pm,
|
|
93
|
+
testCommand: `${pm} test`,
|
|
94
|
+
needsInstall: true,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (exists(`${dir}/Cargo.toml`)) {
|
|
99
|
+
return {
|
|
100
|
+
runtime: "rust",
|
|
101
|
+
packageManager: "cargo",
|
|
102
|
+
testCommand: "cargo test",
|
|
103
|
+
needsInstall: false,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (exists(`${dir}/go.mod`)) {
|
|
107
|
+
return {
|
|
108
|
+
runtime: "go",
|
|
109
|
+
packageManager: "go",
|
|
110
|
+
testCommand: "go test ./...",
|
|
111
|
+
needsInstall: false,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
if (
|
|
115
|
+
exists(`${dir}/pyproject.toml`) || exists(`${dir}/requirements.txt`) ||
|
|
116
|
+
exists(`${dir}/poetry.lock`)
|
|
117
|
+
) {
|
|
118
|
+
const pm = exists(`${dir}/poetry.lock`) ? "poetry" : "pip";
|
|
119
|
+
return { runtime: "python", packageManager: pm, testCommand: "pytest", needsInstall: true };
|
|
120
|
+
}
|
|
121
|
+
if (
|
|
122
|
+
exists(`${dir}/build.gradle`) || exists(`${dir}/build.gradle.kts`) ||
|
|
123
|
+
exists(`${dir}/gradlew`)
|
|
124
|
+
) {
|
|
125
|
+
return {
|
|
126
|
+
runtime: "gradle",
|
|
127
|
+
packageManager: "gradle",
|
|
128
|
+
testCommand: "./gradlew test",
|
|
129
|
+
needsInstall: false,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { runtime: "unknown", packageManager: null, testCommand: "", needsInstall: false };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Convenções observadas no repositório. Campo ausente = fato não encontrado. */
|
|
137
|
+
export interface Conventions {
|
|
138
|
+
denoTasks?: string[];
|
|
139
|
+
denoImportMap?: boolean;
|
|
140
|
+
nodeScripts?: string[];
|
|
141
|
+
nodeModuleType?: string;
|
|
142
|
+
hasTsconfig?: boolean;
|
|
143
|
+
formatter?: "prettier" | "biome";
|
|
144
|
+
linter?: "eslint" | "biome";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const PRETTIER_CONFIGS = [
|
|
148
|
+
".prettierrc",
|
|
149
|
+
".prettierrc.json",
|
|
150
|
+
".prettierrc.yaml",
|
|
151
|
+
".prettierrc.yml",
|
|
152
|
+
".prettierrc.js",
|
|
153
|
+
".prettierrc.cjs",
|
|
154
|
+
"prettier.config.js",
|
|
155
|
+
"prettier.config.mjs",
|
|
156
|
+
"prettier.config.cjs",
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
const ESLINT_CONFIGS = [
|
|
160
|
+
"eslint.config.js",
|
|
161
|
+
"eslint.config.mjs",
|
|
162
|
+
"eslint.config.cjs",
|
|
163
|
+
"eslint.config.ts",
|
|
164
|
+
".eslintrc",
|
|
165
|
+
".eslintrc.json",
|
|
166
|
+
".eslintrc.js",
|
|
167
|
+
".eslintrc.cjs",
|
|
168
|
+
".eslintrc.yaml",
|
|
169
|
+
".eslintrc.yml",
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
const BIOME_CONFIGS = ["biome.json", "biome.jsonc"];
|
|
173
|
+
|
|
174
|
+
function anyExists(dir: string, names: string[]): boolean {
|
|
175
|
+
return names.some((name) => exists(`${dir}/${name}`));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function keysOf(record: unknown): string[] {
|
|
179
|
+
return record && typeof record === "object" ? Object.keys(record as object) : [];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Sonda as convenções do projeto. Só relata o que foi verificado em arquivo — um fato
|
|
184
|
+
* não encontrado é omitido, nunca substituído por um default plausível. Uma convenção
|
|
185
|
+
* inventada é pior que nenhuma: leva o agente a "consertar" código correto.
|
|
186
|
+
*/
|
|
187
|
+
export function detectConventions(dir: string): Conventions {
|
|
188
|
+
const conv: Conventions = {};
|
|
189
|
+
|
|
190
|
+
for (const name of ["deno.json", "deno.jsonc"]) {
|
|
191
|
+
if (!exists(`${dir}/${name}`)) continue;
|
|
192
|
+
try {
|
|
193
|
+
// jsonc com comentários falha aqui; sem o parse não afirmamos nada.
|
|
194
|
+
const parsed = readJson(`${dir}/${name}`) as { tasks?: unknown; imports?: unknown };
|
|
195
|
+
const tasks = keysOf(parsed.tasks);
|
|
196
|
+
if (tasks.length > 0) conv.denoTasks = tasks;
|
|
197
|
+
if (keysOf(parsed.imports).length > 0) conv.denoImportMap = true;
|
|
198
|
+
} catch { /* config ilegível: nenhum fato a relatar */ }
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (exists(`${dir}/package.json`)) {
|
|
203
|
+
try {
|
|
204
|
+
const parsed = readJson(`${dir}/package.json`) as { scripts?: unknown; type?: unknown };
|
|
205
|
+
const scripts = keysOf(parsed.scripts);
|
|
206
|
+
if (scripts.length > 0) conv.nodeScripts = scripts;
|
|
207
|
+
if (typeof parsed.type === "string") conv.nodeModuleType = parsed.type;
|
|
208
|
+
} catch { /* package.json ilegível: nenhum fato a relatar */ }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (exists(`${dir}/tsconfig.json`)) conv.hasTsconfig = true;
|
|
212
|
+
|
|
213
|
+
const hasBiome = anyExists(dir, BIOME_CONFIGS);
|
|
214
|
+
if (anyExists(dir, PRETTIER_CONFIGS)) conv.formatter = "prettier";
|
|
215
|
+
else if (hasBiome) conv.formatter = "biome";
|
|
216
|
+
|
|
217
|
+
if (anyExists(dir, ESLINT_CONFIGS)) conv.linter = "eslint";
|
|
218
|
+
else if (hasBiome) conv.linter = "biome";
|
|
219
|
+
|
|
220
|
+
return conv;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Executa um comando e devolve o código de saída, sem lançar. */
|
|
224
|
+
export async function run(
|
|
225
|
+
cmd: string,
|
|
226
|
+
args: string[],
|
|
227
|
+
cwd: string,
|
|
228
|
+
): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
229
|
+
try {
|
|
230
|
+
const out = await new Deno.Command(cmd, { args, cwd, stdout: "piped", stderr: "piped" })
|
|
231
|
+
.output();
|
|
232
|
+
return {
|
|
233
|
+
code: out.code,
|
|
234
|
+
stdout: new TextDecoder().decode(out.stdout),
|
|
235
|
+
stderr: new TextDecoder().decode(out.stderr),
|
|
236
|
+
};
|
|
237
|
+
} catch (e) {
|
|
238
|
+
return { code: 127, stdout: "", stderr: String(e) };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { assertEquals } from "@std/assert";
|
|
2
|
+
import { normalizeCwd } from "./project.ts";
|
|
3
|
+
|
|
4
|
+
Deno.test({
|
|
5
|
+
name:
|
|
6
|
+
"normalizeCwd converte path POSIX-style de drive (Git Bash Windows) para nativo (issue #307)",
|
|
7
|
+
ignore: Deno.build.os !== "windows",
|
|
8
|
+
fn: () => {
|
|
9
|
+
assertEquals(normalizeCwd("/c/Users/test"), "C:/Users/test");
|
|
10
|
+
},
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
Deno.test({
|
|
14
|
+
name: "normalizeCwd não altera path já nativo (Windows)",
|
|
15
|
+
ignore: Deno.build.os !== "windows",
|
|
16
|
+
fn: () => {
|
|
17
|
+
assertEquals(normalizeCwd("C:/Users/test"), "C:/Users/test");
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Issue #313: um dispatch real do issue-coordinator, rodando de dentro de um mount MSYS sem letra
|
|
22
|
+
// de unidade (`/tmp/...`, comum quando o cwd do Git Bash está sob um diretório temp), quebrou
|
|
23
|
+
// `ensure-external-directory-permission.ts`/`resolve-model.ts` com `$(pwd)` cru — o Deno nativo do
|
|
24
|
+
// Windows não resolve esse path, e o erro de escrita apareceu como um falso "config não encontrado".
|
|
25
|
+
// `normalizeCwd()` só cobre o padrão `/<letra>/...` (issue #307), não mounts MSYS arbitrários — a
|
|
26
|
+
// mitigação escolhida foi no call site (`issue-coordinator.md` usa `$(pwd -W 2>/dev/null || pwd)`
|
|
27
|
+
// em vez de `$(pwd)`), não aqui. Este teste documenta a limitação conhecida para quem for revisitar
|
|
28
|
+
// `normalizeCwd()` no futuro, em vez de deixá-la implícita.
|
|
29
|
+
Deno.test({
|
|
30
|
+
name:
|
|
31
|
+
"normalizeCwd não resolve mounts MSYS sem letra de unidade (ex. /tmp/...) — mitigado no call site (issue #313)",
|
|
32
|
+
ignore: Deno.build.os !== "windows",
|
|
33
|
+
fn: () => {
|
|
34
|
+
assertEquals(normalizeCwd("/tmp/foo/bar"), "/tmp/foo/bar");
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
Deno.test({
|
|
39
|
+
name: "normalizeCwd é identidade fora do Windows",
|
|
40
|
+
ignore: Deno.build.os === "windows",
|
|
41
|
+
fn: () => {
|
|
42
|
+
assertEquals(normalizeCwd("/c/Users/test"), "/c/Users/test");
|
|
43
|
+
assertEquals(normalizeCwd("/home/test"), "/home/test");
|
|
44
|
+
},
|
|
45
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Status file dos workers: <root>/.claude/vetor/status/<branch>.md
|
|
2
|
+
//
|
|
3
|
+
// Vive na raiz do repositório, não no worktree, para que o issue-coordinator consiga
|
|
4
|
+
// agregar o andamento de todos os workers. Compartilhado por safety-check.ts (que barra
|
|
5
|
+
// push de worker não-GREEN) e check-status.ts (que barra worker terminando sem status).
|
|
6
|
+
|
|
7
|
+
import { run } from "./project.ts";
|
|
8
|
+
|
|
9
|
+
/** Estados em que o worker pode legitimamente encerrar. */
|
|
10
|
+
export const TERMINAL_STATES = ["GREEN", "FAILED_MAX_ITERATIONS", "BLOCKED_WAITING"];
|
|
11
|
+
|
|
12
|
+
export interface WorktreeInfo {
|
|
13
|
+
/** Raiz do repositório principal (não do worktree). */
|
|
14
|
+
root: string;
|
|
15
|
+
/** Raiz do worktree atual — a fronteira de escrita do worker. */
|
|
16
|
+
toplevel: string;
|
|
17
|
+
branch: string;
|
|
18
|
+
/** git-dir difere de git-common-dir apenas em worktree linkado. */
|
|
19
|
+
isLinked: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function resolveWorktree(cwd: string): Promise<WorktreeInfo | null> {
|
|
23
|
+
const gitDir = (await run("git", ["rev-parse", "--git-dir"], cwd)).stdout.trim();
|
|
24
|
+
const commonDir = (await run("git", ["rev-parse", "--git-common-dir"], cwd)).stdout.trim();
|
|
25
|
+
if (!gitDir) return null;
|
|
26
|
+
|
|
27
|
+
const branch = (await run("git", ["branch", "--show-current"], cwd)).stdout.trim();
|
|
28
|
+
const root = (await run("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], cwd))
|
|
29
|
+
.stdout.trim().replace(/\/?\.git\/?$/, "");
|
|
30
|
+
// O cwd pode ser um subdiretório do worktree; a fronteira é o toplevel.
|
|
31
|
+
const toplevel = (await run("git", ["rev-parse", "--show-toplevel"], cwd)).stdout.trim();
|
|
32
|
+
if (!branch || !root || !toplevel) return null;
|
|
33
|
+
|
|
34
|
+
return { root, toplevel, branch, isLinked: gitDir !== commonDir };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function statusFilePath(root: string, branch: string): string {
|
|
38
|
+
return `${root}/.claude/vetor/status/${branch.replaceAll("/", "-")}.md`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Onde fica registrado, por `agent_id`, qual worktree (toplevel) esse agente resolveu da
|
|
43
|
+
* primeira vez que o hook de escrita rodou para ele. `agent_id` é estável e único por
|
|
44
|
+
* instância de subagente (ver issue #63) — diferente de `agent_type`, compartilhado por
|
|
45
|
+
* todos os workers do mesmo tipo.
|
|
46
|
+
*/
|
|
47
|
+
export function agentBindingPath(root: string, agentId: string): string {
|
|
48
|
+
return `${root}/.claude/vetor/status/.agent-cwd/${agentId}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Marcador gravado por prepareDeps (prepare-worktree.ts) quando a instalação de deps falha. */
|
|
52
|
+
export function prepareFailedMarkerPath(worktreePath: string): string {
|
|
53
|
+
return `${worktreePath}/.claude/vetor/prepare-failed`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Devolve o valor de `Status:` do arquivo, ou null se ele não existe. */
|
|
57
|
+
export function readStatus(path: string): string | null {
|
|
58
|
+
let content: string;
|
|
59
|
+
try {
|
|
60
|
+
content = Deno.readTextFileSync(path);
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
return content.match(/^Status: *(.+)$/m)?.[1]?.trim() ?? "";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isTerminal(status: string | null): boolean {
|
|
68
|
+
return status !== null && TERMINAL_STATES.includes(status);
|
|
69
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Frescor de worktree: garante que o cwd de um worker continua sendo um worktree válido
|
|
2
|
+
// da raiz do projeto — dentro de `.claude/worktrees/` e ainda registrado em
|
|
3
|
+
// `git worktree list`. Sem isso, um agente cujo worktree foi removido (worktree-ship) ou
|
|
4
|
+
// aponta para fora da árvore esperada seguiria operando com um cwd stale/indevido.
|
|
5
|
+
|
|
6
|
+
import { isWithin, normalizePath } from "./guard.ts";
|
|
7
|
+
|
|
8
|
+
/** Extrai os paths de cada bloco `worktree <path>` de `git worktree list --porcelain`. */
|
|
9
|
+
export function parseWorktreePaths(porcelain: string): string[] {
|
|
10
|
+
return [...porcelain.matchAll(/^worktree (.+)$/gm)].map((m) => m[1].trim());
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isListedWorktree(toplevel: string, porcelain: string): boolean {
|
|
14
|
+
const target = normalizePath(toplevel);
|
|
15
|
+
return parseWorktreePaths(porcelain).some((path) => normalizePath(path) === target);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Avalia se `toplevel` (o cwd resolvido do worker) ainda é um worktree válido de `root`
|
|
20
|
+
* (a raiz do repositório principal): precisa estar dentro de `<root>/.claude/worktrees` e
|
|
21
|
+
* continuar presente em `porcelain` (saída de `git worktree list --porcelain`, rodado a
|
|
22
|
+
* partir de `root`). Devolve a mensagem de bloqueio, ou `null` se estiver tudo certo.
|
|
23
|
+
*/
|
|
24
|
+
export function evaluateFreshness(
|
|
25
|
+
toplevel: string,
|
|
26
|
+
root: string,
|
|
27
|
+
porcelain: string,
|
|
28
|
+
): string | null {
|
|
29
|
+
if (!isWithin(toplevel, `${root}/.claude/worktrees`)) {
|
|
30
|
+
return `ERROR: worktree fora de ${root}/.claude/worktrees bloqueado pelo Vetor Safety Hook: ${toplevel}\n` +
|
|
31
|
+
"Workers só devem operar dentro de .claude/worktrees/<slug> na raiz do projeto.";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!isListedWorktree(toplevel, porcelain)) {
|
|
35
|
+
return `ERROR: worktree stale bloqueado pelo Vetor Safety Hook: ${toplevel}\n` +
|
|
36
|
+
"Este worktree não aparece mais em `git worktree list` — provavelmente já foi removido " +
|
|
37
|
+
"(worktree-ship ou limpeza manual). Encerre esta sessão; ela não pode mais operar aqui.";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// CLI invocado por opencode/plugin/vetor.ts (hook `event`, evento `session.error`) via
|
|
2
|
+
// `deno run -A`, no mesmo padrão de safety-check.ts/check-edit.ts: payload JSON no stdin,
|
|
3
|
+
// silêncio se não houver o que registrar.
|
|
4
|
+
//
|
|
5
|
+
// Contrato de stdin:
|
|
6
|
+
// { model?: string; statusCode?: number; isRetryable?: boolean;
|
|
7
|
+
// responseHeaders?: Record<string,string>; message?: string; cwd?: string }
|
|
8
|
+
//
|
|
9
|
+
// `model` já vem resolvido pelo plugin como "<providerID>/<modelID>" (correlacionado via
|
|
10
|
+
// `chat.params`, já que `session.error` não carrega o model/provider — ver lib/model-health.ts).
|
|
11
|
+
// Sem `model` conhecido, ainda registramos sob a chave "unknown" — perder o sinal de rate-limit
|
|
12
|
+
// custa mais do que uma chave imprecisa (o coordinator trata "unknown" como não-fallback-ável).
|
|
13
|
+
|
|
14
|
+
import { resolveWorktree } from "./lib/status.ts";
|
|
15
|
+
import { computeUntil, isRateLimitOrQuotaStatus, recordModelHealth } from "./lib/model-health.ts";
|
|
16
|
+
|
|
17
|
+
interface Payload {
|
|
18
|
+
model?: string;
|
|
19
|
+
statusCode?: number;
|
|
20
|
+
responseHeaders?: Record<string, string>;
|
|
21
|
+
message?: string;
|
|
22
|
+
cwd?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function main() {
|
|
26
|
+
const raw = new TextDecoder().decode(await new Response(Deno.stdin.readable).arrayBuffer());
|
|
27
|
+
|
|
28
|
+
let input: Payload;
|
|
29
|
+
try {
|
|
30
|
+
input = JSON.parse(raw);
|
|
31
|
+
} catch {
|
|
32
|
+
Deno.exit(0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!isRateLimitOrQuotaStatus(input.statusCode)) Deno.exit(0);
|
|
36
|
+
|
|
37
|
+
const cwd = input.cwd ?? Deno.cwd();
|
|
38
|
+
const worktree = await resolveWorktree(cwd);
|
|
39
|
+
if (!worktree) Deno.exit(0);
|
|
40
|
+
|
|
41
|
+
const path = `${worktree.root}/.claude/vetor/status/model-health.json`;
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
const until = computeUntil(now, input.responseHeaders);
|
|
44
|
+
const key = input.model ?? "unknown";
|
|
45
|
+
const lastError = `HTTP ${input.statusCode}${input.message ? `: ${input.message}` : ""}`;
|
|
46
|
+
|
|
47
|
+
recordModelHealth(path, key, { status: "degraded", until, lastError });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await main();
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Teste de ponta-a-ponta do CLI (issue #83): simula o payload que
|
|
2
|
+
// opencode/plugin/vetor.ts envia via stdin ao detectar um `ApiError` com `statusCode: 429`
|
|
3
|
+
// num evento `session.error`, e confirma que `.claude/vetor/status/model-health.json` é
|
|
4
|
+
// criado/atualizado corretamente — incluindo o cálculo de `until` a partir de `retry-after`.
|
|
5
|
+
|
|
6
|
+
import { assertEquals } from "@std/assert";
|
|
7
|
+
|
|
8
|
+
const SCRIPT = new URL("./model-health.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 runCli(input: unknown): Promise<{ code: number }> {
|
|
24
|
+
const command = new Deno.Command("deno", {
|
|
25
|
+
args: ["run", "-A", SCRIPT],
|
|
26
|
+
stdin: "piped",
|
|
27
|
+
stdout: "piped",
|
|
28
|
+
stderr: "piped",
|
|
29
|
+
});
|
|
30
|
+
const child = command.spawn();
|
|
31
|
+
const writer = child.stdin.getWriter();
|
|
32
|
+
await writer.write(new TextEncoder().encode(JSON.stringify(input)));
|
|
33
|
+
await writer.close();
|
|
34
|
+
const out = await child.output();
|
|
35
|
+
return { code: out.code };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
Deno.test("model-health CLI - ApiError 429 com retry-after grava model-health.json", async () => {
|
|
39
|
+
const repo = await makeRepo();
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
|
|
42
|
+
await runCli({
|
|
43
|
+
model: "anthropic/claude-sonnet-4-5",
|
|
44
|
+
statusCode: 429,
|
|
45
|
+
responseHeaders: { "retry-after": "20" },
|
|
46
|
+
message: "rate limited",
|
|
47
|
+
cwd: repo,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const path = `${repo}/.claude/vetor/status/model-health.json`;
|
|
51
|
+
const file = JSON.parse(await Deno.readTextFile(path));
|
|
52
|
+
const entry = file["anthropic/claude-sonnet-4-5"];
|
|
53
|
+
|
|
54
|
+
assertEquals(entry.status, "degraded");
|
|
55
|
+
assertEquals(entry.lastError, "HTTP 429: rate limited");
|
|
56
|
+
// até 2s de tolerância pelo tempo de subida do processo `deno run`.
|
|
57
|
+
const expected = now + 20_000;
|
|
58
|
+
const diff = Math.abs(entry.until - expected);
|
|
59
|
+
if (diff > 5_000) throw new Error(`until fora da tolerância esperada: diff=${diff}ms`);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
Deno.test("model-health CLI - status não relacionado a rate limit não grava nada", async () => {
|
|
63
|
+
const repo = await makeRepo();
|
|
64
|
+
|
|
65
|
+
const result = await runCli({
|
|
66
|
+
model: "anthropic/claude-sonnet-4-5",
|
|
67
|
+
statusCode: 500,
|
|
68
|
+
message: "internal error",
|
|
69
|
+
cwd: repo,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
assertEquals(result.code, 0);
|
|
73
|
+
let exists = true;
|
|
74
|
+
try {
|
|
75
|
+
await Deno.stat(`${repo}/.claude/vetor/status/model-health.json`);
|
|
76
|
+
} catch {
|
|
77
|
+
exists = false;
|
|
78
|
+
}
|
|
79
|
+
assertEquals(exists, false);
|
|
80
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// CLI invocado pelo issue-coordinator portado (opencode/agent/issue-coordinator.md,
|
|
2
|
+
// issue #84) antes de montar cada comando `opencode run --dir ... --model <provider/model>`.
|
|
3
|
+
//
|
|
4
|
+
// Lê a lista de fallback ordenada de `.claude/vetor/config.json` (`modelFallback.<tier>`) e
|
|
5
|
+
// `.claude/vetor/status/model-health.json` (escrito pelo hook `event` da issue #83), e devolve
|
|
6
|
+
// no stdout o primeiro modelo/provedor saudável da lista.
|
|
7
|
+
//
|
|
8
|
+
// Códigos de saída (distintos porque o coordinator reage diferente a cada um — issue #312):
|
|
9
|
+
// 0 - sucesso, modelo/provedor saudável no stdout.
|
|
10
|
+
// 1 - todos os modelos do tier estão `degraded` (transitório) — o coordinator mantém o grupo
|
|
11
|
+
// `QUEUED` e tenta de novo no próximo ciclo de monitoramento.
|
|
12
|
+
// 2 - `modelFallback.<tier>` não configurado e nenhum `fallback` explícito foi passado (erro de
|
|
13
|
+
// configuração permanente, não transitório) — o coordinator NÃO deve reter o grupo em
|
|
14
|
+
// `QUEUED` esperando algo mudar sozinho; precisa escalar/parar e pedir configuração.
|
|
15
|
+
//
|
|
16
|
+
// Contrato de stdin:
|
|
17
|
+
// { tier?: "simple" | "complex"; fallback?: string[]; cwd?: string }
|
|
18
|
+
// `fallback` explícito tem prioridade sobre `tier` (permite o coordinator sobrescrever a lista
|
|
19
|
+
// por grupo, ex.: resposta do usuário na Fase 2 do plano). Sem nenhum dos dois, usa `tier:
|
|
20
|
+
// "simple"`.
|
|
21
|
+
//
|
|
22
|
+
// Issue #312: não há default embutido de provider/modelo. Um default assumindo `anthropic/...`
|
|
23
|
+
// direto falha com "Unexpected server error" (erro genérico do opencode real, sem pista da causa)
|
|
24
|
+
// em qualquer ambiente configurado só com outro provider (ex.: OpenRouter) — não há como adivinhar
|
|
25
|
+
// o provider certo sem heurística especulativa. Sem `fallback` explícito e sem `modelFallback.<tier>`
|
|
26
|
+
// em `.claude/vetor/config.json`, o script falha cedo (código 1, sem stdout) com mensagem acionável
|
|
27
|
+
// em vez de devolver um modelo que o `opencode run` downstream pode rejeitar.
|
|
28
|
+
|
|
29
|
+
import { normalizeCwd, readJson } from "./lib/project.ts";
|
|
30
|
+
import { resolveWorktree } from "./lib/status.ts";
|
|
31
|
+
import { pickHealthyModel, readModelHealthFile } from "./lib/model-health.ts";
|
|
32
|
+
|
|
33
|
+
interface Payload {
|
|
34
|
+
tier?: "simple" | "complex";
|
|
35
|
+
fallback?: string[];
|
|
36
|
+
cwd?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface VetorConfig {
|
|
40
|
+
modelFallback?: Record<string, string[]>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function exists(path: string): boolean {
|
|
44
|
+
try {
|
|
45
|
+
Deno.statSync(path);
|
|
46
|
+
return true;
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `null` significa "sem lista resolvível" — nem `fallback` explícito, nem `modelFallback.<tier>`
|
|
53
|
+
* configurado em `config.json` (issue #312: sem default embutido, ver nota acima). */
|
|
54
|
+
function resolveFallbackList(root: string, input: Payload): string[] | null {
|
|
55
|
+
if (input.fallback && input.fallback.length > 0) return input.fallback;
|
|
56
|
+
|
|
57
|
+
const tier = input.tier ?? "simple";
|
|
58
|
+
const configPath = `${root}/.claude/vetor/config.json`;
|
|
59
|
+
if (exists(configPath)) {
|
|
60
|
+
try {
|
|
61
|
+
const config = readJson(configPath) as VetorConfig;
|
|
62
|
+
const configured = config.modelFallback?.[tier];
|
|
63
|
+
if (configured && configured.length > 0) return configured;
|
|
64
|
+
} catch {
|
|
65
|
+
// config.json ilegível: trata como "não configurado" — cai no erro acionável do main().
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function main() {
|
|
73
|
+
const raw = new TextDecoder().decode(await new Response(Deno.stdin.readable).arrayBuffer());
|
|
74
|
+
|
|
75
|
+
let input: Payload;
|
|
76
|
+
try {
|
|
77
|
+
input = raw.trim() ? JSON.parse(raw) : {};
|
|
78
|
+
} catch {
|
|
79
|
+
input = {};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const cwd = normalizeCwd(input.cwd ?? Deno.cwd());
|
|
83
|
+
const worktree = await resolveWorktree(cwd);
|
|
84
|
+
const root = worktree?.root ?? cwd;
|
|
85
|
+
|
|
86
|
+
const tier = input.tier ?? "simple";
|
|
87
|
+
const fallback = resolveFallbackList(root, input);
|
|
88
|
+
if (!fallback) {
|
|
89
|
+
const configPath = `${root}/.claude/vetor/config.json`;
|
|
90
|
+
console.error(
|
|
91
|
+
`modelFallback.${tier} não configurado em ${configPath} e nenhum "fallback" explícito foi ` +
|
|
92
|
+
`passado. Configure "modelFallback.${tier}": ["<provider>/<model>", ...] em ` +
|
|
93
|
+
`.claude/vetor/config.json com um provider/modelo válido para o ambiente atual (confira com ` +
|
|
94
|
+
`"opencode models" e "opencode auth list") antes do primeiro dispatch.`,
|
|
95
|
+
);
|
|
96
|
+
Deno.exit(2);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const health = readModelHealthFile(`${root}/.claude/vetor/status/model-health.json`);
|
|
100
|
+
const chosen = pickHealthyModel(fallback, health, Date.now());
|
|
101
|
+
|
|
102
|
+
if (!chosen) {
|
|
103
|
+
console.error(
|
|
104
|
+
`Todos os modelos da lista de fallback estão degraded: ${fallback.join(", ")}`,
|
|
105
|
+
);
|
|
106
|
+
Deno.exit(1);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
console.log(chosen);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
await main();
|