opencode-usage-coach 0.13.6 → 0.14.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/cli.js CHANGED
@@ -146,7 +146,7 @@ function readAggregateStatus() {
146
146
  const decCount = {};
147
147
  let max5h = 0, maxWk = 0, maxMo = 0, activeHarnesses = 0, totalTasks = 0, totalRules = 0, totalFailures = 0, totalDomainNodes = 0, totalDomainEdges = 0;
148
148
  for (const d of dirs) {
149
- let isDir = false;
149
+ let isDir;
150
150
  try {
151
151
  isDir = statSync(d).isDirectory();
152
152
  } catch {
package/dist/index.js CHANGED
@@ -437,6 +437,19 @@ var DEFAULT_MAX_STEPS = Number(process.env.UC_MAX_STEPS ?? 30) || 30;
437
437
  function resolveMaxSteps(cfg, explicit) {
438
438
  return explicit ?? cfg.maxSteps ?? DEFAULT_MAX_STEPS;
439
439
  }
440
+ function normalizeFilePath(p) {
441
+ return p.trim().replace(/^(\.\/)+/, "").toLowerCase();
442
+ }
443
+ function resolveBatchLimit(tasks, decision) {
444
+ if (tasks.length === 0) return 0;
445
+ const seen = /* @__PURE__ */ new Set();
446
+ for (const t of tasks) {
447
+ const files = new Set((t.files ?? []).map(normalizeFilePath).filter(Boolean));
448
+ for (const f of files) if (seen.has(f)) return 1;
449
+ for (const f of files) seen.add(f);
450
+ }
451
+ return decision === "THROTTLE" ? 2 : tasks.length;
452
+ }
440
453
  var WATCHDOG_POLL_MS = Math.max(1e3, Number(process.env.UC_WATCHDOG_POLL_MS ?? 3e3) || 3e3);
441
454
  var WALL_TIMEOUT_MS = Math.max(1, Number(process.env.UC_WALL_TIMEOUT_MIN ?? 30) || 30) * 60 * 1e3;
442
455
  var DEFAULT_MAX_QUESTIONS = Math.max(1, Math.round(Number(process.env.UC_MAX_QUESTIONS ?? 7)) || 7);
@@ -913,8 +926,49 @@ function clearSubSession(sessionID, taskId) {
913
926
  t.subStep = void 0;
914
927
  t.lastActivity = void 0;
915
928
  t.subElapsed = void 0;
929
+ t.lastPollTs = void 0;
916
930
  });
917
931
  }
932
+ function analyzeInjection(sessionID) {
933
+ try {
934
+ const h = readHarness(sessionID);
935
+ if (!h) return { total: 0, passed: 0, failed: 0, withInjection: { total: 0, passRate: 0 }, withoutInjection: { total: 0, passRate: 0 }, byCategory: {} };
936
+ const tasks = h.tasks.filter((t) => t.score === "PASS" || t.score === "FAIL");
937
+ let withInj = 0, withInjPass = 0, withoutInj = 0, withoutInjPass = 0;
938
+ const cats = {};
939
+ for (const t of tasks) {
940
+ const inj = t.injected;
941
+ const hasAny = inj && (inj.rules > 0 || inj.implNotes > 0 || inj.domainNodes > 0 || inj.hasScanFindings);
942
+ if (hasAny) {
943
+ withInj++;
944
+ if (t.score === "PASS") withInjPass++;
945
+ } else {
946
+ withoutInj++;
947
+ if (t.score === "PASS") withoutInjPass++;
948
+ }
949
+ if (inj) {
950
+ for (const k of ["rules", "implNotes", "domainNodes", "hasScanFindings"]) {
951
+ const v = inj[k];
952
+ if (typeof v === "number" && v > 0 || v === true) {
953
+ if (!cats[k]) cats[k] = { total: 0, passed: 0 };
954
+ cats[k].total++;
955
+ if (t.score === "PASS") cats[k].passed++;
956
+ }
957
+ }
958
+ }
959
+ }
960
+ return {
961
+ total: tasks.length,
962
+ passed: tasks.filter((t) => t.score === "PASS").length,
963
+ failed: tasks.filter((t) => t.score === "FAIL").length,
964
+ withInjection: { total: withInj, passRate: withInj > 0 ? Math.round(withInjPass * 100 / withInj) : 0 },
965
+ withoutInjection: { total: withoutInj, passRate: withoutInj > 0 ? Math.round(withoutInjPass * 100 / withoutInj) : 0 },
966
+ byCategory: cats
967
+ };
968
+ } catch {
969
+ return { total: 0, passed: 0, failed: 0, withInjection: { total: 0, passRate: 0 }, withoutInjection: { total: 0, passRate: 0 }, byCategory: {} };
970
+ }
971
+ }
918
972
  function findActiveTaskId(sessionID, status) {
919
973
  try {
920
974
  const h = readHarness(sessionID);
@@ -1101,6 +1155,17 @@ Also output:
1101
1155
  Output as JSON ONLY (no markdown fences, no prose before or after):
1102
1156
  {"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":"..."}]}`;
1103
1157
  }
1158
+ function buildGradePrompt(userPrompt) {
1159
+ return `[usage-coach] Verification protocol \u2014 obey ALL of these:
1160
+ 1. PASS requires evidence cited as file:line references (e.g., src/index.ts:42). No citations -> FAIL.
1161
+ 2. If the project has tests, run them and report the command and the result. If you cannot run them, state why.
1162
+ 3. Do not judge only by what is visible \u2014 actively check for missing edge cases and unhandled errors (WYSIATI).
1163
+ 4. OUTPUT CONTRACT: your FIRST line must be exactly "PASS" or "FAIL" with nothing else on it; all evidence and reasoning go on the following lines.
1164
+
1165
+ ---
1166
+
1167
+ ${userPrompt}`;
1168
+ }
1104
1169
  function parseGapAnalysis(raw, profile, domainHits) {
1105
1170
  const scannedAt = (/* @__PURE__ */ new Date()).toISOString();
1106
1171
  const base = {
@@ -1393,7 +1458,8 @@ async function runModel(client, model, prompt, directory, track, maxSteps = DEFA
1393
1458
  subSessionId: id,
1394
1459
  subStep: step,
1395
1460
  lastActivity: lastTs,
1396
- subElapsed: elapsed2
1461
+ subElapsed: elapsed2,
1462
+ lastPollTs: Date.now()
1397
1463
  });
1398
1464
  }
1399
1465
  } catch (e) {
@@ -2296,12 +2362,16 @@ ${rules}
2296
2362
  ` : "";
2297
2363
  let keywords = [];
2298
2364
  let domainEmpty = true;
2365
+ let domainNodeCount = 0;
2366
+ let domainEdgeCount = 0;
2299
2367
  try {
2300
2368
  keywords = extractKeywords(args.prompt);
2301
2369
  if (keywords.length) {
2302
2370
  const { nodes, edges } = queryDomain(keywords);
2303
2371
  if (nodes && nodes.length || edges && edges.length) {
2304
2372
  domainEmpty = false;
2373
+ domainNodeCount = nodes.length;
2374
+ domainEdgeCount = edges.length;
2305
2375
  prefix = `Known facts from domain DB: ${JSON.stringify({ nodes, edges })}. Use these if relevant.
2306
2376
 
2307
2377
  ---
@@ -2315,9 +2385,11 @@ ${rules}
2315
2385
  } catch (e) {
2316
2386
  log(`generate domain query err: ${String(e)}`);
2317
2387
  }
2388
+ let notesLines = 0;
2318
2389
  try {
2319
2390
  const priorNotes = keywords.length ? readImplNotesByGraph(keywords, 5) : readImplNotes(5);
2320
2391
  if (priorNotes) {
2392
+ notesLines = priorNotes.split("\n").filter(Boolean).length;
2321
2393
  prefix = `Notes from previous runs (context for this task):
2322
2394
  ${priorNotes}
2323
2395
 
@@ -2347,6 +2419,22 @@ ${gate.summary}
2347
2419
  }
2348
2420
  const genTaskId = findActiveTaskId(ctx.sessionID, "generating");
2349
2421
  const maxSteps = resolveMaxSteps(cfg, args.max_steps);
2422
+ const injected = {
2423
+ rules: rules ? rules.split("\n").filter(Boolean).length : 0,
2424
+ implNotes: notesLines,
2425
+ domainNodes: domainNodeCount,
2426
+ domainEdges: domainEdgeCount,
2427
+ hasScanFindings: !!gate.summary
2428
+ };
2429
+ if (genTaskId) {
2430
+ try {
2431
+ mutateHarness(ctx.sessionID, (h) => {
2432
+ const t = h.tasks.find((x) => x.id === genTaskId);
2433
+ if (t) t.injected = injected;
2434
+ });
2435
+ } catch {
2436
+ }
2437
+ }
2350
2438
  const out = await runModel(
2351
2439
  input.client,
2352
2440
  model,
@@ -2384,8 +2472,12 @@ ${gate.summary}
2384
2472
  }
2385
2473
  }),
2386
2474
  generate_batch: tool({
2387
- 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). Resilient: failed tasks are retried sequentially (once); if retry also fails, re-run them individually with generate().",
2388
- 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).") },
2475
+ 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). Resilient: failed tasks are retried sequentially (once); if retry also fails, re-run them individually with generate(). Each task may declare files it will modify via `files: string[]` \u2014 overlapping files across tasks force sequential execution (limit=1).",
2476
+ args: { tasks: tool.schema.array(tool.schema.object({
2477
+ id: tool.schema.number(),
2478
+ prompt: tool.schema.string(),
2479
+ files: tool.schema.array(tool.schema.string()).optional().describe("Files this task will MODIFY. Required when tasks might touch the same files \u2014 overlapping files force sequential execution.")
2480
+ })), max_steps: tool.schema.number().optional().describe("Maximum sub-session steps per task before timeout (default 30).") },
2389
2481
  async execute(args, ctx) {
2390
2482
  const cfg = readHarnessCfg(ctx.directory);
2391
2483
  if (!cfg.generator) {
@@ -2404,7 +2496,8 @@ ${gate.summary}
2404
2496
  if (decision === "STOP") return 'ERROR: quota STOP \u2014 halt the harness loop now. Call task_update(current, "halted_quota") and stop.';
2405
2497
  const throttle = decision === "THROTTLE" && cfg.lighterModel;
2406
2498
  const model = throttle ? cfg.lighterModel : cfg.generator;
2407
- const limit = decision === "THROTTLE" ? 2 : args.tasks.length;
2499
+ const limit = resolveBatchLimit(args.tasks, decision);
2500
+ if (limit === 1 && args.tasks.length > 1) log(`generate_batch: file overlap detected, forcing sequential (limit=1)`);
2408
2501
  const rules = readRules();
2409
2502
  const priorNotes = readImplNotes(5);
2410
2503
  const maxSteps = resolveMaxSteps(cfg, args.max_steps);
@@ -2473,7 +2566,7 @@ ${priorNotes}
2473
2566
  } else {
2474
2567
  const err = String(s.reason ?? "unknown rejection");
2475
2568
  log(`generate_batch task ${t.id} REJECTED: ${err}`);
2476
- failed.push({ id: t.id, prompt: t.prompt, error: err });
2569
+ failed.push({ id: t.id, prompt: t.prompt, files: t.files, error: err });
2477
2570
  }
2478
2571
  }
2479
2572
  }
@@ -2509,7 +2602,7 @@ ${priorNotes}
2509
2602
  const out = await runModel(
2510
2603
  input.client,
2511
2604
  model,
2512
- args.prompt,
2605
+ buildGradePrompt(args.prompt),
2513
2606
  ctx.directory,
2514
2607
  gradeTaskId ? { sessionID: ctx.sessionID, taskId: gradeTaskId } : void 0
2515
2608
  );
@@ -2559,7 +2652,7 @@ The next generate call will automatically include the new rule.`;
2559
2652
  const out = await runModel(
2560
2653
  input.client,
2561
2654
  model,
2562
- t.prompt,
2655
+ buildGradePrompt(t.prompt),
2563
2656
  ctx.directory,
2564
2657
  gradeTaskId ? { sessionID: ctx.sessionID, taskId: gradeTaskId } : void 0
2565
2658
  );
@@ -2808,6 +2901,34 @@ Note: Changes take effect immediately for new generate/grade calls.`,
2808
2901
  ` A running harness will pick up the new config on the next tool call.`
2809
2902
  ].join("\n");
2810
2903
  }
2904
+ }),
2905
+ coach_analyze: tool({
2906
+ description: "Analyze knowledge injection effectiveness \u2014 compares grade PASS rates for tasks WITH injected knowledge (rules, impl-notes, domain DB, scan findings) vs WITHOUT. Run after 20+ harness tasks to see if accumulated knowledge actually helps.",
2907
+ args: {},
2908
+ async execute(_args, ctx) {
2909
+ const a = analyzeInjection(ctx.sessionID);
2910
+ const lines = [
2911
+ "\u2550\u2550\u2550 Knowledge Injection Effectiveness \u2550\u2550\u2550",
2912
+ `Total graded tasks: ${a.total} (PASS: ${a.passed}, FAIL: ${a.failed})`,
2913
+ "",
2914
+ `With injection: ${a.withInjection.total} tasks \u2192 ${a.withInjection.passRate}% PASS`,
2915
+ `Without injection: ${a.withoutInjection.total} tasks \u2192 ${a.withoutInjection.passRate}% PASS`,
2916
+ "",
2917
+ "By category (PASS rate when injected):"
2918
+ ];
2919
+ for (const [cat, d] of Object.entries(a.byCategory)) {
2920
+ const rate = d.total > 0 ? Math.round(d.passed * 100 / d.total) : 0;
2921
+ lines.push(` ${cat}: ${d.passed}/${d.total} = ${rate}%`);
2922
+ }
2923
+ const delta = a.withInjection.passRate - a.withoutInjection.passRate;
2924
+ if (a.withInjection.total > 0 && a.withoutInjection.total > 0) {
2925
+ lines.push("");
2926
+ lines.push(delta > 0 ? `\u2192 Injection helps: +${delta}% PASS rate with knowledge injection.` : delta < 0 ? `\u2192 Injection HURTS: ${delta}% PASS rate. Injected knowledge may be noise.` : `\u2192 No measurable difference (${delta}%).`);
2927
+ } else {
2928
+ lines.push("", "\u2192 Need more data (both groups need 5+ tasks).");
2929
+ }
2930
+ return lines.join("\n");
2931
+ }
2811
2932
  })
2812
2933
  }
2813
2934
  };
package/dist/tui.js CHANGED
@@ -76,6 +76,8 @@ function computeTaskDisplay(t, isStale, now = Date.now()) {
76
76
  const subStepStr = hasSub && t.subStep !== void 0 && t.subStep > 0 ? ` step:${t.subStep}` : "";
77
77
  const subEl = hasSub && t.subElapsed !== void 0 ? ` ${t.subElapsed}s` : "";
78
78
  const subWarn = hasSub && (t.subElapsed ?? 0) > 300;
79
+ const pollAge = t.lastPollTs ? Math.round((now - t.lastPollTs) / 1e3) : -1;
80
+ const hb = hasSub && pollAge >= 0 ? pollAge <= 10 ? " \u25CF" : pollAge <= 30 ? " \u25D0" : " \u25CB" : "";
79
81
  const elapsed = t.startedAt ? Math.max(0, Math.round((now - new Date(t.startedAt).getTime()) / 1e3)) : 0;
80
82
  const taskEl = t.status === "completed" || t.status === "failed" ? "" : elapsed > 0 ? ` ${elapsed}s` : "";
81
83
  const displayEl = hasSub ? subEl : taskEl;
@@ -89,7 +91,8 @@ function computeTaskDisplay(t, isStale, now = Date.now()) {
89
91
  stepStr: subStepStr,
90
92
  elapsedStr: displayEl,
91
93
  hasSub,
92
- subWarn
94
+ subWarn,
95
+ heartbeat: hb
93
96
  };
94
97
  }
95
98
  function decisionThemeKey(decision) {
@@ -467,6 +470,7 @@ function initializeTui(api, disposeRoot) {
467
470
  _$insert(_el$56, () => td.revSuffix, _el$59);
468
471
  _$insert(_el$56, () => td.stepStr, _el$59);
469
472
  _$insert(_el$56, () => td.elapsedStr, _el$59);
473
+ _$insert(_el$56, () => td.heartbeat, _el$59);
470
474
  _$insert(_el$56, () => t.title, null);
471
475
  _$effect((_$p) => _$setProp(_el$56, "style", st(td.themeKey), _$p));
472
476
  return _el$56;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-usage-coach",
3
- "version": "0.13.6",
3
+ "version": "0.14.0",
4
4
  "description": "opencode closed-loop usage coach — quota SENSE -> coaching DECIDE -> loop ACT + TUI integration",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",