opencode-usage-coach 0.3.4 → 0.4.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/README.md CHANGED
@@ -28,6 +28,16 @@ the loop** — and ships a harness agent mode.
28
28
  explicitly ask "run this through the harness" or "use the harness for this". The harness
29
29
  tools are only available when the harness agent mode is active (see Install).
30
30
 
31
+ **Learning from failures (learning loop):**
32
+ - When `grade` returns FAIL, the harness enters a learning cycle: `record_failure` → `investigate` (root-cause analysis) → `verify_diagnosis` → `generalize` (extract a reusable rule).
33
+ - Rules accumulate in `rules.md` → the next `generate` call automatically includes them → the harness avoids repeating the same mistake.
34
+ - Tools: `record_failure`, `investigate`, `verify_diagnosis`, `generalize`.
35
+
36
+ **Domain knowledge base:**
37
+ - `investigate` and `generate` query a local domain DB before running — known facts are injected into the prompt ("Known facts from domain DB: ...").
38
+ - Unknown domains are investigated (webfetch/docs) and stored as graph nodes/edges → accumulates over time → evidence-based judgments instead of speculation.
39
+ - Storage: `nodes.ndjson` + `edges.ndjson` under the project state dir.
40
+
31
41
  ## Requirements
32
42
  - opencode (tested on 1.17.13) with a quota-metered provider configured.
33
43
  - `codexbar` CLI with your provider key wired (e.g. `codexbar config set-api-key --provider zai --stdin`).
@@ -142,6 +152,36 @@ Place in the **work directory**. Each role runs on its model, so per-model quota
142
152
  | `UC_PROVIDER` | (config `provider`) | codexbar provider for the guardian |
143
153
  | `UC_TTL_MS` | 60000 | quota cache TTL (ms) |
144
154
  | `UC_DEBUG` | 0 | set to `1` for a diagnostic log at `~/.cache/opencode-usage-coach/coach.log` |
155
+ | `UC_HARNESS_AGENT` | `Usage-Coach-Harness` | comma-separated agent modes allowed to use harness tools + receive quota coaching (case-insensitive; must match the agent id, e.g. `usage-coach-harness` from `agents/usage-coach-harness.md`) |
156
+
157
+ ## Agent-mode scoping
158
+
159
+ Harness tools (`generate`, `grade`, `harness_start`, …) and quota coaching are **scoped to
160
+ the `usage-coach-harness` agent mode**. Other modes (build, general, your custom agents) stay
161
+ completely clean — no harness tools in their tool list, no quota coaching injected into their
162
+ system prompt.
163
+
164
+ This is enforced on two independent layers (defense in depth):
165
+
166
+ 1. **Agent definition** (`agents/usage-coach-harness.md`) — its `permission` allowlist names the
167
+ harness tools, so they only appear in this mode. Other agents' permission lists don't name
168
+ them, so they're hidden from those modes automatically (this is the standard opencode
169
+ mechanism — tool visibility is the agent definition's responsibility).
170
+ 2. **Plugin runtime gate** (`tool.execute.before`) — even if a harness tool were somehow
171
+ invoked, the plugin resolves the current session's agent (`client.session.get` → `info.agent`,
172
+ 60s-cached) and throws unless it matches `UC_HARNESS_AGENT` (default `Usage-Coach-Harness`,
173
+ case-insensitive). The quota system-prompt injection is gated the same way.
174
+
175
+ **To use the harness tools**, switch to the `usage-coach-harness` agent mode.
176
+
177
+ **To allow additional modes**, set `UC_HARNESS_AGENT` to a comma-separated list:
178
+ ```bash
179
+ export UC_HARNESS_AGENT="usage-coach-harness,my-other-harness"
180
+ ```
181
+
182
+ > Why not the v2 plugin API? v2 has no `tool` registration domain, so a plugin that provides
183
+ > custom tools (like this one) cannot be fully rewritten in v2. Agent `permission` allowlists +
184
+ > the v1 runtime gate is the structurally correct way to scope tool visibility.
145
185
 
146
186
  ## Architecture
147
187
  - **Server module** (`src/index.ts`) — SENSE/DECIDE/ACT + custom harness tools. Loaded via `opencode.json`.
@@ -203,9 +243,25 @@ Recurring issues and fixes — mostly learned the hard way during development.
203
243
  - Harness completion sets `active:false` → hidden from the TUI.
204
244
  - Override the state path with `UC_STATE_DIR` (forces global state).
205
245
 
246
+ **Key gotcha — opencode TUI `ctx` does NOT carry `session_id`.**
247
+ The slot context passed to `panel(ctx)` contains only `{ theme }`. There is no `session_id`/`sessionID` field. The current session ID lives in **`api.route.current.params.sessionID`** instead — the panel reads it from there. If you ever see harnesses from other sessions leaking in, the cause is almost certainly that `sid` resolved to empty (→ fallback broad scan).
248
+
249
+ **Debugging session isolation** (if it breaks again):
250
+ 1. Check `~/.cache/opencode-usage-coach/projects/<hash>/tui-debug.log` — is `panel` being called? What `routeSid` value?
251
+ 2. `api.route.current.params.sessionID` — populated? (Empty → panel falls back to scanning all sessions.)
252
+ 3. New TUI code loaded? `tui-loaded.txt` (MARKER) should show `loaded-v2 ...`. If it still says `loaded`, the new dist isn't being picked up.
253
+ 4. `appendFileSync` imported in `src/tui.tsx`? If missing, **all TUI debug logging silently fails** (ReferenceError swallowed by try/catch) — this wasted a lot of debugging time once.
254
+
255
+ **Past issue (fixed v0.3.4):** panel read `ctx.session_id` which was always `undefined` → fallback scanned every session → another session's active harness leaked in. Fixed by reading `api.route.current.params.sessionID`.
256
+
206
257
  ## Status
207
- - ✅ Quota guardian + TUI panel (per-provider coach view, colors, collapsible Alt+H)
208
- - ✅ Harness: agent mode (triage) with generate/grade model-specific tools (1 terminal, multi-model)
258
+ - ✅ Quota guardian + TUI panel (per-provider coach view, 5h/1w gauges, collapsible Alt+H)
259
+ - ✅ Harness: agent mode with generate/grade tools (multi-model, 1 terminal)
260
+ - ✅ Deterministic loop via NEXT directives (parallel PATH A / sequential PATH B)
261
+ - ✅ Quota-aware tools (GO/THROTTLE/STOP drive model selection + concurrency)
262
+ - ✅ Learning loop (record_failure → investigate → verify_diagnosis → generalize → rules.md)
263
+ - ✅ Domain knowledge base (graph store, investigate/generate injection)
264
+ - ✅ Session isolation (api.route, per-session harness state)
209
265
  - ✅ npm packaging (`opencode plugin install opencode-usage-coach`)
210
266
 
211
267
  License: MIT.
@@ -12,7 +12,12 @@ permission:
12
12
  grep: allow
13
13
  task: allow
14
14
  generate: allow
15
+ generate_batch: allow
15
16
  grade: allow
17
+ investigate: allow
18
+ verify_diagnosis: allow
19
+ generalize: allow
20
+ record_failure: allow
16
21
  harness_start: allow
17
22
  task_update: allow
18
23
  harness_done: allow
package/dist/index.js CHANGED
@@ -1,66 +1,142 @@
1
1
  // src/index.ts
2
- import { mkdirSync, writeFileSync, appendFileSync, readFileSync, existsSync } from "fs";
2
+ import { mkdirSync as mkdirSync2, writeFileSync, appendFileSync as appendFileSync2, readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
3
3
  import { spawn } from "child_process";
4
4
  import { createHash } from "crypto";
5
5
  import { homedir } from "os";
6
- import { join, resolve, dirname } from "path";
6
+ import { join as join2, resolve, dirname } from "path";
7
7
  import { tool } from "@opencode-ai/plugin";
8
+
9
+ // src/domain.ts
10
+ import { mkdirSync, appendFileSync, readFileSync, existsSync } from "fs";
11
+ import { join } from "path";
12
+ var BASE_DIR = "";
13
+ function initDomain(stateDir) {
14
+ BASE_DIR = stateDir;
15
+ }
16
+ var nodesFile = () => join(BASE_DIR, "nodes.ndjson");
17
+ var edgesFile = () => join(BASE_DIR, "edges.ndjson");
18
+ function readNdjson(path) {
19
+ try {
20
+ if (!existsSync(path)) return [];
21
+ return readFileSync(path, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
22
+ } catch {
23
+ return [];
24
+ }
25
+ }
26
+ function readNodes() {
27
+ return readNdjson(nodesFile());
28
+ }
29
+ function readEdges() {
30
+ return readNdjson(edgesFile());
31
+ }
32
+ function uid(prefix) {
33
+ return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
34
+ }
35
+ function addDomainNode(node) {
36
+ const full = { ...node, id: uid("node"), ts: (/* @__PURE__ */ new Date()).toISOString() };
37
+ try {
38
+ mkdirSync(BASE_DIR, { recursive: true });
39
+ appendFileSync(nodesFile(), JSON.stringify(full) + "\n");
40
+ } catch {
41
+ }
42
+ return full.id;
43
+ }
44
+ function queryDomain(keywords) {
45
+ const lc = keywords.map((k) => k.toLowerCase());
46
+ const nodes = readNodes();
47
+ const matched = nodes.filter((n) => {
48
+ const hay = (n.name + " " + JSON.stringify(n.props)).toLowerCase();
49
+ return lc.some((k) => k && hay.includes(k));
50
+ });
51
+ const ids = new Set(matched.map((n) => n.id));
52
+ const edges = readEdges().filter((e) => ids.has(e.from) || ids.has(e.to));
53
+ return { nodes: matched, edges };
54
+ }
55
+ function saveInvestigationResult(keywords, result, source) {
56
+ try {
57
+ return addDomainNode({
58
+ type: "fact",
59
+ name: keywords.join(" "),
60
+ props: { result },
61
+ source: source || "investigation",
62
+ confidence: 0.7
63
+ });
64
+ } catch {
65
+ return "";
66
+ }
67
+ }
68
+
69
+ // src/index.ts
8
70
  var PLUGIN_NAME = "opencode-usage-coach";
9
- var DEBUG = process.env.UC_DEBUG === "1";
10
71
  var TTL_MS = Number(process.env.UC_TTL_MS ?? 6e4);
11
- var STATE_DIR = join(homedir(), ".cache", "opencode-usage-coach");
12
- var STATE_FILE = join(STATE_DIR, "state.json");
13
- var HARNESS_FILE = join(STATE_DIR, "harness.json");
14
- var LOG_FILE = join(STATE_DIR, "coach.log");
72
+ var STATE_DIR = join2(homedir(), ".cache", "opencode-usage-coach");
73
+ var STATE_FILE = join2(STATE_DIR, "state.json");
74
+ var LOG_FILE = join2(STATE_DIR, "coach.log");
15
75
  function projectStateDir(dir) {
16
76
  const abs = resolve(dir || ".");
17
77
  const h = createHash("sha1").update(abs).digest("hex").slice(0, 12);
18
- return join(homedir(), ".cache", "opencode-usage-coach", "projects", h);
78
+ return join2(homedir(), ".cache", "opencode-usage-coach", "projects", h);
19
79
  }
20
80
  function setStateDir(dir) {
21
81
  STATE_DIR = process.env.UC_STATE_DIR ?? projectStateDir(dir);
22
- STATE_FILE = join(STATE_DIR, "state.json");
23
- HARNESS_FILE = join(STATE_DIR, "harness.json");
24
- LOG_FILE = join(STATE_DIR, "coach.log");
82
+ STATE_FILE = join2(STATE_DIR, "state.json");
83
+ LOG_FILE = join2(STATE_DIR, "coach.log");
25
84
  }
26
85
  var NOOP_HOOKS = {};
27
86
  function log(msg) {
28
87
  try {
29
- appendFileSync(LOG_FILE, `${(/* @__PURE__ */ new Date()).toISOString()} ${msg}
88
+ appendFileSync2(LOG_FILE, `${(/* @__PURE__ */ new Date()).toISOString()} ${msg}
30
89
  `);
31
90
  } catch {
32
91
  }
33
92
  }
34
93
  function writeState(c) {
35
94
  try {
36
- mkdirSync(STATE_DIR, { recursive: true });
95
+ mkdirSync2(STATE_DIR, { recursive: true });
37
96
  writeFileSync(STATE_FILE, JSON.stringify({ ...c, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }));
38
97
  } catch {
39
98
  }
40
99
  }
41
100
  function rulesFile() {
42
- return join(STATE_DIR, "rules.md");
101
+ return join2(STATE_DIR, "rules.md");
43
102
  }
44
103
  function failuresFile() {
45
- return join(STATE_DIR, "failures.ndjson");
104
+ return join2(STATE_DIR, "failures.ndjson");
46
105
  }
47
106
  function readRules() {
48
107
  try {
49
108
  const f = rulesFile();
50
- if (!existsSync(f)) return "";
51
- return readFileSync(f, "utf8").trim();
109
+ if (!existsSync2(f)) return "";
110
+ return readFileSync2(f, "utf8").trim();
52
111
  } catch {
53
112
  return "";
54
113
  }
55
114
  }
115
+ function extractKeywords(text) {
116
+ try {
117
+ const STOP = /* @__PURE__ */ new Set(["the", "and", "for", "with", "that", "this", "from", "into", "your", "you", "are", "was", "but", "not", "all", "any", "use", "task", "prompt"]);
118
+ const seen = /* @__PURE__ */ new Set();
119
+ const out = [];
120
+ for (const raw of (text ?? "").toLowerCase().split(/[^a-z0-9_]+/)) {
121
+ const t = raw.trim();
122
+ if (t.length < 3 || STOP.has(t) || seen.has(t)) continue;
123
+ seen.add(t);
124
+ out.push(t);
125
+ if (out.length >= 16) break;
126
+ }
127
+ return out;
128
+ } catch {
129
+ return [];
130
+ }
131
+ }
56
132
  function harnessFile(sessionID) {
57
- return join(STATE_DIR, sessionID || "_default", "harness.json");
133
+ return join2(STATE_DIR, sessionID || "_default", "harness.json");
58
134
  }
59
135
  function readHarness(sessionID) {
60
136
  try {
61
137
  const f = harnessFile(sessionID);
62
- if (!existsSync(f)) return null;
63
- return JSON.parse(readFileSync(f, "utf8"));
138
+ if (!existsSync2(f)) return null;
139
+ return JSON.parse(readFileSync2(f, "utf8"));
64
140
  } catch {
65
141
  return null;
66
142
  }
@@ -68,7 +144,7 @@ function readHarness(sessionID) {
68
144
  function writeHarness(sessionID, h) {
69
145
  try {
70
146
  const f = harnessFile(sessionID);
71
- mkdirSync(dirname(f), { recursive: true });
147
+ mkdirSync2(dirname(f), { recursive: true });
72
148
  h.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
73
149
  writeFileSync(f, JSON.stringify(h, null, 2));
74
150
  } catch {
@@ -77,14 +153,14 @@ function writeHarness(sessionID, h) {
77
153
  function readHarnessCfg(dir) {
78
154
  const tryRead = (p) => {
79
155
  try {
80
- if (existsSync(p)) return JSON.parse(readFileSync(p, "utf8"));
156
+ if (existsSync2(p)) return JSON.parse(readFileSync2(p, "utf8"));
81
157
  } catch {
82
158
  }
83
159
  return {};
84
160
  };
85
161
  return {
86
- ...tryRead(join(homedir(), ".config", "opencode-usage-coach", "harness.config.json")),
87
- ...tryRead(join(dir, "harness.config.json"))
162
+ ...tryRead(join2(homedir(), ".config", "opencode-usage-coach", "harness.config.json")),
163
+ ...tryRead(join2(dir, "harness.config.json"))
88
164
  };
89
165
  }
90
166
  async function runModel(client, model, prompt, directory) {
@@ -105,7 +181,12 @@ async function runModel(client, model, prompt, directory) {
105
181
  const parts = resp?.data?.parts ?? resp?.parts ?? [];
106
182
  const text = parts.filter((p) => p?.type === "text").map((p) => p?.text ?? "").join("");
107
183
  try {
108
- await client.session.remove?.({ path: { id } });
184
+ const summary = await client.session.summarize?.({ path: { id } });
185
+ log(`runModel(${model}): sub-session summary: ${JSON.stringify(summary?.data ?? summary).slice(0, 300)}`);
186
+ } catch {
187
+ }
188
+ try {
189
+ await client.session.delete?.({ path: { id } });
109
190
  } catch {
110
191
  }
111
192
  log(`runModel(${model}): done ${elapsed}s, ${text.length} chars`);
@@ -116,6 +197,7 @@ async function runModel(client, model, prompt, directory) {
116
197
  return `ERROR: runModel exception after ${elapsed}s: ${String(e)}`;
117
198
  }
118
199
  }
200
+ var HARNESS_AGENTS = (process.env.UC_HARNESS_AGENT ?? "Usage-Coach-Harness").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
119
201
  var num = (e, d) => {
120
202
  try {
121
203
  const v = Number(process.env[e]);
@@ -241,10 +323,30 @@ function coach(q, lighter) {
241
323
  if (wk >= THR_WK) return thr(`weekly ${wk}% (${wkR})`);
242
324
  return { decision: "GO", advice: `Comfortable \u2014 weekly ${wk}% \xB7 5h ${h5}% \xB7 monthly ${mo}%. proceed. 5h window ${h5R}.`, weekly: wk, monthly: mo, fiveHour: h5 };
243
325
  }
326
+ var agentCache = /* @__PURE__ */ new Map();
327
+ async function resolveAgent(client, sessionID) {
328
+ if (!sessionID) return "";
329
+ const hit = agentCache.get(sessionID);
330
+ if (hit && Date.now() - hit.ts < 6e4) return hit.agent;
331
+ try {
332
+ const s = await client.session.get({ path: { id: sessionID } });
333
+ const agent = String(s?.data?.info?.agent ?? s?.data?.agent ?? s?.info?.agent ?? "");
334
+ agentCache.set(sessionID, { agent, ts: Date.now() });
335
+ return agent;
336
+ } catch (e) {
337
+ log(`resolveAgent err: ${String(e)}`);
338
+ return "";
339
+ }
340
+ }
341
+ function isHarnessAgent(agent) {
342
+ if (!agent) return false;
343
+ return HARNESS_AGENTS.includes(agent.toLowerCase());
344
+ }
244
345
  var LOADING = { decision: "GO", advice: "quota loading\u2026", weekly: -1, monthly: -1, fiveHour: -1 };
245
346
  async function UsageCoachPlugin(input) {
246
347
  try {
247
348
  setStateDir(input.directory);
349
+ initDomain(STATE_DIR);
248
350
  const cfg0 = readHarnessCfg(input.directory);
249
351
  const PROVIDER = process.env.UC_PROVIDER ?? cfg0.provider ?? "";
250
352
  const LIGHTER = process.env.UC_LIGHTER_MODEL ?? cfg0.lighterModel ?? "a lighter model";
@@ -265,6 +367,10 @@ async function UsageCoachPlugin(input) {
265
367
  providers = await fetchProvidersCoach();
266
368
  } catch {
267
369
  }
370
+ if (providers.length > 0 && last.weekly < 0) {
371
+ const p0 = providers[0];
372
+ last = { ...last, weekly: p0.weekly, fiveHour: p0.fiveHour, monthly: p0.weekly >= 0 ? 0 : -1, advice: p0.advice, decision: p0.weekly >= STOP_WK ? "STOP" : p0.weekly >= THR_WK ? "THROTTLE" : "GO" };
373
+ }
268
374
  writeState({ ...last, providers, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
269
375
  log(`${last.decision} | weekly=${last.weekly}% 5h=${last.fiveHour}% | providers=${providers.length}`);
270
376
  } catch (e) {
@@ -295,9 +401,17 @@ async function UsageCoachPlugin(input) {
295
401
  log(`event err: ${String(e)}`);
296
402
  }
297
403
  },
298
- // ACT(1) hard gate: only intentional STOP throws. Our own bugs never block.
404
+ // ACT(1) hard gate harness tools are restricted to the configured harness
405
+ // agent mode AND gated by quota STOP. General tools (read/edit/bash/grep/task)
406
+ // are NEVER gated, in ANY mode — they don't consume model quota.
299
407
  "tool.execute.before": async (_input) => {
300
- let decision = "GO";
408
+ const harnessTools = ["generate", "generate_batch", "grade", "investigate", "verify_diagnosis", "generalize", "harness_start", "task_update", "harness_done", "record_failure"];
409
+ if (!harnessTools.includes(_input.tool)) return;
410
+ const agent = await resolveAgent(input.client, _input.sessionID);
411
+ if (!isHarnessAgent(agent)) {
412
+ 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.`);
413
+ }
414
+ let decision;
301
415
  try {
302
416
  decision = current().decision;
303
417
  } catch {
@@ -307,9 +421,14 @@ async function UsageCoachPlugin(input) {
307
421
  throw new Error(`[${PLUGIN_NAME}] blocked: quota limit exceeded. ${current().advice}`);
308
422
  }
309
423
  },
310
- // ACT(2) inject coaching into system prompt (double defense). Silent on error.
424
+ // ACT(2) inject coaching into system prompt ONLY in the harness agent mode,
425
+ // so other modes' system prompts stay completely clean. Silent on error.
311
426
  "experimental.chat.system.transform": async (_input, output) => {
312
427
  try {
428
+ if (_input.sessionID) {
429
+ const agent = await resolveAgent(input.client, _input.sessionID);
430
+ if (!isHarnessAgent(agent)) return;
431
+ }
313
432
  const c = current();
314
433
  let instruction = "";
315
434
  if (c.decision === "STOP") instruction = `[${PLUGIN_NAME}] QUOTA limit exceeded. ${c.advice} Stop making further tool calls, finish the in-progress work, then report the quota status to the user.`;
@@ -361,9 +480,12 @@ Then: harness_done(). Follow the [usage-coach NEXT] directive each tool returns.
361
480
  model: tool.schema.string().optional()
362
481
  },
363
482
  async execute(args, ctx) {
483
+ const cfg = readHarnessCfg(ctx.directory);
364
484
  const h = readHarness(ctx.sessionID) ?? { name: "batch", total: 0, current: 0, tasks: [], usage: {}, active: true };
365
485
  h.tasks = h.tasks.filter((x) => x.id !== args.id);
366
- h.tasks.push({ id: args.id, title: args.title, status: args.status, model: args.model ?? "", revisions: args.revisions ?? 0, score: args.score ?? null, startedAt: (/* @__PURE__ */ new Date()).toISOString() });
486
+ const model = args.model || cfg.generator || "";
487
+ if (!model) return `ERROR: task ${args.id} has no model and no generator configured. Set "generator" in harness.config.json.`;
488
+ h.tasks.push({ id: args.id, title: args.title, status: args.status, model, revisions: args.revisions ?? 0, score: args.score ?? null, startedAt: (/* @__PURE__ */ new Date()).toISOString() });
367
489
  if (args.id > h.current) h.current = args.id;
368
490
  writeHarness(ctx.sessionID, h);
369
491
  return `task ${args.id} -> ${args.status}${args.score ? ` (${args.score})` : ""}`;
@@ -394,8 +516,8 @@ Then: harness_done(). Follow the [usage-coach NEXT] directive each tool returns.
394
516
  async execute(args, _ctx) {
395
517
  const rec = { ts: (/* @__PURE__ */ new Date()).toISOString(), task: args.task, prompt: args.prompt, gradeResult: args.gradeResult, model: args.model, revisions: args.revisions };
396
518
  try {
397
- mkdirSync(STATE_DIR, { recursive: true });
398
- appendFileSync(failuresFile(), JSON.stringify(rec) + "\n");
519
+ mkdirSync2(STATE_DIR, { recursive: true });
520
+ appendFileSync2(failuresFile(), JSON.stringify(rec) + "\n");
399
521
  } catch (e) {
400
522
  log(`record_failure err: ${String(e)}`);
401
523
  }
@@ -413,6 +535,25 @@ Then: harness_done(). Follow the [usage-coach NEXT] directive each tool returns.
413
535
  async execute(args, ctx) {
414
536
  const cfg = readHarnessCfg(ctx.directory);
415
537
  if (!cfg.generator) return 'ERROR: no generator model configured. Set "generator" in harness.config.json (see harness.config.example.json).';
538
+ let domainPrefix = "";
539
+ let keywords = [];
540
+ let domainEmpty = true;
541
+ try {
542
+ keywords = extractKeywords(`${args.task} ${args.gradeResult}`);
543
+ if (keywords.length) {
544
+ const { nodes, edges } = queryDomain(keywords);
545
+ if (nodes && nodes.length || edges && edges.length) {
546
+ domainEmpty = false;
547
+ domainPrefix = `Known facts from domain DB: ${JSON.stringify({ nodes, edges })}. Use these if relevant.
548
+
549
+ ---
550
+
551
+ `;
552
+ }
553
+ }
554
+ } catch (e) {
555
+ log(`investigate domain query err: ${String(e)}`);
556
+ }
416
557
  const rcaPrompt = `A task failed. Analyze the ROOT CAUSE (not just the symptom).
417
558
  Task: ${args.task}
418
559
  What was expected (from grade): ${args.gradeResult}
@@ -421,7 +562,14 @@ Output a structured root cause:
421
562
  category: (one of: constraint-violation, missing-context, tool-misuse, model-limitation, other)
422
563
  explanation: <why it failed>
423
564
  evidence: <file/line or specific quote>`;
424
- const out = await runModel(input.client, cfg.generator, rcaPrompt, ctx.directory);
565
+ const out = await runModel(input.client, cfg.generator, domainPrefix + rcaPrompt, ctx.directory);
566
+ if (domainEmpty && keywords.length) {
567
+ try {
568
+ saveInvestigationResult(keywords, out, "investigate");
569
+ } catch (e) {
570
+ log(`investigate save err: ${String(e)}`);
571
+ }
572
+ }
425
573
  return out + "\n[usage-coach NEXT] call verify_diagnosis with this diagnosis.";
426
574
  }
427
575
  }),
@@ -473,8 +621,8 @@ Keep it concrete and actionable.`;
473
621
  const rule = out;
474
622
  try {
475
623
  const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
476
- mkdirSync(STATE_DIR, { recursive: true });
477
- appendFileSync(rulesFile(), `## Rule (${date})
624
+ mkdirSync2(STATE_DIR, { recursive: true });
625
+ appendFileSync2(rulesFile(), `## Rule (${date})
478
626
  ${rule}
479
627
  Origin: ${args.task}
480
628
 
@@ -502,13 +650,38 @@ Origin: ${args.task}
502
650
  const throttle = decision === "THROTTLE" && cfg.lighterModel;
503
651
  const model = throttle ? cfg.lighterModel : cfg.generator;
504
652
  const rules = readRules();
505
- const prefix = rules ? `Lessons learned from previous failures (apply where relevant):
653
+ let prefix = rules ? `Lessons learned from previous failures (apply where relevant):
506
654
  ${rules}
507
655
 
508
656
  ---
509
657
 
510
658
  ` : "";
659
+ let keywords = [];
660
+ let domainEmpty = true;
661
+ try {
662
+ keywords = extractKeywords(args.prompt);
663
+ if (keywords.length) {
664
+ const { nodes, edges } = queryDomain(keywords);
665
+ if (nodes && nodes.length || edges && edges.length) {
666
+ domainEmpty = false;
667
+ prefix = `Known facts from domain DB: ${JSON.stringify({ nodes, edges })}. Use these if relevant.
668
+
669
+ ---
670
+
671
+ ` + prefix;
672
+ }
673
+ }
674
+ } catch (e) {
675
+ log(`generate domain query err: ${String(e)}`);
676
+ }
511
677
  const out = await runModel(input.client, model, prefix + args.prompt, ctx.directory);
678
+ if (domainEmpty && keywords.length) {
679
+ try {
680
+ saveInvestigationResult(keywords, out, "generate");
681
+ } catch (e) {
682
+ log(`generate save err: ${String(e)}`);
683
+ }
684
+ }
512
685
  return out + (throttle ? `
513
686
  [usage-coach] quota THROTTLE \u2014 used lighter model ${cfg.lighterModel}` : "") + `
514
687
  [usage-coach NEXT] call task_update(i, title, "grading"), then grade to evaluate this work.`;
package/dist/tui.js CHANGED
@@ -86,10 +86,12 @@ var TLABEL = {
86
86
  halted_quota: "quota-halt"
87
87
  };
88
88
  function barFill(p) {
89
- return "\u2588".repeat(Math.max(0, Math.min(10, Math.round(p / 10))));
89
+ const n = p <= 0 ? 0 : Math.max(1, Math.min(10, Math.round(p / 10)));
90
+ return "\u2588".repeat(n);
90
91
  }
91
92
  function barEmpty(p) {
92
- return "\u2591".repeat(10 - Math.max(0, Math.min(10, Math.round(p / 10))));
93
+ const n = p <= 0 ? 0 : Math.max(1, Math.min(10, Math.round(p / 10)));
94
+ return "\u2591".repeat(10 - n);
93
95
  }
94
96
  function initializeTui(api, disposeRoot) {
95
97
  STATE_DIR = process.env.UC_STATE_DIR ?? projectStateDir(api.state.path.directory);
@@ -129,11 +131,9 @@ function initializeTui(api, disposeRoot) {
129
131
  tlog(`api probe err: ${String(e)}`);
130
132
  }
131
133
  const [getState, setState] = createSignal(readState());
132
- const [getHarness, setHarness] = createSignal(readHarness());
133
134
  const timer = setInterval(() => {
134
135
  try {
135
136
  setState(readState());
136
- setHarness(readHarness());
137
137
  } catch {
138
138
  }
139
139
  }, 3e3);
@@ -182,7 +182,7 @@ function initializeTui(api, disposeRoot) {
182
182
  return _el$;
183
183
  })();
184
184
  }
185
- let s = null;
185
+ let s;
186
186
  try {
187
187
  s = getState();
188
188
  } catch {
@@ -236,7 +236,7 @@ function initializeTui(api, disposeRoot) {
236
236
  _$insert(_el$12, () => p.fiveHour, _el$14);
237
237
  _$insert(_el$12, () => p.fiveHourReset, null);
238
238
  _$effect((_p$) => {
239
- var _v$ = st("text"), _v$2 = st("textMuted");
239
+ var _v$ = st("text"), _v$2 = st("text");
240
240
  _v$ !== _p$.e && (_p$.e = _$setProp(_el$10, "style", _v$, _p$.e));
241
241
  _v$2 !== _p$.t && (_p$.t = _$setProp(_el$11, "style", _v$2, _p$.t));
242
242
  return _p$;
@@ -253,7 +253,7 @@ function initializeTui(api, disposeRoot) {
253
253
  _$insertNode(_el$15, _el$19);
254
254
  _$insertNode(_el$15, _el$20);
255
255
  _$setProp(_el$15, "flexDirection", "row");
256
- _$insertNode(_el$16, _$createTextNode(` wk `));
256
+ _$insertNode(_el$16, _$createTextNode(` 1w `));
257
257
  _$insert(_el$18, () => barFill(p.weekly));
258
258
  _$insert(_el$19, () => barEmpty(p.weekly));
259
259
  _$insertNode(_el$20, _el$21);
@@ -261,7 +261,7 @@ function initializeTui(api, disposeRoot) {
261
261
  _$insert(_el$20, () => p.weekly, _el$22);
262
262
  _$insert(_el$20, () => p.weeklyReset, null);
263
263
  _$effect((_p$) => {
264
- var _v$3 = st("text"), _v$4 = st("textMuted");
264
+ var _v$3 = st("text"), _v$4 = st("text");
265
265
  _v$3 !== _p$.e && (_p$.e = _$setProp(_el$18, "style", _v$3, _p$.e));
266
266
  _v$4 !== _p$.t && (_p$.t = _$setProp(_el$19, "style", _v$4, _p$.t));
267
267
  return _p$;
@@ -281,7 +281,7 @@ function initializeTui(api, disposeRoot) {
281
281
  }
282
282
  } else {
283
283
  nodes.push((() => {
284
- var _el$27 = _$createElement("box"), _el$28 = _$createElement("text"), _el$30 = _$createElement("text"), _el$31 = _$createElement("text"), _el$32 = _$createElement("text"), _el$33 = _$createTextNode(` `), _el$34 = _$createTextNode(`%`);
284
+ var _el$27 = _$createElement("box"), _el$28 = _$createElement("text"), _el$30 = _$createElement("text"), _el$31 = _$createElement("text"), _el$32 = _$createElement("text");
285
285
  _$insertNode(_el$27, _el$28);
286
286
  _$insertNode(_el$27, _el$30);
287
287
  _$insertNode(_el$27, _el$31);
@@ -290,11 +290,9 @@ function initializeTui(api, disposeRoot) {
290
290
  _$insertNode(_el$28, _$createTextNode(` 5h `));
291
291
  _$insert(_el$30, () => barFill(s.fiveHour));
292
292
  _$insert(_el$31, () => barEmpty(s.fiveHour));
293
- _$insertNode(_el$32, _el$33);
294
- _$insertNode(_el$32, _el$34);
295
- _$insert(_el$32, () => s.fiveHour, _el$34);
293
+ _$insertNode(_el$32, _$createTextNode(` 0%`));
296
294
  _$effect((_p$) => {
297
- var _v$5 = st("text"), _v$6 = st("textMuted");
295
+ var _v$5 = st("text"), _v$6 = st("text");
298
296
  _v$5 !== _p$.e && (_p$.e = _$setProp(_el$30, "style", _v$5, _p$.e));
299
297
  _v$6 !== _p$.t && (_p$.t = _$setProp(_el$31, "style", _v$6, _p$.t));
300
298
  return _p$;
@@ -305,77 +303,51 @@ function initializeTui(api, disposeRoot) {
305
303
  return _el$27;
306
304
  })());
307
305
  nodes.push((() => {
308
- var _el$35 = _$createElement("box"), _el$36 = _$createElement("text"), _el$38 = _$createElement("text"), _el$39 = _$createElement("text"), _el$40 = _$createElement("text"), _el$41 = _$createTextNode(` `), _el$42 = _$createTextNode(`%`);
309
- _$insertNode(_el$35, _el$36);
310
- _$insertNode(_el$35, _el$38);
311
- _$insertNode(_el$35, _el$39);
312
- _$insertNode(_el$35, _el$40);
313
- _$setProp(_el$35, "flexDirection", "row");
314
- _$insertNode(_el$36, _$createTextNode(` wk `));
315
- _$insert(_el$38, () => barFill(s.weekly));
316
- _$insert(_el$39, () => barEmpty(s.weekly));
317
- _$insertNode(_el$40, _el$41);
318
- _$insertNode(_el$40, _el$42);
319
- _$insert(_el$40, () => s.weekly, _el$42);
306
+ var _el$34 = _$createElement("box"), _el$35 = _$createElement("text"), _el$37 = _$createElement("text"), _el$38 = _$createElement("text"), _el$39 = _$createElement("text");
307
+ _$insertNode(_el$34, _el$35);
308
+ _$insertNode(_el$34, _el$37);
309
+ _$insertNode(_el$34, _el$38);
310
+ _$insertNode(_el$34, _el$39);
311
+ _$setProp(_el$34, "flexDirection", "row");
312
+ _$insertNode(_el$35, _$createTextNode(` 1w `));
313
+ _$insert(_el$37, () => barFill(s.weekly));
314
+ _$insert(_el$38, () => barEmpty(s.weekly));
315
+ _$insertNode(_el$39, _$createTextNode(` 0%`));
320
316
  _$effect((_p$) => {
321
- var _v$7 = st("text"), _v$8 = st("textMuted");
322
- _v$7 !== _p$.e && (_p$.e = _$setProp(_el$38, "style", _v$7, _p$.e));
323
- _v$8 !== _p$.t && (_p$.t = _$setProp(_el$39, "style", _v$8, _p$.t));
317
+ var _v$7 = st("text"), _v$8 = st("text");
318
+ _v$7 !== _p$.e && (_p$.e = _$setProp(_el$37, "style", _v$7, _p$.e));
319
+ _v$8 !== _p$.t && (_p$.t = _$setProp(_el$38, "style", _v$8, _p$.t));
324
320
  return _p$;
325
321
  }, {
326
322
  e: void 0,
327
323
  t: void 0
328
324
  });
329
- return _el$35;
330
- })());
331
- nodes.push((() => {
332
- var _el$43 = _$createElement("box"), _el$44 = _$createElement("text"), _el$46 = _$createElement("text"), _el$47 = _$createElement("text"), _el$48 = _$createElement("text"), _el$49 = _$createTextNode(` `), _el$50 = _$createTextNode(`%`);
333
- _$insertNode(_el$43, _el$44);
334
- _$insertNode(_el$43, _el$46);
335
- _$insertNode(_el$43, _el$47);
336
- _$insertNode(_el$43, _el$48);
337
- _$setProp(_el$43, "flexDirection", "row");
338
- _$insertNode(_el$44, _$createTextNode(` mo `));
339
- _$insert(_el$46, () => barFill(s.monthly));
340
- _$insert(_el$47, () => barEmpty(s.monthly));
341
- _$insertNode(_el$48, _el$49);
342
- _$insertNode(_el$48, _el$50);
343
- _$insert(_el$48, () => s.monthly, _el$50);
344
- _$effect((_p$) => {
345
- var _v$9 = st("text"), _v$0 = st("textMuted");
346
- _v$9 !== _p$.e && (_p$.e = _$setProp(_el$46, "style", _v$9, _p$.e));
347
- _v$0 !== _p$.t && (_p$.t = _$setProp(_el$47, "style", _v$0, _p$.t));
348
- return _p$;
349
- }, {
350
- e: void 0,
351
- t: void 0
352
- });
353
- return _el$43;
325
+ return _el$34;
354
326
  })());
355
327
  }
356
328
  } else {
357
329
  nodes.push((() => {
358
- var _el$51 = _$createElement("text");
359
- _$insertNode(_el$51, _$createTextNode(`usage-coach: ...`));
360
- return _el$51;
330
+ var _el$41 = _$createElement("text");
331
+ _$insertNode(_el$41, _$createTextNode(`usage-coach: ...`));
332
+ return _el$41;
361
333
  })());
362
334
  }
363
335
  if (h && h.active !== false && h.tasks.length > 0) {
364
336
  nodes.push((() => {
365
- var _el$53 = _$createElement("text");
366
- _$insertNode(_el$53, _$createTextNode(` `));
367
- return _el$53;
337
+ var _el$43 = _$createElement("text");
338
+ _$insertNode(_el$43, _$createTextNode(` `));
339
+ return _el$43;
368
340
  })());
369
341
  nodes.push((() => {
370
- var _el$55 = _$createElement("text"), _el$56 = _$createTextNode(`harness: `), _el$57 = _$createTextNode(` `), _el$58 = _$createTextNode(`/`);
371
- _$insertNode(_el$55, _el$56);
372
- _$insertNode(_el$55, _el$57);
373
- _$insertNode(_el$55, _el$58);
374
- _$insert(_el$55, () => h.name, _el$57);
375
- _$insert(_el$55, () => h.current, _el$58);
376
- _$insert(_el$55, () => h.total, null);
377
- _$effect((_$p) => _$setProp(_el$55, "style", st("textMuted"), _$p));
378
- return _el$55;
342
+ var _el$45 = _$createElement("text"), _el$46 = _$createTextNode(`harness: `), _el$47 = _$createTextNode(` `), _el$48 = _$createTextNode(`/`);
343
+ _$insertNode(_el$45, _el$46);
344
+ _$insertNode(_el$45, _el$47);
345
+ _$insertNode(_el$45, _el$48);
346
+ _$insert(_el$45, () => h.name, _el$47);
347
+ _$insert(_el$45, () => h.current, _el$48);
348
+ _$insert(_el$45, () => h.total, null);
349
+ _$effect((_$p) => _$setProp(_el$45, "style", st("textMuted"), _$p));
350
+ return _el$45;
379
351
  })());
380
352
  for (const t of h.tasks) {
381
353
  const sKey = statusKey[t.status] ?? "text";
@@ -385,54 +357,54 @@ function initializeTui(api, disposeRoot) {
385
357
  const elapsed = t.startedAt ? Math.max(0, Math.round((Date.now() - new Date(t.startedAt).getTime()) / 1e3)) : 0;
386
358
  const elapsedStr = t.status === "completed" || t.status === "failed" ? "" : elapsed > 0 ? ` ${elapsed}s` : "";
387
359
  nodes.push((() => {
388
- var _el$59 = _$createElement("text"), _el$60 = _$createTextNode(` \u25CF `), _el$61 = _$createTextNode(` `), _el$62 = _$createTextNode(` `);
389
- _$insertNode(_el$59, _el$60);
390
- _$insertNode(_el$59, _el$61);
391
- _$insertNode(_el$59, _el$62);
392
- _$insert(_el$59, () => t.id, _el$61);
393
- _$insert(_el$59, mdl, _el$61);
394
- _$insert(_el$59, lbl, _el$62);
395
- _$insert(_el$59, rev, _el$62);
396
- _$insert(_el$59, elapsedStr, _el$62);
397
- _$insert(_el$59, () => t.title, null);
398
- _$effect((_$p) => _$setProp(_el$59, "style", st(sKey), _$p));
399
- return _el$59;
360
+ var _el$49 = _$createElement("text"), _el$50 = _$createTextNode(` \u25CF `), _el$51 = _$createTextNode(` `), _el$52 = _$createTextNode(` `);
361
+ _$insertNode(_el$49, _el$50);
362
+ _$insertNode(_el$49, _el$51);
363
+ _$insertNode(_el$49, _el$52);
364
+ _$insert(_el$49, () => t.id, _el$51);
365
+ _$insert(_el$49, mdl, _el$51);
366
+ _$insert(_el$49, lbl, _el$52);
367
+ _$insert(_el$49, rev, _el$52);
368
+ _$insert(_el$49, elapsedStr, _el$52);
369
+ _$insert(_el$49, () => t.title, null);
370
+ _$effect((_$p) => _$setProp(_el$49, "style", st(sKey), _$p));
371
+ return _el$49;
400
372
  })());
401
373
  const pv = t.model ? (t.model.split("/")[0] ?? "").split("-")[0] : "";
402
- const provCoach = s?.providers?.find((p) => p.id === pv || pv && p.id.startsWith(pv) || pv && pv.startsWith(p.id));
403
- const rawPct = provCoach ? provCoach.fiveHour : s?.fiveHour ?? 0;
374
+ const provCoach = pv ? s?.providers?.find((p) => p.id === pv || pv && p.id.startsWith(pv) || pv && pv.startsWith(p.id)) : s?.providers?.[0];
375
+ const rawPct = provCoach?.fiveHour ?? s?.fiveHour ?? -1;
404
376
  const pct = rawPct < 0 ? 0 : rawPct;
405
377
  const pctLabel = rawPct < 0 ? "n/a" : `${rawPct}%`;
406
378
  nodes.push((() => {
407
- var _el$63 = _$createElement("box"), _el$64 = _$createElement("text"), _el$66 = _$createElement("text"), _el$67 = _$createElement("text"), _el$68 = _$createElement("text"), _el$69 = _$createTextNode(` `);
408
- _$insertNode(_el$63, _el$64);
409
- _$insertNode(_el$63, _el$66);
410
- _$insertNode(_el$63, _el$67);
411
- _$insertNode(_el$63, _el$68);
412
- _$setProp(_el$63, "flexDirection", "row");
413
- _$insertNode(_el$64, _$createTextNode(` 5h `));
414
- _$insert(_el$66, () => barFill(pct));
415
- _$insert(_el$67, () => barEmpty(pct));
416
- _$insertNode(_el$68, _el$69);
417
- _$insert(_el$68, pctLabel, null);
379
+ var _el$53 = _$createElement("box"), _el$54 = _$createElement("text"), _el$56 = _$createElement("text"), _el$57 = _$createElement("text"), _el$58 = _$createElement("text"), _el$59 = _$createTextNode(` `);
380
+ _$insertNode(_el$53, _el$54);
381
+ _$insertNode(_el$53, _el$56);
382
+ _$insertNode(_el$53, _el$57);
383
+ _$insertNode(_el$53, _el$58);
384
+ _$setProp(_el$53, "flexDirection", "row");
385
+ _$insertNode(_el$54, _$createTextNode(` 5h `));
386
+ _$insert(_el$56, () => barFill(pct));
387
+ _$insert(_el$57, () => barEmpty(pct));
388
+ _$insertNode(_el$58, _el$59);
389
+ _$insert(_el$58, pctLabel, null);
418
390
  _$effect((_p$) => {
419
- var _v$1 = st("text"), _v$10 = st("textMuted");
420
- _v$1 !== _p$.e && (_p$.e = _$setProp(_el$66, "style", _v$1, _p$.e));
421
- _v$10 !== _p$.t && (_p$.t = _$setProp(_el$67, "style", _v$10, _p$.t));
391
+ var _v$9 = st("text"), _v$0 = st("text");
392
+ _v$9 !== _p$.e && (_p$.e = _$setProp(_el$56, "style", _v$9, _p$.e));
393
+ _v$0 !== _p$.t && (_p$.t = _$setProp(_el$57, "style", _v$0, _p$.t));
422
394
  return _p$;
423
395
  }, {
424
396
  e: void 0,
425
397
  t: void 0
426
398
  });
427
- return _el$63;
399
+ return _el$53;
428
400
  })());
429
401
  }
430
402
  }
431
403
  return (() => {
432
- var _el$70 = _$createElement("box");
433
- _$setProp(_el$70, "flexDirection", "column");
434
- _$insert(_el$70, nodes);
435
- return _el$70;
404
+ var _el$60 = _$createElement("box");
405
+ _$setProp(_el$60, "flexDirection", "column");
406
+ _$insert(_el$60, nodes);
407
+ return _el$60;
436
408
  })();
437
409
  };
438
410
  tlog("registering slots");
@@ -441,16 +413,18 @@ function initializeTui(api, disposeRoot) {
441
413
  slots: {
442
414
  sidebar_footer(ctx) {
443
415
  tlog("sidebar_footer slot called");
416
+ let result;
444
417
  try {
445
- return panel(ctx);
418
+ result = panel(ctx);
446
419
  } catch (e) {
447
420
  tlog(`sidebar_footer err: ${String(e)}`);
448
- return (() => {
449
- var _el$71 = _$createElement("text");
450
- _$insertNode(_el$71, _$createTextNode(`usage-coach`));
451
- return _el$71;
421
+ result = (() => {
422
+ var _el$61 = _$createElement("text");
423
+ _$insertNode(_el$61, _$createTextNode(`usage-coach`));
424
+ return _el$61;
452
425
  })();
453
426
  }
427
+ return result;
454
428
  }
455
429
  }
456
430
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-usage-coach",
3
- "version": "0.3.4",
4
- "description": "opencode closed-loop usage coach \u2014 quota SENSE -> coaching DECIDE -> loop ACT + TUI integration",
3
+ "version": "0.4.0",
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",
7
7
  "module": "./dist/index.js",
@@ -18,6 +18,8 @@
18
18
  "scripts": {
19
19
  "build": "tsup",
20
20
  "typecheck": "tsc --noEmit",
21
+ "lint": "eslint .",
22
+ "lint:fix": "eslint . --fix",
21
23
  "prepack": "tsup"
22
24
  },
23
25
  "files": [
@@ -44,12 +46,17 @@
44
46
  "solid-js": ">=1.9.12"
45
47
  },
46
48
  "devDependencies": {
49
+ "@eslint/js": "^10.0.1",
47
50
  "@opencode-ai/plugin": "*",
48
51
  "@opentui/core": ">=0.4.0",
49
52
  "@opentui/solid": ">=0.4.0",
50
53
  "esbuild-plugin-solid": "^0.6.0",
54
+ "eslint": "^10.6.0",
55
+ "eslint-plugin-solid": "^0.14.5",
56
+ "globals": "^17.7.0",
51
57
  "solid-js": "^1.9",
52
58
  "tsup": "^8.5",
53
- "typescript": "^5"
59
+ "typescript": "^5",
60
+ "typescript-eslint": "^8.63.0"
54
61
  }
55
62
  }