@os-eco/overstory-cli 0.6.4 → 0.6.6
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 +61 -61
- package/agents/builder.md +16 -16
- package/agents/coordinator.md +57 -57
- package/agents/issue-reviews.md +71 -0
- package/agents/lead.md +43 -42
- package/agents/merger.md +15 -15
- package/agents/monitor.md +37 -37
- package/agents/pr-reviews.md +60 -0
- package/agents/prioritize.md +110 -0
- package/agents/release.md +56 -0
- package/agents/reviewer.md +15 -15
- package/agents/scout.md +18 -18
- package/agents/supervisor.md +78 -78
- package/package.json +1 -1
- package/src/agents/checkpoint.test.ts +2 -2
- package/src/agents/hooks-deployer.test.ts +59 -25
- package/src/agents/hooks-deployer.ts +24 -6
- package/src/agents/identity.test.ts +27 -27
- package/src/agents/identity.ts +10 -10
- package/src/agents/lifecycle.test.ts +6 -6
- package/src/agents/lifecycle.ts +2 -2
- package/src/agents/overlay.test.ts +14 -14
- package/src/agents/overlay.ts +14 -14
- package/src/commands/agents.test.ts +5 -5
- package/src/commands/agents.ts +10 -9
- package/src/commands/clean.test.ts +5 -5
- package/src/commands/clean.ts +5 -5
- package/src/commands/completions.test.ts +10 -10
- package/src/commands/completions.ts +26 -28
- package/src/commands/coordinator.test.ts +4 -4
- package/src/commands/coordinator.ts +13 -13
- package/src/commands/costs.test.ts +45 -45
- package/src/commands/costs.ts +1 -1
- package/src/commands/dashboard.ts +11 -11
- package/src/commands/doctor.ts +4 -4
- package/src/commands/errors.ts +1 -1
- package/src/commands/feed.ts +1 -1
- package/src/commands/group.ts +3 -3
- package/src/commands/hooks.test.ts +7 -7
- package/src/commands/hooks.ts +7 -7
- package/src/commands/init.test.ts +6 -2
- package/src/commands/init.ts +19 -19
- package/src/commands/inspect.test.ts +16 -16
- package/src/commands/inspect.ts +19 -19
- package/src/commands/log.test.ts +21 -21
- package/src/commands/log.ts +10 -10
- package/src/commands/logs.ts +1 -1
- package/src/commands/mail.test.ts +7 -7
- package/src/commands/mail.ts +28 -11
- package/src/commands/merge.test.ts +8 -8
- package/src/commands/merge.ts +15 -15
- package/src/commands/metrics.test.ts +7 -7
- package/src/commands/metrics.ts +3 -3
- package/src/commands/monitor.test.ts +5 -5
- package/src/commands/monitor.ts +5 -5
- package/src/commands/nudge.test.ts +1 -1
- package/src/commands/nudge.ts +1 -1
- package/src/commands/prime.test.ts +5 -5
- package/src/commands/prime.ts +8 -8
- package/src/commands/replay.ts +1 -1
- package/src/commands/run.test.ts +1 -1
- package/src/commands/run.ts +2 -2
- package/src/commands/sling.test.ts +89 -7
- package/src/commands/sling.ts +109 -18
- package/src/commands/spec.test.ts +2 -2
- package/src/commands/spec.ts +13 -14
- package/src/commands/status.test.ts +99 -3
- package/src/commands/status.ts +19 -20
- package/src/commands/stop.test.ts +1 -1
- package/src/commands/stop.ts +2 -2
- package/src/commands/supervisor.test.ts +10 -10
- package/src/commands/supervisor.ts +14 -14
- package/src/commands/trace.test.ts +7 -7
- package/src/commands/trace.ts +10 -10
- package/src/commands/watch.ts +5 -5
- package/src/commands/worktree.test.ts +208 -32
- package/src/commands/worktree.ts +56 -18
- package/src/doctor/consistency.test.ts +14 -14
- package/src/doctor/dependencies.test.ts +5 -5
- package/src/doctor/dependencies.ts +2 -2
- package/src/doctor/logs.ts +1 -1
- package/src/doctor/merge-queue.test.ts +4 -4
- package/src/doctor/structure.test.ts +1 -1
- package/src/doctor/structure.ts +1 -1
- package/src/doctor/version.test.ts +3 -3
- package/src/doctor/version.ts +1 -1
- package/src/e2e/init-sling-lifecycle.test.ts +8 -4
- package/src/errors.ts +1 -1
- package/src/index.ts +13 -11
- package/src/mail/broadcast.test.ts +1 -1
- package/src/mail/client.test.ts +7 -7
- package/src/mail/client.ts +2 -2
- package/src/mail/store.test.ts +3 -3
- package/src/merge/queue.test.ts +12 -12
- package/src/merge/queue.ts +2 -2
- package/src/merge/resolver.test.ts +159 -7
- package/src/merge/resolver.ts +46 -2
- package/src/metrics/store.test.ts +44 -44
- package/src/metrics/store.ts +2 -2
- package/src/metrics/summary.test.ts +35 -35
- package/src/mulch/client.test.ts +1 -1
- package/src/mulch/client.ts +1 -1
- package/src/sessions/compat.test.ts +3 -3
- package/src/sessions/compat.ts +1 -1
- package/src/sessions/store.test.ts +4 -4
- package/src/sessions/store.ts +2 -2
- package/src/types.ts +14 -14
- package/src/watchdog/daemon.test.ts +10 -10
- package/src/watchdog/daemon.ts +1 -1
- package/src/watchdog/health.test.ts +1 -1
- package/src/worktree/manager.test.ts +20 -20
- package/src/worktree/manager.ts +120 -4
- package/src/worktree/tmux.test.ts +8 -3
- package/src/worktree/tmux.ts +19 -18
- package/templates/CLAUDE.md.tmpl +27 -27
- package/templates/hooks.json.tmpl +15 -11
- package/templates/overlay.md.tmpl +7 -7
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
createMergeResolver,
|
|
25
25
|
looksLikeProse,
|
|
26
26
|
parseConflictPatterns,
|
|
27
|
+
resolveConflictsUnion,
|
|
27
28
|
} from "./resolver.ts";
|
|
28
29
|
|
|
29
30
|
/**
|
|
@@ -53,7 +54,7 @@ function mockSpawnResult(
|
|
|
53
54
|
function makeTestEntry(overrides?: Partial<MergeEntry>): MergeEntry {
|
|
54
55
|
return {
|
|
55
56
|
branchName: overrides?.branchName ?? "feature-branch",
|
|
56
|
-
|
|
57
|
+
taskId: overrides?.taskId ?? "bead-123",
|
|
57
58
|
agentName: overrides?.agentName ?? "test-agent",
|
|
58
59
|
filesModified: overrides?.filesModified ?? ["src/test.ts"],
|
|
59
60
|
enqueuedAt: overrides?.enqueuedAt ?? new Date().toISOString(),
|
|
@@ -551,7 +552,7 @@ describe("createMergeResolver", () => {
|
|
|
551
552
|
|
|
552
553
|
const entry = makeTestEntry({
|
|
553
554
|
branchName: "overstory/my-agent/bead-xyz",
|
|
554
|
-
|
|
555
|
+
taskId: "bead-xyz",
|
|
555
556
|
agentName: "my-agent",
|
|
556
557
|
filesModified: ["src/test.ts"],
|
|
557
558
|
});
|
|
@@ -564,7 +565,7 @@ describe("createMergeResolver", () => {
|
|
|
564
565
|
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
565
566
|
|
|
566
567
|
expect(result.entry.branchName).toBe("overstory/my-agent/bead-xyz");
|
|
567
|
-
expect(result.entry.
|
|
568
|
+
expect(result.entry.taskId).toBe("bead-xyz");
|
|
568
569
|
expect(result.entry.agentName).toBe("my-agent");
|
|
569
570
|
});
|
|
570
571
|
});
|
|
@@ -744,7 +745,7 @@ describe("createMergeResolver", () => {
|
|
|
744
745
|
|
|
745
746
|
const entry = makeTestEntry({
|
|
746
747
|
branchName: "feature-branch",
|
|
747
|
-
|
|
748
|
+
taskId: "bead-abc-123",
|
|
748
749
|
agentName: "test-builder",
|
|
749
750
|
filesModified: ["src/test.ts"],
|
|
750
751
|
});
|
|
@@ -811,7 +812,7 @@ describe("createMergeResolver", () => {
|
|
|
811
812
|
|
|
812
813
|
const entry = makeTestEntry({
|
|
813
814
|
branchName: "feature-branch",
|
|
814
|
-
|
|
815
|
+
taskId: "bead-fail-456",
|
|
815
816
|
agentName: "test-agent",
|
|
816
817
|
filesModified: ["src/test.ts"],
|
|
817
818
|
});
|
|
@@ -906,7 +907,7 @@ describe("createMergeResolver", () => {
|
|
|
906
907
|
|
|
907
908
|
const entry = makeTestEntry({
|
|
908
909
|
branchName: "feature-branch",
|
|
909
|
-
|
|
910
|
+
taskId: "bead-ai-789",
|
|
910
911
|
filesModified: ["src/test.ts"],
|
|
911
912
|
});
|
|
912
913
|
|
|
@@ -981,7 +982,7 @@ describe("createMergeResolver", () => {
|
|
|
981
982
|
|
|
982
983
|
const entry = makeTestEntry({
|
|
983
984
|
branchName: "feature-branch",
|
|
984
|
-
|
|
985
|
+
taskId: "bead-reimagine-xyz",
|
|
985
986
|
filesModified: ["src/reimagine-target.ts"],
|
|
986
987
|
});
|
|
987
988
|
|
|
@@ -1288,6 +1289,157 @@ describe("createMergeResolver", () => {
|
|
|
1288
1289
|
});
|
|
1289
1290
|
});
|
|
1290
1291
|
|
|
1292
|
+
describe("resolveConflictsUnion", () => {
|
|
1293
|
+
test("returns null when no conflict markers are present", () => {
|
|
1294
|
+
expect(resolveConflictsUnion("no conflicts here\n")).toBeNull();
|
|
1295
|
+
expect(resolveConflictsUnion("")).toBeNull();
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
test("keeps both canonical and incoming content for a single conflict", () => {
|
|
1299
|
+
const content = [
|
|
1300
|
+
"<<<<<<< HEAD\n",
|
|
1301
|
+
'{"id":"a"}\n',
|
|
1302
|
+
'{"id":"c"}\n',
|
|
1303
|
+
"=======\n",
|
|
1304
|
+
'{"id":"a"}\n',
|
|
1305
|
+
'{"id":"b"}\n',
|
|
1306
|
+
">>>>>>> feature-branch\n",
|
|
1307
|
+
].join("");
|
|
1308
|
+
const result = resolveConflictsUnion(content);
|
|
1309
|
+
expect(result).not.toBeNull();
|
|
1310
|
+
expect(result).toContain('{"id":"a"}\n{"id":"c"}\n');
|
|
1311
|
+
expect(result).toContain('{"id":"a"}\n{"id":"b"}\n');
|
|
1312
|
+
// No conflict markers remain
|
|
1313
|
+
expect(result).not.toContain("<<<<<<<");
|
|
1314
|
+
expect(result).not.toContain("=======");
|
|
1315
|
+
expect(result).not.toContain(">>>>>>>");
|
|
1316
|
+
});
|
|
1317
|
+
|
|
1318
|
+
test("resolves multiple conflict blocks with union strategy", () => {
|
|
1319
|
+
const block = (canonical: string, incoming: string): string =>
|
|
1320
|
+
`<<<<<<< HEAD\n${canonical}\n=======\n${incoming}\n>>>>>>> branch\n`;
|
|
1321
|
+
const content = `${block("line-a\n", "line-b\n")}middle\n${block("line-c\n", "line-d\n")}`;
|
|
1322
|
+
const result = resolveConflictsUnion(content);
|
|
1323
|
+
expect(result).not.toBeNull();
|
|
1324
|
+
expect(result).toContain("line-a\n");
|
|
1325
|
+
expect(result).toContain("line-b\n");
|
|
1326
|
+
expect(result).toContain("middle\n");
|
|
1327
|
+
expect(result).toContain("line-c\n");
|
|
1328
|
+
expect(result).toContain("line-d\n");
|
|
1329
|
+
expect(result).not.toContain("<<<<<<<");
|
|
1330
|
+
});
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
describe("merge=union gitattribute support", () => {
|
|
1334
|
+
test("union strategy preserves lines from both sides (git handles cleanly or via auto-resolve)", async () => {
|
|
1335
|
+
const repoDir = await createTempGitRepo();
|
|
1336
|
+
try {
|
|
1337
|
+
const defaultBranch = await getDefaultBranch(repoDir);
|
|
1338
|
+
|
|
1339
|
+
// Set up .gitattributes with merge=union for *.jsonl files so that
|
|
1340
|
+
// both git's built-in union driver AND overstory's Tier 2 union path
|
|
1341
|
+
// are configured to keep all lines from both sides.
|
|
1342
|
+
await commitFile(repoDir, ".gitattributes", "*.jsonl merge=union\n");
|
|
1343
|
+
// Common ancestor: one line
|
|
1344
|
+
await commitFile(repoDir, "data.jsonl", '{"id":"a"}\n');
|
|
1345
|
+
|
|
1346
|
+
// Feature branch: adds line b
|
|
1347
|
+
await runGitInDir(repoDir, ["checkout", "-b", "feature-branch"]);
|
|
1348
|
+
await commitFile(repoDir, "data.jsonl", '{"id":"a"}\n{"id":"b"}\n');
|
|
1349
|
+
|
|
1350
|
+
// Back to main: adds line c (diverges from ancestor)
|
|
1351
|
+
await runGitInDir(repoDir, ["checkout", defaultBranch]);
|
|
1352
|
+
await commitFile(repoDir, "data.jsonl", '{"id":"a"}\n{"id":"c"}\n');
|
|
1353
|
+
|
|
1354
|
+
const entry = makeTestEntry({
|
|
1355
|
+
branchName: "feature-branch",
|
|
1356
|
+
filesModified: ["data.jsonl"],
|
|
1357
|
+
});
|
|
1358
|
+
|
|
1359
|
+
const resolver = createMergeResolver({
|
|
1360
|
+
aiResolveEnabled: false,
|
|
1361
|
+
reimagineEnabled: false,
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
1365
|
+
|
|
1366
|
+
// With merge=union, git either resolves cleanly (Tier 1) or
|
|
1367
|
+
// overstory's union path handles it (Tier 2). Either way, success
|
|
1368
|
+
// and both sides' content must be preserved.
|
|
1369
|
+
expect(result.success).toBe(true);
|
|
1370
|
+
expect(result.entry.status).toBe("merged");
|
|
1371
|
+
|
|
1372
|
+
// Both sides' lines must be present — no lines dropped
|
|
1373
|
+
const file = Bun.file(join(repoDir, "data.jsonl"));
|
|
1374
|
+
const content = await file.text();
|
|
1375
|
+
expect(content).toContain('{"id":"a"}');
|
|
1376
|
+
expect(content).toContain('{"id":"b"}');
|
|
1377
|
+
expect(content).toContain('{"id":"c"}');
|
|
1378
|
+
} finally {
|
|
1379
|
+
await cleanupTempDir(repoDir);
|
|
1380
|
+
}
|
|
1381
|
+
});
|
|
1382
|
+
|
|
1383
|
+
test("Tier 2 union auto-resolve keeps both sides when git produces conflict markers", async () => {
|
|
1384
|
+
// This test verifies the Tier 2 code path: when git produces conflict
|
|
1385
|
+
// markers for a file that has merge=union set in .gitattributes,
|
|
1386
|
+
// overstory resolves it by keeping both canonical and incoming content.
|
|
1387
|
+
// We produce conflict markers by doing a content conflict on a file whose
|
|
1388
|
+
// attribute is set to merge=union AFTER the conflict state exists, then
|
|
1389
|
+
// run only the auto-resolve path via a standalone resolver call.
|
|
1390
|
+
//
|
|
1391
|
+
// To force conflict markers despite merge=union: we DON'T commit
|
|
1392
|
+
// .gitattributes before the merge, so git uses the default driver and
|
|
1393
|
+
// produces conflict markers. Then we write .gitattributes to the working
|
|
1394
|
+
// tree (not committed) so git check-attr sees it and our code detects union.
|
|
1395
|
+
const repoDir = await createTempGitRepo();
|
|
1396
|
+
try {
|
|
1397
|
+
const defaultBranch = await getDefaultBranch(repoDir);
|
|
1398
|
+
|
|
1399
|
+
// Common ancestor
|
|
1400
|
+
await commitFile(repoDir, "data.jsonl", '{"id":"a"}\n');
|
|
1401
|
+
|
|
1402
|
+
// Feature branch: adds line b (also appends to same position as main)
|
|
1403
|
+
await runGitInDir(repoDir, ["checkout", "-b", "feature-branch"]);
|
|
1404
|
+
await commitFile(repoDir, "data.jsonl", '{"id":"a"}\n{"id":"b"}\n');
|
|
1405
|
+
|
|
1406
|
+
// Back to main: adds line c — diverges from ancestor at same position
|
|
1407
|
+
await runGitInDir(repoDir, ["checkout", defaultBranch]);
|
|
1408
|
+
await commitFile(repoDir, "data.jsonl", '{"id":"a"}\n{"id":"c"}\n');
|
|
1409
|
+
|
|
1410
|
+
// Now WRITE .gitattributes to working tree (not committed).
|
|
1411
|
+
// git check-attr reads from the working tree during Tier 2.
|
|
1412
|
+
await Bun.write(`${repoDir}/.gitattributes`, "*.jsonl merge=union\n");
|
|
1413
|
+
|
|
1414
|
+
// Attempt merge — git uses default driver (no committed .gitattributes)
|
|
1415
|
+
// so it WILL produce conflict markers if branches diverge at same position.
|
|
1416
|
+
// (If git resolves cleanly, the test still passes because content is preserved.)
|
|
1417
|
+
const entry = makeTestEntry({
|
|
1418
|
+
branchName: "feature-branch",
|
|
1419
|
+
filesModified: ["data.jsonl"],
|
|
1420
|
+
});
|
|
1421
|
+
|
|
1422
|
+
const resolver = createMergeResolver({
|
|
1423
|
+
aiResolveEnabled: false,
|
|
1424
|
+
reimagineEnabled: false,
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
1428
|
+
|
|
1429
|
+
expect(result.success).toBe(true);
|
|
1430
|
+
expect(result.entry.status).toBe("merged");
|
|
1431
|
+
|
|
1432
|
+
const file = Bun.file(join(repoDir, "data.jsonl"));
|
|
1433
|
+
const content = await file.text();
|
|
1434
|
+
expect(content).toContain('{"id":"a"}');
|
|
1435
|
+
expect(content).toContain('{"id":"b"}');
|
|
1436
|
+
expect(content).toContain('{"id":"c"}');
|
|
1437
|
+
} finally {
|
|
1438
|
+
await cleanupTempDir(repoDir);
|
|
1439
|
+
}
|
|
1440
|
+
});
|
|
1441
|
+
});
|
|
1442
|
+
|
|
1291
1443
|
describe("AI-resolve with history context", () => {
|
|
1292
1444
|
test("includes historical context in AI prompt when available", async () => {
|
|
1293
1445
|
const repoDir = await createTempGitRepo();
|
package/src/merge/resolver.ts
CHANGED
|
@@ -89,6 +89,47 @@ function resolveConflictsKeepIncoming(content: string): string | null {
|
|
|
89
89
|
});
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Parse conflict markers in file content and keep ALL lines from both sides.
|
|
94
|
+
* Used when the file has `merge=union` gitattribute — dedup-on-read handles duplicates.
|
|
95
|
+
*
|
|
96
|
+
* A conflict block looks like:
|
|
97
|
+
* ```
|
|
98
|
+
* <<<<<<< HEAD
|
|
99
|
+
* canonical content
|
|
100
|
+
* =======
|
|
101
|
+
* incoming content
|
|
102
|
+
* >>>>>>> branch
|
|
103
|
+
* ```
|
|
104
|
+
*
|
|
105
|
+
* This function replaces each conflict block with canonical + incoming content concatenated.
|
|
106
|
+
* Returns the resolved content, or null if no conflict markers were found.
|
|
107
|
+
*/
|
|
108
|
+
export function resolveConflictsUnion(content: string): string | null {
|
|
109
|
+
const conflictPattern = /^<{7} .+\n([\s\S]*?)^={7}\n([\s\S]*?)^>{7} .+\n?/gm;
|
|
110
|
+
|
|
111
|
+
if (!conflictPattern.test(content)) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Reset regex lastIndex after test()
|
|
116
|
+
conflictPattern.lastIndex = 0;
|
|
117
|
+
|
|
118
|
+
return content.replace(conflictPattern, (_match, canonical: string, incoming: string) => {
|
|
119
|
+
return canonical + incoming;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Check if a file has the `merge=union` gitattribute set.
|
|
125
|
+
* Returns true if `git check-attr merge -- <file>` ends with ": merge: union".
|
|
126
|
+
*/
|
|
127
|
+
async function checkMergeUnion(repoRoot: string, filePath: string): Promise<boolean> {
|
|
128
|
+
const { stdout, exitCode } = await runGit(repoRoot, ["check-attr", "merge", "--", filePath]);
|
|
129
|
+
if (exitCode !== 0) return false;
|
|
130
|
+
return stdout.trim().endsWith(": merge: union");
|
|
131
|
+
}
|
|
132
|
+
|
|
92
133
|
/**
|
|
93
134
|
* Read a file's content using Bun.file().
|
|
94
135
|
*/
|
|
@@ -138,7 +179,10 @@ async function tryAutoResolve(
|
|
|
138
179
|
|
|
139
180
|
try {
|
|
140
181
|
const content = await readFile(filePath);
|
|
141
|
-
const
|
|
182
|
+
const isUnion = await checkMergeUnion(repoRoot, file);
|
|
183
|
+
const resolved = isUnion
|
|
184
|
+
? resolveConflictsUnion(content)
|
|
185
|
+
: resolveConflictsKeepIncoming(content);
|
|
142
186
|
|
|
143
187
|
if (resolved === null) {
|
|
144
188
|
// No conflict markers found (shouldn't happen but be defensive)
|
|
@@ -496,7 +540,7 @@ function recordConflictPattern(
|
|
|
496
540
|
type: "pattern",
|
|
497
541
|
description,
|
|
498
542
|
tags: ["merge-conflict"],
|
|
499
|
-
evidenceBead: entry.
|
|
543
|
+
evidenceBead: entry.taskId,
|
|
500
544
|
})
|
|
501
545
|
.catch(() => {});
|
|
502
546
|
}
|
|
@@ -32,7 +32,7 @@ afterEach(async () => {
|
|
|
32
32
|
function makeSession(overrides: Partial<SessionMetrics> = {}): SessionMetrics {
|
|
33
33
|
return {
|
|
34
34
|
agentName: "test-agent",
|
|
35
|
-
|
|
35
|
+
taskId: "test-task-123",
|
|
36
36
|
capability: "builder",
|
|
37
37
|
startedAt: new Date("2026-01-01T00:00:00Z").toISOString(),
|
|
38
38
|
completedAt: new Date("2026-01-01T00:05:00Z").toISOString(),
|
|
@@ -78,7 +78,7 @@ describe("recordSession", () => {
|
|
|
78
78
|
test("all fields roundtrip correctly (camelCase TS → snake_case SQLite → camelCase TS)", () => {
|
|
79
79
|
const session = makeSession({
|
|
80
80
|
agentName: "special-agent",
|
|
81
|
-
|
|
81
|
+
taskId: "task-xyz",
|
|
82
82
|
capability: "reviewer",
|
|
83
83
|
startedAt: "2026-02-01T12:00:00Z",
|
|
84
84
|
completedAt: "2026-02-01T12:30:00Z",
|
|
@@ -119,15 +119,15 @@ describe("recordSession", () => {
|
|
|
119
119
|
describe("getRecentSessions", () => {
|
|
120
120
|
test("returns sessions ordered by started_at DESC (most recent first)", () => {
|
|
121
121
|
const session1 = makeSession({
|
|
122
|
-
|
|
122
|
+
taskId: "task-1",
|
|
123
123
|
startedAt: "2026-01-01T10:00:00Z",
|
|
124
124
|
});
|
|
125
125
|
const session2 = makeSession({
|
|
126
|
-
|
|
126
|
+
taskId: "task-2",
|
|
127
127
|
startedAt: "2026-01-01T12:00:00Z",
|
|
128
128
|
});
|
|
129
129
|
const session3 = makeSession({
|
|
130
|
-
|
|
130
|
+
taskId: "task-3",
|
|
131
131
|
startedAt: "2026-01-01T11:00:00Z",
|
|
132
132
|
});
|
|
133
133
|
|
|
@@ -137,9 +137,9 @@ describe("getRecentSessions", () => {
|
|
|
137
137
|
|
|
138
138
|
const retrieved = store.getRecentSessions(10);
|
|
139
139
|
expect(retrieved).toHaveLength(3);
|
|
140
|
-
expect(retrieved[0]?.
|
|
141
|
-
expect(retrieved[1]?.
|
|
142
|
-
expect(retrieved[2]?.
|
|
140
|
+
expect(retrieved[0]?.taskId).toBe("task-2"); // most recent
|
|
141
|
+
expect(retrieved[1]?.taskId).toBe("task-3");
|
|
142
|
+
expect(retrieved[2]?.taskId).toBe("task-1"); // oldest
|
|
143
143
|
});
|
|
144
144
|
|
|
145
145
|
test("default limit is 20", () => {
|
|
@@ -147,7 +147,7 @@ describe("getRecentSessions", () => {
|
|
|
147
147
|
for (let i = 0; i < 25; i++) {
|
|
148
148
|
store.recordSession(
|
|
149
149
|
makeSession({
|
|
150
|
-
|
|
150
|
+
taskId: `task-${i}`,
|
|
151
151
|
startedAt: new Date(Date.now() + i * 1000).toISOString(),
|
|
152
152
|
}),
|
|
153
153
|
);
|
|
@@ -158,9 +158,9 @@ describe("getRecentSessions", () => {
|
|
|
158
158
|
});
|
|
159
159
|
|
|
160
160
|
test("custom limit works (e.g., limit=2 returns only 2)", () => {
|
|
161
|
-
store.recordSession(makeSession({
|
|
162
|
-
store.recordSession(makeSession({
|
|
163
|
-
store.recordSession(makeSession({
|
|
161
|
+
store.recordSession(makeSession({ taskId: "task-1" }));
|
|
162
|
+
store.recordSession(makeSession({ taskId: "task-2" }));
|
|
163
|
+
store.recordSession(makeSession({ taskId: "task-3" }));
|
|
164
164
|
|
|
165
165
|
const retrieved = store.getRecentSessions(2);
|
|
166
166
|
expect(retrieved).toHaveLength(2);
|
|
@@ -176,9 +176,9 @@ describe("getRecentSessions", () => {
|
|
|
176
176
|
|
|
177
177
|
describe("getSessionsByAgent", () => {
|
|
178
178
|
test("filters by agent name correctly", () => {
|
|
179
|
-
store.recordSession(makeSession({ agentName: "agent-a",
|
|
180
|
-
store.recordSession(makeSession({ agentName: "agent-b",
|
|
181
|
-
store.recordSession(makeSession({ agentName: "agent-a",
|
|
179
|
+
store.recordSession(makeSession({ agentName: "agent-a", taskId: "task-1" }));
|
|
180
|
+
store.recordSession(makeSession({ agentName: "agent-b", taskId: "task-2" }));
|
|
181
|
+
store.recordSession(makeSession({ agentName: "agent-a", taskId: "task-3" }));
|
|
182
182
|
|
|
183
183
|
const retrieved = store.getSessionsByAgent("agent-a");
|
|
184
184
|
expect(retrieved).toHaveLength(2);
|
|
@@ -197,30 +197,30 @@ describe("getSessionsByAgent", () => {
|
|
|
197
197
|
store.recordSession(
|
|
198
198
|
makeSession({
|
|
199
199
|
agentName: "agent-x",
|
|
200
|
-
|
|
200
|
+
taskId: "task-1",
|
|
201
201
|
startedAt: "2026-01-01T10:00:00Z",
|
|
202
202
|
}),
|
|
203
203
|
);
|
|
204
204
|
store.recordSession(
|
|
205
205
|
makeSession({
|
|
206
206
|
agentName: "agent-x",
|
|
207
|
-
|
|
207
|
+
taskId: "task-2",
|
|
208
208
|
startedAt: "2026-01-01T12:00:00Z",
|
|
209
209
|
}),
|
|
210
210
|
);
|
|
211
211
|
store.recordSession(
|
|
212
212
|
makeSession({
|
|
213
213
|
agentName: "agent-x",
|
|
214
|
-
|
|
214
|
+
taskId: "task-3",
|
|
215
215
|
startedAt: "2026-01-01T11:00:00Z",
|
|
216
216
|
}),
|
|
217
217
|
);
|
|
218
218
|
|
|
219
219
|
const retrieved = store.getSessionsByAgent("agent-x");
|
|
220
220
|
expect(retrieved).toHaveLength(3);
|
|
221
|
-
expect(retrieved[0]?.
|
|
222
|
-
expect(retrieved[1]?.
|
|
223
|
-
expect(retrieved[2]?.
|
|
221
|
+
expect(retrieved[0]?.taskId).toBe("task-2"); // most recent
|
|
222
|
+
expect(retrieved[1]?.taskId).toBe("task-3");
|
|
223
|
+
expect(retrieved[2]?.taskId).toBe("task-1"); // oldest
|
|
224
224
|
});
|
|
225
225
|
});
|
|
226
226
|
|
|
@@ -228,9 +228,9 @@ describe("getSessionsByAgent", () => {
|
|
|
228
228
|
|
|
229
229
|
describe("getAverageDuration", () => {
|
|
230
230
|
test("average across all completed sessions (completedAt IS NOT NULL)", () => {
|
|
231
|
-
store.recordSession(makeSession({
|
|
232
|
-
store.recordSession(makeSession({
|
|
233
|
-
store.recordSession(makeSession({
|
|
231
|
+
store.recordSession(makeSession({ taskId: "task-1", durationMs: 100_000 }));
|
|
232
|
+
store.recordSession(makeSession({ taskId: "task-2", durationMs: 200_000 }));
|
|
233
|
+
store.recordSession(makeSession({ taskId: "task-3", durationMs: 300_000 }));
|
|
234
234
|
|
|
235
235
|
const avg = store.getAverageDuration();
|
|
236
236
|
expect(avg).toBe(200_000);
|
|
@@ -238,11 +238,11 @@ describe("getAverageDuration", () => {
|
|
|
238
238
|
|
|
239
239
|
test("average filtered by capability", () => {
|
|
240
240
|
store.recordSession(
|
|
241
|
-
makeSession({
|
|
241
|
+
makeSession({ taskId: "task-1", capability: "builder", durationMs: 100_000 }),
|
|
242
242
|
);
|
|
243
|
-
store.recordSession(makeSession({
|
|
243
|
+
store.recordSession(makeSession({ taskId: "task-2", capability: "scout", durationMs: 50_000 }));
|
|
244
244
|
store.recordSession(
|
|
245
|
-
makeSession({
|
|
245
|
+
makeSession({ taskId: "task-3", capability: "builder", durationMs: 200_000 }),
|
|
246
246
|
);
|
|
247
247
|
|
|
248
248
|
const avgBuilder = store.getAverageDuration("builder");
|
|
@@ -258,9 +258,9 @@ describe("getAverageDuration", () => {
|
|
|
258
258
|
});
|
|
259
259
|
|
|
260
260
|
test("sessions with completedAt=null are excluded from average", () => {
|
|
261
|
-
store.recordSession(makeSession({
|
|
262
|
-
store.recordSession(makeSession({
|
|
263
|
-
store.recordSession(makeSession({
|
|
261
|
+
store.recordSession(makeSession({ taskId: "task-1", durationMs: 100_000, completedAt: null }));
|
|
262
|
+
store.recordSession(makeSession({ taskId: "task-2", durationMs: 200_000 }));
|
|
263
|
+
store.recordSession(makeSession({ taskId: "task-3", durationMs: 300_000 }));
|
|
264
264
|
|
|
265
265
|
const avg = store.getAverageDuration();
|
|
266
266
|
expect(avg).toBe(250_000); // (200_000 + 300_000) / 2
|
|
@@ -359,7 +359,7 @@ describe("token fields", () => {
|
|
|
359
359
|
store.recordSession(
|
|
360
360
|
makeSession({
|
|
361
361
|
agentName: "new-agent",
|
|
362
|
-
|
|
362
|
+
taskId: "new-task",
|
|
363
363
|
inputTokens: 5000,
|
|
364
364
|
outputTokens: 1000,
|
|
365
365
|
estimatedCostUsd: 0.42,
|
|
@@ -378,9 +378,9 @@ describe("token fields", () => {
|
|
|
378
378
|
|
|
379
379
|
describe("getSessionsByRun", () => {
|
|
380
380
|
test("returns sessions matching run_id", () => {
|
|
381
|
-
store.recordSession(makeSession({ agentName: "a1",
|
|
382
|
-
store.recordSession(makeSession({ agentName: "a2",
|
|
383
|
-
store.recordSession(makeSession({ agentName: "a3",
|
|
381
|
+
store.recordSession(makeSession({ agentName: "a1", taskId: "t1", runId: "run-001" }));
|
|
382
|
+
store.recordSession(makeSession({ agentName: "a2", taskId: "t2", runId: "run-001" }));
|
|
383
|
+
store.recordSession(makeSession({ agentName: "a3", taskId: "t3", runId: "run-002" }));
|
|
384
384
|
|
|
385
385
|
const sessions = store.getSessionsByRun("run-001");
|
|
386
386
|
expect(sessions).toHaveLength(2);
|
|
@@ -388,13 +388,13 @@ describe("getSessionsByRun", () => {
|
|
|
388
388
|
});
|
|
389
389
|
|
|
390
390
|
test("returns empty array for unknown run_id", () => {
|
|
391
|
-
store.recordSession(makeSession({ agentName: "a1",
|
|
391
|
+
store.recordSession(makeSession({ agentName: "a1", taskId: "t1", runId: "run-001" }));
|
|
392
392
|
expect(store.getSessionsByRun("run-nonexistent")).toEqual([]);
|
|
393
393
|
});
|
|
394
394
|
|
|
395
395
|
test("sessions with null run_id are not returned", () => {
|
|
396
|
-
store.recordSession(makeSession({ agentName: "a1",
|
|
397
|
-
store.recordSession(makeSession({ agentName: "a2",
|
|
396
|
+
store.recordSession(makeSession({ agentName: "a1", taskId: "t1", runId: null }));
|
|
397
|
+
store.recordSession(makeSession({ agentName: "a2", taskId: "t2", runId: "run-001" }));
|
|
398
398
|
expect(store.getSessionsByRun("run-001")).toHaveLength(1);
|
|
399
399
|
});
|
|
400
400
|
});
|
|
@@ -403,9 +403,9 @@ describe("getSessionsByRun", () => {
|
|
|
403
403
|
|
|
404
404
|
describe("purge", () => {
|
|
405
405
|
test("purge all deletes everything and returns count", () => {
|
|
406
|
-
store.recordSession(makeSession({ agentName: "agent-a",
|
|
407
|
-
store.recordSession(makeSession({ agentName: "agent-b",
|
|
408
|
-
store.recordSession(makeSession({ agentName: "agent-c",
|
|
406
|
+
store.recordSession(makeSession({ agentName: "agent-a", taskId: "task-1" }));
|
|
407
|
+
store.recordSession(makeSession({ agentName: "agent-b", taskId: "task-2" }));
|
|
408
|
+
store.recordSession(makeSession({ agentName: "agent-c", taskId: "task-3" }));
|
|
409
409
|
|
|
410
410
|
const count = store.purge({ all: true });
|
|
411
411
|
expect(count).toBe(3);
|
|
@@ -413,9 +413,9 @@ describe("purge", () => {
|
|
|
413
413
|
});
|
|
414
414
|
|
|
415
415
|
test("purge by agent deletes only that agent's records", () => {
|
|
416
|
-
store.recordSession(makeSession({ agentName: "agent-a",
|
|
417
|
-
store.recordSession(makeSession({ agentName: "agent-b",
|
|
418
|
-
store.recordSession(makeSession({ agentName: "agent-a",
|
|
416
|
+
store.recordSession(makeSession({ agentName: "agent-a", taskId: "task-1" }));
|
|
417
|
+
store.recordSession(makeSession({ agentName: "agent-b", taskId: "task-2" }));
|
|
418
|
+
store.recordSession(makeSession({ agentName: "agent-a", taskId: "task-3" }));
|
|
419
419
|
|
|
420
420
|
const count = store.purge({ agent: "agent-a" });
|
|
421
421
|
expect(count).toBe(2);
|
|
@@ -431,7 +431,7 @@ describe("purge", () => {
|
|
|
431
431
|
});
|
|
432
432
|
|
|
433
433
|
test("purge with no options returns 0 without deleting", () => {
|
|
434
|
-
store.recordSession(makeSession({
|
|
434
|
+
store.recordSession(makeSession({ taskId: "task-1" }));
|
|
435
435
|
|
|
436
436
|
const count = store.purge({});
|
|
437
437
|
expect(count).toBe(0);
|
package/src/metrics/store.ts
CHANGED
|
@@ -152,7 +152,7 @@ function migrateTokenColumns(db: Database): void {
|
|
|
152
152
|
function rowToMetrics(row: SessionRow): SessionMetrics {
|
|
153
153
|
return {
|
|
154
154
|
agentName: row.agent_name,
|
|
155
|
-
|
|
155
|
+
taskId: row.task_id,
|
|
156
156
|
capability: row.capability,
|
|
157
157
|
startedAt: row.started_at,
|
|
158
158
|
completedAt: row.completed_at,
|
|
@@ -303,7 +303,7 @@ export function createMetricsStore(dbPath: string): MetricsStore {
|
|
|
303
303
|
recordSession(metrics: SessionMetrics): void {
|
|
304
304
|
insertStmt.run({
|
|
305
305
|
$agent_name: metrics.agentName,
|
|
306
|
-
$task_id: metrics.
|
|
306
|
+
$task_id: metrics.taskId,
|
|
307
307
|
$capability: metrics.capability,
|
|
308
308
|
$started_at: metrics.startedAt,
|
|
309
309
|
$completed_at: metrics.completedAt,
|