opencode-usage-coach 0.7.1 → 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 "";
@@ -116,6 +184,7 @@ var PLUGIN_NAME = "opencode-usage-coach";
116
184
  var TTL_MS = Number(process.env.UC_TTL_MS ?? 6e4);
117
185
  var DEFAULT_MAX_STEPS = Number(process.env.UC_MAX_STEPS ?? 30) || 30;
118
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);
119
188
  var PIPE_LOG = join2(homedir(), ".cache", "opencode-usage-coach", "pipeline.log");
120
189
  function pipeLog(msg) {
121
190
  try {
@@ -169,6 +238,106 @@ function readRules() {
169
238
  return "";
170
239
  }
171
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
+ }
172
341
  function extractKeywords(text) {
173
342
  try {
174
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"]);
@@ -207,6 +376,136 @@ function writeHarness(sessionID, h) {
207
376
  } catch {
208
377
  }
209
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
+ }
210
509
  function updateSubSession(sessionID, taskId, fields) {
211
510
  try {
212
511
  const h = readHarness(sessionID);
@@ -241,6 +540,308 @@ function findActiveTaskId(sessionID, status) {
241
540
  return void 0;
242
541
  }
243
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
+ }
244
845
  function readHarnessCfg(dir) {
245
846
  const tryRead = (p) => {
246
847
  try {
@@ -634,7 +1235,7 @@ async function UsageCoachPlugin(input) {
634
1235
  const agent = await resolveAgent(input.client, _input.sessionID);
635
1236
  currentAgent = agent;
636
1237
  refreshBackground();
637
- 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"];
638
1239
  if (!harnessTools.includes(_input.tool)) return;
639
1240
  if (!isHarnessAgent(agent)) {
640
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.`);
@@ -677,6 +1278,10 @@ async function UsageCoachPlugin(input) {
677
1278
  writeHarness(ctx.sessionID, { name: args.name, total: args.total, current: 0, tasks: [], usage: {}, active: true, startedAt: (/* @__PURE__ */ new Date()).toISOString() });
678
1279
  return `Harness '${args.name}' started (${args.total} tasks).
679
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
+
680
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.
681
1286
 
682
1287
  DETERMINISTIC LOOP \u2014 first classify the tasks:
@@ -700,6 +1305,104 @@ PATH B \u2014 DEPENDENT (sequential):
700
1305
  Then: harness_done(). Follow the [usage-coach NEXT] directive each tool returns. Do NOT improvise the sequence.`;
701
1306
  }
702
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
+ }),
703
1406
  task_update: tool({
704
1407
  description: "Update a harness task's status on the panel. Call whenever a task transitions to generating/grading/revising/completed/failed.",
705
1408
  args: {
@@ -926,6 +1629,20 @@ ${rules}
926
1629
  } catch (e) {
927
1630
  log(`generate domain query err: ${String(e)}`);
928
1631
  }
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;
929
1646
  const genTaskId = findActiveTaskId(ctx.sessionID, "generating");
930
1647
  const maxSteps = args.max_steps ?? DEFAULT_MAX_STEPS;
931
1648
  const out = await runModel(
@@ -944,6 +1661,20 @@ ${rules}
944
1661
  log(`generate save err: ${String(e)}`);
945
1662
  }
946
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
+ }
947
1678
  if (out.startsWith("Task appears too large")) return out;
948
1679
  return out + (throttle ? `
949
1680
  [usage-coach] quota THROTTLE \u2014 used lighter model ${cfg.lighterModel}` : "") + `
@@ -965,18 +1696,49 @@ ${rules}
965
1696
  const throttle = decision === "THROTTLE" && cfg.lighterModel;
966
1697
  const model = throttle ? cfg.lighterModel : cfg.generator;
967
1698
  const limit = decision === "THROTTLE" ? 2 : args.tasks.length;
1699
+ const rules = readRules();
1700
+ const priorNotes = readImplNotes(5);
968
1701
  const results = [];
969
1702
  for (let i = 0; i < args.tasks.length; i += limit) {
970
1703
  const batch = args.tasks.slice(i, i + limit);
971
1704
  const out = await Promise.all(batch.map(async (t) => {
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;
972
1718
  const r = await runModel(
973
1719
  input.client,
974
1720
  model,
975
- t.prompt,
1721
+ prefix + t.prompt,
976
1722
  ctx.directory,
977
1723
  { sessionID: ctx.sessionID, taskId: t.id },
978
1724
  args.max_steps ?? DEFAULT_MAX_STEPS
979
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
+ }
980
1742
  return `[task ${t.id}] ${r}`;
981
1743
  }));
982
1744
  results.push(...out);
@@ -1018,6 +1780,140 @@ ${rules}
1018
1780
  The next generate call will automatically include the new rule.`;
1019
1781
  return out + "\n" + next;
1020
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
+ }
1021
1917
  })
1022
1918
  }
1023
1919
  };