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/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 saveInvestigationResult(keywords, result, source) {
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: 0.7
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
- log(`runModel(${model}): session ${id} created, sending prompt (${prompt.length} chars)`);
231
- const resp = await client.session.prompt({
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 out = await runModel(input.client, cfg.generator, domainPrefix + rcaPrompt, ctx.directory);
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 out = await runModel(input.client, model, verifyPrompt, ctx.directory);
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 out = await runModel(input.client, cfg.generator, genPrompt, ctx.directory);
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
- const out = await runModel(input.client, model, prefix + args.prompt, ctx.directory);
785
- if (domainEmpty && keywords.length) {
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
- const r = await runModel(input.client, model, t.prompt, ctx.directory);
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 out = await runModel(input.client, model, args.prompt, ctx.directory);
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
  };