@os-eco/overstory-cli 0.9.4 → 0.10.3
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 +47 -18
- package/agents/builder.md +9 -8
- package/agents/coordinator.md +6 -6
- package/agents/lead.md +98 -82
- package/agents/merger.md +25 -14
- package/agents/reviewer.md +22 -16
- package/agents/scout.md +17 -12
- package/package.json +6 -3
- package/src/agents/capabilities.test.ts +85 -0
- package/src/agents/capabilities.ts +125 -0
- package/src/agents/headless-mail-injector.test.ts +448 -0
- package/src/agents/headless-mail-injector.ts +211 -0
- package/src/agents/headless-prompt.test.ts +102 -0
- package/src/agents/headless-prompt.ts +68 -0
- package/src/agents/hooks-deployer.test.ts +514 -14
- package/src/agents/hooks-deployer.ts +141 -0
- package/src/agents/overlay.test.ts +4 -4
- package/src/agents/overlay.ts +30 -8
- package/src/agents/turn-lock.test.ts +181 -0
- package/src/agents/turn-lock.ts +235 -0
- package/src/agents/turn-runner-dispatch.test.ts +182 -0
- package/src/agents/turn-runner-dispatch.ts +105 -0
- package/src/agents/turn-runner.test.ts +1450 -0
- package/src/agents/turn-runner.ts +1166 -0
- package/src/commands/clean.ts +54 -0
- package/src/commands/coordinator.test.ts +127 -0
- package/src/commands/coordinator.ts +203 -5
- package/src/commands/dashboard.test.ts +188 -0
- package/src/commands/dashboard.ts +13 -3
- package/src/commands/doctor.ts +3 -1
- package/src/commands/group.test.ts +94 -0
- package/src/commands/group.ts +49 -20
- package/src/commands/init.test.ts +8 -0
- package/src/commands/init.ts +8 -1
- package/src/commands/log.test.ts +56 -11
- package/src/commands/log.ts +134 -69
- package/src/commands/mail.test.ts +162 -0
- package/src/commands/mail.ts +64 -9
- package/src/commands/merge.test.ts +112 -1
- package/src/commands/merge.ts +17 -4
- package/src/commands/nudge.test.ts +351 -4
- package/src/commands/nudge.ts +356 -34
- package/src/commands/run.test.ts +43 -7
- package/src/commands/serve/build.test.ts +202 -0
- package/src/commands/serve/build.ts +206 -0
- package/src/commands/serve/coordinator-actions.test.ts +339 -0
- package/src/commands/serve/coordinator-actions.ts +408 -0
- package/src/commands/serve/dev.test.ts +168 -0
- package/src/commands/serve/dev.ts +117 -0
- package/src/commands/serve/mail-actions.test.ts +312 -0
- package/src/commands/serve/mail-actions.ts +167 -0
- package/src/commands/serve/rest.test.ts +1323 -0
- package/src/commands/serve/rest.ts +708 -0
- package/src/commands/serve/static.ts +51 -0
- package/src/commands/serve/ws.test.ts +361 -0
- package/src/commands/serve/ws.ts +332 -0
- package/src/commands/serve.test.ts +459 -0
- package/src/commands/serve.ts +565 -0
- package/src/commands/sling.test.ts +73 -1
- package/src/commands/sling.ts +149 -64
- package/src/commands/status.test.ts +9 -0
- package/src/commands/status.ts +12 -4
- package/src/commands/stop.test.ts +174 -1
- package/src/commands/stop.ts +107 -8
- package/src/commands/watch.test.ts +43 -0
- package/src/commands/watch.ts +153 -28
- package/src/config.ts +23 -0
- package/src/doctor/consistency.test.ts +106 -0
- package/src/doctor/consistency.ts +48 -1
- package/src/doctor/serve.test.ts +95 -0
- package/src/doctor/serve.ts +86 -0
- package/src/doctor/types.ts +2 -1
- package/src/doctor/watchdog.ts +57 -1
- package/src/events/tailer.test.ts +234 -1
- package/src/events/tailer.ts +90 -0
- package/src/index.ts +53 -6
- package/src/json.ts +29 -0
- package/src/mail/client.ts +15 -2
- package/src/mail/store.test.ts +82 -0
- package/src/mail/store.ts +41 -4
- package/src/merge/lock.test.ts +149 -0
- package/src/merge/lock.ts +140 -0
- package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
- package/src/runtimes/claude.test.ts +791 -1
- package/src/runtimes/claude.ts +323 -1
- package/src/runtimes/connections.test.ts +141 -1
- package/src/runtimes/connections.ts +73 -4
- package/src/runtimes/headless-connection.test.ts +264 -0
- package/src/runtimes/headless-connection.ts +158 -0
- package/src/runtimes/types.ts +10 -0
- package/src/schema-consistency.test.ts +1 -0
- package/src/sessions/store.test.ts +390 -24
- package/src/sessions/store.ts +184 -19
- package/src/test-setup.test.ts +31 -0
- package/src/test-setup.ts +28 -0
- package/src/types.ts +56 -1
- package/src/utils/pid.test.ts +85 -1
- package/src/utils/pid.ts +86 -1
- package/src/utils/process-scan.test.ts +53 -0
- package/src/utils/process-scan.ts +76 -0
- package/src/watchdog/daemon.test.ts +1520 -411
- package/src/watchdog/daemon.ts +442 -83
- package/src/watchdog/health.test.ts +157 -0
- package/src/watchdog/health.ts +92 -25
- package/src/worktree/process.test.ts +71 -0
- package/src/worktree/process.ts +25 -5
- package/src/worktree/tmux.test.ts +3 -0
- package/src/worktree/tmux.ts +10 -3
- package/templates/CLAUDE.md.tmpl +19 -8
- package/templates/overlay.md.tmpl +3 -2
|
@@ -295,6 +295,7 @@ function makeDashboardData(
|
|
|
295
295
|
worktrees: [],
|
|
296
296
|
tmuxSessions: [],
|
|
297
297
|
unreadMailCount: 0,
|
|
298
|
+
unreadMailScope: "orchestrator",
|
|
298
299
|
mergeQueueCount: 0,
|
|
299
300
|
recentMetricsCount: 0,
|
|
300
301
|
},
|
|
@@ -447,6 +448,7 @@ describe("renderAgentPanel", () => {
|
|
|
447
448
|
worktrees: [],
|
|
448
449
|
tmuxSessions: [], // no tmux sessions
|
|
449
450
|
unreadMailCount: 0,
|
|
451
|
+
unreadMailScope: "orchestrator",
|
|
450
452
|
mergeQueueCount: 0,
|
|
451
453
|
recentMetricsCount: 0,
|
|
452
454
|
},
|
|
@@ -487,6 +489,7 @@ describe("renderAgentPanel", () => {
|
|
|
487
489
|
worktrees: [],
|
|
488
490
|
tmuxSessions: [],
|
|
489
491
|
unreadMailCount: 0,
|
|
492
|
+
unreadMailScope: "orchestrator",
|
|
490
493
|
mergeQueueCount: 0,
|
|
491
494
|
recentMetricsCount: 0,
|
|
492
495
|
},
|
|
@@ -495,6 +498,191 @@ describe("renderAgentPanel", () => {
|
|
|
495
498
|
expect(out).toContain("x");
|
|
496
499
|
expect(out).toContain("dead-headless");
|
|
497
500
|
});
|
|
501
|
+
|
|
502
|
+
test("renders mixed tmux + headless agents in same frame with correct liveness", () => {
|
|
503
|
+
const data = {
|
|
504
|
+
...makeDashboardData({}),
|
|
505
|
+
status: {
|
|
506
|
+
currentRunId: null,
|
|
507
|
+
agents: [
|
|
508
|
+
{
|
|
509
|
+
id: "sess-tmux-1",
|
|
510
|
+
agentName: "pane-agent",
|
|
511
|
+
capability: "builder",
|
|
512
|
+
worktreePath: "/tmp/wt/pane-agent",
|
|
513
|
+
branchName: "overstory/pane-agent/task-t1",
|
|
514
|
+
taskId: "task-t1",
|
|
515
|
+
tmuxSession: "overstory-pane-agent",
|
|
516
|
+
state: "working" as const,
|
|
517
|
+
pid: 99999,
|
|
518
|
+
parentAgent: null,
|
|
519
|
+
depth: 0,
|
|
520
|
+
runId: null,
|
|
521
|
+
startedAt: new Date(Date.now() - 10_000).toISOString(),
|
|
522
|
+
lastActivity: new Date().toISOString(),
|
|
523
|
+
escalationLevel: 0,
|
|
524
|
+
stalledSince: null,
|
|
525
|
+
transcriptPath: null,
|
|
526
|
+
},
|
|
527
|
+
{
|
|
528
|
+
id: "sess-headless-1",
|
|
529
|
+
agentName: "live-headless",
|
|
530
|
+
capability: "builder",
|
|
531
|
+
worktreePath: "/tmp/wt/live-headless",
|
|
532
|
+
branchName: "overstory/live-headless/task-h1",
|
|
533
|
+
taskId: "task-h1",
|
|
534
|
+
tmuxSession: "", // headless
|
|
535
|
+
state: "working" as const,
|
|
536
|
+
pid: process.pid, // own PID — guaranteed alive
|
|
537
|
+
parentAgent: null,
|
|
538
|
+
depth: 0,
|
|
539
|
+
runId: null,
|
|
540
|
+
startedAt: new Date(Date.now() - 10_000).toISOString(),
|
|
541
|
+
lastActivity: new Date().toISOString(),
|
|
542
|
+
escalationLevel: 0,
|
|
543
|
+
stalledSince: null,
|
|
544
|
+
transcriptPath: null,
|
|
545
|
+
},
|
|
546
|
+
],
|
|
547
|
+
worktrees: [],
|
|
548
|
+
tmuxSessions: [{ name: "overstory-pane-agent", pid: 99998 }],
|
|
549
|
+
unreadMailCount: 0,
|
|
550
|
+
unreadMailScope: "orchestrator",
|
|
551
|
+
mergeQueueCount: 0,
|
|
552
|
+
recentMetricsCount: 0,
|
|
553
|
+
},
|
|
554
|
+
};
|
|
555
|
+
const out = renderAgentPanel(data, 100, 12, 3);
|
|
556
|
+
expect(out).toContain("pane-agent");
|
|
557
|
+
expect(out).toContain("live-headless");
|
|
558
|
+
const aliveMarkers = (out.match(/>/g) ?? []).length;
|
|
559
|
+
expect(aliveMarkers).toBeGreaterThanOrEqual(2);
|
|
560
|
+
expect(out).not.toContain("x");
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test("spawn-per-turn worker (no tmux, no pid) renders alive when state is non-terminal (overstory-7a34)", () => {
|
|
564
|
+
// Repro: freshly slung headless lead has tmuxSession='' and pid=null.
|
|
565
|
+
// Previously fell into the tmux path → never matched → red "x" while
|
|
566
|
+
// ov feed showed live tool events from the same agent.
|
|
567
|
+
const data = {
|
|
568
|
+
...makeDashboardData({}),
|
|
569
|
+
status: {
|
|
570
|
+
currentRunId: null,
|
|
571
|
+
agents: [
|
|
572
|
+
{
|
|
573
|
+
id: "sess-spt-1",
|
|
574
|
+
agentName: "freshly-slung",
|
|
575
|
+
capability: "lead",
|
|
576
|
+
worktreePath: "/tmp/wt/freshly-slung",
|
|
577
|
+
branchName: "overstory/freshly-slung/task-l1",
|
|
578
|
+
taskId: "task-l1",
|
|
579
|
+
tmuxSession: "", // headless
|
|
580
|
+
state: "working" as const,
|
|
581
|
+
pid: null, // spawn-per-turn: no persistent process between turns
|
|
582
|
+
parentAgent: null,
|
|
583
|
+
depth: 0,
|
|
584
|
+
runId: null,
|
|
585
|
+
startedAt: new Date(Date.now() - 5_000).toISOString(),
|
|
586
|
+
lastActivity: new Date().toISOString(),
|
|
587
|
+
escalationLevel: 0,
|
|
588
|
+
stalledSince: null,
|
|
589
|
+
transcriptPath: null,
|
|
590
|
+
},
|
|
591
|
+
],
|
|
592
|
+
worktrees: [],
|
|
593
|
+
tmuxSessions: [],
|
|
594
|
+
unreadMailCount: 0,
|
|
595
|
+
unreadMailScope: "orchestrator",
|
|
596
|
+
mergeQueueCount: 0,
|
|
597
|
+
recentMetricsCount: 0,
|
|
598
|
+
},
|
|
599
|
+
};
|
|
600
|
+
const out = renderAgentPanel(data, 100, 12, 3);
|
|
601
|
+
expect(out).toContain("freshly-slung");
|
|
602
|
+
// Green ">" — agent is logically alive between turns
|
|
603
|
+
expect(out).toContain(">");
|
|
604
|
+
// No red marker should be present (name 'freshly-slung' has no 'x')
|
|
605
|
+
expect(out).not.toContain("x");
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
test("spawn-per-turn worker in zombie state renders dead marker (overstory-7a34)", () => {
|
|
609
|
+
const data = {
|
|
610
|
+
...makeDashboardData({}),
|
|
611
|
+
status: {
|
|
612
|
+
currentRunId: null,
|
|
613
|
+
agents: [
|
|
614
|
+
{
|
|
615
|
+
id: "sess-spt-2",
|
|
616
|
+
agentName: "abandoned-spt",
|
|
617
|
+
capability: "builder",
|
|
618
|
+
worktreePath: "/tmp/wt/abandoned-spt",
|
|
619
|
+
branchName: "overstory/abandoned-spt/task-a1",
|
|
620
|
+
taskId: "task-a1",
|
|
621
|
+
tmuxSession: "",
|
|
622
|
+
state: "zombie" as const,
|
|
623
|
+
pid: null,
|
|
624
|
+
parentAgent: null,
|
|
625
|
+
depth: 0,
|
|
626
|
+
runId: null,
|
|
627
|
+
startedAt: new Date(Date.now() - 600_000).toISOString(),
|
|
628
|
+
lastActivity: new Date(Date.now() - 600_000).toISOString(),
|
|
629
|
+
escalationLevel: 0,
|
|
630
|
+
stalledSince: null,
|
|
631
|
+
transcriptPath: null,
|
|
632
|
+
},
|
|
633
|
+
],
|
|
634
|
+
worktrees: [],
|
|
635
|
+
tmuxSessions: [],
|
|
636
|
+
unreadMailCount: 0,
|
|
637
|
+
unreadMailScope: "orchestrator",
|
|
638
|
+
mergeQueueCount: 0,
|
|
639
|
+
recentMetricsCount: 0,
|
|
640
|
+
},
|
|
641
|
+
};
|
|
642
|
+
const out = renderAgentPanel(data, 100, 12, 3);
|
|
643
|
+
expect(out).toContain("abandoned-spt");
|
|
644
|
+
expect(out).toContain("x");
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
test("headless agent renders dead marker when tmux session list is non-empty", () => {
|
|
648
|
+
const deadPid = 2_147_483_647;
|
|
649
|
+
const data = {
|
|
650
|
+
...makeDashboardData({}),
|
|
651
|
+
status: {
|
|
652
|
+
currentRunId: null,
|
|
653
|
+
agents: [
|
|
654
|
+
{
|
|
655
|
+
id: "sess-dead-headless-1",
|
|
656
|
+
agentName: "gone-headless",
|
|
657
|
+
capability: "builder",
|
|
658
|
+
worktreePath: "/tmp/wt/gone-headless",
|
|
659
|
+
branchName: "overstory/gone-headless/task-g1",
|
|
660
|
+
taskId: "task-g1",
|
|
661
|
+
tmuxSession: "", // headless
|
|
662
|
+
state: "working" as const,
|
|
663
|
+
pid: deadPid,
|
|
664
|
+
parentAgent: null,
|
|
665
|
+
depth: 0,
|
|
666
|
+
runId: null,
|
|
667
|
+
startedAt: new Date(Date.now() - 10_000).toISOString(),
|
|
668
|
+
lastActivity: new Date().toISOString(),
|
|
669
|
+
escalationLevel: 0,
|
|
670
|
+
stalledSince: null,
|
|
671
|
+
transcriptPath: null,
|
|
672
|
+
},
|
|
673
|
+
],
|
|
674
|
+
worktrees: [],
|
|
675
|
+
tmuxSessions: [{ name: "overstory-other-tmux", pid: 11111 }],
|
|
676
|
+
unreadMailCount: 0,
|
|
677
|
+
unreadMailScope: "orchestrator",
|
|
678
|
+
mergeQueueCount: 0,
|
|
679
|
+
recentMetricsCount: 0,
|
|
680
|
+
},
|
|
681
|
+
};
|
|
682
|
+
const out = renderAgentPanel(data, 100, 12, 3);
|
|
683
|
+
expect(out).toContain("x");
|
|
684
|
+
expect(out).toContain("gone-headless");
|
|
685
|
+
});
|
|
498
686
|
});
|
|
499
687
|
|
|
500
688
|
describe("openDashboardStores", () => {
|
|
@@ -434,6 +434,7 @@ async function loadDashboardData(
|
|
|
434
434
|
worktrees,
|
|
435
435
|
tmuxSessions,
|
|
436
436
|
unreadMailCount,
|
|
437
|
+
unreadMailScope: "orchestrator",
|
|
437
438
|
mergeQueueCount,
|
|
438
439
|
recentMetricsCount,
|
|
439
440
|
};
|
|
@@ -644,10 +645,19 @@ export function renderAgentPanel(
|
|
|
644
645
|
: now;
|
|
645
646
|
const duration = formatDuration(endTime - new Date(agent.startedAt).getTime());
|
|
646
647
|
const durationPadded = pad(duration, 9);
|
|
648
|
+
// Three liveness topologies (overstory-7a34):
|
|
649
|
+
// tmux: tmuxSession !== "" → tmux session must exist
|
|
650
|
+
// long-lived headless: tmuxSession === "" && pid !== null → PID must be alive
|
|
651
|
+
// spawn-per-turn: tmuxSession === "" && pid === null → no process between
|
|
652
|
+
// turns is normal, so liveness reduces to "state is non-terminal".
|
|
653
|
+
// Time-based stale/zombie classification is handled in evaluateHealth.
|
|
647
654
|
const isHeadless = agent.tmuxSession === "" && agent.pid !== null;
|
|
648
|
-
const
|
|
649
|
-
|
|
650
|
-
|
|
655
|
+
const isSpawnPerTurn = agent.tmuxSession === "" && agent.pid === null;
|
|
656
|
+
const alive = isSpawnPerTurn
|
|
657
|
+
? agent.state !== "zombie" && agent.state !== "completed"
|
|
658
|
+
: isHeadless
|
|
659
|
+
? agent.pid !== null && isProcessAlive(agent.pid)
|
|
660
|
+
: data.status.tmuxSessions.some((s) => s.name === agent.tmuxSession);
|
|
651
661
|
const aliveDot = alive ? color.green(">") : color.red("x");
|
|
652
662
|
|
|
653
663
|
const lineContent = `${dimBox.vertical} ${stateColorFn(icon)} ${name} ${capability} ${color.dim(runtime)} ${stateColorFn(state)} ${taskId} ${durationPadded} ${aliveDot} `;
|
package/src/commands/doctor.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { checkEcosystem } from "../doctor/ecosystem.ts";
|
|
|
16
16
|
import { checkLogs } from "../doctor/logs.ts";
|
|
17
17
|
import { checkMergeQueue } from "../doctor/merge-queue.ts";
|
|
18
18
|
import { checkProviders } from "../doctor/providers.ts";
|
|
19
|
+
import { checkServe } from "../doctor/serve.ts";
|
|
19
20
|
import { checkStructure } from "../doctor/structure.ts";
|
|
20
21
|
import type { DoctorCategory, DoctorCheck, DoctorCheckFn } from "../doctor/types.ts";
|
|
21
22
|
import { checkVersion } from "../doctor/version.ts";
|
|
@@ -39,6 +40,7 @@ const ALL_CHECKS: Array<{ category: DoctorCategory; fn: DoctorCheckFn }> = [
|
|
|
39
40
|
{ category: "ecosystem", fn: checkEcosystem },
|
|
40
41
|
{ category: "providers", fn: checkProviders },
|
|
41
42
|
{ category: "watchdog", fn: checkWatchdog },
|
|
43
|
+
{ category: "serve", fn: checkServe },
|
|
42
44
|
];
|
|
43
45
|
|
|
44
46
|
/**
|
|
@@ -241,7 +243,7 @@ function buildDoctorCommand(
|
|
|
241
243
|
.option("--fix", "Attempt to auto-fix issues")
|
|
242
244
|
.addHelpText(
|
|
243
245
|
"after",
|
|
244
|
-
"\nCategories: dependencies, structure, config, databases, consistency, agents, merge, logs, version, ecosystem, providers, watchdog",
|
|
246
|
+
"\nCategories: dependencies, structure, config, databases, consistency, agents, merge, logs, version, ecosystem, providers, watchdog, serve",
|
|
245
247
|
)
|
|
246
248
|
.action(async (opts: DoctorActionOpts) => {
|
|
247
249
|
onResult(await runDoctorChecks(opts, checkRunners));
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
loadGroups,
|
|
21
21
|
printGroupProgress,
|
|
22
22
|
removeFromGroup,
|
|
23
|
+
resolveGroup,
|
|
23
24
|
} from "./group.ts";
|
|
24
25
|
|
|
25
26
|
let tempDir: string;
|
|
@@ -379,3 +380,96 @@ describe("printGroupProgress", () => {
|
|
|
379
380
|
expect(output).toContain("2026-01-15T10:00:00.000Z");
|
|
380
381
|
});
|
|
381
382
|
});
|
|
383
|
+
|
|
384
|
+
// -- resolveGroup --
|
|
385
|
+
|
|
386
|
+
describe("resolveGroup", () => {
|
|
387
|
+
test("resolves by exact UUID", () => {
|
|
388
|
+
const a = makeGroup({ id: "group-aaaaaaaa", name: "alpha" });
|
|
389
|
+
const b = makeGroup({ id: "group-bbbbbbbb", name: "beta" });
|
|
390
|
+
expect(resolveGroup([a, b], "group-aaaaaaaa")).toBe(a);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("resolves by unique name", () => {
|
|
394
|
+
const a = makeGroup({ id: "group-aaaaaaaa", name: "alpha" });
|
|
395
|
+
const b = makeGroup({ id: "group-bbbbbbbb", name: "beta" });
|
|
396
|
+
expect(resolveGroup([a, b], "beta")).toBe(b);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("ID match wins when name === some-other-group's-id", () => {
|
|
400
|
+
const a = makeGroup({ id: "group-aaaaaaaa", name: "group-bbbbbbbb" });
|
|
401
|
+
const b = makeGroup({ id: "group-bbbbbbbb", name: "beta" });
|
|
402
|
+
expect(resolveGroup([a, b], "group-bbbbbbbb")).toBe(b);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("name match prefers the active group when others are completed", () => {
|
|
406
|
+
const old = makeGroup({ id: "group-aaaaaaaa", name: "dup", status: "completed" });
|
|
407
|
+
const live = makeGroup({ id: "group-bbbbbbbb", name: "dup", status: "active" });
|
|
408
|
+
expect(resolveGroup([old, live], "dup")).toBe(live);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("ambiguous when multiple active groups share a name", () => {
|
|
412
|
+
const x = makeGroup({ id: "group-aaaaaaaa", name: "dup", status: "active" });
|
|
413
|
+
const y = makeGroup({ id: "group-bbbbbbbb", name: "dup", status: "active" });
|
|
414
|
+
expect(() => resolveGroup([x, y], "dup")).toThrow(GroupError);
|
|
415
|
+
try {
|
|
416
|
+
resolveGroup([x, y], "dup");
|
|
417
|
+
} catch (err) {
|
|
418
|
+
expect(err).toBeInstanceOf(GroupError);
|
|
419
|
+
const message = (err as GroupError).message;
|
|
420
|
+
expect(message).toContain("ambiguous");
|
|
421
|
+
expect(message).toContain("group-aaaaaaaa");
|
|
422
|
+
expect(message).toContain("group-bbbbbbbb");
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("ambiguous when zero active groups share the name", () => {
|
|
427
|
+
const x = makeGroup({ id: "group-aaaaaaaa", name: "dup", status: "completed" });
|
|
428
|
+
const y = makeGroup({ id: "group-bbbbbbbb", name: "dup", status: "completed" });
|
|
429
|
+
expect(() => resolveGroup([x, y], "dup")).toThrow(GroupError);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("throws not-found for unknown identifier", () => {
|
|
433
|
+
const a = makeGroup({ id: "group-aaaaaaaa", name: "alpha" });
|
|
434
|
+
expect(() => resolveGroup([a], "nope")).toThrow(/not found/);
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// -- name-or-id lookup in addToGroup / removeFromGroup --
|
|
439
|
+
|
|
440
|
+
describe("name-or-id lookup", () => {
|
|
441
|
+
test("addToGroup resolves by name", async () => {
|
|
442
|
+
const group = makeGroup({ id: "group-aaaaaaaa", name: "alpha", memberIssueIds: ["i1"] });
|
|
443
|
+
await writeGroups([group]);
|
|
444
|
+
const tracker = stubTrackerOk();
|
|
445
|
+
const updated = await addToGroup(tempDir, "alpha", ["i2"], false, tracker);
|
|
446
|
+
expect(updated.id).toBe("group-aaaaaaaa");
|
|
447
|
+
expect(updated.memberIssueIds).toEqual(["i1", "i2"]);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("removeFromGroup resolves by name", async () => {
|
|
451
|
+
const group = makeGroup({ id: "group-aaaaaaaa", name: "alpha", memberIssueIds: ["i1", "i2"] });
|
|
452
|
+
await writeGroups([group]);
|
|
453
|
+
const updated = await removeFromGroup(tempDir, "alpha", ["i2"]);
|
|
454
|
+
expect(updated.id).toBe("group-aaaaaaaa");
|
|
455
|
+
expect(updated.memberIssueIds).toEqual(["i1"]);
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
function stubTrackerOk(): import("../tracker/types.ts").TrackerClient {
|
|
460
|
+
return {
|
|
461
|
+
ready: async () => [],
|
|
462
|
+
show: async (id: string): Promise<TrackerIssue> => ({
|
|
463
|
+
id,
|
|
464
|
+
title: id,
|
|
465
|
+
status: "open",
|
|
466
|
+
priority: 2,
|
|
467
|
+
type: "task",
|
|
468
|
+
}),
|
|
469
|
+
create: async () => "stub-id",
|
|
470
|
+
claim: async () => undefined,
|
|
471
|
+
close: async () => undefined,
|
|
472
|
+
list: async () => [],
|
|
473
|
+
sync: async () => undefined,
|
|
474
|
+
};
|
|
475
|
+
}
|
package/src/commands/group.ts
CHANGED
|
@@ -79,6 +79,44 @@ function generateGroupId(): string {
|
|
|
79
79
|
return `group-${crypto.randomUUID().slice(0, 8)}`;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Resolve a group by ID or name.
|
|
84
|
+
*
|
|
85
|
+
* Names are not enforced unique by `createGroup`, so live `groups.json` files
|
|
86
|
+
* contain duplicate names — a naive name lookup would silently pick the wrong
|
|
87
|
+
* group. Resolution precedence:
|
|
88
|
+
* 1. Exact ID match wins (UUIDs are unambiguous).
|
|
89
|
+
* 2. Otherwise filter by name. If exactly one match, return it.
|
|
90
|
+
* 3. If multiple name matches, prefer a single `active` one. If still
|
|
91
|
+
* ambiguous, throw with the matching IDs so the caller can disambiguate
|
|
92
|
+
* by passing the UUID.
|
|
93
|
+
*
|
|
94
|
+
* @internal Exported for testing.
|
|
95
|
+
*/
|
|
96
|
+
export function resolveGroup(groups: TaskGroup[], identifier: string): TaskGroup {
|
|
97
|
+
const byId = groups.find((g) => g.id === identifier);
|
|
98
|
+
if (byId) return byId;
|
|
99
|
+
|
|
100
|
+
const byName = groups.filter((g) => g.name === identifier);
|
|
101
|
+
if (byName.length === 1) {
|
|
102
|
+
const only = byName[0];
|
|
103
|
+
if (only) return only;
|
|
104
|
+
}
|
|
105
|
+
if (byName.length > 1) {
|
|
106
|
+
const active = byName.filter((g) => g.status === "active");
|
|
107
|
+
if (active.length === 1) {
|
|
108
|
+
const only = active[0];
|
|
109
|
+
if (only) return only;
|
|
110
|
+
}
|
|
111
|
+
const ids = byName.map((g) => g.id).join(", ");
|
|
112
|
+
throw new GroupError(
|
|
113
|
+
`Group name "${identifier}" is ambiguous (matches: ${ids}). Use the group ID.`,
|
|
114
|
+
{ groupId: identifier },
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
throw new GroupError(`Group "${identifier}" not found`, { groupId: identifier });
|
|
118
|
+
}
|
|
119
|
+
|
|
82
120
|
/**
|
|
83
121
|
* Create a new task group.
|
|
84
122
|
* @internal Exported for testing.
|
|
@@ -140,16 +178,13 @@ export async function addToGroup(
|
|
|
140
178
|
}
|
|
141
179
|
|
|
142
180
|
const groups = await loadGroups(projectRoot);
|
|
143
|
-
const group = groups
|
|
144
|
-
if (!group) {
|
|
145
|
-
throw new GroupError(`Group "${groupId}" not found`, { groupId });
|
|
146
|
-
}
|
|
181
|
+
const group = resolveGroup(groups, groupId);
|
|
147
182
|
|
|
148
183
|
// Check for duplicates against existing members
|
|
149
184
|
for (const id of issueIds) {
|
|
150
185
|
if (group.memberIssueIds.includes(id)) {
|
|
151
|
-
throw new GroupError(`Issue "${id}" is already a member of group "${
|
|
152
|
-
groupId,
|
|
186
|
+
throw new GroupError(`Issue "${id}" is already a member of group "${group.id}"`, {
|
|
187
|
+
groupId: group.id,
|
|
153
188
|
});
|
|
154
189
|
}
|
|
155
190
|
}
|
|
@@ -187,16 +222,13 @@ export async function removeFromGroup(
|
|
|
187
222
|
}
|
|
188
223
|
|
|
189
224
|
const groups = await loadGroups(projectRoot);
|
|
190
|
-
const group = groups
|
|
191
|
-
if (!group) {
|
|
192
|
-
throw new GroupError(`Group "${groupId}" not found`, { groupId });
|
|
193
|
-
}
|
|
225
|
+
const group = resolveGroup(groups, groupId);
|
|
194
226
|
|
|
195
227
|
// Validate all issues are members
|
|
196
228
|
for (const id of issueIds) {
|
|
197
229
|
if (!group.memberIssueIds.includes(id)) {
|
|
198
|
-
throw new GroupError(`Issue "${id}" is not a member of group "${
|
|
199
|
-
groupId,
|
|
230
|
+
throw new GroupError(`Issue "${id}" is not a member of group "${group.id}"`, {
|
|
231
|
+
groupId: group.id,
|
|
200
232
|
});
|
|
201
233
|
}
|
|
202
234
|
}
|
|
@@ -204,7 +236,7 @@ export async function removeFromGroup(
|
|
|
204
236
|
// Check that removal won't empty the group
|
|
205
237
|
const remaining = group.memberIssueIds.filter((id) => !issueIds.includes(id));
|
|
206
238
|
if (remaining.length === 0) {
|
|
207
|
-
throw new GroupError("Cannot remove all issues from a group", { groupId });
|
|
239
|
+
throw new GroupError("Cannot remove all issues from a group", { groupId: group.id });
|
|
208
240
|
}
|
|
209
241
|
|
|
210
242
|
group.memberIssueIds = remaining;
|
|
@@ -347,7 +379,7 @@ export function createGroupCommand(): Command {
|
|
|
347
379
|
cmd
|
|
348
380
|
.command("status")
|
|
349
381
|
.description("Show progress for one or all groups")
|
|
350
|
-
.argument("[group-id]", "Group ID (optional, shows all if omitted)")
|
|
382
|
+
.argument("[group-id-or-name]", "Group ID or name (optional, shows all if omitted)")
|
|
351
383
|
.option("--json", "Output as JSON")
|
|
352
384
|
.option("--skip-validation", "Skip task validation (for offline use)")
|
|
353
385
|
.action(
|
|
@@ -361,10 +393,7 @@ export function createGroupCommand(): Command {
|
|
|
361
393
|
const groups = await loadGroups(projectRoot);
|
|
362
394
|
|
|
363
395
|
if (groupId) {
|
|
364
|
-
const group = groups
|
|
365
|
-
if (!group) {
|
|
366
|
-
throw new GroupError(`Group "${groupId}" not found`, { groupId });
|
|
367
|
-
}
|
|
396
|
+
const group = resolveGroup(groups, groupId);
|
|
368
397
|
const progress = await getGroupProgress(projectRoot, group, groups, tracker);
|
|
369
398
|
if (json) {
|
|
370
399
|
jsonOutput("group status", { ...progress });
|
|
@@ -401,7 +430,7 @@ export function createGroupCommand(): Command {
|
|
|
401
430
|
cmd
|
|
402
431
|
.command("add")
|
|
403
432
|
.description("Add issues to a group")
|
|
404
|
-
.argument("<group-id>", "Group ID")
|
|
433
|
+
.argument("<group-id-or-name>", "Group ID or name")
|
|
405
434
|
.argument("<ids...>", "Issue IDs to add")
|
|
406
435
|
.option("--json", "Output as JSON")
|
|
407
436
|
.option("--skip-validation", "Skip task validation (for offline use)")
|
|
@@ -437,7 +466,7 @@ export function createGroupCommand(): Command {
|
|
|
437
466
|
cmd
|
|
438
467
|
.command("remove")
|
|
439
468
|
.description("Remove issues from a group")
|
|
440
|
-
.argument("<group-id>", "Group ID")
|
|
469
|
+
.argument("<group-id-or-name>", "Group ID or name")
|
|
441
470
|
.argument("<ids...>", "Issue IDs to remove")
|
|
442
471
|
.option("--json", "Output as JSON")
|
|
443
472
|
.action(async (groupId: string, ids: string[], opts: { json?: boolean }) => {
|
|
@@ -353,6 +353,14 @@ describe("initCommand: canonical branch detection", () => {
|
|
|
353
353
|
const content = await Bun.file(configPath).text();
|
|
354
354
|
expect(content).toContain("canonicalBranch: main");
|
|
355
355
|
});
|
|
356
|
+
|
|
357
|
+
test("generated config opts into headless Claude by default (overstory-caec)", async () => {
|
|
358
|
+
await initCommand({ _spawner: noopSpawner });
|
|
359
|
+
|
|
360
|
+
const configPath = join(tempDir, ".overstory", "config.yaml");
|
|
361
|
+
const content = await Bun.file(configPath).text();
|
|
362
|
+
expect(content).toContain("claudeHeadlessByDefault: true");
|
|
363
|
+
});
|
|
356
364
|
});
|
|
357
365
|
|
|
358
366
|
describe("initCommand: --yes flag", () => {
|
package/src/commands/init.ts
CHANGED
|
@@ -816,6 +816,10 @@ export async function initCommand(opts: InitOptions): Promise<void> {
|
|
|
816
816
|
config.project.canonicalBranch = canonicalBranch;
|
|
817
817
|
if (config.runtime) {
|
|
818
818
|
config.runtime.default = defaultRuntime;
|
|
819
|
+
// New projects default to headless Claude spawns; the UI (`ov serve`) is the
|
|
820
|
+
// primary operator surface and tmux is opt-in via `--no-headless`. Existing
|
|
821
|
+
// projects keep tmux until they edit their config (overstory-caec).
|
|
822
|
+
config.runtime.claudeHeadlessByDefault = true;
|
|
819
823
|
}
|
|
820
824
|
|
|
821
825
|
const configYaml = serializeConfigToYaml(config);
|
|
@@ -956,5 +960,8 @@ export async function initCommand(opts: InitOptions): Promise<void> {
|
|
|
956
960
|
|
|
957
961
|
printSuccess("Initialized");
|
|
958
962
|
printHint("Next: run `ov hooks install` to enable Claude Code hooks.");
|
|
959
|
-
printHint("Then:
|
|
963
|
+
printHint("Then: `ov coordinator start` and `ov serve` — open http://localhost:7321");
|
|
964
|
+
printHint(
|
|
965
|
+
" (UI is the primary operator surface; pass `--no-headless` to ov sling for tmux attach)",
|
|
966
|
+
);
|
|
960
967
|
}
|
package/src/commands/log.test.ts
CHANGED
|
@@ -633,8 +633,55 @@ describe("logCommand", () => {
|
|
|
633
633
|
});
|
|
634
634
|
});
|
|
635
635
|
|
|
636
|
-
test("session-end
|
|
637
|
-
//
|
|
636
|
+
test("session-end does NOT transition lead to completed (persistent agent)", async () => {
|
|
637
|
+
// Regression test for overstory-49a7:
|
|
638
|
+
// The lead's Stop hook fires every turn (interactive Claude Code), not just at
|
|
639
|
+
// true session end. session-end must NOT mark leads completed, or they vanish
|
|
640
|
+
// from getActive() after their first turn while their tmux is still alive.
|
|
641
|
+
const dbPath = join(tempDir, ".overstory", "sessions.db");
|
|
642
|
+
const session: AgentSession = {
|
|
643
|
+
id: "session-lead",
|
|
644
|
+
agentName: "lead-alpha",
|
|
645
|
+
capability: "lead",
|
|
646
|
+
worktreePath: tempDir,
|
|
647
|
+
branchName: "lead-alpha-branch",
|
|
648
|
+
taskId: "bead-lead-001",
|
|
649
|
+
tmuxSession: "overstory-lead-alpha",
|
|
650
|
+
state: "working",
|
|
651
|
+
pid: 33333,
|
|
652
|
+
parentAgent: null,
|
|
653
|
+
depth: 0,
|
|
654
|
+
runId: null,
|
|
655
|
+
startedAt: new Date().toISOString(),
|
|
656
|
+
lastActivity: new Date(Date.now() - 60_000).toISOString(),
|
|
657
|
+
escalationLevel: 0,
|
|
658
|
+
stalledSince: null,
|
|
659
|
+
transcriptPath: null,
|
|
660
|
+
};
|
|
661
|
+
const store = createSessionStore(dbPath);
|
|
662
|
+
store.upsert(session);
|
|
663
|
+
store.close();
|
|
664
|
+
|
|
665
|
+
await logCommand(["session-end", "--agent", "lead-alpha"]);
|
|
666
|
+
|
|
667
|
+
// Lead should remain 'working', not transition to 'completed'
|
|
668
|
+
const readStore = createSessionStore(dbPath);
|
|
669
|
+
const updatedSession = readStore.getByName("lead-alpha");
|
|
670
|
+
readStore.close();
|
|
671
|
+
|
|
672
|
+
expect(updatedSession).toBeDefined();
|
|
673
|
+
expect(updatedSession?.state).toBe("working");
|
|
674
|
+
// But lastActivity should be updated
|
|
675
|
+
expect(new Date(updatedSession?.lastActivity ?? "").getTime()).toBeGreaterThan(
|
|
676
|
+
new Date(session.lastActivity).getTime(),
|
|
677
|
+
);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
test("session-end does NOT write pending-nudge marker for leads (moved to ov stop)", async () => {
|
|
681
|
+
// Regression test for overstory-49a7:
|
|
682
|
+
// The lead_completed nudge used to fire from the per-turn Stop hook, spamming
|
|
683
|
+
// the coordinator with false completion signals every turn. It is now emitted
|
|
684
|
+
// only by `ov stop <lead>` (the real completion signal).
|
|
638
685
|
const dbPath = join(tempDir, ".overstory", "sessions.db");
|
|
639
686
|
const session: AgentSession = {
|
|
640
687
|
id: "session-lead",
|
|
@@ -661,17 +708,10 @@ describe("logCommand", () => {
|
|
|
661
708
|
|
|
662
709
|
await logCommand(["session-end", "--agent", "lead-alpha"]);
|
|
663
710
|
|
|
664
|
-
//
|
|
711
|
+
// No pending-nudge marker should be written from session-end
|
|
665
712
|
const markerPath = join(tempDir, ".overstory", "pending-nudges", "coordinator.json");
|
|
666
713
|
const markerFile = Bun.file(markerPath);
|
|
667
|
-
expect(await markerFile.exists()).toBe(
|
|
668
|
-
|
|
669
|
-
const marker = JSON.parse(await markerFile.text());
|
|
670
|
-
expect(marker.from).toBe("lead-alpha");
|
|
671
|
-
expect(marker.reason).toBe("lead_completed");
|
|
672
|
-
expect(marker.subject).toContain("lead-alpha");
|
|
673
|
-
expect(marker.messageId).toContain("auto-nudge-lead-alpha-");
|
|
674
|
-
expect(marker.createdAt).toBeDefined();
|
|
714
|
+
expect(await markerFile.exists()).toBe(false);
|
|
675
715
|
});
|
|
676
716
|
|
|
677
717
|
test("session-end does NOT write pending-nudge marker for non-lead agents", async () => {
|
|
@@ -1312,6 +1352,10 @@ try {
|
|
|
1312
1352
|
stdin: "pipe",
|
|
1313
1353
|
stdout: "pipe",
|
|
1314
1354
|
stderr: "pipe",
|
|
1355
|
+
// Pin project root to tempDir. Without this, a subprocess started from
|
|
1356
|
+
// inside an `ov sling`-spawned worktree inherits OVERSTORY_PROJECT_ROOT
|
|
1357
|
+
// pointing at the parent project, and writes events to prod's events.db.
|
|
1358
|
+
env: { ...process.env, OVERSTORY_PROJECT_ROOT: tempDir },
|
|
1315
1359
|
});
|
|
1316
1360
|
|
|
1317
1361
|
// Write the JSON payload to stdin and close
|
|
@@ -1501,6 +1545,7 @@ try {
|
|
|
1501
1545
|
stdin: "pipe",
|
|
1502
1546
|
stdout: "pipe",
|
|
1503
1547
|
stderr: "pipe",
|
|
1548
|
+
env: { ...process.env, OVERSTORY_PROJECT_ROOT: tempDir },
|
|
1504
1549
|
});
|
|
1505
1550
|
|
|
1506
1551
|
// Write empty string and close immediately
|