@os-eco/overstory-cli 0.10.3 → 0.11.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 +4 -2
- package/agents/builder.md +10 -1
- package/agents/lead.md +106 -5
- package/package.json +1 -1
- package/src/agents/headless-mail-injector.ts +8 -0
- package/src/agents/mail-poll-detect.test.ts +153 -0
- package/src/agents/mail-poll-detect.ts +73 -0
- package/src/agents/overlay.test.ts +56 -0
- package/src/agents/overlay.ts +33 -0
- package/src/agents/scope-detect.test.ts +190 -0
- package/src/agents/scope-detect.ts +146 -0
- package/src/agents/turn-runner.test.ts +862 -0
- package/src/agents/turn-runner.ts +225 -8
- package/src/commands/agents.ts +9 -0
- package/src/commands/coordinator.test.ts +127 -0
- package/src/commands/coordinator.ts +71 -4
- package/src/commands/dashboard.ts +1 -1
- package/src/commands/log.test.ts +131 -0
- package/src/commands/log.ts +37 -2
- package/src/commands/merge.test.ts +118 -0
- package/src/commands/merge.ts +51 -8
- package/src/commands/sling.test.ts +104 -0
- package/src/commands/sling.ts +95 -8
- package/src/commands/stop.test.ts +81 -0
- package/src/index.ts +5 -1
- package/src/insights/quality-gates.test.ts +141 -0
- package/src/insights/quality-gates.ts +156 -0
- package/src/logging/theme.ts +4 -0
- package/src/merge/predict.test.ts +387 -0
- package/src/merge/predict.ts +249 -0
- package/src/merge/resolver.ts +1 -1
- package/src/mulch/client.ts +3 -3
- package/src/sessions/store.test.ts +267 -5
- package/src/sessions/store.ts +105 -7
- package/src/types.ts +51 -1
- package/src/watchdog/daemon.test.ts +124 -2
- package/src/watchdog/daemon.ts +27 -12
- package/src/watchdog/health.test.ts +133 -8
- package/src/watchdog/health.ts +37 -5
- package/src/worktree/manager.test.ts +218 -1
- package/src/worktree/manager.ts +55 -0
- package/src/worktree/tmux.test.ts +25 -0
- package/src/worktree/tmux.ts +17 -0
- package/templates/overlay.md.tmpl +2 -0
package/src/commands/log.test.ts
CHANGED
|
@@ -1299,6 +1299,81 @@ describe("logCommand", () => {
|
|
|
1299
1299
|
expect(mail?.body).toContain("10 tool calls");
|
|
1300
1300
|
expect(mail?.body).toContain("pattern"); // At least 1 pattern insight
|
|
1301
1301
|
});
|
|
1302
|
+
|
|
1303
|
+
test("threads outcomeStatus into per-domain reference and per-insight records", async () => {
|
|
1304
|
+
const learnResult: MulchLearnResult = {
|
|
1305
|
+
success: true,
|
|
1306
|
+
command: "mulch learn",
|
|
1307
|
+
changedFiles: ["src/foo.ts"],
|
|
1308
|
+
suggestedDomains: ["typescript"],
|
|
1309
|
+
unmatchedFiles: [],
|
|
1310
|
+
};
|
|
1311
|
+
const { client, recordCalls } = createFakeMulchClient(learnResult);
|
|
1312
|
+
const mailDbPath = join(tempDir, ".overstory", "auto-record-outcome.db");
|
|
1313
|
+
|
|
1314
|
+
// Seed events so analyzer emits at least one insight (10+ tool calls).
|
|
1315
|
+
const eventsDbPath = join(tempDir, ".overstory", "events.db");
|
|
1316
|
+
const eventStore = createEventStore(eventsDbPath);
|
|
1317
|
+
const sessionStartedAt = new Date(Date.now() - 60_000).toISOString();
|
|
1318
|
+
for (let i = 0; i < 10; i++) {
|
|
1319
|
+
eventStore.insert({
|
|
1320
|
+
runId: null,
|
|
1321
|
+
agentName: "outcome-agent",
|
|
1322
|
+
sessionId: "sess-outcome",
|
|
1323
|
+
eventType: "tool_start",
|
|
1324
|
+
toolName: "Read",
|
|
1325
|
+
toolArgs: JSON.stringify({ file_path: `/src/file${i}.ts` }),
|
|
1326
|
+
toolDurationMs: null,
|
|
1327
|
+
level: "info",
|
|
1328
|
+
data: JSON.stringify({ summary: `read: /src/file${i}.ts` }),
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
eventStore.close();
|
|
1332
|
+
|
|
1333
|
+
await autoRecordExpertise({
|
|
1334
|
+
mulchClient: client,
|
|
1335
|
+
agentName: "outcome-agent",
|
|
1336
|
+
capability: "builder",
|
|
1337
|
+
taskId: "bead-outcome",
|
|
1338
|
+
mailDbPath,
|
|
1339
|
+
parentAgent: "parent-agent",
|
|
1340
|
+
projectRoot: tempDir,
|
|
1341
|
+
sessionStartedAt,
|
|
1342
|
+
outcomeStatus: "partial",
|
|
1343
|
+
});
|
|
1344
|
+
|
|
1345
|
+
expect(recordCalls.length).toBeGreaterThanOrEqual(2);
|
|
1346
|
+
for (const call of recordCalls) {
|
|
1347
|
+
expect(call.options.outcomeStatus).toBe("partial");
|
|
1348
|
+
expect(call.options.outcomeAgent).toBe("outcome-agent");
|
|
1349
|
+
}
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1352
|
+
test("omits outcomeStatus when caller does not supply one", async () => {
|
|
1353
|
+
const learnResult: MulchLearnResult = {
|
|
1354
|
+
success: true,
|
|
1355
|
+
command: "mulch learn",
|
|
1356
|
+
changedFiles: ["src/foo.ts"],
|
|
1357
|
+
suggestedDomains: ["typescript"],
|
|
1358
|
+
unmatchedFiles: [],
|
|
1359
|
+
};
|
|
1360
|
+
const { client, recordCalls } = createFakeMulchClient(learnResult);
|
|
1361
|
+
const mailDbPath = join(tempDir, ".overstory", "auto-record-no-outcome.db");
|
|
1362
|
+
|
|
1363
|
+
await autoRecordExpertise({
|
|
1364
|
+
mulchClient: client,
|
|
1365
|
+
agentName: "no-outcome-agent",
|
|
1366
|
+
capability: "builder",
|
|
1367
|
+
taskId: null,
|
|
1368
|
+
mailDbPath,
|
|
1369
|
+
parentAgent: null,
|
|
1370
|
+
projectRoot: tempDir,
|
|
1371
|
+
sessionStartedAt: new Date().toISOString(),
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
expect(recordCalls).toHaveLength(1);
|
|
1375
|
+
expect(recordCalls[0]?.options.outcomeStatus).toBeUndefined();
|
|
1376
|
+
});
|
|
1302
1377
|
});
|
|
1303
1378
|
|
|
1304
1379
|
/**
|
|
@@ -1779,4 +1854,60 @@ describe("appendOutcomeToAppliedRecords", () => {
|
|
|
1779
1854
|
});
|
|
1780
1855
|
expect(count).toBe(0);
|
|
1781
1856
|
});
|
|
1857
|
+
|
|
1858
|
+
test("uses supplied outcomeStatus when provided", async () => {
|
|
1859
|
+
const agentDir = join(tempDir, ".overstory", "agents", "test-agent");
|
|
1860
|
+
await mkdir(agentDir, { recursive: true });
|
|
1861
|
+
await Bun.write(
|
|
1862
|
+
join(agentDir, "applied-records.json"),
|
|
1863
|
+
JSON.stringify({
|
|
1864
|
+
taskId: "bead-outcome",
|
|
1865
|
+
agentName: "test-agent",
|
|
1866
|
+
capability: "builder",
|
|
1867
|
+
records: [{ id: "mx-aaa111", domain: "agents" }],
|
|
1868
|
+
}),
|
|
1869
|
+
);
|
|
1870
|
+
|
|
1871
|
+
const { client, appendOutcomeCalls } = makeOutcomeClient();
|
|
1872
|
+
await appendOutcomeToAppliedRecords({
|
|
1873
|
+
mulchClient: client,
|
|
1874
|
+
agentName: "test-agent",
|
|
1875
|
+
capability: "builder",
|
|
1876
|
+
taskId: "bead-outcome",
|
|
1877
|
+
projectRoot: tempDir,
|
|
1878
|
+
outcomeStatus: "failure",
|
|
1879
|
+
});
|
|
1880
|
+
|
|
1881
|
+
expect(appendOutcomeCalls).toHaveLength(1);
|
|
1882
|
+
expect(appendOutcomeCalls[0]?.outcome).toMatchObject({ status: "failure" });
|
|
1883
|
+
expect(appendOutcomeCalls[0]?.outcome.notes).toContain("Quality gates: failure");
|
|
1884
|
+
});
|
|
1885
|
+
|
|
1886
|
+
test("falls back to 'success' when outcomeStatus is undefined (backward compat)", async () => {
|
|
1887
|
+
const agentDir = join(tempDir, ".overstory", "agents", "test-agent");
|
|
1888
|
+
await mkdir(agentDir, { recursive: true });
|
|
1889
|
+
await Bun.write(
|
|
1890
|
+
join(agentDir, "applied-records.json"),
|
|
1891
|
+
JSON.stringify({
|
|
1892
|
+
taskId: "bead-default",
|
|
1893
|
+
agentName: "test-agent",
|
|
1894
|
+
capability: "builder",
|
|
1895
|
+
records: [{ id: "mx-bbb222", domain: "agents" }],
|
|
1896
|
+
}),
|
|
1897
|
+
);
|
|
1898
|
+
|
|
1899
|
+
const { client, appendOutcomeCalls } = makeOutcomeClient();
|
|
1900
|
+
await appendOutcomeToAppliedRecords({
|
|
1901
|
+
mulchClient: client,
|
|
1902
|
+
agentName: "test-agent",
|
|
1903
|
+
capability: "builder",
|
|
1904
|
+
taskId: "bead-default",
|
|
1905
|
+
projectRoot: tempDir,
|
|
1906
|
+
});
|
|
1907
|
+
|
|
1908
|
+
expect(appendOutcomeCalls).toHaveLength(1);
|
|
1909
|
+
expect(appendOutcomeCalls[0]?.outcome.status).toBe("success");
|
|
1910
|
+
// No "Quality gates:" annotation when caller didn't provide outcomeStatus
|
|
1911
|
+
expect(appendOutcomeCalls[0]?.outcome.notes).not.toContain("Quality gates:");
|
|
1912
|
+
});
|
|
1782
1913
|
});
|
package/src/commands/log.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { ValidationError } from "../errors.ts";
|
|
|
19
19
|
import { createEventStore } from "../events/store.ts";
|
|
20
20
|
import { filterToolArgs } from "../events/tool-filter.ts";
|
|
21
21
|
import { analyzeSessionInsights } from "../insights/analyzer.ts";
|
|
22
|
+
import { hasWorkToVerify, runQualityGates } from "../insights/quality-gates.ts";
|
|
22
23
|
import { createLogger } from "../logging/logger.ts";
|
|
23
24
|
import { createMailClient } from "../mail/client.ts";
|
|
24
25
|
import { createMailStore } from "../mail/store.ts";
|
|
@@ -379,6 +380,7 @@ export async function autoRecordExpertise(params: {
|
|
|
379
380
|
parentAgent: string | null;
|
|
380
381
|
projectRoot: string;
|
|
381
382
|
sessionStartedAt: string;
|
|
383
|
+
outcomeStatus?: "success" | "partial" | "failure";
|
|
382
384
|
}): Promise<string[]> {
|
|
383
385
|
const learnResult = await params.mulchClient.learn({ since: "HEAD~1" });
|
|
384
386
|
if (learnResult.suggestedDomains.length === 0) {
|
|
@@ -395,6 +397,8 @@ export async function autoRecordExpertise(params: {
|
|
|
395
397
|
description: `${params.capability} agent ${params.agentName} completed work in this domain. Files: ${filesList}`,
|
|
396
398
|
tags: ["auto-session-end", params.capability],
|
|
397
399
|
evidenceBead: params.taskId ?? undefined,
|
|
400
|
+
outcomeStatus: params.outcomeStatus,
|
|
401
|
+
outcomeAgent: params.agentName,
|
|
398
402
|
});
|
|
399
403
|
recordedDomains.push(domain);
|
|
400
404
|
} catch {
|
|
@@ -434,6 +438,8 @@ export async function autoRecordExpertise(params: {
|
|
|
434
438
|
description: insight.description,
|
|
435
439
|
tags: insight.tags,
|
|
436
440
|
evidenceBead: params.taskId ?? undefined,
|
|
441
|
+
outcomeStatus: params.outcomeStatus,
|
|
442
|
+
outcomeAgent: params.agentName,
|
|
437
443
|
});
|
|
438
444
|
if (!recordedDomains.includes(insight.domain)) {
|
|
439
445
|
recordedDomains.push(insight.domain);
|
|
@@ -500,6 +506,7 @@ export async function appendOutcomeToAppliedRecords(params: {
|
|
|
500
506
|
capability: string;
|
|
501
507
|
taskId: string | null;
|
|
502
508
|
projectRoot: string;
|
|
509
|
+
outcomeStatus?: "success" | "partial" | "failure";
|
|
503
510
|
}): Promise<number> {
|
|
504
511
|
const appliedRecordsPath = join(
|
|
505
512
|
params.projectRoot,
|
|
@@ -522,10 +529,12 @@ export async function appendOutcomeToAppliedRecords(params: {
|
|
|
522
529
|
if (!records || records.length === 0) return 0;
|
|
523
530
|
|
|
524
531
|
const taskSuffix = params.taskId ? ` for task ${params.taskId}` : "";
|
|
532
|
+
const status: "success" | "partial" | "failure" = params.outcomeStatus ?? "success";
|
|
533
|
+
const gateNote = params.outcomeStatus ? ` Quality gates: ${params.outcomeStatus}.` : "";
|
|
525
534
|
const outcome = {
|
|
526
|
-
status
|
|
535
|
+
status,
|
|
527
536
|
agent: params.agentName,
|
|
528
|
-
notes: `Applied by ${params.capability} agent ${params.agentName}${taskSuffix}. Session completed
|
|
537
|
+
notes: `Applied by ${params.capability} agent ${params.agentName}${taskSuffix}. Session completed.${gateNote}`,
|
|
529
538
|
};
|
|
530
539
|
|
|
531
540
|
let appended = 0;
|
|
@@ -793,6 +802,30 @@ async function runLog(opts: {
|
|
|
793
802
|
// Non-fatal: metrics recording should not break session-end handling
|
|
794
803
|
}
|
|
795
804
|
|
|
805
|
+
// Resolve outcome status from quality-gate results, threaded into
|
|
806
|
+
// every session-end mulch record write so confirmation scoring
|
|
807
|
+
// reflects whether tests/lint/typecheck actually passed.
|
|
808
|
+
let outcomeStatus: "success" | "partial" | "failure" | undefined;
|
|
809
|
+
if (!isStopHookPersistentCapability(agentSession.capability)) {
|
|
810
|
+
try {
|
|
811
|
+
let baseRef = "main";
|
|
812
|
+
const baseBranchPath = join(config.project.root, ".overstory", "session-branch.txt");
|
|
813
|
+
const baseFile = Bun.file(baseBranchPath);
|
|
814
|
+
if (await baseFile.exists()) {
|
|
815
|
+
const txt = (await baseFile.text()).trim();
|
|
816
|
+
if (txt.length > 0) baseRef = txt;
|
|
817
|
+
}
|
|
818
|
+
const hasWork = await hasWorkToVerify(agentSession.worktreePath, baseRef);
|
|
819
|
+
if (hasWork) {
|
|
820
|
+
const gates = config.project.qualityGates ?? [];
|
|
821
|
+
const outcome = await runQualityGates(gates, agentSession.worktreePath);
|
|
822
|
+
if (outcome) outcomeStatus = outcome.status;
|
|
823
|
+
}
|
|
824
|
+
} catch {
|
|
825
|
+
// Non-fatal: outcome status is optional
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
796
829
|
// Auto-record expertise via mulch learn + record (post-session).
|
|
797
830
|
// Skip persistent agents whose Stop hook fires every turn.
|
|
798
831
|
if (!isStopHookPersistentCapability(agentSession.capability)) {
|
|
@@ -808,6 +841,7 @@ async function runLog(opts: {
|
|
|
808
841
|
parentAgent: agentSession.parentAgent,
|
|
809
842
|
projectRoot: config.project.root,
|
|
810
843
|
sessionStartedAt: agentSession.startedAt,
|
|
844
|
+
outcomeStatus,
|
|
811
845
|
});
|
|
812
846
|
} catch {
|
|
813
847
|
// Non-fatal: mulch learn/record should not break session-end handling
|
|
@@ -825,6 +859,7 @@ async function runLog(opts: {
|
|
|
825
859
|
capability: agentSession.capability,
|
|
826
860
|
taskId,
|
|
827
861
|
projectRoot: config.project.root,
|
|
862
|
+
outcomeStatus,
|
|
828
863
|
});
|
|
829
864
|
} catch {
|
|
830
865
|
// Non-fatal
|
|
@@ -708,6 +708,124 @@ merge:
|
|
|
708
708
|
});
|
|
709
709
|
});
|
|
710
710
|
|
|
711
|
+
describe("--dry-run conflict prediction", () => {
|
|
712
|
+
test("--dry-run --json includes prediction.wouldRequireAgent for contentful-canonical conflict", async () => {
|
|
713
|
+
await setupProject(repoDir, defaultBranch);
|
|
714
|
+
|
|
715
|
+
// Build a real contentful-canonical conflict on src/shared.ts.
|
|
716
|
+
await commitFile(repoDir, "src/shared.ts", "shared base\n");
|
|
717
|
+
const branchName = "overstory/builder/bead-predict-1";
|
|
718
|
+
await runGitInDir(repoDir, ["checkout", "-b", branchName]);
|
|
719
|
+
await commitFile(repoDir, "src/shared.ts", "feature side\n");
|
|
720
|
+
await runGitInDir(repoDir, ["checkout", defaultBranch]);
|
|
721
|
+
await commitFile(repoDir, "src/shared.ts", "main side\n");
|
|
722
|
+
|
|
723
|
+
let output = "";
|
|
724
|
+
const originalWrite = process.stdout.write.bind(process.stdout);
|
|
725
|
+
process.stdout.write = (chunk: unknown): boolean => {
|
|
726
|
+
output += String(chunk);
|
|
727
|
+
return true;
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
try {
|
|
731
|
+
await mergeCommand({ branch: branchName, dryRun: true, json: true });
|
|
732
|
+
} finally {
|
|
733
|
+
process.stdout.write = originalWrite;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const parsed = JSON.parse(output);
|
|
737
|
+
expect(parsed.prediction).toBeDefined();
|
|
738
|
+
expect(parsed.prediction.wouldRequireAgent).toBe(true);
|
|
739
|
+
expect(parsed.prediction.predictedTier).toBe("ai-resolve");
|
|
740
|
+
expect(parsed.prediction.conflictFiles).toContain("src/shared.ts");
|
|
741
|
+
expect(typeof parsed.prediction.reason).toBe("string");
|
|
742
|
+
|
|
743
|
+
// Existing dry-run fields must still be present.
|
|
744
|
+
expect(parsed.branchName).toBe(branchName);
|
|
745
|
+
expect(parsed.status).toBe("pending");
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
test("--dry-run --json reports clean-merge prediction for non-conflicting branch", async () => {
|
|
749
|
+
await setupProject(repoDir, defaultBranch);
|
|
750
|
+
const branchName = "overstory/builder/bead-predict-2";
|
|
751
|
+
await createCleanFeatureBranch(repoDir, branchName);
|
|
752
|
+
|
|
753
|
+
let output = "";
|
|
754
|
+
const originalWrite = process.stdout.write.bind(process.stdout);
|
|
755
|
+
process.stdout.write = (chunk: unknown): boolean => {
|
|
756
|
+
output += String(chunk);
|
|
757
|
+
return true;
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
try {
|
|
761
|
+
await mergeCommand({ branch: branchName, dryRun: true, json: true });
|
|
762
|
+
} finally {
|
|
763
|
+
process.stdout.write = originalWrite;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const parsed = JSON.parse(output);
|
|
767
|
+
expect(parsed.prediction.predictedTier).toBe("clean-merge");
|
|
768
|
+
expect(parsed.prediction.wouldRequireAgent).toBe(false);
|
|
769
|
+
expect(parsed.prediction.conflictFiles).toEqual([]);
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
test("--all --dry-run --json attaches prediction to each entry", async () => {
|
|
773
|
+
await setupProject(repoDir, defaultBranch);
|
|
774
|
+
const cleanBranch = "overstory/builder/bead-predict-all-clean";
|
|
775
|
+
await createCleanFeatureBranch(repoDir, cleanBranch);
|
|
776
|
+
|
|
777
|
+
// Add a contentful-canonical conflict branch.
|
|
778
|
+
await commitFile(repoDir, "src/shared.ts", "shared base\n");
|
|
779
|
+
const conflictBranch = "overstory/builder/bead-predict-all-conflict";
|
|
780
|
+
await runGitInDir(repoDir, ["checkout", "-b", conflictBranch]);
|
|
781
|
+
await commitFile(repoDir, "src/shared.ts", "feature side\n");
|
|
782
|
+
await runGitInDir(repoDir, ["checkout", defaultBranch]);
|
|
783
|
+
await commitFile(repoDir, "src/shared.ts", "main side\n");
|
|
784
|
+
|
|
785
|
+
const queuePath = join(repoDir, ".overstory", "merge-queue.db");
|
|
786
|
+
const queue = createMergeQueue(queuePath);
|
|
787
|
+
queue.enqueue({
|
|
788
|
+
branchName: cleanBranch,
|
|
789
|
+
taskId: "bead-predict-all-clean",
|
|
790
|
+
agentName: "builder",
|
|
791
|
+
filesModified: [`src/${cleanBranch}.ts`],
|
|
792
|
+
});
|
|
793
|
+
queue.enqueue({
|
|
794
|
+
branchName: conflictBranch,
|
|
795
|
+
taskId: "bead-predict-all-conflict",
|
|
796
|
+
agentName: "builder",
|
|
797
|
+
filesModified: ["src/shared.ts"],
|
|
798
|
+
});
|
|
799
|
+
queue.close();
|
|
800
|
+
|
|
801
|
+
let output = "";
|
|
802
|
+
const originalWrite = process.stdout.write.bind(process.stdout);
|
|
803
|
+
process.stdout.write = (chunk: unknown): boolean => {
|
|
804
|
+
output += String(chunk);
|
|
805
|
+
return true;
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
try {
|
|
809
|
+
await mergeCommand({ all: true, dryRun: true, json: true });
|
|
810
|
+
} finally {
|
|
811
|
+
process.stdout.write = originalWrite;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const parsed = JSON.parse(output);
|
|
815
|
+
expect(parsed.entries).toHaveLength(2);
|
|
816
|
+
const cleanEntry = parsed.entries.find(
|
|
817
|
+
(e: { branchName: string }) => e.branchName === cleanBranch,
|
|
818
|
+
);
|
|
819
|
+
const conflictEntry = parsed.entries.find(
|
|
820
|
+
(e: { branchName: string }) => e.branchName === conflictBranch,
|
|
821
|
+
);
|
|
822
|
+
expect(cleanEntry.prediction.predictedTier).toBe("clean-merge");
|
|
823
|
+
expect(cleanEntry.prediction.wouldRequireAgent).toBe(false);
|
|
824
|
+
expect(conflictEntry.prediction.predictedTier).toBe("ai-resolve");
|
|
825
|
+
expect(conflictEntry.prediction.wouldRequireAgent).toBe(true);
|
|
826
|
+
});
|
|
827
|
+
});
|
|
828
|
+
|
|
711
829
|
describe("conflict handling", () => {
|
|
712
830
|
test("content conflict auto-resolves: same file modified on both branches, verify incoming content wins", async () => {
|
|
713
831
|
await setupProject(repoDir, defaultBranch);
|
package/src/commands/merge.ts
CHANGED
|
@@ -17,10 +17,11 @@ import { MergeError, ValidationError } from "../errors.ts";
|
|
|
17
17
|
import { jsonOutput } from "../json.ts";
|
|
18
18
|
import { accent, printHint } from "../logging/color.ts";
|
|
19
19
|
import { acquireMergeLock } from "../merge/lock.ts";
|
|
20
|
+
import { predictConflicts } from "../merge/predict.ts";
|
|
20
21
|
import { createMergeQueue } from "../merge/queue.ts";
|
|
21
22
|
import { createMergeResolver } from "../merge/resolver.ts";
|
|
22
23
|
import { createMulchClient } from "../mulch/client.ts";
|
|
23
|
-
import type { MergeEntry, MergeResult } from "../types.ts";
|
|
24
|
+
import type { ConflictPrediction, MergeEntry, MergeResult } from "../types.ts";
|
|
24
25
|
|
|
25
26
|
export interface MergeOptions {
|
|
26
27
|
branch?: string;
|
|
@@ -109,7 +110,7 @@ function formatResult(result: MergeResult): string {
|
|
|
109
110
|
}
|
|
110
111
|
|
|
111
112
|
/** Format a dry-run report for a merge entry. */
|
|
112
|
-
function formatDryRun(entry: MergeEntry): string {
|
|
113
|
+
function formatDryRun(entry: MergeEntry, prediction?: ConflictPrediction): string {
|
|
113
114
|
const lines: string[] = [
|
|
114
115
|
`[dry-run] Branch: ${accent(entry.branchName)}`,
|
|
115
116
|
` Agent: ${accent(entry.agentName)} | Task: ${accent(entry.taskId)}`,
|
|
@@ -123,9 +124,41 @@ function formatDryRun(entry: MergeEntry): string {
|
|
|
123
124
|
}
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
if (prediction) {
|
|
128
|
+
const agentSuffix = prediction.wouldRequireAgent ? " (would require merger agent)" : "";
|
|
129
|
+
lines.push(` Prediction: ${prediction.predictedTier}${agentSuffix} — ${prediction.reason}`);
|
|
130
|
+
if (prediction.conflictFiles.length > 0) {
|
|
131
|
+
lines.push(` Conflict files: ${prediction.conflictFiles.join(", ")}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
126
135
|
return lines.join("\n");
|
|
127
136
|
}
|
|
128
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Predict the merge tier for a single entry, swallowing errors into a
|
|
140
|
+
* deterministic `ai-resolve` envelope so that `--all --dry-run` can keep
|
|
141
|
+
* going if one branch's prediction blows up.
|
|
142
|
+
*/
|
|
143
|
+
async function safePredictForEntry(
|
|
144
|
+
entry: MergeEntry,
|
|
145
|
+
canonicalBranch: string,
|
|
146
|
+
repoRoot: string,
|
|
147
|
+
mulchClient: ReturnType<typeof createMulchClient>,
|
|
148
|
+
): Promise<ConflictPrediction> {
|
|
149
|
+
try {
|
|
150
|
+
return await predictConflicts(entry, canonicalBranch, repoRoot, mulchClient);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
153
|
+
return {
|
|
154
|
+
predictedTier: "ai-resolve",
|
|
155
|
+
conflictFiles: [],
|
|
156
|
+
wouldRequireAgent: true,
|
|
157
|
+
reason: `prediction-failed: ${msg}`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
129
162
|
/**
|
|
130
163
|
* Entry point for `ov merge [flags]`.
|
|
131
164
|
*
|
|
@@ -238,10 +271,13 @@ async function handleBranch(
|
|
|
238
271
|
}
|
|
239
272
|
|
|
240
273
|
if (dryRun) {
|
|
274
|
+
const mulchClient = createMulchClient(config.project.root);
|
|
275
|
+
const prediction = await safePredictForEntry(entry, canonicalBranch, repoRoot, mulchClient);
|
|
276
|
+
|
|
241
277
|
if (json) {
|
|
242
|
-
jsonOutput("merge", { ...entry });
|
|
278
|
+
jsonOutput("merge", { ...entry, prediction });
|
|
243
279
|
} else {
|
|
244
|
-
process.stdout.write(`${formatDryRun(entry)}\n`);
|
|
280
|
+
process.stdout.write(`${formatDryRun(entry, prediction)}\n`);
|
|
245
281
|
}
|
|
246
282
|
return;
|
|
247
283
|
}
|
|
@@ -293,14 +329,21 @@ async function handleAll(
|
|
|
293
329
|
}
|
|
294
330
|
|
|
295
331
|
if (dryRun) {
|
|
332
|
+
const mulchClient = createMulchClient(config.project.root);
|
|
333
|
+
const enrichedEntries: Array<MergeEntry & { prediction: ConflictPrediction }> = [];
|
|
334
|
+
for (const entry of pendingEntries) {
|
|
335
|
+
const prediction = await safePredictForEntry(entry, canonicalBranch, repoRoot, mulchClient);
|
|
336
|
+
enrichedEntries.push({ ...entry, prediction });
|
|
337
|
+
}
|
|
338
|
+
|
|
296
339
|
if (json) {
|
|
297
|
-
jsonOutput("merge", { entries:
|
|
340
|
+
jsonOutput("merge", { entries: enrichedEntries });
|
|
298
341
|
} else {
|
|
299
342
|
process.stdout.write(
|
|
300
|
-
`${
|
|
343
|
+
`${enrichedEntries.length} pending branch${enrichedEntries.length === 1 ? "" : "es"}:\n\n`,
|
|
301
344
|
);
|
|
302
|
-
for (const entry of
|
|
303
|
-
process.stdout.write(`${formatDryRun(entry)}\n\n`);
|
|
345
|
+
for (const entry of enrichedEntries) {
|
|
346
|
+
process.stdout.write(`${formatDryRun(entry, entry.prediction)}\n\n`);
|
|
304
347
|
}
|
|
305
348
|
}
|
|
306
349
|
return;
|
|
@@ -27,6 +27,8 @@ import {
|
|
|
27
27
|
isRunningAsRoot,
|
|
28
28
|
isTaskWorkable,
|
|
29
29
|
parentHasScouts,
|
|
30
|
+
parseSiblings,
|
|
31
|
+
resolveParentAgent,
|
|
30
32
|
resolveUseHeadless,
|
|
31
33
|
shouldShowScoutWarning,
|
|
32
34
|
validateHierarchy,
|
|
@@ -787,6 +789,48 @@ describe("isTaskWorkable", () => {
|
|
|
787
789
|
});
|
|
788
790
|
});
|
|
789
791
|
|
|
792
|
+
// --- resolveParentAgent (overstory-de3c) ---
|
|
793
|
+
//
|
|
794
|
+
// Witnessed bug: a coordinator/lead recovered a zombie spawn-per-turn worker
|
|
795
|
+
// via `ov sling --recover --name <existing>` without threading `--parent`.
|
|
796
|
+
// The pre-fix `parentAgent = opts.parent ?? null` overwrote the prior
|
|
797
|
+
// `parent_agent` row to null on upsert, so the runner could not emit
|
|
798
|
+
// `worker_died` on a resumed-turn parser stall — the lead waited forever.
|
|
799
|
+
// The fix: when --parent is not explicitly passed, fall back to the prior
|
|
800
|
+
// session row's parentAgent. Explicit caller intent (any string, including
|
|
801
|
+
// empty) always wins.
|
|
802
|
+
describe("resolveParentAgent", () => {
|
|
803
|
+
test("case A: explicit --parent wins over prior session linkage", () => {
|
|
804
|
+
const existing = { parentAgent: "old-lead" };
|
|
805
|
+
expect(resolveParentAgent("new-lead", existing)).toBe("new-lead");
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
test("case B: --parent omitted preserves prior session's parentAgent on re-spawn", () => {
|
|
809
|
+
// THE REGRESSION CHECK. Pre-fix this returned null, severing the link
|
|
810
|
+
// the runner needs to emit worker_died (overstory-de3c).
|
|
811
|
+
const existing = { parentAgent: "lead-r" };
|
|
812
|
+
expect(resolveParentAgent(undefined, existing)).toBe("lead-r");
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
test("--parent omitted with no prior session yields null (fresh agent)", () => {
|
|
816
|
+
expect(resolveParentAgent(undefined, null)).toBeNull();
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
test("--parent omitted with prior session whose parent is null yields null", () => {
|
|
820
|
+
// A coordinator-spawned root agent has parentAgent=null. Re-spawn must
|
|
821
|
+
// not synthesize a parent.
|
|
822
|
+
const existing = { parentAgent: null };
|
|
823
|
+
expect(resolveParentAgent(undefined, existing)).toBeNull();
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
test("explicit --parent='' (empty string) is honored — caller intent wins", () => {
|
|
827
|
+
// Empty string is `defined` but `null`-y; we honor it as caller intent
|
|
828
|
+
// rather than silently falling back to the prior linkage.
|
|
829
|
+
const existing = { parentAgent: "lead-r" };
|
|
830
|
+
expect(resolveParentAgent("", existing)).toBe("");
|
|
831
|
+
});
|
|
832
|
+
});
|
|
833
|
+
|
|
790
834
|
describe("checkDuplicateLead", () => {
|
|
791
835
|
test("returns lead agent name when an active lead exists for the task", () => {
|
|
792
836
|
const sessions = [
|
|
@@ -1167,6 +1211,7 @@ function makeAutoDispatchOpts(overrides?: Partial<AutoDispatchOptions>): AutoDis
|
|
|
1167
1211
|
capability: "builder",
|
|
1168
1212
|
specPath: "/path/to/spec.md",
|
|
1169
1213
|
parentAgent: "lead-alpha",
|
|
1214
|
+
slingerName: null,
|
|
1170
1215
|
instructionPath: ".claude/CLAUDE.md",
|
|
1171
1216
|
...overrides,
|
|
1172
1217
|
};
|
|
@@ -1180,6 +1225,7 @@ describe("buildAutoDispatch", () => {
|
|
|
1180
1225
|
capability: "builder",
|
|
1181
1226
|
specPath: "/path/to/spec.md",
|
|
1182
1227
|
parentAgent: "lead-alpha",
|
|
1228
|
+
slingerName: null,
|
|
1183
1229
|
instructionPath: ".claude/CLAUDE.md",
|
|
1184
1230
|
});
|
|
1185
1231
|
expect(dispatch.from).toBe("lead-alpha");
|
|
@@ -1195,6 +1241,7 @@ describe("buildAutoDispatch", () => {
|
|
|
1195
1241
|
capability: "lead",
|
|
1196
1242
|
specPath: null,
|
|
1197
1243
|
parentAgent: null,
|
|
1244
|
+
slingerName: null,
|
|
1198
1245
|
instructionPath: ".claude/CLAUDE.md",
|
|
1199
1246
|
});
|
|
1200
1247
|
expect(dispatch.from).toBe("orchestrator");
|
|
@@ -1208,6 +1255,7 @@ describe("buildAutoDispatch", () => {
|
|
|
1208
1255
|
capability: "scout",
|
|
1209
1256
|
specPath: null,
|
|
1210
1257
|
parentAgent: "lead-alpha",
|
|
1258
|
+
slingerName: null,
|
|
1211
1259
|
instructionPath: ".claude/CLAUDE.md",
|
|
1212
1260
|
});
|
|
1213
1261
|
expect(dispatch.body).toContain("scout");
|
|
@@ -1220,11 +1268,33 @@ describe("buildAutoDispatch", () => {
|
|
|
1220
1268
|
capability: "builder",
|
|
1221
1269
|
specPath: "/abs/path/to/spec.md",
|
|
1222
1270
|
parentAgent: "lead-alpha",
|
|
1271
|
+
slingerName: null,
|
|
1223
1272
|
instructionPath: ".claude/CLAUDE.md",
|
|
1224
1273
|
});
|
|
1225
1274
|
expect(dispatch.body).toContain("/abs/path/to/spec.md");
|
|
1226
1275
|
});
|
|
1227
1276
|
|
|
1277
|
+
test("slinger takes precedence over parent agent for from field", () => {
|
|
1278
|
+
const dispatch = buildAutoDispatch(
|
|
1279
|
+
makeAutoDispatchOpts({ slingerName: "coordinator", parentAgent: "lead-alpha" }),
|
|
1280
|
+
);
|
|
1281
|
+
expect(dispatch.from).toBe("coordinator");
|
|
1282
|
+
});
|
|
1283
|
+
|
|
1284
|
+
test("slinger fills in when parent agent is null", () => {
|
|
1285
|
+
const dispatch = buildAutoDispatch(
|
|
1286
|
+
makeAutoDispatchOpts({ slingerName: "coordinator", parentAgent: null }),
|
|
1287
|
+
);
|
|
1288
|
+
expect(dispatch.from).toBe("coordinator");
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
test("falls back to orchestrator when both slinger and parent are null", () => {
|
|
1292
|
+
const dispatch = buildAutoDispatch(
|
|
1293
|
+
makeAutoDispatchOpts({ slingerName: null, parentAgent: null }),
|
|
1294
|
+
);
|
|
1295
|
+
expect(dispatch.from).toBe("orchestrator");
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1228
1298
|
test("subject contains task ID", () => {
|
|
1229
1299
|
const dispatch = buildAutoDispatch(makeAutoDispatchOpts({ taskId: "overstory-zz99" }));
|
|
1230
1300
|
expect(dispatch.subject).toContain("overstory-zz99");
|
|
@@ -1477,3 +1547,37 @@ describe("resolveUseHeadless", () => {
|
|
|
1477
1547
|
expect(resolveUseHeadless(saplingLike, false, baseConfig)).toBe(true);
|
|
1478
1548
|
});
|
|
1479
1549
|
});
|
|
1550
|
+
|
|
1551
|
+
describe("parseSiblings (overstory-f76a)", () => {
|
|
1552
|
+
test("undefined input returns empty array", () => {
|
|
1553
|
+
expect(parseSiblings(undefined)).toEqual([]);
|
|
1554
|
+
});
|
|
1555
|
+
|
|
1556
|
+
test("empty string returns empty array", () => {
|
|
1557
|
+
expect(parseSiblings("")).toEqual([]);
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
test("single name returns one-element array", () => {
|
|
1561
|
+
expect(parseSiblings("sibling-a")).toEqual(["sibling-a"]);
|
|
1562
|
+
});
|
|
1563
|
+
|
|
1564
|
+
test("comma-separated names are split and trimmed", () => {
|
|
1565
|
+
expect(parseSiblings("sibling-a, sibling-b ,sibling-c")).toEqual([
|
|
1566
|
+
"sibling-a",
|
|
1567
|
+
"sibling-b",
|
|
1568
|
+
"sibling-c",
|
|
1569
|
+
]);
|
|
1570
|
+
});
|
|
1571
|
+
|
|
1572
|
+
test("blank entries between commas are dropped", () => {
|
|
1573
|
+
expect(parseSiblings("sibling-a,,sibling-b, ,sibling-c")).toEqual([
|
|
1574
|
+
"sibling-a",
|
|
1575
|
+
"sibling-b",
|
|
1576
|
+
"sibling-c",
|
|
1577
|
+
]);
|
|
1578
|
+
});
|
|
1579
|
+
|
|
1580
|
+
test("whitespace-only input returns empty array", () => {
|
|
1581
|
+
expect(parseSiblings(" ")).toEqual([]);
|
|
1582
|
+
});
|
|
1583
|
+
});
|