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,291 @@
|
|
|
1
|
+
import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);
|
|
2
|
+
|
|
3
|
+
// ../packages/toolbox-core/dist/llm.js
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
var LlmError = class extends Error {
|
|
7
|
+
code;
|
|
8
|
+
constructor(code, message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "LlmError";
|
|
11
|
+
this.code = code;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
var DEFAULT_MAX_TOKENS = 16e3;
|
|
15
|
+
var OPENAI_MAX_TOKENS_CAP = 131072;
|
|
16
|
+
var DRAFT_TIMEOUT_MS = 3e5;
|
|
17
|
+
var HEALTH_TIMEOUT_MS = 1500;
|
|
18
|
+
var FUTURE_PROVIDER_IDS = ["copilot"];
|
|
19
|
+
async function raiseHttp(res) {
|
|
20
|
+
const body = (await res.text().catch(() => "")).slice(0, 300);
|
|
21
|
+
let code = "provider_error";
|
|
22
|
+
if (res.status === 401) {
|
|
23
|
+
code = "unauthorized";
|
|
24
|
+
} else if (body.includes('"1113"')) {
|
|
25
|
+
code = "insufficient_balance";
|
|
26
|
+
}
|
|
27
|
+
throw new LlmError(code, `HTTP ${res.status}: ${body}`);
|
|
28
|
+
}
|
|
29
|
+
async function* sseData(body) {
|
|
30
|
+
if (!body) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const decoder = new TextDecoder();
|
|
34
|
+
let buf = "";
|
|
35
|
+
for await (const chunk of body) {
|
|
36
|
+
buf += decoder.decode(chunk, { stream: true });
|
|
37
|
+
let idx = buf.indexOf("\n");
|
|
38
|
+
while (idx !== -1) {
|
|
39
|
+
const line = buf.slice(0, idx).trimEnd();
|
|
40
|
+
buf = buf.slice(idx + 1);
|
|
41
|
+
if (line.startsWith("data:")) {
|
|
42
|
+
yield line.slice(5).trim();
|
|
43
|
+
}
|
|
44
|
+
idx = buf.indexOf("\n");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function parseJson(payload) {
|
|
49
|
+
try {
|
|
50
|
+
const value = JSON.parse(payload);
|
|
51
|
+
return value && typeof value === "object" ? value : null;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function anthropicProvider(options) {
|
|
57
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
58
|
+
const base = options.baseUrl.replace(/\/$/, "");
|
|
59
|
+
return {
|
|
60
|
+
id: options.id,
|
|
61
|
+
model: options.model,
|
|
62
|
+
available: async () => options.apiKey.length > 0,
|
|
63
|
+
async draft(req, onDelta) {
|
|
64
|
+
const headers = {
|
|
65
|
+
"Content-Type": "application/json",
|
|
66
|
+
"anthropic-version": "2023-06-01"
|
|
67
|
+
};
|
|
68
|
+
if ((options.auth ?? "x-api-key") === "bearer") {
|
|
69
|
+
headers.Authorization = `Bearer ${options.apiKey}`;
|
|
70
|
+
} else {
|
|
71
|
+
headers["x-api-key"] = options.apiKey;
|
|
72
|
+
}
|
|
73
|
+
const model = req.model ?? options.model;
|
|
74
|
+
const res = await doFetch(`${base}/v1/messages`, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers,
|
|
77
|
+
signal: AbortSignal.timeout(DRAFT_TIMEOUT_MS),
|
|
78
|
+
body: JSON.stringify({
|
|
79
|
+
model,
|
|
80
|
+
max_tokens: req.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
81
|
+
stream: true,
|
|
82
|
+
...req.system ? { system: req.system } : {},
|
|
83
|
+
messages: [{ role: "user", content: req.prompt }]
|
|
84
|
+
})
|
|
85
|
+
});
|
|
86
|
+
if (!res.ok) {
|
|
87
|
+
await raiseHttp(res);
|
|
88
|
+
}
|
|
89
|
+
let text = "";
|
|
90
|
+
let stopReason = null;
|
|
91
|
+
for await (const payload of sseData(res.body)) {
|
|
92
|
+
const event = parseJson(payload);
|
|
93
|
+
if (!event) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (event.type === "content_block_delta") {
|
|
97
|
+
const delta = event.delta;
|
|
98
|
+
if (delta?.type === "text_delta" && typeof delta.text === "string") {
|
|
99
|
+
text += delta.text;
|
|
100
|
+
onDelta(delta.text);
|
|
101
|
+
}
|
|
102
|
+
} else if (event.type === "message_delta") {
|
|
103
|
+
const delta = event.delta;
|
|
104
|
+
if (typeof delta?.stop_reason === "string") {
|
|
105
|
+
stopReason = delta.stop_reason;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { text, model, stopReason };
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function openAiCompatProvider(options) {
|
|
114
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
115
|
+
const base = options.baseUrl.replace(/\/$/, "");
|
|
116
|
+
return {
|
|
117
|
+
id: options.id,
|
|
118
|
+
model: options.model,
|
|
119
|
+
async available() {
|
|
120
|
+
if (!options.healthCheck) {
|
|
121
|
+
return (options.apiKey ?? "").length > 0;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
const res = await doFetch(`${base}/models`, {
|
|
125
|
+
signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS)
|
|
126
|
+
});
|
|
127
|
+
return res.ok;
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
async draft(req, onDelta) {
|
|
133
|
+
const headers = { "Content-Type": "application/json" };
|
|
134
|
+
if (options.apiKey) {
|
|
135
|
+
headers.Authorization = `Bearer ${options.apiKey}`;
|
|
136
|
+
}
|
|
137
|
+
const messages = [
|
|
138
|
+
...req.system ? [{ role: "system", content: req.system }] : [],
|
|
139
|
+
{ role: "user", content: req.prompt }
|
|
140
|
+
];
|
|
141
|
+
const model = req.model ?? options.model;
|
|
142
|
+
const res = await doFetch(`${base}/chat/completions`, {
|
|
143
|
+
method: "POST",
|
|
144
|
+
headers,
|
|
145
|
+
signal: AbortSignal.timeout(DRAFT_TIMEOUT_MS),
|
|
146
|
+
body: JSON.stringify({
|
|
147
|
+
model,
|
|
148
|
+
messages,
|
|
149
|
+
stream: true,
|
|
150
|
+
max_tokens: Math.min(req.maxTokens ?? DEFAULT_MAX_TOKENS, OPENAI_MAX_TOKENS_CAP),
|
|
151
|
+
...options.thinkingControl && !req.reasoning ? { thinking: { type: "disabled" } } : {}
|
|
152
|
+
})
|
|
153
|
+
});
|
|
154
|
+
if (!res.ok) {
|
|
155
|
+
await raiseHttp(res);
|
|
156
|
+
}
|
|
157
|
+
let text = "";
|
|
158
|
+
let stopReason = null;
|
|
159
|
+
for await (const payload of sseData(res.body)) {
|
|
160
|
+
if (payload === "[DONE]") {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const event = parseJson(payload);
|
|
164
|
+
const choice = event?.choices?.[0];
|
|
165
|
+
if (!choice) {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const delta = choice.delta;
|
|
169
|
+
if (typeof delta?.content === "string" && delta.content.length > 0) {
|
|
170
|
+
text += delta.content;
|
|
171
|
+
onDelta(delta.content);
|
|
172
|
+
}
|
|
173
|
+
if (typeof choice.finish_reason === "string") {
|
|
174
|
+
stopReason = choice.finish_reason;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { text, model, stopReason };
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
var PROVIDER_REGISTRY = [
|
|
182
|
+
{
|
|
183
|
+
id: "zai",
|
|
184
|
+
apiKeyEnvKey: "Z_AI_API_KEY",
|
|
185
|
+
modelEnvKey: "Z_AI_MODEL",
|
|
186
|
+
defaultModel: "glm-5.2",
|
|
187
|
+
resolveVariant: (env) => env.Z_AI_API_MODE === "paas" ? {
|
|
188
|
+
adapter: "openai-compat",
|
|
189
|
+
baseUrl: "https://api.z.ai/api/paas/v4",
|
|
190
|
+
thinkingControl: true
|
|
191
|
+
} : {
|
|
192
|
+
adapter: "anthropic",
|
|
193
|
+
baseUrl: "https://api.z.ai/api/anthropic",
|
|
194
|
+
auth: "bearer"
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
id: "claude",
|
|
199
|
+
apiKeyEnvKey: "ANTHROPIC_API_KEY",
|
|
200
|
+
modelEnvKey: "ANTHROPIC_MODEL",
|
|
201
|
+
defaultModel: "claude-opus-4-8",
|
|
202
|
+
resolveVariant: () => ({
|
|
203
|
+
adapter: "anthropic",
|
|
204
|
+
baseUrl: "https://api.anthropic.com"
|
|
205
|
+
})
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
id: "ollama",
|
|
209
|
+
modelEnvKey: "OLLAMA_MODEL",
|
|
210
|
+
defaultModel: "llama3.2",
|
|
211
|
+
resolveVariant: (env) => ({
|
|
212
|
+
adapter: "openai-compat",
|
|
213
|
+
baseUrl: env.OLLAMA_BASE_URL ?? "http://127.0.0.1:11434/v1",
|
|
214
|
+
healthCheck: true
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
];
|
|
218
|
+
function buildProviders(env, fetchImpl) {
|
|
219
|
+
const providers = [];
|
|
220
|
+
for (const entry of PROVIDER_REGISTRY) {
|
|
221
|
+
const apiKey = entry.apiKeyEnvKey ? env[entry.apiKeyEnvKey] ?? "" : "";
|
|
222
|
+
if (entry.apiKeyEnvKey && !apiKey) {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
const model = entry.modelEnvKey ? env[entry.modelEnvKey] ?? entry.defaultModel : entry.defaultModel;
|
|
226
|
+
const variant = entry.resolveVariant(env);
|
|
227
|
+
providers.push(variant.adapter === "anthropic" ? anthropicProvider({
|
|
228
|
+
id: entry.id,
|
|
229
|
+
model,
|
|
230
|
+
baseUrl: variant.baseUrl,
|
|
231
|
+
apiKey,
|
|
232
|
+
auth: variant.auth,
|
|
233
|
+
fetchImpl
|
|
234
|
+
}) : openAiCompatProvider({
|
|
235
|
+
id: entry.id,
|
|
236
|
+
model,
|
|
237
|
+
baseUrl: variant.baseUrl,
|
|
238
|
+
apiKey: apiKey || void 0,
|
|
239
|
+
thinkingControl: variant.thinkingControl,
|
|
240
|
+
healthCheck: variant.healthCheck,
|
|
241
|
+
fetchImpl
|
|
242
|
+
}));
|
|
243
|
+
}
|
|
244
|
+
return providers;
|
|
245
|
+
}
|
|
246
|
+
async function providersInfo(providers) {
|
|
247
|
+
const infos = await Promise.all(providers.map(async (p) => ({
|
|
248
|
+
id: p.id,
|
|
249
|
+
available: await p.available().catch(() => false),
|
|
250
|
+
model: p.model
|
|
251
|
+
})));
|
|
252
|
+
for (const id of FUTURE_PROVIDER_IDS) {
|
|
253
|
+
infos.push({ id, available: false, model: null });
|
|
254
|
+
}
|
|
255
|
+
return infos;
|
|
256
|
+
}
|
|
257
|
+
function loadDotEnv(root, env = process.env) {
|
|
258
|
+
let raw;
|
|
259
|
+
try {
|
|
260
|
+
raw = readFileSync(join(root, ".env"), "utf-8");
|
|
261
|
+
} catch {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
for (const line of raw.split("\n")) {
|
|
265
|
+
if (line.trimStart().startsWith("#")) {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line);
|
|
269
|
+
if (!match) {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const key = match[1];
|
|
273
|
+
let value = match[2];
|
|
274
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
275
|
+
value = value.slice(1, -1);
|
|
276
|
+
}
|
|
277
|
+
if (env[key] === void 0) {
|
|
278
|
+
env[key] = value;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
export {
|
|
283
|
+
FUTURE_PROVIDER_IDS,
|
|
284
|
+
LlmError,
|
|
285
|
+
PROVIDER_REGISTRY,
|
|
286
|
+
anthropicProvider,
|
|
287
|
+
buildProviders,
|
|
288
|
+
loadDotEnv,
|
|
289
|
+
openAiCompatProvider,
|
|
290
|
+
providersInfo
|
|
291
|
+
};
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);
|
|
2
|
+
|
|
3
|
+
// ../packages/toolbox-core/dist/retro.js
|
|
4
|
+
import { readdirSync as readdirSync2 } from "node:fs";
|
|
5
|
+
import { join as join2 } from "node:path";
|
|
6
|
+
|
|
7
|
+
// ../packages/toolbox-core/dist/llm.js
|
|
8
|
+
var LlmError = class extends Error {
|
|
9
|
+
code;
|
|
10
|
+
constructor(code, message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "LlmError";
|
|
13
|
+
this.code = code;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// ../packages/toolbox-core/dist/state.js
|
|
18
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
19
|
+
import { join, resolve, sep } from "node:path";
|
|
20
|
+
var INTAKE_MAX_BYTES = 256 * 1024;
|
|
21
|
+
var CSP_HEADER = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'";
|
|
22
|
+
var HTML_CSP_HEADER = CSP_HEADER.replace("img-src 'self' data:", "img-src 'self' data: https://picsum.photos");
|
|
23
|
+
function readText(path) {
|
|
24
|
+
try {
|
|
25
|
+
return readFileSync(path, "utf-8");
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function readFeatures(workspace) {
|
|
31
|
+
const raw = readText(join(workspace, "feature_list.json"));
|
|
32
|
+
if (raw === null) {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
let data;
|
|
36
|
+
try {
|
|
37
|
+
data = JSON.parse(raw);
|
|
38
|
+
} catch {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
const features = data && typeof data === "object" && !Array.isArray(data) ? data.features : [];
|
|
42
|
+
if (!Array.isArray(features)) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
return features.filter((f) => !!f && typeof f === "object" && !Array.isArray(f)).map((f) => ({
|
|
46
|
+
id: typeof f.id === "number" ? f.id : null,
|
|
47
|
+
name: String(f.name ?? ""),
|
|
48
|
+
title: String(f.title ?? f.name ?? ""),
|
|
49
|
+
status: String(f.status ?? "pending"),
|
|
50
|
+
sprint: f.sprint ? String(f.sprint) : null,
|
|
51
|
+
blocked_reason: f.blocked_reason ? String(f.blocked_reason) : null,
|
|
52
|
+
depends_on: Array.isArray(f.depends_on) ? f.depends_on.filter(Number.isInteger) : []
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
var MD_TOKEN_PATHS = {
|
|
56
|
+
current: (workspace) => join(workspace, "progress", "current.md"),
|
|
57
|
+
history: (workspace) => join(workspace, "progress", "history.md"),
|
|
58
|
+
index: (workspace) => join(workspace, "index.md"),
|
|
59
|
+
checkpoints: (_workspace, root) => join(root, "CHECKPOINTS.md")
|
|
60
|
+
};
|
|
61
|
+
var MD_TOKENS = Object.keys(MD_TOKEN_PATHS);
|
|
62
|
+
|
|
63
|
+
// ../packages/toolbox-core/dist/retro.js
|
|
64
|
+
var RETRO_MIN_EVIDENCE = 2;
|
|
65
|
+
var RETRO_EXCERPT_CHARS = 1200;
|
|
66
|
+
var RETRO_MAX_PATTERNS = 5;
|
|
67
|
+
function readRetroCorpus(workspace) {
|
|
68
|
+
const history = readText(join2(workspace, "progress", "history.md")) ?? "";
|
|
69
|
+
const closed = readFeatures(workspace).filter((f) => f.status === "done" && f.name).map((f) => f.name);
|
|
70
|
+
const closedSet = new Set(closed);
|
|
71
|
+
let names;
|
|
72
|
+
try {
|
|
73
|
+
names = readdirSync2(join2(workspace, "backlog")).filter((n) => n.endsWith(".md"));
|
|
74
|
+
} catch {
|
|
75
|
+
return { history, docs: [], closed };
|
|
76
|
+
}
|
|
77
|
+
names.sort();
|
|
78
|
+
const docs = [];
|
|
79
|
+
for (const name of names) {
|
|
80
|
+
const feature = name.replace(/^(impl|review|explore)_/, "").replace(/\.md$/, "");
|
|
81
|
+
if (!closedSet.has(feature)) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
docs.push({
|
|
85
|
+
id: name,
|
|
86
|
+
feature,
|
|
87
|
+
excerpt: (readText(join2(workspace, "backlog", name)) ?? "").slice(0, RETRO_EXCERPT_CHARS)
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return { history, docs, closed };
|
|
91
|
+
}
|
|
92
|
+
function composeRetroSystem() {
|
|
93
|
+
return [
|
|
94
|
+
"Destilas lecciones de las features ya cerradas de un harness Handyman.",
|
|
95
|
+
"",
|
|
96
|
+
"Responde SOLO un objeto JSON, sin texto alrededor y sin bloque de codigo:",
|
|
97
|
+
'{"patterns":[{"titulo":"<frase corta>","tipo":"patron|antipatron","features":["<nombre de feature>","<otro>"],"detalle":"<que se repitio y por que importa>"}]}',
|
|
98
|
+
"",
|
|
99
|
+
"Reglas duras:",
|
|
100
|
+
`- Entre 3 y ${RETRO_MAX_PATTERNS} patrones. Ni uno mas.`,
|
|
101
|
+
`- Cada patron necesita AL MENOS ${RETRO_MIN_EVIDENCE} features distintas como`,
|
|
102
|
+
" evidencia, citadas por nombre en 'features'. Un patron con una sola",
|
|
103
|
+
" feature es una anecdota: DESCARTALO en vez de generalizarlo.",
|
|
104
|
+
"- Usa solo nombres de features que aparezcan en el material recibido.",
|
|
105
|
+
"- Si el material no da para 3 patrones con esa evidencia, devuelve menos.",
|
|
106
|
+
" Es correcto decir que no hay suficiente historia todavia.",
|
|
107
|
+
"- No propongas editar docs/conventions.md: son sugerencias, decide un humano."
|
|
108
|
+
].join("\n");
|
|
109
|
+
}
|
|
110
|
+
function composeRetroPrompt(corpus) {
|
|
111
|
+
const docs = corpus.docs.map((d) => `--- ${d.id} (feature: ${d.feature})
|
|
112
|
+
${d.excerpt}`).join("\n\n");
|
|
113
|
+
return [
|
|
114
|
+
`Features cerradas (${corpus.closed.length}): ${corpus.closed.join(", ") || "(ninguna)"}`,
|
|
115
|
+
"",
|
|
116
|
+
"---- progress/history.md",
|
|
117
|
+
corpus.history || "(sin historia registrada)",
|
|
118
|
+
"",
|
|
119
|
+
`---- backlog de features cerradas (${corpus.docs.length})`,
|
|
120
|
+
docs || "(sin documentos de backlog cerrados)"
|
|
121
|
+
].join("\n");
|
|
122
|
+
}
|
|
123
|
+
function parseRetroPatterns(raw) {
|
|
124
|
+
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
125
|
+
const candidate = fenced?.[1] ?? raw;
|
|
126
|
+
const start = candidate.indexOf("{");
|
|
127
|
+
const end = candidate.lastIndexOf("}");
|
|
128
|
+
if (start === -1 || end <= start) {
|
|
129
|
+
return { patterns: [], discarded: 0 };
|
|
130
|
+
}
|
|
131
|
+
let data;
|
|
132
|
+
try {
|
|
133
|
+
data = JSON.parse(candidate.slice(start, end + 1));
|
|
134
|
+
} catch {
|
|
135
|
+
return { patterns: [], discarded: 0 };
|
|
136
|
+
}
|
|
137
|
+
const list = data?.patterns;
|
|
138
|
+
if (!Array.isArray(list)) {
|
|
139
|
+
return { patterns: [], discarded: 0 };
|
|
140
|
+
}
|
|
141
|
+
const patterns = [];
|
|
142
|
+
let discarded = 0;
|
|
143
|
+
for (const entry of list) {
|
|
144
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
145
|
+
discarded += 1;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const e = entry;
|
|
149
|
+
const titulo = typeof e.titulo === "string" ? e.titulo.trim() : "";
|
|
150
|
+
const features = Array.isArray(e.features) ? [...new Set(e.features.filter((f) => typeof f === "string" && f.length > 0))] : [];
|
|
151
|
+
if (!titulo || features.length < RETRO_MIN_EVIDENCE) {
|
|
152
|
+
discarded += 1;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
patterns.push({
|
|
156
|
+
titulo,
|
|
157
|
+
tipo: e.tipo === "antipatron" ? "antipatron" : "patron",
|
|
158
|
+
features,
|
|
159
|
+
detalle: typeof e.detalle === "string" ? e.detalle : ""
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (patterns.length > RETRO_MAX_PATTERNS) {
|
|
163
|
+
discarded += patterns.length - RETRO_MAX_PATTERNS;
|
|
164
|
+
patterns.length = RETRO_MAX_PATTERNS;
|
|
165
|
+
}
|
|
166
|
+
return { patterns, discarded };
|
|
167
|
+
}
|
|
168
|
+
async function relayRetro(options) {
|
|
169
|
+
const { system, prompt, draft, onDelta, onResult, onError } = options;
|
|
170
|
+
try {
|
|
171
|
+
const result = await draft({ prompt, system }, onDelta);
|
|
172
|
+
const { patterns, discarded } = parseRetroPatterns(result.text);
|
|
173
|
+
onResult({ patterns, discarded, model: result.model });
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (error instanceof LlmError) {
|
|
176
|
+
onError(error);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
onError(new LlmError("provider_error", error instanceof Error ? error.message : String(error)));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
export {
|
|
183
|
+
RETRO_EXCERPT_CHARS,
|
|
184
|
+
RETRO_MAX_PATTERNS,
|
|
185
|
+
RETRO_MIN_EVIDENCE,
|
|
186
|
+
composeRetroPrompt,
|
|
187
|
+
composeRetroSystem,
|
|
188
|
+
parseRetroPatterns,
|
|
189
|
+
readRetroCorpus,
|
|
190
|
+
relayRetro
|
|
191
|
+
};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);
|
|
2
|
+
|
|
3
|
+
// ../packages/toolbox-core/dist/reviewNotes.js
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { join as join3 } from "node:path";
|
|
6
|
+
|
|
7
|
+
// ../packages/toolbox-core/dist/llm.js
|
|
8
|
+
var LlmError = class extends Error {
|
|
9
|
+
code;
|
|
10
|
+
constructor(code, message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "LlmError";
|
|
13
|
+
this.code = code;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// ../packages/toolbox-core/dist/state.js
|
|
18
|
+
import { readdirSync, readFileSync as readFileSync2, statSync as statSync2 } from "node:fs";
|
|
19
|
+
import { join as join2, resolve, sep } from "node:path";
|
|
20
|
+
|
|
21
|
+
// ../packages/toolbox-core/dist/workspace.js
|
|
22
|
+
import { readFileSync, statSync } from "node:fs";
|
|
23
|
+
import { isAbsolute, join } from "node:path";
|
|
24
|
+
function isFile(path) {
|
|
25
|
+
try {
|
|
26
|
+
return statSync(path).isFile();
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function readJsonSwallow(path) {
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
34
|
+
} catch {
|
|
35
|
+
return void 0;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function usableWorkspaceValue(ws) {
|
|
39
|
+
return typeof ws === "string" && ws.length > 0;
|
|
40
|
+
}
|
|
41
|
+
function resolveAgainstRoot(root, ws) {
|
|
42
|
+
return isAbsolute(ws) ? ws : join(root, ws);
|
|
43
|
+
}
|
|
44
|
+
function resolveWorkspace(root) {
|
|
45
|
+
const configPath = join(root, "harness.config.json");
|
|
46
|
+
if (isFile(configPath)) {
|
|
47
|
+
const data = readJsonSwallow(configPath);
|
|
48
|
+
const ws = data && typeof data === "object" ? data.harness_workspace : void 0;
|
|
49
|
+
if (usableWorkspaceValue(ws)) {
|
|
50
|
+
return resolveAgainstRoot(root, ws);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const rootFeatureList = join(root, "feature_list.json");
|
|
54
|
+
if (isFile(rootFeatureList)) {
|
|
55
|
+
const data = readJsonSwallow(rootFeatureList);
|
|
56
|
+
const config = data && typeof data === "object" ? data.config : void 0;
|
|
57
|
+
const ws = config && typeof config === "object" ? config.harness_workspace : void 0;
|
|
58
|
+
if (usableWorkspaceValue(ws)) {
|
|
59
|
+
return resolveAgainstRoot(root, ws);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (isFile(join(root, ".handyman", "feature_list.json"))) {
|
|
63
|
+
return join(root, ".handyman");
|
|
64
|
+
}
|
|
65
|
+
return root;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ../packages/toolbox-core/dist/state.js
|
|
69
|
+
var INTAKE_MAX_BYTES = 256 * 1024;
|
|
70
|
+
var CSP_HEADER = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'";
|
|
71
|
+
var HTML_CSP_HEADER = CSP_HEADER.replace("img-src 'self' data:", "img-src 'self' data: https://picsum.photos");
|
|
72
|
+
function readText(path) {
|
|
73
|
+
try {
|
|
74
|
+
return readFileSync2(path, "utf-8");
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
var MD_TOKEN_PATHS = {
|
|
80
|
+
current: (workspace) => join2(workspace, "progress", "current.md"),
|
|
81
|
+
history: (workspace) => join2(workspace, "progress", "history.md"),
|
|
82
|
+
index: (workspace) => join2(workspace, "index.md"),
|
|
83
|
+
checkpoints: (_workspace, root) => join2(root, "CHECKPOINTS.md")
|
|
84
|
+
};
|
|
85
|
+
var MD_TOKENS = Object.keys(MD_TOKEN_PATHS);
|
|
86
|
+
|
|
87
|
+
// ../packages/toolbox-core/dist/reviewNotes.js
|
|
88
|
+
var REVIEW_DIFF_MAX_CHARS = 6e4;
|
|
89
|
+
function readImplReport(workspace, feature) {
|
|
90
|
+
return readText(join3(workspace, "backlog", `impl_${feature}.md`));
|
|
91
|
+
}
|
|
92
|
+
function readFeatureDiff(root, maxChars = REVIEW_DIFF_MAX_CHARS) {
|
|
93
|
+
let raw;
|
|
94
|
+
try {
|
|
95
|
+
raw = execFileSync("git", ["diff", "HEAD"], {
|
|
96
|
+
cwd: root,
|
|
97
|
+
encoding: "utf-8",
|
|
98
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
99
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
100
|
+
});
|
|
101
|
+
} catch {
|
|
102
|
+
return { diff: "", truncated: false };
|
|
103
|
+
}
|
|
104
|
+
if (raw.length <= maxChars) {
|
|
105
|
+
return { diff: raw, truncated: false };
|
|
106
|
+
}
|
|
107
|
+
return { diff: raw.slice(0, maxChars), truncated: true };
|
|
108
|
+
}
|
|
109
|
+
function composeReviewNotesSystem() {
|
|
110
|
+
return [
|
|
111
|
+
"Eres un asistente que prepara la revision de una feature en un harness Handyman.",
|
|
112
|
+
"Produces un CHECKLIST de revision en markdown: puntos criticos, invariantes en",
|
|
113
|
+
"riesgo y preguntas concretas que el reviewer debe verificar contra la evidencia.",
|
|
114
|
+
"",
|
|
115
|
+
"Reglas duras:",
|
|
116
|
+
"- Tu salida es un BORRADOR: el reviewer verifica TODO. Dilo en la primera linea.",
|
|
117
|
+
"- Escribes PREGUNTAS y PUNTOS A VERIFICAR, no conclusiones.",
|
|
118
|
+
" Ej: '- invariante X respetada?', '- hay test que cubra el caso Y?'.",
|
|
119
|
+
"- NUNCA emites un veredicto: no escribas APPROVED ni CHANGES_REQUESTED ni",
|
|
120
|
+
" equivalentes ('esta listo', 'se puede cerrar'). No es tu decision.",
|
|
121
|
+
"- NUNCA propongas un patch ni escribas codigo de reemplazo.",
|
|
122
|
+
"- Si el diff o el reporte no alcanzan para juzgar algo, dilo explicitamente",
|
|
123
|
+
" ('no hay evidencia suficiente para X') en vez de suponer."
|
|
124
|
+
].join("\n");
|
|
125
|
+
}
|
|
126
|
+
function composeReviewNotesPrompt(feature, implReport, diff, truncated) {
|
|
127
|
+
const parts = [`Feature bajo revision: ${feature}`, ""];
|
|
128
|
+
parts.push("---- reporte del implementer (backlog/impl_" + feature + ".md)");
|
|
129
|
+
parts.push(implReport ?? "(no hay reporte del implementer para esta feature)");
|
|
130
|
+
parts.push("");
|
|
131
|
+
parts.push("---- diff de trabajo (git diff HEAD)");
|
|
132
|
+
parts.push(diff.length > 0 ? diff : "(diff vacio o el root no es un repo git)");
|
|
133
|
+
if (truncated) {
|
|
134
|
+
parts.push("");
|
|
135
|
+
parts.push("[el diff fue truncado por tamano: senala que quedo sin revisar]");
|
|
136
|
+
}
|
|
137
|
+
return parts.join("\n");
|
|
138
|
+
}
|
|
139
|
+
function composeReviewNotesRequest(root, feature) {
|
|
140
|
+
const implReport = readImplReport(resolveWorkspace(root), feature);
|
|
141
|
+
const { diff, truncated } = readFeatureDiff(root);
|
|
142
|
+
return {
|
|
143
|
+
system: composeReviewNotesSystem(),
|
|
144
|
+
prompt: composeReviewNotesPrompt(feature, implReport, diff, truncated),
|
|
145
|
+
diffTruncated: truncated
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
async function relayReviewNotes(options) {
|
|
149
|
+
const { system, prompt, draft, diffTruncated, onDelta, onResult, onError } = options;
|
|
150
|
+
try {
|
|
151
|
+
const result = await draft({ prompt, system }, onDelta);
|
|
152
|
+
onResult({ checklist_md: result.text, model: result.model, diff_truncated: diffTruncated });
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (error instanceof LlmError) {
|
|
155
|
+
onError(error);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
onError(new LlmError("provider_error", error instanceof Error ? error.message : String(error)));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
export {
|
|
162
|
+
REVIEW_DIFF_MAX_CHARS,
|
|
163
|
+
composeReviewNotesPrompt,
|
|
164
|
+
composeReviewNotesRequest,
|
|
165
|
+
composeReviewNotesSystem,
|
|
166
|
+
readFeatureDiff,
|
|
167
|
+
readImplReport,
|
|
168
|
+
relayReviewNotes
|
|
169
|
+
};
|