handyman-harness 3.0.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/LICENSE +21 -0
- package/NOTICE +17 -0
- package/README.md +32 -0
- package/assets/AGENTS.template.md +36 -0
- package/assets/CHECKPOINTS.template.md +32 -0
- package/assets/backlog-explore.template.md +29 -0
- package/assets/backlog-impl.template.md +24 -0
- package/assets/backlog-review.template.md +35 -0
- package/assets/docs-architecture.template.md +23 -0
- package/assets/docs-business.template.md +67 -0
- package/assets/docs-conventions.template.md +28 -0
- package/assets/docs-verification.template.md +25 -0
- package/assets/feature-request.template.md +170 -0
- package/assets/feature_list.template.json +33 -0
- package/assets/harness.config.global.template.json +27 -0
- package/assets/harness.config.local.template.json +27 -0
- package/assets/harness.gitignore.template +11 -0
- package/assets/index.template.md +34 -0
- package/assets/init.template.sh +330 -0
- package/assets/progress-current.template.md +30 -0
- package/assets/progress-history.template.md +19 -0
- package/assets/role-explorer.template.md +17 -0
- package/assets/role-implementer.template.md +22 -0
- package/assets/role-leader.template.md +20 -0
- package/assets/role-reviewer.template.md +21 -0
- package/assets/schemas/feature_list.schema.json +97 -0
- package/assets/schemas/harness.config.schema.json +65 -0
- package/assets/schemas/registry.schema.json +24 -0
- package/assets/schemas/sprint.schema.json +20 -0
- package/assets/schemas/trigger_eval.schema.json +18 -0
- package/assets/sprint.template.md +50 -0
- package/dist/backlog.js +7124 -0
- package/dist/cli.js +43 -0
- package/dist/evals.js +7189 -0
- package/dist/feature.js +8085 -0
- package/dist/index_md.js +6852 -0
- package/dist/metrics.js +6996 -0
- package/dist/preflight.js +6873 -0
- package/dist/sprint.js +7388 -0
- package/dist/toolbox.js +9733 -0
- package/dist/toolbox_acceptance.js +77 -0
- package/dist/toolbox_assets.js +119 -0
- package/dist/toolbox_draft.js +2166 -0
- package/dist/toolbox_llm.js +291 -0
- package/dist/toolbox_retro.js +191 -0
- package/dist/toolbox_review_notes.js +169 -0
- package/dist/toolbox_review_notes_cli.js +598 -0
- package/dist/toolbox_state.js +9986 -0
- package/dist/toolbox_triage.js +168 -0
- package/dist/tools_discovery.js +7751 -0
- package/dist/update_harness.js +7531 -0
- package/dist/upgrade_harness.js +7355 -0
- package/dist/validate_harness.js +7304 -0
- package/package.json +33 -0
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);
|
|
3
|
+
|
|
4
|
+
// src/toolbox_review_notes_cli.ts
|
|
5
|
+
import { resolve as resolve3 } from "node:path";
|
|
6
|
+
|
|
7
|
+
// ../packages/toolbox-core/dist/llm.js
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
var LlmError = class extends Error {
|
|
11
|
+
code;
|
|
12
|
+
constructor(code, message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "LlmError";
|
|
15
|
+
this.code = code;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
var DEFAULT_MAX_TOKENS = 16e3;
|
|
19
|
+
var OPENAI_MAX_TOKENS_CAP = 131072;
|
|
20
|
+
var DRAFT_TIMEOUT_MS = 3e5;
|
|
21
|
+
var HEALTH_TIMEOUT_MS = 1500;
|
|
22
|
+
async function raiseHttp(res) {
|
|
23
|
+
const body = (await res.text().catch(() => "")).slice(0, 300);
|
|
24
|
+
let code = "provider_error";
|
|
25
|
+
if (res.status === 401) {
|
|
26
|
+
code = "unauthorized";
|
|
27
|
+
} else if (body.includes('"1113"')) {
|
|
28
|
+
code = "insufficient_balance";
|
|
29
|
+
}
|
|
30
|
+
throw new LlmError(code, `HTTP ${res.status}: ${body}`);
|
|
31
|
+
}
|
|
32
|
+
async function* sseData(body) {
|
|
33
|
+
if (!body) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const decoder = new TextDecoder();
|
|
37
|
+
let buf = "";
|
|
38
|
+
for await (const chunk of body) {
|
|
39
|
+
buf += decoder.decode(chunk, { stream: true });
|
|
40
|
+
let idx = buf.indexOf("\n");
|
|
41
|
+
while (idx !== -1) {
|
|
42
|
+
const line = buf.slice(0, idx).trimEnd();
|
|
43
|
+
buf = buf.slice(idx + 1);
|
|
44
|
+
if (line.startsWith("data:")) {
|
|
45
|
+
yield line.slice(5).trim();
|
|
46
|
+
}
|
|
47
|
+
idx = buf.indexOf("\n");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function parseJson(payload) {
|
|
52
|
+
try {
|
|
53
|
+
const value = JSON.parse(payload);
|
|
54
|
+
return value && typeof value === "object" ? value : null;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function anthropicProvider(options) {
|
|
60
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
61
|
+
const base = options.baseUrl.replace(/\/$/, "");
|
|
62
|
+
return {
|
|
63
|
+
id: options.id,
|
|
64
|
+
model: options.model,
|
|
65
|
+
available: async () => options.apiKey.length > 0,
|
|
66
|
+
async draft(req, onDelta) {
|
|
67
|
+
const headers = {
|
|
68
|
+
"Content-Type": "application/json",
|
|
69
|
+
"anthropic-version": "2023-06-01"
|
|
70
|
+
};
|
|
71
|
+
if ((options.auth ?? "x-api-key") === "bearer") {
|
|
72
|
+
headers.Authorization = `Bearer ${options.apiKey}`;
|
|
73
|
+
} else {
|
|
74
|
+
headers["x-api-key"] = options.apiKey;
|
|
75
|
+
}
|
|
76
|
+
const model = req.model ?? options.model;
|
|
77
|
+
const res = await doFetch(`${base}/v1/messages`, {
|
|
78
|
+
method: "POST",
|
|
79
|
+
headers,
|
|
80
|
+
signal: AbortSignal.timeout(DRAFT_TIMEOUT_MS),
|
|
81
|
+
body: JSON.stringify({
|
|
82
|
+
model,
|
|
83
|
+
max_tokens: req.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
84
|
+
stream: true,
|
|
85
|
+
...req.system ? { system: req.system } : {},
|
|
86
|
+
messages: [{ role: "user", content: req.prompt }]
|
|
87
|
+
})
|
|
88
|
+
});
|
|
89
|
+
if (!res.ok) {
|
|
90
|
+
await raiseHttp(res);
|
|
91
|
+
}
|
|
92
|
+
let text = "";
|
|
93
|
+
let stopReason = null;
|
|
94
|
+
for await (const payload of sseData(res.body)) {
|
|
95
|
+
const event = parseJson(payload);
|
|
96
|
+
if (!event) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (event.type === "content_block_delta") {
|
|
100
|
+
const delta = event.delta;
|
|
101
|
+
if (delta?.type === "text_delta" && typeof delta.text === "string") {
|
|
102
|
+
text += delta.text;
|
|
103
|
+
onDelta(delta.text);
|
|
104
|
+
}
|
|
105
|
+
} else if (event.type === "message_delta") {
|
|
106
|
+
const delta = event.delta;
|
|
107
|
+
if (typeof delta?.stop_reason === "string") {
|
|
108
|
+
stopReason = delta.stop_reason;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { text, model, stopReason };
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
function openAiCompatProvider(options) {
|
|
117
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
118
|
+
const base = options.baseUrl.replace(/\/$/, "");
|
|
119
|
+
return {
|
|
120
|
+
id: options.id,
|
|
121
|
+
model: options.model,
|
|
122
|
+
async available() {
|
|
123
|
+
if (!options.healthCheck) {
|
|
124
|
+
return (options.apiKey ?? "").length > 0;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
const res = await doFetch(`${base}/models`, {
|
|
128
|
+
signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS)
|
|
129
|
+
});
|
|
130
|
+
return res.ok;
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
async draft(req, onDelta) {
|
|
136
|
+
const headers = { "Content-Type": "application/json" };
|
|
137
|
+
if (options.apiKey) {
|
|
138
|
+
headers.Authorization = `Bearer ${options.apiKey}`;
|
|
139
|
+
}
|
|
140
|
+
const messages = [
|
|
141
|
+
...req.system ? [{ role: "system", content: req.system }] : [],
|
|
142
|
+
{ role: "user", content: req.prompt }
|
|
143
|
+
];
|
|
144
|
+
const model = req.model ?? options.model;
|
|
145
|
+
const res = await doFetch(`${base}/chat/completions`, {
|
|
146
|
+
method: "POST",
|
|
147
|
+
headers,
|
|
148
|
+
signal: AbortSignal.timeout(DRAFT_TIMEOUT_MS),
|
|
149
|
+
body: JSON.stringify({
|
|
150
|
+
model,
|
|
151
|
+
messages,
|
|
152
|
+
stream: true,
|
|
153
|
+
max_tokens: Math.min(req.maxTokens ?? DEFAULT_MAX_TOKENS, OPENAI_MAX_TOKENS_CAP),
|
|
154
|
+
...options.thinkingControl && !req.reasoning ? { thinking: { type: "disabled" } } : {}
|
|
155
|
+
})
|
|
156
|
+
});
|
|
157
|
+
if (!res.ok) {
|
|
158
|
+
await raiseHttp(res);
|
|
159
|
+
}
|
|
160
|
+
let text = "";
|
|
161
|
+
let stopReason = null;
|
|
162
|
+
for await (const payload of sseData(res.body)) {
|
|
163
|
+
if (payload === "[DONE]") {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const event = parseJson(payload);
|
|
167
|
+
const choice = event?.choices?.[0];
|
|
168
|
+
if (!choice) {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const delta = choice.delta;
|
|
172
|
+
if (typeof delta?.content === "string" && delta.content.length > 0) {
|
|
173
|
+
text += delta.content;
|
|
174
|
+
onDelta(delta.content);
|
|
175
|
+
}
|
|
176
|
+
if (typeof choice.finish_reason === "string") {
|
|
177
|
+
stopReason = choice.finish_reason;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return { text, model, stopReason };
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
var PROVIDER_REGISTRY = [
|
|
185
|
+
{
|
|
186
|
+
id: "zai",
|
|
187
|
+
apiKeyEnvKey: "Z_AI_API_KEY",
|
|
188
|
+
modelEnvKey: "Z_AI_MODEL",
|
|
189
|
+
defaultModel: "glm-5.2",
|
|
190
|
+
resolveVariant: (env) => env.Z_AI_API_MODE === "paas" ? {
|
|
191
|
+
adapter: "openai-compat",
|
|
192
|
+
baseUrl: "https://api.z.ai/api/paas/v4",
|
|
193
|
+
thinkingControl: true
|
|
194
|
+
} : {
|
|
195
|
+
adapter: "anthropic",
|
|
196
|
+
baseUrl: "https://api.z.ai/api/anthropic",
|
|
197
|
+
auth: "bearer"
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
id: "claude",
|
|
202
|
+
apiKeyEnvKey: "ANTHROPIC_API_KEY",
|
|
203
|
+
modelEnvKey: "ANTHROPIC_MODEL",
|
|
204
|
+
defaultModel: "claude-opus-4-8",
|
|
205
|
+
resolveVariant: () => ({
|
|
206
|
+
adapter: "anthropic",
|
|
207
|
+
baseUrl: "https://api.anthropic.com"
|
|
208
|
+
})
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
id: "ollama",
|
|
212
|
+
modelEnvKey: "OLLAMA_MODEL",
|
|
213
|
+
defaultModel: "llama3.2",
|
|
214
|
+
resolveVariant: (env) => ({
|
|
215
|
+
adapter: "openai-compat",
|
|
216
|
+
baseUrl: env.OLLAMA_BASE_URL ?? "http://127.0.0.1:11434/v1",
|
|
217
|
+
healthCheck: true
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
];
|
|
221
|
+
function buildProviders(env, fetchImpl) {
|
|
222
|
+
const providers = [];
|
|
223
|
+
for (const entry of PROVIDER_REGISTRY) {
|
|
224
|
+
const apiKey = entry.apiKeyEnvKey ? env[entry.apiKeyEnvKey] ?? "" : "";
|
|
225
|
+
if (entry.apiKeyEnvKey && !apiKey) {
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const model = entry.modelEnvKey ? env[entry.modelEnvKey] ?? entry.defaultModel : entry.defaultModel;
|
|
229
|
+
const variant = entry.resolveVariant(env);
|
|
230
|
+
providers.push(variant.adapter === "anthropic" ? anthropicProvider({
|
|
231
|
+
id: entry.id,
|
|
232
|
+
model,
|
|
233
|
+
baseUrl: variant.baseUrl,
|
|
234
|
+
apiKey,
|
|
235
|
+
auth: variant.auth,
|
|
236
|
+
fetchImpl
|
|
237
|
+
}) : openAiCompatProvider({
|
|
238
|
+
id: entry.id,
|
|
239
|
+
model,
|
|
240
|
+
baseUrl: variant.baseUrl,
|
|
241
|
+
apiKey: apiKey || void 0,
|
|
242
|
+
thinkingControl: variant.thinkingControl,
|
|
243
|
+
healthCheck: variant.healthCheck,
|
|
244
|
+
fetchImpl
|
|
245
|
+
}));
|
|
246
|
+
}
|
|
247
|
+
return providers;
|
|
248
|
+
}
|
|
249
|
+
function loadDotEnv(root, env = process.env) {
|
|
250
|
+
let raw;
|
|
251
|
+
try {
|
|
252
|
+
raw = readFileSync(join(root, ".env"), "utf-8");
|
|
253
|
+
} catch {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
for (const line of raw.split("\n")) {
|
|
257
|
+
if (line.trimStart().startsWith("#")) {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line);
|
|
261
|
+
if (!match) {
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const key = match[1];
|
|
265
|
+
let value = match[2];
|
|
266
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
267
|
+
value = value.slice(1, -1);
|
|
268
|
+
}
|
|
269
|
+
if (env[key] === void 0) {
|
|
270
|
+
env[key] = value;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ../packages/toolbox-core/dist/workspace.js
|
|
276
|
+
import { readFileSync as readFileSync2, statSync } from "node:fs";
|
|
277
|
+
import { isAbsolute, join as join2 } from "node:path";
|
|
278
|
+
function isFile(path) {
|
|
279
|
+
try {
|
|
280
|
+
return statSync(path).isFile();
|
|
281
|
+
} catch {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
function readJsonSwallow(path) {
|
|
286
|
+
try {
|
|
287
|
+
return JSON.parse(readFileSync2(path, "utf-8"));
|
|
288
|
+
} catch {
|
|
289
|
+
return void 0;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
function usableWorkspaceValue(ws) {
|
|
293
|
+
return typeof ws === "string" && ws.length > 0;
|
|
294
|
+
}
|
|
295
|
+
function resolveAgainstRoot(root, ws) {
|
|
296
|
+
return isAbsolute(ws) ? ws : join2(root, ws);
|
|
297
|
+
}
|
|
298
|
+
function resolveWorkspace(root) {
|
|
299
|
+
const configPath = join2(root, "harness.config.json");
|
|
300
|
+
if (isFile(configPath)) {
|
|
301
|
+
const data = readJsonSwallow(configPath);
|
|
302
|
+
const ws = data && typeof data === "object" ? data.harness_workspace : void 0;
|
|
303
|
+
if (usableWorkspaceValue(ws)) {
|
|
304
|
+
return resolveAgainstRoot(root, ws);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const rootFeatureList = join2(root, "feature_list.json");
|
|
308
|
+
if (isFile(rootFeatureList)) {
|
|
309
|
+
const data = readJsonSwallow(rootFeatureList);
|
|
310
|
+
const config = data && typeof data === "object" ? data.config : void 0;
|
|
311
|
+
const ws = config && typeof config === "object" ? config.harness_workspace : void 0;
|
|
312
|
+
if (usableWorkspaceValue(ws)) {
|
|
313
|
+
return resolveAgainstRoot(root, ws);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (isFile(join2(root, ".handyman", "feature_list.json"))) {
|
|
317
|
+
return join2(root, ".handyman");
|
|
318
|
+
}
|
|
319
|
+
return root;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ../packages/toolbox-core/dist/state.js
|
|
323
|
+
import { readdirSync, readFileSync as readFileSync4, statSync as statSync3 } from "node:fs";
|
|
324
|
+
import { join as join4, resolve as resolve2, sep } from "node:path";
|
|
325
|
+
|
|
326
|
+
// ../packages/toolbox-core/dist/registry.js
|
|
327
|
+
import { mkdirSync, readFileSync as readFileSync3, statSync as statSync2, writeFileSync } from "node:fs";
|
|
328
|
+
import { homedir } from "node:os";
|
|
329
|
+
import { join as join3, resolve } from "node:path";
|
|
330
|
+
function handymanRoot(cliOverride) {
|
|
331
|
+
if (cliOverride) {
|
|
332
|
+
return resolve(cliOverride.replace(/^~(?=$|\/)/, homedir()));
|
|
333
|
+
}
|
|
334
|
+
const env = process.env.HANDYMAN_ROOT;
|
|
335
|
+
if (env) {
|
|
336
|
+
return resolve(env.replace(/^~(?=$|\/)/, homedir()));
|
|
337
|
+
}
|
|
338
|
+
return join3(homedir(), "HANDYMAN");
|
|
339
|
+
}
|
|
340
|
+
function registryPath(hroot) {
|
|
341
|
+
return join3(hroot, "registry.json");
|
|
342
|
+
}
|
|
343
|
+
function loadRegistry(hroot) {
|
|
344
|
+
const empty = { version: 1, harnesses: [] };
|
|
345
|
+
const path = registryPath(hroot);
|
|
346
|
+
let raw;
|
|
347
|
+
try {
|
|
348
|
+
raw = readFileSync3(path, "utf-8");
|
|
349
|
+
} catch {
|
|
350
|
+
return [empty, null];
|
|
351
|
+
}
|
|
352
|
+
let data;
|
|
353
|
+
try {
|
|
354
|
+
data = JSON.parse(raw);
|
|
355
|
+
} catch {
|
|
356
|
+
return [empty, `registry does not parse: ${path}`];
|
|
357
|
+
}
|
|
358
|
+
if (!data || typeof data !== "object" || Array.isArray(data) || !Array.isArray(data.harnesses)) {
|
|
359
|
+
return [empty, `registry has no 'harnesses' array: ${path}`];
|
|
360
|
+
}
|
|
361
|
+
const record = data;
|
|
362
|
+
const harnesses = record.harnesses.filter((e) => !!e && typeof e === "object" && !Array.isArray(e)).map((e) => ({
|
|
363
|
+
project_root: String(e.project_root ?? ""),
|
|
364
|
+
registered: String(e.registered ?? "")
|
|
365
|
+
}));
|
|
366
|
+
return [{ version: Number(record.version ?? 1), harnesses }, null];
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ../packages/toolbox-core/dist/state.js
|
|
370
|
+
var INTAKE_MAX_BYTES = 256 * 1024;
|
|
371
|
+
var CSP_HEADER = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'";
|
|
372
|
+
var HTML_CSP_HEADER = CSP_HEADER.replace("img-src 'self' data:", "img-src 'self' data: https://picsum.photos");
|
|
373
|
+
function readText(path) {
|
|
374
|
+
try {
|
|
375
|
+
return readFileSync4(path, "utf-8");
|
|
376
|
+
} catch {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
function isRegisteredRoot(hroot, root) {
|
|
381
|
+
const [registry] = loadRegistry(hroot);
|
|
382
|
+
return registry.harnesses.some((e) => e.project_root === root);
|
|
383
|
+
}
|
|
384
|
+
var MD_TOKEN_PATHS = {
|
|
385
|
+
current: (workspace) => join4(workspace, "progress", "current.md"),
|
|
386
|
+
history: (workspace) => join4(workspace, "progress", "history.md"),
|
|
387
|
+
index: (workspace) => join4(workspace, "index.md"),
|
|
388
|
+
checkpoints: (_workspace, root) => join4(root, "CHECKPOINTS.md")
|
|
389
|
+
};
|
|
390
|
+
var MD_TOKENS = Object.keys(MD_TOKEN_PATHS);
|
|
391
|
+
|
|
392
|
+
// ../packages/toolbox-core/dist/reviewNotes.js
|
|
393
|
+
import { execFileSync } from "node:child_process";
|
|
394
|
+
import { join as join5 } from "node:path";
|
|
395
|
+
var REVIEW_DIFF_MAX_CHARS = 6e4;
|
|
396
|
+
function readImplReport(workspace, feature) {
|
|
397
|
+
return readText(join5(workspace, "backlog", `impl_${feature}.md`));
|
|
398
|
+
}
|
|
399
|
+
function readFeatureDiff(root, maxChars = REVIEW_DIFF_MAX_CHARS) {
|
|
400
|
+
let raw;
|
|
401
|
+
try {
|
|
402
|
+
raw = execFileSync("git", ["diff", "HEAD"], {
|
|
403
|
+
cwd: root,
|
|
404
|
+
encoding: "utf-8",
|
|
405
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
406
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
407
|
+
});
|
|
408
|
+
} catch {
|
|
409
|
+
return { diff: "", truncated: false };
|
|
410
|
+
}
|
|
411
|
+
if (raw.length <= maxChars) {
|
|
412
|
+
return { diff: raw, truncated: false };
|
|
413
|
+
}
|
|
414
|
+
return { diff: raw.slice(0, maxChars), truncated: true };
|
|
415
|
+
}
|
|
416
|
+
function composeReviewNotesSystem() {
|
|
417
|
+
return [
|
|
418
|
+
"Eres un asistente que prepara la revision de una feature en un harness Handyman.",
|
|
419
|
+
"Produces un CHECKLIST de revision en markdown: puntos criticos, invariantes en",
|
|
420
|
+
"riesgo y preguntas concretas que el reviewer debe verificar contra la evidencia.",
|
|
421
|
+
"",
|
|
422
|
+
"Reglas duras:",
|
|
423
|
+
"- Tu salida es un BORRADOR: el reviewer verifica TODO. Dilo en la primera linea.",
|
|
424
|
+
"- Escribes PREGUNTAS y PUNTOS A VERIFICAR, no conclusiones.",
|
|
425
|
+
" Ej: '- invariante X respetada?', '- hay test que cubra el caso Y?'.",
|
|
426
|
+
"- NUNCA emites un veredicto: no escribas APPROVED ni CHANGES_REQUESTED ni",
|
|
427
|
+
" equivalentes ('esta listo', 'se puede cerrar'). No es tu decision.",
|
|
428
|
+
"- NUNCA propongas un patch ni escribas codigo de reemplazo.",
|
|
429
|
+
"- Si el diff o el reporte no alcanzan para juzgar algo, dilo explicitamente",
|
|
430
|
+
" ('no hay evidencia suficiente para X') en vez de suponer."
|
|
431
|
+
].join("\n");
|
|
432
|
+
}
|
|
433
|
+
function composeReviewNotesPrompt(feature, implReport, diff, truncated) {
|
|
434
|
+
const parts = [`Feature bajo revision: ${feature}`, ""];
|
|
435
|
+
parts.push("---- reporte del implementer (backlog/impl_" + feature + ".md)");
|
|
436
|
+
parts.push(implReport ?? "(no hay reporte del implementer para esta feature)");
|
|
437
|
+
parts.push("");
|
|
438
|
+
parts.push("---- diff de trabajo (git diff HEAD)");
|
|
439
|
+
parts.push(diff.length > 0 ? diff : "(diff vacio o el root no es un repo git)");
|
|
440
|
+
if (truncated) {
|
|
441
|
+
parts.push("");
|
|
442
|
+
parts.push("[el diff fue truncado por tamano: senala que quedo sin revisar]");
|
|
443
|
+
}
|
|
444
|
+
return parts.join("\n");
|
|
445
|
+
}
|
|
446
|
+
function composeReviewNotesRequest(root, feature) {
|
|
447
|
+
const implReport = readImplReport(resolveWorkspace(root), feature);
|
|
448
|
+
const { diff, truncated } = readFeatureDiff(root);
|
|
449
|
+
return {
|
|
450
|
+
system: composeReviewNotesSystem(),
|
|
451
|
+
prompt: composeReviewNotesPrompt(feature, implReport, diff, truncated),
|
|
452
|
+
diffTruncated: truncated
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
async function relayReviewNotes(options) {
|
|
456
|
+
const { system, prompt, draft, diffTruncated, onDelta, onResult, onError } = options;
|
|
457
|
+
try {
|
|
458
|
+
const result = await draft({ prompt, system }, onDelta);
|
|
459
|
+
onResult({ checklist_md: result.text, model: result.model, diff_truncated: diffTruncated });
|
|
460
|
+
} catch (error) {
|
|
461
|
+
if (error instanceof LlmError) {
|
|
462
|
+
onError(error);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
onError(new LlmError("provider_error", error instanceof Error ? error.message : String(error)));
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ../packages/toolbox-core/dist/summary.js
|
|
470
|
+
function resolveSummaryModel(bodyModel, providerId, env) {
|
|
471
|
+
return bodyModel ?? env.TOOLBOX_SUMMARY_MODEL ?? (providerId === "zai" && env.Z_AI_API_MODE === "paas" ? "glm-4.7-flash" : void 0);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// src/toolbox_review_notes_cli.ts
|
|
475
|
+
var FEATURE_NAME = /^[A-Za-z0-9_-]+$/;
|
|
476
|
+
var DEFAULT_PROVIDER = "zai";
|
|
477
|
+
var REVIEW_NOTES_USAGE = "usage: toolbox.js review-notes --root PATH --feature NAME [--provider ID] [--model M] [--json]";
|
|
478
|
+
function fail(message) {
|
|
479
|
+
process.stderr.write(`review-notes: ${message}
|
|
480
|
+
`);
|
|
481
|
+
return 1;
|
|
482
|
+
}
|
|
483
|
+
function parseReviewNotesArgs(argv) {
|
|
484
|
+
const opts = {
|
|
485
|
+
root: "",
|
|
486
|
+
feature: "",
|
|
487
|
+
provider: DEFAULT_PROVIDER,
|
|
488
|
+
model: null,
|
|
489
|
+
json: false
|
|
490
|
+
};
|
|
491
|
+
const named = ["root", "feature", "provider", "model"];
|
|
492
|
+
for (let i = 0; i < argv.length; i++) {
|
|
493
|
+
const arg = argv[i];
|
|
494
|
+
if (arg === "--json") {
|
|
495
|
+
opts.json = true;
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
if (arg === "-h" || arg === "--help") {
|
|
499
|
+
process.stdout.write(`${REVIEW_NOTES_USAGE}
|
|
500
|
+
`);
|
|
501
|
+
return 0;
|
|
502
|
+
}
|
|
503
|
+
const key = named.find((n) => arg === `--${n}` || arg.startsWith(`--${n}=`));
|
|
504
|
+
if (key === void 0) {
|
|
505
|
+
process.stderr.write(`${REVIEW_NOTES_USAGE}
|
|
506
|
+
review-notes: unknown argument: ${arg}
|
|
507
|
+
`);
|
|
508
|
+
return 2;
|
|
509
|
+
}
|
|
510
|
+
let value;
|
|
511
|
+
if (arg.startsWith(`--${key}=`)) {
|
|
512
|
+
value = arg.slice(String(key).length + 3);
|
|
513
|
+
} else {
|
|
514
|
+
const next = argv[i + 1];
|
|
515
|
+
if (next !== void 0 && !next.startsWith("--")) {
|
|
516
|
+
value = next;
|
|
517
|
+
i += 1;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (value === void 0 || value === "") {
|
|
521
|
+
process.stderr.write(`${REVIEW_NOTES_USAGE}
|
|
522
|
+
review-notes: --${key} expects a value
|
|
523
|
+
`);
|
|
524
|
+
return 2;
|
|
525
|
+
}
|
|
526
|
+
opts[key] = value;
|
|
527
|
+
}
|
|
528
|
+
return opts;
|
|
529
|
+
}
|
|
530
|
+
async function reviewNotesMain(argv) {
|
|
531
|
+
const parsed = parseReviewNotesArgs(argv);
|
|
532
|
+
if (typeof parsed === "number") {
|
|
533
|
+
return parsed;
|
|
534
|
+
}
|
|
535
|
+
const { feature, provider: providerId, model, json } = parsed;
|
|
536
|
+
if (!parsed.root) {
|
|
537
|
+
return fail("--root is required");
|
|
538
|
+
}
|
|
539
|
+
const root = resolve3(parsed.root);
|
|
540
|
+
loadDotEnv(process.cwd());
|
|
541
|
+
const hroot = handymanRoot(null);
|
|
542
|
+
if (!isRegisteredRoot(hroot, root)) {
|
|
543
|
+
return fail(`root not registered: ${root} (register it with 'toolbox.js register')`);
|
|
544
|
+
}
|
|
545
|
+
if (!feature) {
|
|
546
|
+
return fail("--feature is required");
|
|
547
|
+
}
|
|
548
|
+
if (!FEATURE_NAME.test(feature)) {
|
|
549
|
+
return fail(`invalid feature name: ${feature}`);
|
|
550
|
+
}
|
|
551
|
+
const providers = buildProviders(process.env);
|
|
552
|
+
const provider = providers.find((p) => p.id === providerId);
|
|
553
|
+
if (!provider) {
|
|
554
|
+
const available = providers.map((p) => p.id).join(", ") || "(none configured)";
|
|
555
|
+
return fail(`unknown provider: ${providerId} (available: ${available})`);
|
|
556
|
+
}
|
|
557
|
+
const composed = composeReviewNotesRequest(root, feature);
|
|
558
|
+
const resolvedModel = resolveSummaryModel(model ?? void 0, providerId, process.env);
|
|
559
|
+
let exitCode = 0;
|
|
560
|
+
await relayReviewNotes({
|
|
561
|
+
system: composed.system,
|
|
562
|
+
prompt: composed.prompt,
|
|
563
|
+
diffTruncated: composed.diffTruncated,
|
|
564
|
+
draft: (r, onDelta) => provider.draft({ prompt: r.prompt, system: r.system, model: resolvedModel }, onDelta),
|
|
565
|
+
// Streaming text is the point of the non-JSON mode: the reviewer reads the
|
|
566
|
+
// checklist as it lands. Under --json the deltas are swallowed so stdout
|
|
567
|
+
// holds exactly one parseable object.
|
|
568
|
+
onDelta: (text) => {
|
|
569
|
+
if (!json) {
|
|
570
|
+
process.stdout.write(text);
|
|
571
|
+
}
|
|
572
|
+
},
|
|
573
|
+
onResult: (event) => {
|
|
574
|
+
if (json) {
|
|
575
|
+
process.stdout.write(`${JSON.stringify(event)}
|
|
576
|
+
`);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
process.stdout.write("\n");
|
|
580
|
+
if (event.diff_truncated) {
|
|
581
|
+
process.stderr.write(
|
|
582
|
+
"NOTE: the diff was truncated by size; part of the change was not seen.\n"
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
},
|
|
586
|
+
onError: (error) => {
|
|
587
|
+
process.stderr.write(`review-notes: ${error.code}: ${error.message}
|
|
588
|
+
`);
|
|
589
|
+
exitCode = 1;
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
return exitCode;
|
|
593
|
+
}
|
|
594
|
+
export {
|
|
595
|
+
REVIEW_NOTES_USAGE,
|
|
596
|
+
parseReviewNotesArgs,
|
|
597
|
+
reviewNotesMain
|
|
598
|
+
};
|