opencode-usage-coach 0.3.0 → 0.3.2

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/README.md CHANGED
@@ -52,6 +52,39 @@ cp dist/index.js ~/.config/opencode/plugins/opencode-usage-coach.js
52
52
  # ~/.config/opencode/tui.json: { "plugin": ["/abs/path/dist/tui.js"] }
53
53
  ```
54
54
 
55
+ ## Prompting the harness
56
+
57
+ How to trigger and use the harness loop for substantive work.
58
+
59
+ ### Triggering
60
+ With the harness agent mode active (`agents/usage-coach-harness.md` installed), ask for substantive work — explicitly or just describe a multi-step task:
61
+ - `"run this through the harness: write CONTRIBUTING.md from git log"`
62
+ - `"harness: add TypeScript strict mode across the repo"`
63
+ - Or just describe the work; the agent triages and enters the loop.
64
+
65
+ Trivial requests (a one-line fix, a direct answer) are done directly — the loop only runs when generate→grade adds value.
66
+
67
+ ### Independent vs dependent tasks
68
+ The harness picks the loop path based on task dependency:
69
+ - **Independent** (task B doesn't need A's output) → `generate_batch` runs all tasks in **parallel** (faster). Example: `"CONTRIBUTING.md, PR template, issue template"` — three separate docs.
70
+ - **Dependent** (B needs A) → **sequential** `generate` calls. Example: `"1) schema, 2) migration, 3) API"` — each needs the prior.
71
+
72
+ ### Deterministic NEXT directives
73
+ Each tool appends a `[usage-coach NEXT]` line to its return value, telling the agent exactly what to call next:
74
+ - `generate` → NEXT: grade the work
75
+ - `grade` PASS → NEXT: mark completed, proceed
76
+ - `grade` FAIL → NEXT: revise (up to 2x) or mark failed
77
+
78
+ The agent follows NEXT — you just describe the work, the loop runs itself.
79
+
80
+ ### Quota-aware (automatic)
81
+ You don't manage quota — the tools adapt:
82
+ - **GO** + headroom → strong generator, full parallel
83
+ - **THROTTLE** → auto-switch to `lighterModel`, concurrency capped at 2
84
+ - **STOP** → loop halts
85
+
86
+ Set `lighterModel` in `harness.config.json` to enable THROTTLE switching.
87
+
55
88
  ## Config
56
89
 
57
90
  This plugin has **four config surfaces**. Only the first two are required to run.
@@ -36,7 +36,12 @@ The user's message is the task source. If it has multiple distinct parts, decomp
36
36
 
37
37
  **Multi-model, 1 terminal:** the work runs via the plugin tools `generate` and `grade`, which use the configured models (generator/grader from harness.config.json) on the same server — so you get e.g. a paid generator + a free grader without a second terminal. Do NOT use the `task` tool for harness work; use `generate`/`grade`.
38
38
 
39
- **Parallel independent tasks:** if the decomposed tasks are INDEPENDENT, prefer `generate_batch` (one call, all results at once) over multiple sequential `generate` calls. Cap by quota coaching (big-OK 3-4; throttle 1-2; STOP → none). DEPENDENT tasks (B needs A) sequential `generate` calls.
39
+ **Quota-aware loop strategy (the tools handle this):** the quota coaching injected into your system prompt drives loop strategy. You do NOT pick the model — `generate`/`generate_batch` auto-switch to a lighter model on THROTTLE (if `lighterModel` is configured) and refuse on STOP. You pick the **task size and parallelism**:
40
+ - **GO + headroom** → big tasks OK, use `generate_batch` for independent tasks (full parallel).
41
+ - **THROTTLE** → small tasks only, sequential or limited parallelism. Tools cap concurrency at 2 and use a lighter model automatically.
42
+ - **STOP** → finish the in-progress task, then halt. `generate_batch` returns an error on STOP — call `task_update(current, "halted_quota")` and stop.
43
+
44
+ DEPENDENT tasks (B needs A) → always sequential `generate` calls, regardless of quota.
40
45
 
41
46
  1. Call `harness_start(name, N)` to register the run on the panel.
42
47
  2. For each task i (1..N):
@@ -53,6 +58,7 @@ The user's message is the task source. If it has multiple distinct parts, decomp
53
58
  3. When all tasks are done → `harness_done()`.
54
59
 
55
60
  ## Rules
61
+ - **Follow the [usage-coach NEXT] directive each tool returns.** `harness_start`, `generate`, and `grade` all append a `NEXT` line telling you exactly what to call next. This makes the loop deterministic — do not improvise the sequence, follow `NEXT`.
56
62
  - In the loop, do NOT do the work yourself — call `generate`/`grade` (they run the configured models). You orchestrate. (Outside the loop, for trivial requests, act directly.)
57
63
  - Call `task_update` on every state transition — the sidebar panel reads it for live visibility.
58
64
  - Grading criteria come from the user's request, or sensible defaults; ask the user only if it is truly ambiguous and grading matters.
package/dist/index.js CHANGED
@@ -340,7 +340,27 @@ async function UsageCoachPlugin(input) {
340
340
  args: { name: tool.schema.string(), total: tool.schema.number() },
341
341
  async execute(args, ctx) {
342
342
  writeHarness(ctx.sessionID, { name: args.name, total: args.total, current: 0, tasks: [], usage: {}, active: true, startedAt: (/* @__PURE__ */ new Date()).toISOString() });
343
- return `Harness '${args.name}' started (${args.total} tasks). Now report each task's status via task_update and run the loop.`;
343
+ return `Harness '${args.name}' started (${args.total} tasks).
344
+
345
+ DETERMINISTIC LOOP \u2014 first classify the tasks:
346
+ INDEPENDENT = task B does NOT need task A's output -> use PATH A (parallel, faster)
347
+ DEPENDENT = task B needs task A's output -> use PATH B (sequential)
348
+
349
+ PATH A \u2014 INDEPENDENT (parallel via generate_batch):
350
+ 1. task_update(1..${args.total}, title, "generating")
351
+ 2. generate_batch({tasks: [{id:1, prompt:"Task: <title1>. Perform it."}, ...]}) -> all results + NEXT
352
+ 3. for each i: task_update(i, title, "grading") + grade({prompt:"Evaluate... PASS/FAIL first line. Task: <title>"}) -> verdict + NEXT
353
+ 4. for each i: PASS -> task_update(i, title, "completed", "PASS"); FAIL -> revise (up to 2x) or task_update(i, title, "failed", "FAIL")
354
+
355
+ PATH B \u2014 DEPENDENT (sequential):
356
+ for i in 1..${args.total}:
357
+ 1. task_update(i, title, "generating")
358
+ 2. generate({prompt:"Task: <title>. Perform it."}) -> work + NEXT
359
+ 3. task_update(i, title, "grading")
360
+ 4. grade(...) -> verdict + NEXT
361
+ 5. PASS -> task_update(i, title, "completed", "PASS"); FAIL -> revise (up to 2x) or failed
362
+
363
+ Then: harness_done(). Follow the [usage-coach NEXT] directive each tool returns. Do NOT improvise the sequence.`;
344
364
  }
345
365
  }),
346
366
  task_update: tool({
@@ -375,50 +395,75 @@ async function UsageCoachPlugin(input) {
375
395
  return "Harness complete.";
376
396
  }
377
397
  }),
378
- // Per-role model execution (config-driven, same server, no deadlock).
398
+ // Per-role model execution (config-driven, quota-aware, same server, no deadlock).
399
+ // P1: quota decision drives model selection + concurrency.
379
400
  generate: tool({
380
- description: "Run the GENERATOR model on a prompt. The generator can use tools (write/edit files). Returns the model's text response.",
401
+ description: "Run the GENERATOR model on a prompt. Quota-aware: on THROTTLE, auto-switches to lighterModel if configured. Returns the model's text response.",
381
402
  args: { prompt: tool.schema.string() },
382
403
  async execute(args, ctx) {
383
404
  const cfg = readHarnessCfg(ctx.directory);
384
405
  if (!cfg.generator) return 'ERROR: no generator model configured. Set "generator" in harness.config.json (see harness.config.example.json).';
385
- const out = await runModel(input.client, cfg.generator, args.prompt, ctx.directory);
386
- return out;
406
+ let decision = "GO";
407
+ try {
408
+ decision = current().decision;
409
+ } catch {
410
+ }
411
+ const throttle = decision === "THROTTLE" && cfg.lighterModel;
412
+ const model = throttle ? cfg.lighterModel : cfg.generator;
413
+ const out = await runModel(input.client, model, args.prompt, ctx.directory);
414
+ return out + (throttle ? `
415
+ [usage-coach] quota THROTTLE \u2014 used lighter model ${cfg.lighterModel}` : "") + `
416
+ [usage-coach NEXT] call task_update(i, title, "grading"), then grade to evaluate this work.`;
387
417
  }
388
418
  }),
389
419
  generate_batch: tool({
390
- description: "Run the GENERATOR model on MULTIPLE tasks in PARALLEL. Pass an array of {id, prompt}. Returns all results at once. Use for INDEPENDENT tasks (much faster than sequential generate calls).",
420
+ 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.",
391
421
  args: { tasks: tool.schema.array(tool.schema.object({ id: tool.schema.number(), prompt: tool.schema.string() })) },
392
422
  async execute(args, ctx) {
393
423
  const cfg = readHarnessCfg(ctx.directory);
394
424
  if (!cfg.generator) return 'ERROR: no generator model configured. Set "generator" in harness.config.json (see harness.config.example.json).';
395
- const results = await Promise.all(args.tasks.map(async (t) => {
396
- const out = await runModel(input.client, cfg.generator, t.prompt, ctx.directory);
397
- return `[task ${t.id}] ${out}`;
398
- }));
399
- return results.join("\n\n");
425
+ let decision = "GO";
426
+ try {
427
+ decision = current().decision;
428
+ } catch {
429
+ }
430
+ if (decision === "STOP") return 'ERROR: quota STOP \u2014 halt the harness loop now. Call task_update(current, "halted_quota") and stop.';
431
+ const throttle = decision === "THROTTLE" && cfg.lighterModel;
432
+ const model = throttle ? cfg.lighterModel : cfg.generator;
433
+ const limit = decision === "THROTTLE" ? 2 : args.tasks.length;
434
+ const results = [];
435
+ for (let i = 0; i < args.tasks.length; i += limit) {
436
+ const batch = args.tasks.slice(i, i + limit);
437
+ const out = await Promise.all(batch.map(async (t) => {
438
+ const r = await runModel(input.client, model, t.prompt, ctx.directory);
439
+ return `[task ${t.id}] ${r}`;
440
+ }));
441
+ results.push(...out);
442
+ }
443
+ const note = throttle ? `
444
+ [usage-coach] quota THROTTLE \u2014 lighter model ${cfg.lighterModel}, concurrency capped at ${limit}` : "";
445
+ return results.join("\n\n") + note;
400
446
  }
401
447
  }),
402
448
  grade: tool({
403
- description: "Run the GRADER model on a prompt. Returns PASS/FAIL on the first line. Falls back to generator if grader quota is out.",
449
+ description: "Run the GRADER model on a prompt. Returns PASS/FAIL on the first line + a [usage-coach NEXT] directive. Falls back to generator if grader quota is out.",
404
450
  args: { prompt: tool.schema.string() },
405
451
  async execute(args, ctx) {
406
452
  const cfg = readHarnessCfg(ctx.directory);
407
453
  const model = cfg.grader ?? cfg.generator;
408
- if (!model) return 'FAIL\n(ERROR: no grader/generator model configured. Set "grader" and "generator" in harness.config.json.)';
454
+ if (!model) return "FAIL\n(ERROR: no grader/generator model configured.)\n[usage-coach NEXT] configure grader in harness.config.json, then retry grade.";
409
455
  const out = await runModel(input.client, model, args.prompt, ctx.directory);
410
- if (out.startsWith("ERROR:")) return `FAIL
411
- (${out})`;
412
- const firstLine = out.split("\n").find((l) => l.trim()) ?? "";
413
- const f = firstLine.trim();
414
- const isPass = /^pass\b/i.test(f);
415
- const isFail = /^fail\b/i.test(f);
416
- if (!isPass && !isFail) {
417
- return `FAIL
418
- (grader did not output PASS/FAIL on the first line; defaulting to FAIL to trigger a revise)
419
- ${out}`;
456
+ let verdict = "FAIL";
457
+ if (!out.startsWith("ERROR:")) {
458
+ const f = (out.split("\n").find((l) => l.trim()) ?? "").trim();
459
+ if (/^pass\b/i.test(f)) verdict = "PASS";
460
+ else if (/^fail\b/i.test(f)) verdict = "FAIL";
461
+ else verdict = "FAIL";
420
462
  }
421
- return out;
463
+ const next = verdict === "PASS" ? `
464
+ [usage-coach NEXT] PASS -> call task_update(i, title, "completed", "PASS"), then proceed to next task (or harness_done if last).` : `
465
+ [usage-coach NEXT] FAIL -> if revisions < 2: task_update(i, title, "revising", revisions+1) + generate({prompt: "Apply feedback:\\n{grade result}\\nTask: {title}"}); else: task_update(i, title, "failed", "FAIL") -> next task.`;
466
+ return out + "\n" + next;
422
467
  }
423
468
  })
424
469
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-usage-coach",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "opencode closed-loop usage coach \u2014 quota SENSE -> coaching DECIDE -> loop ACT + TUI integration",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",