opencode-usage-coach 0.7.0 → 0.8.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/agents/usage-coach-harness.md +11 -2
- package/dist/index.js +1085 -19
- package/dist/tui.js +142 -139
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -41,6 +41,14 @@ function addDomainNode(node) {
|
|
|
41
41
|
}
|
|
42
42
|
return full.id;
|
|
43
43
|
}
|
|
44
|
+
function addDomainEdge(edge) {
|
|
45
|
+
const full = { ...edge, ts: (/* @__PURE__ */ new Date()).toISOString() };
|
|
46
|
+
try {
|
|
47
|
+
mkdirSync(BASE_DIR, { recursive: true });
|
|
48
|
+
appendFileSync(edgesFile(), JSON.stringify(full) + "\n");
|
|
49
|
+
} catch {
|
|
50
|
+
}
|
|
51
|
+
}
|
|
44
52
|
function writeNodes(nodes) {
|
|
45
53
|
try {
|
|
46
54
|
mkdirSync(BASE_DIR, { recursive: true });
|
|
@@ -97,14 +105,74 @@ function evictStale(maxAgeDays = 30, maxNodes = 1e3) {
|
|
|
97
105
|
return { removed: 0, kept: 0 };
|
|
98
106
|
}
|
|
99
107
|
}
|
|
100
|
-
function
|
|
108
|
+
function traverseNeighborhood(seedNodeIds, maxDepth = 2, opts = {}) {
|
|
109
|
+
const maxNodes = opts.maxNodes ?? 60;
|
|
110
|
+
const allNodes = readNodes();
|
|
111
|
+
const allEdges = readEdges();
|
|
112
|
+
const byId = new Map(allNodes.map((n) => [n.id, n]));
|
|
113
|
+
const seeds = seedNodeIds.filter((id) => byId.has(id));
|
|
114
|
+
if (seeds.length === 0) return { nodes: [], edges: [] };
|
|
115
|
+
const adj = /* @__PURE__ */ new Map();
|
|
116
|
+
const link = (a, b) => {
|
|
117
|
+
const s = adj.get(a) ?? /* @__PURE__ */ new Set();
|
|
118
|
+
s.add(b);
|
|
119
|
+
adj.set(a, s);
|
|
120
|
+
};
|
|
121
|
+
for (const e of allEdges) {
|
|
122
|
+
link(e.from, e.to);
|
|
123
|
+
link(e.to, e.from);
|
|
124
|
+
}
|
|
125
|
+
const distance = /* @__PURE__ */ new Map();
|
|
126
|
+
const visited = /* @__PURE__ */ new Set();
|
|
127
|
+
const frontier = [];
|
|
128
|
+
for (const s of seeds) {
|
|
129
|
+
if (visited.has(s)) continue;
|
|
130
|
+
visited.add(s);
|
|
131
|
+
distance.set(s, 0);
|
|
132
|
+
frontier.push(s);
|
|
133
|
+
if (visited.size >= maxNodes) break;
|
|
134
|
+
}
|
|
135
|
+
while (frontier.length > 0) {
|
|
136
|
+
if (visited.size >= maxNodes) break;
|
|
137
|
+
const cur = frontier.shift();
|
|
138
|
+
const d = distance.get(cur) ?? 0;
|
|
139
|
+
if (d >= maxDepth) continue;
|
|
140
|
+
for (const nxt of adj.get(cur) ?? []) {
|
|
141
|
+
if (visited.has(nxt)) continue;
|
|
142
|
+
visited.add(nxt);
|
|
143
|
+
distance.set(nxt, d + 1);
|
|
144
|
+
frontier.push(nxt);
|
|
145
|
+
if (visited.size >= maxNodes) break;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const nodes = [];
|
|
149
|
+
for (const id of visited) {
|
|
150
|
+
const n = byId.get(id);
|
|
151
|
+
if (n) nodes.push({ ...n, distance: distance.get(id) ?? 0 });
|
|
152
|
+
}
|
|
153
|
+
const edges = allEdges.filter((e) => visited.has(e.from) && visited.has(e.to));
|
|
154
|
+
return { nodes, edges };
|
|
155
|
+
}
|
|
156
|
+
function queryDomainGraph(keywords, maxDepth = 2, opts = {}) {
|
|
157
|
+
const seed = queryDomain(keywords);
|
|
158
|
+
const seedIds = seed.nodes.map((n) => n.id);
|
|
159
|
+
if (maxDepth <= 0 || seedIds.length === 0) {
|
|
160
|
+
return { nodes: seed.nodes, edges: seed.edges };
|
|
161
|
+
}
|
|
162
|
+
const graph = traverseNeighborhood(seedIds, maxDepth, opts);
|
|
163
|
+
const seedSet = new Set(seedIds);
|
|
164
|
+
const neighborIds = graph.nodes.filter((n) => !seedSet.has(n.id)).map((n) => n.id);
|
|
165
|
+
if (neighborIds.length > 0) touchNodes(new Set(neighborIds));
|
|
166
|
+
return { nodes: graph.nodes, edges: graph.edges };
|
|
167
|
+
}
|
|
168
|
+
function saveInvestigationResult(keywords, result, source, confidence = 0.7) {
|
|
101
169
|
try {
|
|
102
170
|
return addDomainNode({
|
|
103
171
|
type: "fact",
|
|
104
172
|
name: keywords.join(" "),
|
|
105
173
|
props: { result },
|
|
106
174
|
source: source || "investigation",
|
|
107
|
-
confidence
|
|
175
|
+
confidence
|
|
108
176
|
});
|
|
109
177
|
} catch {
|
|
110
178
|
return "";
|
|
@@ -114,6 +182,9 @@ function saveInvestigationResult(keywords, result, source) {
|
|
|
114
182
|
// src/index.ts
|
|
115
183
|
var PLUGIN_NAME = "opencode-usage-coach";
|
|
116
184
|
var TTL_MS = Number(process.env.UC_TTL_MS ?? 6e4);
|
|
185
|
+
var DEFAULT_MAX_STEPS = Number(process.env.UC_MAX_STEPS ?? 30) || 30;
|
|
186
|
+
var WATCHDOG_POLL_MS = Math.max(1e3, Number(process.env.UC_WATCHDOG_POLL_MS ?? 3e3) || 3e3);
|
|
187
|
+
var DEFAULT_MAX_QUESTIONS = Math.max(1, Math.round(Number(process.env.UC_MAX_QUESTIONS ?? 7)) || 7);
|
|
117
188
|
var PIPE_LOG = join2(homedir(), ".cache", "opencode-usage-coach", "pipeline.log");
|
|
118
189
|
function pipeLog(msg) {
|
|
119
190
|
try {
|
|
@@ -167,6 +238,106 @@ function readRules() {
|
|
|
167
238
|
return "";
|
|
168
239
|
}
|
|
169
240
|
}
|
|
241
|
+
function implNotesFile() {
|
|
242
|
+
return join2(STATE_DIR, "impl-notes.md");
|
|
243
|
+
}
|
|
244
|
+
var IMPL_NOTE_INSTRUCTION = `
|
|
245
|
+
## Implementation Notes (important!)
|
|
246
|
+
During this task, if you:
|
|
247
|
+
- Made a decision that differed from the obvious/expected approach
|
|
248
|
+
- Discovered a constraint, limitation, or surprising behavior in the codebase/API
|
|
249
|
+
- Found something that was different from what the prompt implied
|
|
250
|
+
Then append a block at the END of your response in this exact format:
|
|
251
|
+
|
|
252
|
+
<impl-notes>
|
|
253
|
+
- **Decision**: <what you chose and why it wasn't obvious>
|
|
254
|
+
- **Constraint**: <limitation/surprise you discovered>
|
|
255
|
+
- **Unexpected**: <how reality differed from the prompt's assumption>
|
|
256
|
+
</impl-notes>
|
|
257
|
+
|
|
258
|
+
If nothing notable happened, omit the block entirely. Do not fabricate notes.
|
|
259
|
+
`;
|
|
260
|
+
function readImplNotes(limit = 5) {
|
|
261
|
+
try {
|
|
262
|
+
const f = implNotesFile();
|
|
263
|
+
if (!existsSync2(f)) return "";
|
|
264
|
+
const content = readFileSync2(f, "utf8").trim();
|
|
265
|
+
if (!content) return "";
|
|
266
|
+
const notes = content.split(/^## Note /m).filter(Boolean);
|
|
267
|
+
const recent = notes.slice(-limit);
|
|
268
|
+
return recent.map((n) => `## Note ${n.trim()}`).join("\n\n");
|
|
269
|
+
} catch {
|
|
270
|
+
return "";
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function extractImplNotes(output) {
|
|
274
|
+
const match = output.match(/<impl-notes>([\s\S]*?)<\/impl-notes>/i);
|
|
275
|
+
if (!match) return { notes: "", cleanText: output };
|
|
276
|
+
const notes = match[1].trim();
|
|
277
|
+
const cleanText = output.replace(/<impl-notes>[\s\S]*?<\/impl-notes>\s*/i, "").trim();
|
|
278
|
+
return { notes, cleanText };
|
|
279
|
+
}
|
|
280
|
+
function appendImplNotes(notes, taskSummary) {
|
|
281
|
+
try {
|
|
282
|
+
const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
283
|
+
const shortTask = taskSummary.slice(0, 80).replace(/\n/g, " ");
|
|
284
|
+
const entry = `## Note (${date}, task: "${shortTask}")
|
|
285
|
+
${notes}
|
|
286
|
+
Source: generate task "${shortTask}"
|
|
287
|
+
|
|
288
|
+
`;
|
|
289
|
+
mkdirSync2(STATE_DIR, { recursive: true });
|
|
290
|
+
appendFileSync2(implNotesFile(), entry);
|
|
291
|
+
} catch (e) {
|
|
292
|
+
log(`appendImplNotes err: ${String(e)}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function linkImplNoteToDomain(noteNodeId, noteText, maxEdges = 3) {
|
|
296
|
+
try {
|
|
297
|
+
const words = new Set(
|
|
298
|
+
noteText.toLowerCase().replace(/[^a-z0-9가-힣\s-]/g, " ").split(/\s+/).filter((w) => w.length >= 3)
|
|
299
|
+
);
|
|
300
|
+
if (words.size === 0) return 0;
|
|
301
|
+
const existing = readNodes().filter((n) => n.id !== noteNodeId);
|
|
302
|
+
const scored = existing.map((n) => {
|
|
303
|
+
const hay = (n.name + " " + JSON.stringify(n.props)).toLowerCase();
|
|
304
|
+
let hits = 0;
|
|
305
|
+
for (const w of words) if (hay.includes(w)) hits++;
|
|
306
|
+
return { node: n, hits };
|
|
307
|
+
}).filter((s) => s.hits > 0).sort((a, b) => b.hits - a.hits).slice(0, maxEdges);
|
|
308
|
+
const edgeKey = new Set(readEdges().map((e) => `${e.from}|${e.to}|${e.rel}`));
|
|
309
|
+
let added = 0;
|
|
310
|
+
for (const { node } of scored) {
|
|
311
|
+
const k1 = `${noteNodeId}|${node.id}|related-to`;
|
|
312
|
+
const k2 = `${node.id}|${noteNodeId}|related-to`;
|
|
313
|
+
if (edgeKey.has(k1) || edgeKey.has(k2)) continue;
|
|
314
|
+
addDomainEdge({ from: noteNodeId, to: node.id, rel: "related-to", note: "impl-note auto-link" });
|
|
315
|
+
edgeKey.add(k1);
|
|
316
|
+
added++;
|
|
317
|
+
}
|
|
318
|
+
return added;
|
|
319
|
+
} catch {
|
|
320
|
+
return 0;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
function readImplNotesByGraph(keywords, limit = 5) {
|
|
324
|
+
try {
|
|
325
|
+
if (!keywords.length) return readImplNotes(limit);
|
|
326
|
+
const { nodes: neighborhood } = queryDomainGraph(keywords, 2);
|
|
327
|
+
if (neighborhood.length === 0) return readImplNotes(limit);
|
|
328
|
+
const noteTexts = neighborhood.filter((n) => n.source === "impl-note" || n.source === "generate").map((n) => String(n.props?.result ?? ""));
|
|
329
|
+
if (noteTexts.length === 0) return readImplNotes(limit);
|
|
330
|
+
const f = implNotesFile();
|
|
331
|
+
const fileContent = existsSync2(f) ? readFileSync2(f, "utf8") : "";
|
|
332
|
+
if (!fileContent.trim()) return readImplNotes(limit);
|
|
333
|
+
const entries = fileContent.split(/^## Note /m).filter(Boolean);
|
|
334
|
+
const matched = entries.filter((e) => noteTexts.some((t) => t && e.includes(t.slice(0, 60)))).slice(0, limit);
|
|
335
|
+
if (matched.length === 0) return readImplNotes(limit);
|
|
336
|
+
return matched.map((e) => `## Note ${e.trim()}`).join("\n\n");
|
|
337
|
+
} catch {
|
|
338
|
+
return readImplNotes(limit);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
170
341
|
function extractKeywords(text) {
|
|
171
342
|
try {
|
|
172
343
|
const STOP = /* @__PURE__ */ new Set(["the", "and", "for", "with", "that", "this", "from", "into", "your", "you", "are", "was", "but", "not", "all", "any", "use", "task", "prompt"]);
|
|
@@ -205,6 +376,472 @@ function writeHarness(sessionID, h) {
|
|
|
205
376
|
} catch {
|
|
206
377
|
}
|
|
207
378
|
}
|
|
379
|
+
function interviewFile(sessionID) {
|
|
380
|
+
return join2(STATE_DIR, sessionID || "_default", "interview.json");
|
|
381
|
+
}
|
|
382
|
+
function readInterview(sessionID) {
|
|
383
|
+
try {
|
|
384
|
+
const f = interviewFile(sessionID);
|
|
385
|
+
if (!existsSync2(f)) return null;
|
|
386
|
+
return JSON.parse(readFileSync2(f, "utf8"));
|
|
387
|
+
} catch {
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
function writeInterview(sessionID, s) {
|
|
392
|
+
try {
|
|
393
|
+
const f = interviewFile(sessionID);
|
|
394
|
+
mkdirSync2(dirname(f), { recursive: true });
|
|
395
|
+
s.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
396
|
+
writeFileSync2(f, JSON.stringify(s, null, 2));
|
|
397
|
+
} catch {
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
var PRIORITY_WEIGHT = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
401
|
+
var CATEGORY_WEIGHT = { architecture: 0, scope: 1, constraint: 2, tradeoff: 3, preference: 4, "constraint-env": 5 };
|
|
402
|
+
function resolveGraphDistance(concept, graphNodes) {
|
|
403
|
+
if (!concept) return 9;
|
|
404
|
+
const lc = concept.toLowerCase().trim();
|
|
405
|
+
if (!lc) return 9;
|
|
406
|
+
for (const n of graphNodes) {
|
|
407
|
+
if ((n.name ?? "").toLowerCase() === lc) return n.distance ?? 9;
|
|
408
|
+
}
|
|
409
|
+
for (const n of graphNodes) {
|
|
410
|
+
const nm = (n.name ?? "").toLowerCase();
|
|
411
|
+
if (nm.includes(lc) || lc.includes(nm)) return n.distance ?? 9;
|
|
412
|
+
}
|
|
413
|
+
return 9;
|
|
414
|
+
}
|
|
415
|
+
function parseInterviewQuestions(raw, maxQ, graphNodes) {
|
|
416
|
+
try {
|
|
417
|
+
let s = raw.trim();
|
|
418
|
+
const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
419
|
+
if (fence) s = fence[1].trim();
|
|
420
|
+
const first = s.indexOf("{");
|
|
421
|
+
const last = s.lastIndexOf("}");
|
|
422
|
+
if (first >= 0 && last > first) s = s.slice(first, last + 1);
|
|
423
|
+
const p = JSON.parse(s);
|
|
424
|
+
const arr = Array.isArray(p.questions) ? p.questions : [];
|
|
425
|
+
const qs = arr.map((q) => ({
|
|
426
|
+
id: `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`,
|
|
427
|
+
text: String(q.text || ""),
|
|
428
|
+
why: String(q.why || ""),
|
|
429
|
+
priority: ["critical", "high", "medium", "low"].includes(String(q.priority)) ? String(q.priority) : "medium",
|
|
430
|
+
category: String(q.category || "scope"),
|
|
431
|
+
optional: Boolean(q.optional),
|
|
432
|
+
concept: q.concept ? String(q.concept) : void 0
|
|
433
|
+
})).filter((q) => q.text);
|
|
434
|
+
qs.sort((a, b) => {
|
|
435
|
+
const ka = (PRIORITY_WEIGHT[a.priority] ?? 3) * 100 + resolveGraphDistance(a.concept, graphNodes) * 10 + (CATEGORY_WEIGHT[a.category] ?? 5);
|
|
436
|
+
const kb = (PRIORITY_WEIGHT[b.priority] ?? 3) * 100 + resolveGraphDistance(b.concept, graphNodes) * 10 + (CATEGORY_WEIGHT[b.category] ?? 5);
|
|
437
|
+
return ka - kb;
|
|
438
|
+
});
|
|
439
|
+
return qs.slice(0, maxQ);
|
|
440
|
+
} catch {
|
|
441
|
+
return [];
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
function formatQuestionOutput(qNum, total, q) {
|
|
445
|
+
return `Interview Q${qNum}/${total} [${q.priority}]: ${q.text}
|
|
446
|
+
` + (q.why ? ` Why it matters: ${q.why}
|
|
447
|
+
` : "") + `
|
|
448
|
+
[usage-coach NEXT] Present Q${qNum}/${total} to the user VERBATIM (copy the question text above). Do NOT answer it yourself \u2014 you are interviewing the USER, not guessing. End your turn after presenting the question. When the user responds, call reverse_interview({task: "...", answer: "<their response>"}) to record the answer and get the next question.`;
|
|
449
|
+
}
|
|
450
|
+
function formatCompleteOutput(state) {
|
|
451
|
+
const n = state.answers.length;
|
|
452
|
+
const lines = [`Interview complete (${n} question${n === 1 ? "" : "s"} answered).`, "", "Resolved constraints:"];
|
|
453
|
+
if (state.answers.length) {
|
|
454
|
+
state.answers.forEach((a, i) => lines.push(`${i + 1}. ${a.questionText}: ${a.answer}`));
|
|
455
|
+
} else {
|
|
456
|
+
lines.push("(no questions were asked)");
|
|
457
|
+
}
|
|
458
|
+
lines.push("");
|
|
459
|
+
lines.push("[usage-coach NEXT] Interview complete. The resolved constraints above MUST be injected into every generate prompt. Call harness_start(name, N) now, then for each task call generate with the constraints prepended to the prompt.");
|
|
460
|
+
return lines.join("\n");
|
|
461
|
+
}
|
|
462
|
+
async function completeInterview(state, sessionID, client, model, directory) {
|
|
463
|
+
if (state.phase === "complete") return formatCompleteOutput(state);
|
|
464
|
+
const pairs = state.answers.map((a, i) => `Q${i + 1}: ${a.questionText}
|
|
465
|
+
A: ${a.answer}`).join("\n\n");
|
|
466
|
+
let summary = "";
|
|
467
|
+
const constraints = {};
|
|
468
|
+
if (model) {
|
|
469
|
+
const sumPrompt = `Summarize this reverse interview as actionable constraints for implementation.
|
|
470
|
+
|
|
471
|
+
Q&A pairs:
|
|
472
|
+
${pairs || "(none)"}
|
|
473
|
+
|
|
474
|
+
Output JSON ONLY (no markdown fences, no prose):
|
|
475
|
+
{"summary":"human-readable bullet list of resolved decisions","constraints":{"key":"value"}}`;
|
|
476
|
+
const out = await runModel(client, model, sumPrompt, directory);
|
|
477
|
+
if (!out.startsWith("ERROR:") && !out.startsWith("Task appears too large")) {
|
|
478
|
+
try {
|
|
479
|
+
let s = out.trim();
|
|
480
|
+
const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
481
|
+
if (fence) s = fence[1].trim();
|
|
482
|
+
const fi = s.indexOf("{");
|
|
483
|
+
const la = s.lastIndexOf("}");
|
|
484
|
+
if (fi >= 0 && la > fi) s = s.slice(fi, la + 1);
|
|
485
|
+
const p = JSON.parse(s);
|
|
486
|
+
summary = String(p.summary ?? "");
|
|
487
|
+
if (p.constraints && typeof p.constraints === "object") {
|
|
488
|
+
for (const [k, v] of Object.entries(p.constraints)) constraints[String(k)] = String(v);
|
|
489
|
+
}
|
|
490
|
+
} catch {
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (!summary) {
|
|
495
|
+
summary = state.answers.length ? state.answers.map((a, i) => `${i + 1}. ${a.questionText}: ${a.answer}`).join("\n") : "No significant ambiguities found. The task appears well-specified.";
|
|
496
|
+
}
|
|
497
|
+
state.summary = summary;
|
|
498
|
+
state.constraints = constraints;
|
|
499
|
+
state.phase = "complete";
|
|
500
|
+
writeInterview(sessionID, state);
|
|
501
|
+
try {
|
|
502
|
+
const kw = extractKeywords(state.task);
|
|
503
|
+
if (kw.length) saveInvestigationResult(kw, summary, "reverse_interview", 0.9);
|
|
504
|
+
} catch (e) {
|
|
505
|
+
log(`reverse_interview save err: ${String(e)}`);
|
|
506
|
+
}
|
|
507
|
+
return formatCompleteOutput(state);
|
|
508
|
+
}
|
|
509
|
+
function updateSubSession(sessionID, taskId, fields) {
|
|
510
|
+
try {
|
|
511
|
+
const h = readHarness(sessionID);
|
|
512
|
+
if (!h) return;
|
|
513
|
+
const t = h.tasks.find((x) => x.id === taskId);
|
|
514
|
+
if (!t) return;
|
|
515
|
+
Object.assign(t, fields);
|
|
516
|
+
writeHarness(sessionID, h);
|
|
517
|
+
} catch {
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
function clearSubSession(sessionID, taskId) {
|
|
521
|
+
try {
|
|
522
|
+
const h = readHarness(sessionID);
|
|
523
|
+
if (!h) return;
|
|
524
|
+
const t = h.tasks.find((x) => x.id === taskId);
|
|
525
|
+
if (!t) return;
|
|
526
|
+
t.subSessionId = void 0;
|
|
527
|
+
t.subStep = void 0;
|
|
528
|
+
t.lastActivity = void 0;
|
|
529
|
+
t.subElapsed = void 0;
|
|
530
|
+
writeHarness(sessionID, h);
|
|
531
|
+
} catch {
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
function findActiveTaskId(sessionID, status) {
|
|
535
|
+
try {
|
|
536
|
+
const h = readHarness(sessionID);
|
|
537
|
+
if (!h) return void 0;
|
|
538
|
+
return h.tasks.find((x) => x.status === status)?.id;
|
|
539
|
+
} catch {
|
|
540
|
+
return void 0;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
var MANIFEST_FILES_SET = /* @__PURE__ */ new Set([
|
|
544
|
+
"package.json",
|
|
545
|
+
"go.mod",
|
|
546
|
+
"requirements.txt",
|
|
547
|
+
"pyproject.toml",
|
|
548
|
+
"Cargo.toml",
|
|
549
|
+
"deno.json",
|
|
550
|
+
"pom.xml",
|
|
551
|
+
"build.gradle",
|
|
552
|
+
"mix.exs",
|
|
553
|
+
"Gemfile"
|
|
554
|
+
]);
|
|
555
|
+
var FRAMEWORK_SIG = {
|
|
556
|
+
"react": ["react", "react-dom", "next"],
|
|
557
|
+
"solid-js": ["solid-js", "@opentui/solid"],
|
|
558
|
+
"vue": ["vue", "nuxt"],
|
|
559
|
+
"svelte": ["svelte", "@sveltejs/kit"],
|
|
560
|
+
"express": ["express"],
|
|
561
|
+
"fastify": ["fastify"],
|
|
562
|
+
"hono": ["hono"],
|
|
563
|
+
"vitest": ["vitest"],
|
|
564
|
+
"jest": ["jest"],
|
|
565
|
+
"tsup": ["tsup"],
|
|
566
|
+
"eslint": ["eslint"]
|
|
567
|
+
};
|
|
568
|
+
function parseFileList(rawList, baseDir) {
|
|
569
|
+
const empty = {
|
|
570
|
+
skipped: false,
|
|
571
|
+
language: "unknown",
|
|
572
|
+
frameworks: [],
|
|
573
|
+
structure: [],
|
|
574
|
+
manifestFiles: [],
|
|
575
|
+
keyDeps: [],
|
|
576
|
+
configFiles: [],
|
|
577
|
+
totalFiles: 0
|
|
578
|
+
};
|
|
579
|
+
try {
|
|
580
|
+
const lines = (rawList || "").split("\n").map((l) => l.trim()).filter(Boolean);
|
|
581
|
+
if (lines.length === 0) return { ...empty, skipped: true, reason: "no files found" };
|
|
582
|
+
const relFiles = lines.map((l) => l.startsWith(baseDir) ? l.slice(baseDir.length).replace(/^\//, "") : l).slice(0, 200);
|
|
583
|
+
const extCounts = {};
|
|
584
|
+
for (const f of relFiles) {
|
|
585
|
+
const dot = f.lastIndexOf(".");
|
|
586
|
+
if (dot >= 0) {
|
|
587
|
+
const ext = f.slice(dot + 1).toLowerCase();
|
|
588
|
+
extCounts[ext] = (extCounts[ext] || 0) + 1;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
const language = detectLanguage(extCounts);
|
|
592
|
+
const manifestFiles = relFiles.filter((f) => {
|
|
593
|
+
const base = f.split("/").pop() || f;
|
|
594
|
+
return MANIFEST_FILES_SET.has(base);
|
|
595
|
+
});
|
|
596
|
+
let keyDeps = [];
|
|
597
|
+
let frameworks = [];
|
|
598
|
+
let testPattern;
|
|
599
|
+
let testFramework;
|
|
600
|
+
for (const mf of manifestFiles) {
|
|
601
|
+
const full = join2(baseDir, mf);
|
|
602
|
+
if (existsSync2(full)) {
|
|
603
|
+
try {
|
|
604
|
+
const content = JSON.parse(readFileSync2(full, "utf8"));
|
|
605
|
+
const depNames = Object.keys({ ...content.dependencies || {}, ...content.devDependencies || {} });
|
|
606
|
+
keyDeps = [.../* @__PURE__ */ new Set([...keyDeps, ...depNames])].slice(0, 50);
|
|
607
|
+
for (const [fw, sigs] of Object.entries(FRAMEWORK_SIG)) {
|
|
608
|
+
if (sigs.some((s) => depNames.includes(s))) frameworks = [.../* @__PURE__ */ new Set([...frameworks, fw])];
|
|
609
|
+
}
|
|
610
|
+
if (depNames.includes("vitest")) testFramework = "vitest";
|
|
611
|
+
else if (depNames.includes("jest")) testFramework = "jest";
|
|
612
|
+
else if (depNames.includes("pytest")) testFramework = "pytest";
|
|
613
|
+
} catch {
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
const testFiles = relFiles.filter(
|
|
618
|
+
(f) => /\.(test|spec)\.(ts|tsx|js|jsx|mjs)$/.test(f) || /(^|\/)(test_[^.]+|.+_test)\.(py|go)$/.test(f)
|
|
619
|
+
);
|
|
620
|
+
if (testFiles.length) {
|
|
621
|
+
const m = testFiles[0].match(/(\.(test|spec)\.[a-z]+|test_[a-z]+\.(py|go)|_[a-z]+_test\.(py|go))$/i);
|
|
622
|
+
if (m) testPattern = "*" + m[0];
|
|
623
|
+
}
|
|
624
|
+
const configFiles = relFiles.filter(
|
|
625
|
+
(f) => /\.(eslintrc|prettierrc|tsconfig|jsconfig|babelrc|stylelintrc)/.test(f) || f.endsWith("tsconfig.json") || f.endsWith(".eslintrc") || f.endsWith(".prettierrc")
|
|
626
|
+
);
|
|
627
|
+
const dirCounts = {};
|
|
628
|
+
for (const f of relFiles) {
|
|
629
|
+
const parts = f.split("/");
|
|
630
|
+
const dir = parts.length > 1 ? parts[0] : ".";
|
|
631
|
+
dirCounts[dir] = (dirCounts[dir] || 0) + 1;
|
|
632
|
+
}
|
|
633
|
+
const structure = Object.entries(dirCounts).map(([dir, fileCount]) => ({ dir, fileCount })).sort((a, b) => b.fileCount - a.fileCount).slice(0, 8);
|
|
634
|
+
return {
|
|
635
|
+
skipped: false,
|
|
636
|
+
language,
|
|
637
|
+
frameworks,
|
|
638
|
+
testPattern,
|
|
639
|
+
testFramework,
|
|
640
|
+
structure,
|
|
641
|
+
manifestFiles,
|
|
642
|
+
keyDeps,
|
|
643
|
+
configFiles,
|
|
644
|
+
totalFiles: lines.length
|
|
645
|
+
};
|
|
646
|
+
} catch {
|
|
647
|
+
return { ...empty, skipped: true, reason: "scan error" };
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
function detectLanguage(extCounts) {
|
|
651
|
+
const sum = (exts) => exts.reduce((s, e) => s + (extCounts[e] || 0), 0);
|
|
652
|
+
const ts = sum(["ts", "tsx", "mts", "cts"]);
|
|
653
|
+
const js = sum(["js", "jsx", "mjs", "cjs"]);
|
|
654
|
+
if (ts > 0 && ts >= js) return "TypeScript";
|
|
655
|
+
if (js > 0) return "JavaScript";
|
|
656
|
+
if ((extCounts["py"] || 0) > 0) return "Python";
|
|
657
|
+
if ((extCounts["go"] || 0) > 0) return "Go";
|
|
658
|
+
if ((extCounts["rs"] || 0) > 0) return "Rust";
|
|
659
|
+
if ((extCounts["java"] || 0) > 0) return "Java";
|
|
660
|
+
return "unknown";
|
|
661
|
+
}
|
|
662
|
+
function buildGapPrompt(userRequest, tasks, profile, domainNodes) {
|
|
663
|
+
const taskList = tasks.map((t) => `${t.id}: ${t.title}`).join("\n");
|
|
664
|
+
const profileStr = profile.skipped ? `(skipped \u2014 ${profile.reason || "unknown reason"})` : [
|
|
665
|
+
`- Language: ${profile.language}`,
|
|
666
|
+
`- Frameworks: ${profile.frameworks.join(", ") || "none detected"}`,
|
|
667
|
+
`- Test pattern: ${profile.testPattern || "not detected"} (${profile.testFramework || "?"})`,
|
|
668
|
+
`- Structure: ${profile.structure.map((s) => `${s.dir}/(${s.fileCount})`).join(", ")}`,
|
|
669
|
+
`- Manifest: ${profile.manifestFiles.join(", ") || "none"}`,
|
|
670
|
+
`- Key deps: ${profile.keyDeps.slice(0, 20).join(", ") || "none"}`,
|
|
671
|
+
`- Config: ${profile.configFiles.join(", ") || "none"}`,
|
|
672
|
+
`- Total files: ${profile.totalFiles}`
|
|
673
|
+
].join("\n");
|
|
674
|
+
const domainStr = domainNodes.length ? domainNodes.slice(0, 15).map((n) => `- ${n.name}: ${JSON.stringify(n.props).slice(0, 200)}`).join("\n") : "(empty \u2014 no prior knowledge stored)";
|
|
675
|
+
return `You are a pre-flight gap analyst. Compare the user's request (the MAP) with the actual codebase (the TERRITORY) and classify every gap.
|
|
676
|
+
|
|
677
|
+
USER REQUEST:
|
|
678
|
+
${userRequest}
|
|
679
|
+
|
|
680
|
+
PROPOSED TASKS:
|
|
681
|
+
${taskList}
|
|
682
|
+
|
|
683
|
+
CODEBASE PROFILE:
|
|
684
|
+
${profileStr}
|
|
685
|
+
|
|
686
|
+
EXISTING DOMAIN KNOWLEDGE (from local DB):
|
|
687
|
+
${domainStr}
|
|
688
|
+
|
|
689
|
+
Classify into EXACTLY these 4 categories:
|
|
690
|
+
|
|
691
|
+
1. KNOWN KNOWNS \u2014 requirements explicitly stated in the user request.
|
|
692
|
+
For each: which task it maps to, and the specific requirement.
|
|
693
|
+
2. KNOWN UNKNOWNS \u2014 the user left ambiguous / didn't specify.
|
|
694
|
+
For each: what is ambiguous, which task it affects, and a suggestion.
|
|
695
|
+
3. UNKNOWN KNOWNS \u2014 implicit knowledge in the codebase not mentioned in the prompt
|
|
696
|
+
(conventions, patterns, dependencies, tool registration style, test framework).
|
|
697
|
+
For each: the finding and where in the codebase it comes from.
|
|
698
|
+
4. UNKNOWN UNKNOWNS \u2014 blind spots: things neither the prompt nor the codebase surface,
|
|
699
|
+
but that WILL affect the work (platform quirks, hidden coupling, ordering deps).
|
|
700
|
+
For each: the finding, its impact (high/medium/low), and a mitigation.
|
|
701
|
+
|
|
702
|
+
Also output:
|
|
703
|
+
- QUESTIONS: questions the agent should ask the user before proceeding.
|
|
704
|
+
Only include questions where the answer materially changes the approach.
|
|
705
|
+
- TASK REFINEMENTS: suggestions to split, merge, reorder, add, or remove tasks.
|
|
706
|
+
|
|
707
|
+
Output as JSON ONLY (no markdown fences, no prose before or after):
|
|
708
|
+
{"knownKnowns":[{"taskId":1,"title":"","note":"requirement from prompt"}],"knownUnknowns":[{"taskId":1,"gap":"what is ambiguous","suggestion":"how to resolve"}],"unknownKnowns":[{"finding":"implicit knowledge","source":"file or pattern"}],"unknownUnknowns":[{"finding":"blind spot","impact":"high","mitigation":"how to handle"}],"questions":[{"id":"Q1","question":"..."}],"taskRefinements":[{"taskId":1,"action":"split","detail":"..."}]}`;
|
|
709
|
+
}
|
|
710
|
+
function parseGapAnalysis(raw, profile, domainHits) {
|
|
711
|
+
const scannedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
712
|
+
const base = {
|
|
713
|
+
scannedAt,
|
|
714
|
+
codebaseProfile: profile,
|
|
715
|
+
knownKnowns: [],
|
|
716
|
+
knownUnknowns: [],
|
|
717
|
+
unknownKnowns: [],
|
|
718
|
+
unknownUnknowns: [],
|
|
719
|
+
questions: [],
|
|
720
|
+
taskRefinements: [],
|
|
721
|
+
domainHits,
|
|
722
|
+
domainMisses: 0
|
|
723
|
+
};
|
|
724
|
+
if (!raw || raw.startsWith("ERROR:") || raw.startsWith("Task appears too large")) {
|
|
725
|
+
base.rawAnalysis = raw;
|
|
726
|
+
return base;
|
|
727
|
+
}
|
|
728
|
+
try {
|
|
729
|
+
let jsonStr = raw.trim();
|
|
730
|
+
const fence = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
731
|
+
if (fence) jsonStr = fence[1].trim();
|
|
732
|
+
const first = jsonStr.indexOf("{");
|
|
733
|
+
const last = jsonStr.lastIndexOf("}");
|
|
734
|
+
if (first >= 0 && last > first) jsonStr = jsonStr.slice(first, last + 1);
|
|
735
|
+
const p = JSON.parse(jsonStr);
|
|
736
|
+
const arr = (v, map) => Array.isArray(v) ? v.map(map) : [];
|
|
737
|
+
base.knownKnowns = arr(p.knownKnowns, (k) => ({
|
|
738
|
+
taskId: Number(k.taskId) || 0,
|
|
739
|
+
title: String(k.title || ""),
|
|
740
|
+
note: String(k.note || "")
|
|
741
|
+
}));
|
|
742
|
+
base.knownUnknowns = arr(p.knownUnknowns, (k) => ({
|
|
743
|
+
taskId: Number(k.taskId) || 0,
|
|
744
|
+
gap: String(k.gap || ""),
|
|
745
|
+
suggestion: k.suggestion ? String(k.suggestion) : void 0
|
|
746
|
+
}));
|
|
747
|
+
base.unknownKnowns = arr(p.unknownKnowns, (k) => ({
|
|
748
|
+
finding: String(k.finding || ""),
|
|
749
|
+
source: String(k.source || "codebase")
|
|
750
|
+
}));
|
|
751
|
+
base.unknownUnknowns = arr(p.unknownUnknowns, (k) => ({
|
|
752
|
+
finding: String(k.finding || ""),
|
|
753
|
+
impact: String(k.impact || "medium"),
|
|
754
|
+
mitigation: k.mitigation ? String(k.mitigation) : void 0
|
|
755
|
+
}));
|
|
756
|
+
base.questions = arr(p.questions, (q, i) => ({
|
|
757
|
+
id: String(q.id || `Q${i + 1}`),
|
|
758
|
+
question: String(q.question || "")
|
|
759
|
+
})).filter((q) => q.question);
|
|
760
|
+
base.taskRefinements = arr(p.taskRefinements, (t) => ({
|
|
761
|
+
taskId: Number(t.taskId) || 0,
|
|
762
|
+
action: String(t.action || "split"),
|
|
763
|
+
detail: String(t.detail || "")
|
|
764
|
+
}));
|
|
765
|
+
base.domainMisses = base.unknownKnowns.length + base.unknownUnknowns.length;
|
|
766
|
+
} catch {
|
|
767
|
+
base.unknownUnknowns = [{ finding: raw.slice(0, 500), impact: "medium", mitigation: "review the raw analysis" }];
|
|
768
|
+
base.rawAnalysis = raw;
|
|
769
|
+
base.domainMisses = 1;
|
|
770
|
+
}
|
|
771
|
+
return base;
|
|
772
|
+
}
|
|
773
|
+
function formatReport(r) {
|
|
774
|
+
const L = [];
|
|
775
|
+
L.push(`Unknown Scan Report \u2014 ${new Date(r.scannedAt).toLocaleString()}`);
|
|
776
|
+
L.push("=".repeat(50));
|
|
777
|
+
const p = r.codebaseProfile;
|
|
778
|
+
if (p.skipped) {
|
|
779
|
+
L.push(`
|
|
780
|
+
Codebase Profile: SKIPPED (${p.reason || "unknown"})`);
|
|
781
|
+
} else {
|
|
782
|
+
L.push("\nCodebase Profile:");
|
|
783
|
+
L.push(` language: ${p.language}`);
|
|
784
|
+
if (p.frameworks.length) L.push(` frameworks: ${p.frameworks.join(", ")}`);
|
|
785
|
+
if (p.testPattern) L.push(` test: ${p.testPattern} (${p.testFramework || "?"})`);
|
|
786
|
+
if (p.structure.length) L.push(` structure: ${p.structure.map((s) => `${s.dir}/(${s.fileCount})`).join(", ")}`);
|
|
787
|
+
L.push(` total files: ${p.totalFiles}`);
|
|
788
|
+
}
|
|
789
|
+
L.push(`
|
|
790
|
+
Domain DB: ${r.domainHits} hits, ${r.domainMisses} new findings`);
|
|
791
|
+
if (r.knownKnowns.length) {
|
|
792
|
+
L.push(`
|
|
793
|
+
Known Knowns (${r.knownKnowns.length}):`);
|
|
794
|
+
for (const k of r.knownKnowns) L.push(` + task ${k.taskId}: ${k.note}`);
|
|
795
|
+
}
|
|
796
|
+
if (r.knownUnknowns.length) {
|
|
797
|
+
L.push(`
|
|
798
|
+
Known Unknowns (${r.knownUnknowns.length}):`);
|
|
799
|
+
for (const k of r.knownUnknowns) {
|
|
800
|
+
L.push(` ! task ${k.taskId}: ${k.gap}`);
|
|
801
|
+
if (k.suggestion) L.push(` -> ${k.suggestion}`);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
if (r.unknownKnowns.length) {
|
|
805
|
+
L.push(`
|
|
806
|
+
Unknown Knowns (${r.unknownKnowns.length}) \u2014 implicit, from codebase:`);
|
|
807
|
+
for (const k of r.unknownKnowns) L.push(` i ${k.finding}`);
|
|
808
|
+
}
|
|
809
|
+
if (r.unknownUnknowns.length) {
|
|
810
|
+
L.push(`
|
|
811
|
+
Unknown Unknowns (${r.unknownUnknowns.length}) \u2014 blind spots:`);
|
|
812
|
+
for (const k of r.unknownUnknowns) {
|
|
813
|
+
L.push(` * ${k.finding}`);
|
|
814
|
+
L.push(` impact: ${k.impact}`);
|
|
815
|
+
if (k.mitigation) L.push(` mitigation: ${k.mitigation}`);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
if (r.questions.length) {
|
|
819
|
+
L.push(`
|
|
820
|
+
Questions for the user (${r.questions.length}):`);
|
|
821
|
+
for (const q of r.questions) L.push(` [${q.id}] ${q.question}`);
|
|
822
|
+
}
|
|
823
|
+
if (r.taskRefinements.length) {
|
|
824
|
+
L.push("\nTask Refinement Suggestions:");
|
|
825
|
+
for (const t of r.taskRefinements) L.push(` -> task ${t.taskId}: ${t.action} - ${t.detail}`);
|
|
826
|
+
}
|
|
827
|
+
if (r.rawAnalysis) L.push(`
|
|
828
|
+
Raw analysis: ${r.rawAnalysis.slice(0, 200)}`);
|
|
829
|
+
L.push("\n[usage-coach NEXT] unknowns reviewed:");
|
|
830
|
+
L.push(" - If questions are flagged, ask the user first.");
|
|
831
|
+
L.push(" - If task splits are suggested, adjust via task_update.");
|
|
832
|
+
L.push(" - Then proceed to generate/generate_batch.");
|
|
833
|
+
return L.join("\n");
|
|
834
|
+
}
|
|
835
|
+
function writeUnknownScan(sessionID, result) {
|
|
836
|
+
try {
|
|
837
|
+
const h = readHarness(sessionID);
|
|
838
|
+
if (h) {
|
|
839
|
+
h.unknownScan = result;
|
|
840
|
+
writeHarness(sessionID, h);
|
|
841
|
+
}
|
|
842
|
+
} catch {
|
|
843
|
+
}
|
|
844
|
+
}
|
|
208
845
|
function readHarnessCfg(dir) {
|
|
209
846
|
const tryRead = (p) => {
|
|
210
847
|
try {
|
|
@@ -218,8 +855,16 @@ function readHarnessCfg(dir) {
|
|
|
218
855
|
...tryRead(join2(dir, "harness.config.json"))
|
|
219
856
|
};
|
|
220
857
|
}
|
|
221
|
-
async function runModel(client, model, prompt, directory) {
|
|
858
|
+
async function runModel(client, model, prompt, directory, track, maxSteps = DEFAULT_MAX_STEPS) {
|
|
222
859
|
const t0 = Date.now();
|
|
860
|
+
const subStart = Date.now();
|
|
861
|
+
let poller = null;
|
|
862
|
+
let subId = null;
|
|
863
|
+
let timedOut = false;
|
|
864
|
+
let signalTimeout;
|
|
865
|
+
const timeoutSignal = new Promise((resolve2) => {
|
|
866
|
+
signalTimeout = resolve2;
|
|
867
|
+
});
|
|
223
868
|
try {
|
|
224
869
|
const slash = model.indexOf("/");
|
|
225
870
|
const providerID = slash >= 0 ? model.slice(0, slash) : model;
|
|
@@ -227,12 +872,75 @@ async function runModel(client, model, prompt, directory) {
|
|
|
227
872
|
const s = await client.session.create({ body: { title: "uc-harness-sub" }, query: { directory } });
|
|
228
873
|
const id = s?.data?.info?.id ?? s?.data?.id ?? s?.id;
|
|
229
874
|
if (!id) return `ERROR: session.create returned no id (response: ${JSON.stringify(s?.data ?? s).slice(0, 200)})`;
|
|
230
|
-
|
|
231
|
-
|
|
875
|
+
subId = id;
|
|
876
|
+
log(`runModel(${model}): session ${id} created, sending prompt (${prompt.length} chars), max_steps=${maxSteps}`);
|
|
877
|
+
poller = setInterval(async () => {
|
|
878
|
+
if (timedOut) return;
|
|
879
|
+
try {
|
|
880
|
+
let step = 0;
|
|
881
|
+
let lastTs = (/* @__PURE__ */ new Date()).toISOString();
|
|
882
|
+
try {
|
|
883
|
+
const msgs = await client.session.messages?.({ path: { id } });
|
|
884
|
+
const msgList = Array.isArray(msgs?.data) ? msgs.data : Array.isArray(msgs) ? msgs : [];
|
|
885
|
+
if (msgList.length) {
|
|
886
|
+
step = msgList.filter((m) => {
|
|
887
|
+
const role = m?.role ?? m?.info?.role;
|
|
888
|
+
return role === "assistant";
|
|
889
|
+
}).length;
|
|
890
|
+
const last = msgList[msgList.length - 1];
|
|
891
|
+
const ts = last?.ts ?? last?.info?.updatedAt ?? last?.info?.completedAt ?? last?.updatedAt;
|
|
892
|
+
if (ts) lastTs = String(ts);
|
|
893
|
+
}
|
|
894
|
+
} catch {
|
|
895
|
+
}
|
|
896
|
+
if (step > maxSteps) {
|
|
897
|
+
log(`runModel(${model}): STEP LIMIT exceeded (${step} > ${maxSteps}), aborting session ${id}`);
|
|
898
|
+
timedOut = true;
|
|
899
|
+
try {
|
|
900
|
+
await client.session.abort?.({ path: { id } });
|
|
901
|
+
} catch {
|
|
902
|
+
}
|
|
903
|
+
signalTimeout();
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
if (track) {
|
|
907
|
+
const elapsed2 = Math.round((Date.now() - subStart) / 1e3);
|
|
908
|
+
updateSubSession(track.sessionID, track.taskId, {
|
|
909
|
+
subSessionId: id,
|
|
910
|
+
subStep: step,
|
|
911
|
+
lastActivity: lastTs,
|
|
912
|
+
subElapsed: elapsed2
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
} catch (e) {
|
|
916
|
+
log(`runModel poller err: ${String(e)}`);
|
|
917
|
+
}
|
|
918
|
+
}, WATCHDOG_POLL_MS);
|
|
919
|
+
const promptP = client.session.prompt({
|
|
232
920
|
path: { id },
|
|
233
921
|
body: { model: { providerID, modelID }, parts: [{ type: "text", text: prompt }] }
|
|
234
|
-
})
|
|
922
|
+
}).then(
|
|
923
|
+
(r) => r,
|
|
924
|
+
() => null
|
|
925
|
+
// abort causes rejection -> return null (handled via timedOut flag)
|
|
926
|
+
);
|
|
927
|
+
const resp = await Promise.race([promptP, timeoutSignal.then(() => null)]);
|
|
235
928
|
const elapsed = Math.round((Date.now() - t0) / 1e3);
|
|
929
|
+
if (timedOut) {
|
|
930
|
+
try {
|
|
931
|
+
const summary = await client.session.summarize?.({ path: { id } });
|
|
932
|
+
log(`runModel(${model}): TIMED OUT summary: ${JSON.stringify(summary?.data ?? summary).slice(0, 300)}`);
|
|
933
|
+
} catch {
|
|
934
|
+
}
|
|
935
|
+
try {
|
|
936
|
+
await client.session.delete?.({ path: { id } });
|
|
937
|
+
} catch {
|
|
938
|
+
}
|
|
939
|
+
subId = null;
|
|
940
|
+
log(`runModel(${model}): TIMED OUT after ${elapsed}s (${maxSteps} steps exceeded)`);
|
|
941
|
+
return `Task appears too large (exceeded ${maxSteps} steps). Consider splitting into smaller subtasks.
|
|
942
|
+
[usage-coach NEXT] split the original task into smaller subtasks (each should complete within ${maxSteps} steps), then re-run generate for each subtask.`;
|
|
943
|
+
}
|
|
236
944
|
const parts = resp?.data?.parts ?? resp?.parts ?? [];
|
|
237
945
|
const text = parts.filter((p) => p?.type === "text").map((p) => p?.text ?? "").join("");
|
|
238
946
|
try {
|
|
@@ -244,12 +952,27 @@ async function runModel(client, model, prompt, directory) {
|
|
|
244
952
|
await client.session.delete?.({ path: { id } });
|
|
245
953
|
} catch {
|
|
246
954
|
}
|
|
955
|
+
subId = null;
|
|
247
956
|
log(`runModel(${model}): done ${elapsed}s, ${text.length} chars`);
|
|
248
957
|
return text.trim() || `ERROR: no assistant text in prompt response after ${elapsed}s (parts: ${parts.length}, types: ${parts.map((p) => p?.type).join(",")})`;
|
|
249
958
|
} catch (e) {
|
|
250
959
|
const elapsed = Math.round((Date.now() - t0) / 1e3);
|
|
251
960
|
log(`runModel err (${model}, ${elapsed}s): ${String(e)}`);
|
|
252
961
|
return `ERROR: runModel exception after ${elapsed}s: ${String(e)}`;
|
|
962
|
+
} finally {
|
|
963
|
+
if (poller) clearInterval(poller);
|
|
964
|
+
if (track) {
|
|
965
|
+
try {
|
|
966
|
+
clearSubSession(track.sessionID, track.taskId);
|
|
967
|
+
} catch {
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
if (subId) {
|
|
971
|
+
try {
|
|
972
|
+
await client.session.delete?.({ path: { id: subId } });
|
|
973
|
+
} catch {
|
|
974
|
+
}
|
|
975
|
+
}
|
|
253
976
|
}
|
|
254
977
|
}
|
|
255
978
|
var HARNESS_AGENTS = (process.env.UC_HARNESS_AGENT ?? "Usage-Coach-Harness").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
@@ -512,7 +1235,7 @@ async function UsageCoachPlugin(input) {
|
|
|
512
1235
|
const agent = await resolveAgent(input.client, _input.sessionID);
|
|
513
1236
|
currentAgent = agent;
|
|
514
1237
|
refreshBackground();
|
|
515
|
-
const harnessTools = ["generate", "generate_batch", "grade", "investigate", "verify_diagnosis", "generalize", "harness_start", "task_update", "harness_done", "record_failure"];
|
|
1238
|
+
const harnessTools = ["unknown_scan", "generate", "generate_batch", "grade", "investigate", "verify_diagnosis", "generalize", "harness_start", "task_update", "harness_done", "record_failure", "reverse_interview"];
|
|
516
1239
|
if (!harnessTools.includes(_input.tool)) return;
|
|
517
1240
|
if (!isHarnessAgent(agent)) {
|
|
518
1241
|
throw new Error(`[${PLUGIN_NAME}] '${_input.tool}' is restricted to agent mode ${JSON.stringify(HARNESS_AGENTS)} (current: ${JSON.stringify(agent || "unknown")}). Switch to that agent mode to use it.`);
|
|
@@ -549,12 +1272,18 @@ async function UsageCoachPlugin(input) {
|
|
|
549
1272
|
// Custom tools for the harness agent mode — report status to the panel.
|
|
550
1273
|
tool: {
|
|
551
1274
|
harness_start: tool({
|
|
552
|
-
description: "Start the harness: register the total task count on the panel. Call once when the harness loop begins.",
|
|
1275
|
+
description: "Start the harness: register the total task count on the panel. Call once when the harness loop begins. IMPORTANT: each generate/generate_batch sub-session is step-limited (default 30). If any task seems too large, split it into smaller subtasks BEFORE starting \u2014 oversized tasks will timeout.",
|
|
553
1276
|
args: { name: tool.schema.string(), total: tool.schema.number() },
|
|
554
1277
|
async execute(args, ctx) {
|
|
555
1278
|
writeHarness(ctx.sessionID, { name: args.name, total: args.total, current: 0, tasks: [], usage: {}, active: true, startedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
556
1279
|
return `Harness '${args.name}' started (${args.total} tasks).
|
|
557
1280
|
|
|
1281
|
+
PRE-FLIGHT (unknown_scan): Before starting generate, call unknown_scan to check for blind spots. This scans the codebase against your tasks and finds gaps (unknown unknowns) that could waste steps if discovered late.
|
|
1282
|
+
unknown_scan({ prompt: "<user request>", tasks: [{id:1, title:"..."}, ...] })
|
|
1283
|
+
Review the report: if questions are flagged, ask the user first. If task splits are suggested, adjust via task_update. THEN proceed to the loop below.
|
|
1284
|
+
|
|
1285
|
+
STEP LIMIT (default ${DEFAULT_MAX_STEPS}): each generate call creates a sub-session that is automatically aborted if it exceeds ${DEFAULT_MAX_STEPS} assistant steps. Before starting the loop, review each task: can it be completed in a focused, single-pass effort? If a task seems too broad (multiple files, multiple features, open-ended research), SPLIT it now into 2-3 smaller subtasks. A timeout wastes quota \u2014 split upfront.
|
|
1286
|
+
|
|
558
1287
|
DETERMINISTIC LOOP \u2014 first classify the tasks:
|
|
559
1288
|
INDEPENDENT = task B does NOT need task A's output -> use PATH A (parallel, faster)
|
|
560
1289
|
DEPENDENT = task B needs task A's output -> use PATH B (sequential)
|
|
@@ -576,6 +1305,104 @@ PATH B \u2014 DEPENDENT (sequential):
|
|
|
576
1305
|
Then: harness_done(). Follow the [usage-coach NEXT] directive each tool returns. Do NOT improvise the sequence.`;
|
|
577
1306
|
}
|
|
578
1307
|
}),
|
|
1308
|
+
unknown_scan: tool({
|
|
1309
|
+
description: "Pre-flight gap analysis: call AFTER harness_start, BEFORE generate. Scans the codebase against the user's request to find blind spots (unknown unknowns) that could waste 30+ steps if discovered late. Returns identified unknowns + task split suggestions + user confirmation questions. Writes results to harness.json for TUI display.",
|
|
1310
|
+
args: {
|
|
1311
|
+
prompt: tool.schema.string().describe("The user's original request (the full prompt that triggered the harness)."),
|
|
1312
|
+
tasks: tool.schema.array(tool.schema.object({ id: tool.schema.number(), title: tool.schema.string() })).optional().describe("Tasks registered via harness_start (id + title). If omitted, a single task derived from the prompt is assumed."),
|
|
1313
|
+
skip_scan: tool.schema.boolean().optional().describe("true: skip codebase scan and do prompt analysis only (default false; auto-skips for dirs with <5 files).")
|
|
1314
|
+
},
|
|
1315
|
+
async execute(args, ctx) {
|
|
1316
|
+
const tasks = args.tasks && args.tasks.length > 0 ? args.tasks : [{ id: 1, title: args.prompt.slice(0, 80) }];
|
|
1317
|
+
let profile;
|
|
1318
|
+
if (args.skip_scan) {
|
|
1319
|
+
profile = { skipped: true, reason: "skip_scan requested", language: "unknown", frameworks: [], structure: [], manifestFiles: [], keyDeps: [], configFiles: [], totalFiles: 0 };
|
|
1320
|
+
} else {
|
|
1321
|
+
try {
|
|
1322
|
+
const res = await input.$`find ${ctx.directory} -maxdepth 4 -type f -not -path '*/node_modules/*' -not -path '*/.git/*' -not -path '*/dist/*' -not -path '*/.cache/*' 2>/dev/null`;
|
|
1323
|
+
const fileList = typeof res?.stdout === "string" ? res.stdout : res?.stdout?.toString?.() ?? "";
|
|
1324
|
+
profile = parseFileList(fileList, ctx.directory);
|
|
1325
|
+
if (profile.totalFiles < 5) {
|
|
1326
|
+
profile = { ...profile, skipped: true, reason: `directory nearly empty (${profile.totalFiles} files)` };
|
|
1327
|
+
}
|
|
1328
|
+
} catch (e) {
|
|
1329
|
+
profile = { skipped: true, reason: `scan error: ${String(e).slice(0, 100)}`, language: "unknown", frameworks: [], structure: [], manifestFiles: [], keyDeps: [], configFiles: [], totalFiles: 0 };
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
let domainNodes = [];
|
|
1333
|
+
let domainHits = 0;
|
|
1334
|
+
try {
|
|
1335
|
+
const combined = `${args.prompt} ${tasks.map((t) => t.title).join(" ")}`;
|
|
1336
|
+
const kw = extractKeywords(combined);
|
|
1337
|
+
if (kw.length) {
|
|
1338
|
+
const graph = queryDomainGraph(kw, 2);
|
|
1339
|
+
domainNodes = graph.nodes || [];
|
|
1340
|
+
domainHits = domainNodes.length;
|
|
1341
|
+
}
|
|
1342
|
+
} catch (e) {
|
|
1343
|
+
log(`unknown_scan domain query err: ${String(e)}`);
|
|
1344
|
+
}
|
|
1345
|
+
const cfg = readHarnessCfg(ctx.directory);
|
|
1346
|
+
if (!cfg.generator) {
|
|
1347
|
+
const result2 = {
|
|
1348
|
+
scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1349
|
+
codebaseProfile: profile,
|
|
1350
|
+
knownKnowns: [],
|
|
1351
|
+
knownUnknowns: [],
|
|
1352
|
+
unknownKnowns: [],
|
|
1353
|
+
unknownUnknowns: [],
|
|
1354
|
+
questions: [],
|
|
1355
|
+
taskRefinements: [],
|
|
1356
|
+
domainHits,
|
|
1357
|
+
domainMisses: 0,
|
|
1358
|
+
rawAnalysis: "ERROR: no generator configured"
|
|
1359
|
+
};
|
|
1360
|
+
writeUnknownScan(ctx.sessionID, result2);
|
|
1361
|
+
return formatReport(result2) + '\n\nERROR: no generator model configured. Set "generator" in harness.config.json for model-assisted analysis.';
|
|
1362
|
+
}
|
|
1363
|
+
let decision = "GO";
|
|
1364
|
+
try {
|
|
1365
|
+
decision = current().decision;
|
|
1366
|
+
} catch {
|
|
1367
|
+
}
|
|
1368
|
+
if (decision === "STOP") {
|
|
1369
|
+
const result2 = {
|
|
1370
|
+
scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1371
|
+
codebaseProfile: profile,
|
|
1372
|
+
knownKnowns: [],
|
|
1373
|
+
knownUnknowns: [],
|
|
1374
|
+
unknownKnowns: [],
|
|
1375
|
+
unknownUnknowns: [],
|
|
1376
|
+
questions: [],
|
|
1377
|
+
taskRefinements: [],
|
|
1378
|
+
domainHits,
|
|
1379
|
+
domainMisses: 0,
|
|
1380
|
+
rawAnalysis: "quota STOP \u2014 model analysis skipped"
|
|
1381
|
+
};
|
|
1382
|
+
writeUnknownScan(ctx.sessionID, result2);
|
|
1383
|
+
return formatReport(result2) + "\n\n[usage-coach] quota STOP \u2014 model-assisted analysis skipped. Proceed with caution.";
|
|
1384
|
+
}
|
|
1385
|
+
const throttle = decision === "THROTTLE" && cfg.lighterModel;
|
|
1386
|
+
const model = throttle ? cfg.lighterModel : cfg.generator;
|
|
1387
|
+
const gapPrompt = buildGapPrompt(args.prompt, tasks, profile, domainNodes);
|
|
1388
|
+
const raw = await runModel(input.client, model, gapPrompt, ctx.directory, void 0, 15);
|
|
1389
|
+
const result = parseGapAnalysis(raw, profile, domainHits);
|
|
1390
|
+
try {
|
|
1391
|
+
for (const uk of result.unknownKnowns.slice(0, 5)) {
|
|
1392
|
+
const kw = extractKeywords(uk.finding);
|
|
1393
|
+
if (kw.length) saveInvestigationResult(kw, uk.finding, "unknown_scan");
|
|
1394
|
+
}
|
|
1395
|
+
for (const uu of result.unknownUnknowns.slice(0, 3)) {
|
|
1396
|
+
const kw = extractKeywords(uu.finding);
|
|
1397
|
+
if (kw.length) saveInvestigationResult(kw, `${uu.finding} (impact: ${uu.impact})`, "unknown_scan");
|
|
1398
|
+
}
|
|
1399
|
+
} catch (e) {
|
|
1400
|
+
log(`unknown_scan save err: ${String(e)}`);
|
|
1401
|
+
}
|
|
1402
|
+
writeUnknownScan(ctx.sessionID, result);
|
|
1403
|
+
return formatReport(result);
|
|
1404
|
+
}
|
|
1405
|
+
}),
|
|
579
1406
|
task_update: tool({
|
|
580
1407
|
description: "Update a harness task's status on the panel. Call whenever a task transitions to generating/grading/revising/completed/failed.",
|
|
581
1408
|
args: {
|
|
@@ -669,7 +1496,14 @@ Output a structured root cause:
|
|
|
669
1496
|
category: (one of: constraint-violation, missing-context, tool-misuse, model-limitation, other)
|
|
670
1497
|
explanation: <why it failed>
|
|
671
1498
|
evidence: <file/line or specific quote>`;
|
|
672
|
-
const
|
|
1499
|
+
const invTaskId = findActiveTaskId(ctx.sessionID, "revising");
|
|
1500
|
+
const out = await runModel(
|
|
1501
|
+
input.client,
|
|
1502
|
+
cfg.generator,
|
|
1503
|
+
domainPrefix + rcaPrompt,
|
|
1504
|
+
ctx.directory,
|
|
1505
|
+
invTaskId ? { sessionID: ctx.sessionID, taskId: invTaskId } : void 0
|
|
1506
|
+
);
|
|
673
1507
|
if (domainEmpty && keywords.length) {
|
|
674
1508
|
try {
|
|
675
1509
|
saveInvestigationResult(keywords, out, "investigate");
|
|
@@ -697,7 +1531,14 @@ Grade feedback: ${args.gradeResult}
|
|
|
697
1531
|
Diagnosis: ${args.diagnosis}
|
|
698
1532
|
Is the diagnosis CORRECT and ACTIONABLE (leads to a useful rule)?
|
|
699
1533
|
Output PASS (the diagnosis is right) or FAIL (re-investigate needed), then reason.`;
|
|
700
|
-
const
|
|
1534
|
+
const verTaskId = findActiveTaskId(ctx.sessionID, "revising");
|
|
1535
|
+
const out = await runModel(
|
|
1536
|
+
input.client,
|
|
1537
|
+
model,
|
|
1538
|
+
verifyPrompt,
|
|
1539
|
+
ctx.directory,
|
|
1540
|
+
verTaskId ? { sessionID: ctx.sessionID, taskId: verTaskId } : void 0
|
|
1541
|
+
);
|
|
701
1542
|
let verdict = "FAIL";
|
|
702
1543
|
if (!out.startsWith("ERROR:")) {
|
|
703
1544
|
const f = (out.split("\n").find((l) => l.trim()) ?? "").trim();
|
|
@@ -724,7 +1565,14 @@ Diagnosis: ${args.diagnosis}
|
|
|
724
1565
|
Failed task: ${args.task}
|
|
725
1566
|
Output a single rule in the form: 'For <task-type> tasks, always <check/do X> because <reason>.'
|
|
726
1567
|
Keep it concrete and actionable.`;
|
|
727
|
-
const
|
|
1568
|
+
const genRuleTaskId = findActiveTaskId(ctx.sessionID, "revising");
|
|
1569
|
+
const out = await runModel(
|
|
1570
|
+
input.client,
|
|
1571
|
+
cfg.generator,
|
|
1572
|
+
genPrompt,
|
|
1573
|
+
ctx.directory,
|
|
1574
|
+
genRuleTaskId ? { sessionID: ctx.sessionID, taskId: genRuleTaskId } : void 0
|
|
1575
|
+
);
|
|
728
1576
|
const rule = out;
|
|
729
1577
|
try {
|
|
730
1578
|
const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
@@ -744,8 +1592,8 @@ Origin: ${args.task}
|
|
|
744
1592
|
// Per-role model execution (config-driven, quota-aware, same server, no deadlock).
|
|
745
1593
|
// P1: quota decision drives model selection + concurrency.
|
|
746
1594
|
generate: tool({
|
|
747
|
-
description: "Run the GENERATOR model on a prompt. Quota-aware: on THROTTLE, auto-switches to lighterModel if configured. Returns the model's text response.",
|
|
748
|
-
args: { prompt: tool.schema.string() },
|
|
1595
|
+
description: "Run the GENERATOR model on a prompt. Quota-aware: on THROTTLE, auto-switches to lighterModel if configured. Returns the model's text response. Step-limited: aborts after max_steps (default 30) to prevent runaway tasks.",
|
|
1596
|
+
args: { prompt: tool.schema.string(), max_steps: tool.schema.number().optional().describe("Maximum sub-session steps before timeout (default 30). Increase for complex tasks, decrease to fail fast on scope creep.") },
|
|
749
1597
|
async execute(args, ctx) {
|
|
750
1598
|
const cfg = readHarnessCfg(ctx.directory);
|
|
751
1599
|
if (!cfg.generator) return 'ERROR: no generator model configured. Set "generator" in harness.config.json (see harness.config.example.json).';
|
|
@@ -781,22 +1629,61 @@ ${rules}
|
|
|
781
1629
|
} catch (e) {
|
|
782
1630
|
log(`generate domain query err: ${String(e)}`);
|
|
783
1631
|
}
|
|
784
|
-
|
|
785
|
-
|
|
1632
|
+
try {
|
|
1633
|
+
const priorNotes = keywords.length ? readImplNotesByGraph(keywords, 5) : readImplNotes(5);
|
|
1634
|
+
if (priorNotes) {
|
|
1635
|
+
prefix = `Notes from previous runs (context for this task):
|
|
1636
|
+
${priorNotes}
|
|
1637
|
+
|
|
1638
|
+
---
|
|
1639
|
+
|
|
1640
|
+
` + prefix;
|
|
1641
|
+
}
|
|
1642
|
+
} catch (e) {
|
|
1643
|
+
log(`generate impl-notes read err: ${String(e)}`);
|
|
1644
|
+
}
|
|
1645
|
+
prefix += IMPL_NOTE_INSTRUCTION;
|
|
1646
|
+
const genTaskId = findActiveTaskId(ctx.sessionID, "generating");
|
|
1647
|
+
const maxSteps = args.max_steps ?? DEFAULT_MAX_STEPS;
|
|
1648
|
+
const out = await runModel(
|
|
1649
|
+
input.client,
|
|
1650
|
+
model,
|
|
1651
|
+
prefix + args.prompt,
|
|
1652
|
+
ctx.directory,
|
|
1653
|
+
genTaskId ? { sessionID: ctx.sessionID, taskId: genTaskId } : void 0,
|
|
1654
|
+
maxSteps
|
|
1655
|
+
);
|
|
1656
|
+
const isTimeoutOrError = out.startsWith("Task appears too large") || out.startsWith("ERROR:");
|
|
1657
|
+
if (domainEmpty && keywords.length && !isTimeoutOrError) {
|
|
786
1658
|
try {
|
|
787
1659
|
saveInvestigationResult(keywords, out, "generate");
|
|
788
1660
|
} catch (e) {
|
|
789
1661
|
log(`generate save err: ${String(e)}`);
|
|
790
1662
|
}
|
|
791
1663
|
}
|
|
1664
|
+
if (!isTimeoutOrError) {
|
|
1665
|
+
try {
|
|
1666
|
+
const { notes } = extractImplNotes(out);
|
|
1667
|
+
if (notes) {
|
|
1668
|
+
appendImplNotes(notes, args.prompt);
|
|
1669
|
+
if (keywords.length) {
|
|
1670
|
+
const noteNodeId = saveInvestigationResult(keywords, notes, "impl-note", 0.5);
|
|
1671
|
+
if (noteNodeId) linkImplNoteToDomain(noteNodeId, notes);
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
} catch (e) {
|
|
1675
|
+
log(`generate impl-notes extract err: ${String(e)}`);
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
if (out.startsWith("Task appears too large")) return out;
|
|
792
1679
|
return out + (throttle ? `
|
|
793
1680
|
[usage-coach] quota THROTTLE \u2014 used lighter model ${cfg.lighterModel}` : "") + `
|
|
794
1681
|
[usage-coach NEXT] call task_update(i, title, "grading"), then grade to evaluate this work.`;
|
|
795
1682
|
}
|
|
796
1683
|
}),
|
|
797
1684
|
generate_batch: tool({
|
|
798
|
-
description: "Run the GENERATOR model on MULTIPLE tasks. Quota-aware: GO = full parallel; THROTTLE = lighter model + concurrency capped at 2; STOP = refused. Use for INDEPENDENT tasks.",
|
|
799
|
-
args: { tasks: tool.schema.array(tool.schema.object({ id: tool.schema.number(), prompt: tool.schema.string() })) },
|
|
1685
|
+
description: "Run the GENERATOR model on MULTIPLE tasks. Quota-aware: GO = full parallel; THROTTLE = lighter model + concurrency capped at 2; STOP = refused. Use for INDEPENDENT tasks. Step-limited: each sub-session aborts after max_steps (default 30).",
|
|
1686
|
+
args: { tasks: tool.schema.array(tool.schema.object({ id: tool.schema.number(), prompt: tool.schema.string() })), max_steps: tool.schema.number().optional().describe("Maximum sub-session steps per task before timeout (default 30).") },
|
|
800
1687
|
async execute(args, ctx) {
|
|
801
1688
|
const cfg = readHarnessCfg(ctx.directory);
|
|
802
1689
|
if (!cfg.generator) return 'ERROR: no generator model configured. Set "generator" in harness.config.json (see harness.config.example.json).';
|
|
@@ -809,11 +1696,49 @@ ${rules}
|
|
|
809
1696
|
const throttle = decision === "THROTTLE" && cfg.lighterModel;
|
|
810
1697
|
const model = throttle ? cfg.lighterModel : cfg.generator;
|
|
811
1698
|
const limit = decision === "THROTTLE" ? 2 : args.tasks.length;
|
|
1699
|
+
const rules = readRules();
|
|
1700
|
+
const priorNotes = readImplNotes(5);
|
|
812
1701
|
const results = [];
|
|
813
1702
|
for (let i = 0; i < args.tasks.length; i += limit) {
|
|
814
1703
|
const batch = args.tasks.slice(i, i + limit);
|
|
815
1704
|
const out = await Promise.all(batch.map(async (t) => {
|
|
816
|
-
|
|
1705
|
+
let prefix = rules ? `Lessons learned from previous failures (apply where relevant):
|
|
1706
|
+
${rules}
|
|
1707
|
+
|
|
1708
|
+
---
|
|
1709
|
+
|
|
1710
|
+
` : "";
|
|
1711
|
+
if (priorNotes) prefix = `Notes from previous runs (context for this task):
|
|
1712
|
+
${priorNotes}
|
|
1713
|
+
|
|
1714
|
+
---
|
|
1715
|
+
|
|
1716
|
+
` + prefix;
|
|
1717
|
+
prefix += IMPL_NOTE_INSTRUCTION;
|
|
1718
|
+
const r = await runModel(
|
|
1719
|
+
input.client,
|
|
1720
|
+
model,
|
|
1721
|
+
prefix + t.prompt,
|
|
1722
|
+
ctx.directory,
|
|
1723
|
+
{ sessionID: ctx.sessionID, taskId: t.id },
|
|
1724
|
+
args.max_steps ?? DEFAULT_MAX_STEPS
|
|
1725
|
+
);
|
|
1726
|
+
const isTimeoutOrError = r.startsWith("Task appears too large") || r.startsWith("ERROR:");
|
|
1727
|
+
if (!isTimeoutOrError) {
|
|
1728
|
+
try {
|
|
1729
|
+
const { notes } = extractImplNotes(r);
|
|
1730
|
+
if (notes) {
|
|
1731
|
+
appendImplNotes(notes, t.prompt);
|
|
1732
|
+
const kw = extractKeywords(t.prompt);
|
|
1733
|
+
if (kw.length) {
|
|
1734
|
+
const noteNodeId = saveInvestigationResult(kw, notes, "impl-note", 0.5);
|
|
1735
|
+
if (noteNodeId) linkImplNoteToDomain(noteNodeId, notes);
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
} catch (e) {
|
|
1739
|
+
log(`generate_batch impl-notes extract err: ${String(e)}`);
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
817
1742
|
return `[task ${t.id}] ${r}`;
|
|
818
1743
|
}));
|
|
819
1744
|
results.push(...out);
|
|
@@ -830,7 +1755,14 @@ ${rules}
|
|
|
830
1755
|
const cfg = readHarnessCfg(ctx.directory);
|
|
831
1756
|
const model = cfg.grader ?? cfg.generator;
|
|
832
1757
|
if (!model) return "FAIL\n(ERROR: no grader/generator model configured.)\n[usage-coach NEXT] configure grader in harness.config.json, then retry grade.";
|
|
833
|
-
const
|
|
1758
|
+
const gradeTaskId = findActiveTaskId(ctx.sessionID, "grading");
|
|
1759
|
+
const out = await runModel(
|
|
1760
|
+
input.client,
|
|
1761
|
+
model,
|
|
1762
|
+
args.prompt,
|
|
1763
|
+
ctx.directory,
|
|
1764
|
+
gradeTaskId ? { sessionID: ctx.sessionID, taskId: gradeTaskId } : void 0
|
|
1765
|
+
);
|
|
834
1766
|
let verdict = "FAIL";
|
|
835
1767
|
if (!out.startsWith("ERROR:")) {
|
|
836
1768
|
const f = (out.split("\n").find((l) => l.trim()) ?? "").trim();
|
|
@@ -848,6 +1780,140 @@ ${rules}
|
|
|
848
1780
|
The next generate call will automatically include the new rule.`;
|
|
849
1781
|
return out + "\n" + next;
|
|
850
1782
|
}
|
|
1783
|
+
}),
|
|
1784
|
+
reverse_interview: tool({
|
|
1785
|
+
description: "Reverse interview: identify ambiguities in the task and ask the user one question at a time, highest design-impact first. Call WITHOUT answer to start or get the next question. Call WITH answer to record the user's response and advance. Returns the next question or a completion summary. ALWAYS present the question to the user verbatim \u2014 do NOT answer it yourself.",
|
|
1786
|
+
args: {
|
|
1787
|
+
task: tool.schema.string().describe(
|
|
1788
|
+
"The current task description (what the user asked for)."
|
|
1789
|
+
),
|
|
1790
|
+
context: tool.schema.string().optional().describe(
|
|
1791
|
+
"Additional context from unknown_scan, codebase exploration, or prior turns. Injected into the question-generation prompt for better prioritization."
|
|
1792
|
+
),
|
|
1793
|
+
answer: tool.schema.string().optional().describe(
|
|
1794
|
+
"The user's response to the previous question. Omit on the first call (or when there is no answer to record)."
|
|
1795
|
+
),
|
|
1796
|
+
force_complete: tool.schema.boolean().optional().describe(
|
|
1797
|
+
"Force the interview to end now and return the summary. Use when the user says 'that's enough' or 'just proceed'."
|
|
1798
|
+
)
|
|
1799
|
+
},
|
|
1800
|
+
async execute(args, ctx) {
|
|
1801
|
+
const sessionID = ctx.sessionID;
|
|
1802
|
+
const cfg = readHarnessCfg(ctx.directory);
|
|
1803
|
+
const resolveModel = () => {
|
|
1804
|
+
if (!cfg.generator) return null;
|
|
1805
|
+
let decision = "GO";
|
|
1806
|
+
try {
|
|
1807
|
+
decision = current().decision;
|
|
1808
|
+
} catch {
|
|
1809
|
+
}
|
|
1810
|
+
const throttle = decision === "THROTTLE" && cfg.lighterModel;
|
|
1811
|
+
return throttle ? cfg.lighterModel : cfg.generator;
|
|
1812
|
+
};
|
|
1813
|
+
let state = readInterview(sessionID);
|
|
1814
|
+
if (args.force_complete) {
|
|
1815
|
+
if (state && state.phase !== "complete") {
|
|
1816
|
+
return await completeInterview(state, sessionID, input.client, resolveModel(), ctx.directory);
|
|
1817
|
+
}
|
|
1818
|
+
return "No active interview to complete.\n[usage-coach NEXT] proceed to harness_start \u2192 generate.";
|
|
1819
|
+
}
|
|
1820
|
+
if (args.answer !== void 0 && args.answer !== null && state && state.phase === "asking" && state.currentIndex < state.questions.length) {
|
|
1821
|
+
const q2 = state.questions[state.currentIndex];
|
|
1822
|
+
state.answers.push({
|
|
1823
|
+
questionId: q2.id,
|
|
1824
|
+
questionText: q2.text,
|
|
1825
|
+
answer: String(args.answer),
|
|
1826
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
1827
|
+
});
|
|
1828
|
+
state.currentIndex++;
|
|
1829
|
+
writeInterview(sessionID, state);
|
|
1830
|
+
const maxQ = state.maxQuestions;
|
|
1831
|
+
if (state.currentIndex >= state.questions.length || state.answers.length >= maxQ) {
|
|
1832
|
+
return await completeInterview(state, sessionID, input.client, resolveModel(), ctx.directory);
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
if (!state || state.phase === "complete") {
|
|
1836
|
+
if (!cfg.generator) return 'ERROR: no generator model configured. Set "generator" in harness.config.json (see harness.config.example.json).\n[usage-coach NEXT] proceed to harness_start \u2192 generate using best-effort assumptions.';
|
|
1837
|
+
const model = resolveModel();
|
|
1838
|
+
const userRequest = args.task;
|
|
1839
|
+
const mQ = DEFAULT_MAX_QUESTIONS;
|
|
1840
|
+
let graphNodes = [];
|
|
1841
|
+
try {
|
|
1842
|
+
const keywords = extractKeywords(args.task + " " + (args.context ?? ""));
|
|
1843
|
+
if (keywords.length) {
|
|
1844
|
+
const g = queryDomainGraph(keywords, 2);
|
|
1845
|
+
graphNodes = g.nodes || [];
|
|
1846
|
+
}
|
|
1847
|
+
} catch (e) {
|
|
1848
|
+
log(`reverse_interview domain query err: ${String(e)}`);
|
|
1849
|
+
}
|
|
1850
|
+
const domainSection = graphNodes.length ? `
|
|
1851
|
+
Domain knowledge (from local graph DB \u2014 do NOT ask about things already known):
|
|
1852
|
+
${graphNodes.slice(0, 20).map(
|
|
1853
|
+
(n) => `- [d${n.distance ?? 9}] ${n.name} (${n.type}): ${String(n.props?.result ?? JSON.stringify(n.props)).slice(0, 150)}`
|
|
1854
|
+
).join("\n")}
|
|
1855
|
+
` : "";
|
|
1856
|
+
const planningPrompt = `You are a senior architect conducting a reverse interview.
|
|
1857
|
+
Analyze this task and identify the TOP ambiguities that, if left unresolved, would lead to the WRONG implementation.
|
|
1858
|
+
|
|
1859
|
+
Task: ${args.task}
|
|
1860
|
+
User request: ${userRequest}
|
|
1861
|
+
Additional context: ${args.context ?? "none"}${domainSection}
|
|
1862
|
+
|
|
1863
|
+
Rules:
|
|
1864
|
+
1. Focus on questions whose answers CHANGE THE ARCHITECTURE or SCOPE.
|
|
1865
|
+
"What database?" is high-impact. "Variable naming?" is low-impact \u2014 exclude it.
|
|
1866
|
+
2. Maximum ${mQ} questions.
|
|
1867
|
+
3. Rank by design impact: critical > high > medium > low.
|
|
1868
|
+
4. For each question, explain WHY it matters (the consequence of guessing wrong).
|
|
1869
|
+
5. Categorize each: architecture | scope | constraint | preference | constraint-env | tradeoff.
|
|
1870
|
+
6. For each question, name the core CONCEPT it targets (a single word/phrase).
|
|
1871
|
+
|
|
1872
|
+
Output JSON ONLY (no markdown fences, no prose):
|
|
1873
|
+
{"questions":[{"text":"the question (concise, specific)","why":"what goes wrong if we guess","priority":"critical|high|medium|low","category":"architecture|scope|constraint|preference|constraint-env|tradeoff","optional":true,"concept":"single-word-concept"}]}
|
|
1874
|
+
If the task is already well-specified with no significant ambiguities, return {"questions":[]}.`;
|
|
1875
|
+
const out = await runModel(input.client, model, planningPrompt, ctx.directory);
|
|
1876
|
+
if (out.startsWith("ERROR:") || out.startsWith("Task appears too large")) {
|
|
1877
|
+
return `Cannot generate interview questions: ${out}
|
|
1878
|
+
[usage-coach NEXT] proceed to harness_start \u2192 generate using best-effort assumptions, or wait for quota reset and retry reverse_interview.`;
|
|
1879
|
+
}
|
|
1880
|
+
const parsed = parseInterviewQuestions(out, mQ, graphNodes);
|
|
1881
|
+
if (parsed.length === 0) {
|
|
1882
|
+
state = {
|
|
1883
|
+
id: `int_${Date.now().toString(36)}`,
|
|
1884
|
+
task: args.task,
|
|
1885
|
+
userRequest,
|
|
1886
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1887
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1888
|
+
questions: [],
|
|
1889
|
+
answers: [],
|
|
1890
|
+
currentIndex: 0,
|
|
1891
|
+
phase: "complete",
|
|
1892
|
+
maxQuestions: mQ,
|
|
1893
|
+
summary: "No significant ambiguities found. The task appears well-specified."
|
|
1894
|
+
};
|
|
1895
|
+
writeInterview(sessionID, state);
|
|
1896
|
+
return "No significant ambiguities found \u2014 the task appears well-specified.\n[usage-coach NEXT] proceed directly to harness_start \u2192 generate.";
|
|
1897
|
+
}
|
|
1898
|
+
state = {
|
|
1899
|
+
id: `int_${Date.now().toString(36)}`,
|
|
1900
|
+
task: args.task,
|
|
1901
|
+
userRequest,
|
|
1902
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1903
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1904
|
+
questions: parsed,
|
|
1905
|
+
answers: [],
|
|
1906
|
+
currentIndex: 0,
|
|
1907
|
+
phase: "asking",
|
|
1908
|
+
maxQuestions: mQ
|
|
1909
|
+
};
|
|
1910
|
+
writeInterview(sessionID, state);
|
|
1911
|
+
}
|
|
1912
|
+
const q = state.questions[state.currentIndex];
|
|
1913
|
+
const total = state.questions.length;
|
|
1914
|
+
const qNum = state.currentIndex + 1;
|
|
1915
|
+
return formatQuestionOutput(qNum, total, q);
|
|
1916
|
+
}
|
|
851
1917
|
})
|
|
852
1918
|
}
|
|
853
1919
|
};
|