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 +1 -1
- package/dist/index.js +128 -7
- package/dist/tui.js +5 -1
- package/package.json +1 -1
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
|
|
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({
|
|
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 =
|
|
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