@os-eco/overstory-cli 0.9.3 → 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 +49 -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 +56 -1
- package/src/commands/completions.test.ts +4 -1
- package/src/commands/coordinator.test.ts +127 -0
- package/src/commands/coordinator.ts +205 -6
- package/src/commands/dashboard.test.ts +188 -0
- package/src/commands/dashboard.ts +13 -3
- package/src/commands/doctor.ts +94 -77
- 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/monitor.ts +2 -1
- 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 +85 -1
- package/src/commands/sling.ts +153 -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/supervisor.ts +2 -1
- package/src/commands/watch.test.ts +49 -4
- package/src/commands/watch.ts +153 -28
- package/src/commands/worktree.test.ts +319 -3
- package/src/commands/worktree.ts +86 -0
- package/src/config.test.ts +78 -0
- package/src/config.ts +43 -1
- package/src/doctor/consistency.test.ts +106 -0
- package/src/doctor/consistency.ts +50 -3
- 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 +39 -0
- package/src/worktree/tmux.ts +23 -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
|
/**
|
|
@@ -158,10 +160,81 @@ export interface DoctorCommandOptions {
|
|
|
158
160
|
checkRunners?: Array<{ category: DoctorCategory; fn: DoctorCheckFn }>;
|
|
159
161
|
}
|
|
160
162
|
|
|
163
|
+
interface DoctorActionOpts {
|
|
164
|
+
json?: boolean;
|
|
165
|
+
verbose?: boolean;
|
|
166
|
+
category?: string;
|
|
167
|
+
fix?: boolean;
|
|
168
|
+
}
|
|
169
|
+
|
|
161
170
|
/**
|
|
162
|
-
*
|
|
171
|
+
* Run the doctor checks. Returns true if any check failed.
|
|
172
|
+
* Shared by both the Commander action and the programmatic entry point so the
|
|
173
|
+
* exit-code signal never has to travel through `process.exitCode` (which is
|
|
174
|
+
* global mutable state and races with other tests in parallel bun test runs).
|
|
163
175
|
*/
|
|
164
|
-
|
|
176
|
+
async function runDoctorChecks(
|
|
177
|
+
opts: DoctorActionOpts,
|
|
178
|
+
checkRunners: Array<{ category: DoctorCategory; fn: DoctorCheckFn }>,
|
|
179
|
+
): Promise<boolean> {
|
|
180
|
+
const json = opts.json ?? false;
|
|
181
|
+
const verbose = opts.verbose ?? false;
|
|
182
|
+
const categoryFilter = opts.category;
|
|
183
|
+
const fix = opts.fix ?? false;
|
|
184
|
+
|
|
185
|
+
if (categoryFilter !== undefined) {
|
|
186
|
+
const validCategories = ALL_CHECKS.map((c) => c.category);
|
|
187
|
+
if (!validCategories.includes(categoryFilter as DoctorCategory)) {
|
|
188
|
+
throw new ValidationError(
|
|
189
|
+
`Invalid category: ${categoryFilter}. Valid categories: ${validCategories.join(", ")}`,
|
|
190
|
+
{
|
|
191
|
+
field: "category",
|
|
192
|
+
value: categoryFilter,
|
|
193
|
+
},
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const cwd = process.cwd();
|
|
199
|
+
const config = await loadConfig(cwd);
|
|
200
|
+
const overstoryDir = join(config.project.root, ".overstory");
|
|
201
|
+
|
|
202
|
+
const checksToRun = categoryFilter
|
|
203
|
+
? checkRunners.filter((c) => c.category === categoryFilter)
|
|
204
|
+
: checkRunners;
|
|
205
|
+
|
|
206
|
+
let results: DoctorCheck[] = [];
|
|
207
|
+
for (const { fn } of checksToRun) {
|
|
208
|
+
const checkResults = await fn(config, overstoryDir);
|
|
209
|
+
results.push(...checkResults);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
let fixedItems: string[] | undefined;
|
|
213
|
+
if (fix) {
|
|
214
|
+
const applied = await applyFixes(results);
|
|
215
|
+
if (applied.length > 0) {
|
|
216
|
+
fixedItems = applied;
|
|
217
|
+
results = [];
|
|
218
|
+
for (const { fn } of checksToRun) {
|
|
219
|
+
const checkResults = await fn(config, overstoryDir);
|
|
220
|
+
results.push(...checkResults);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (json) {
|
|
226
|
+
printJSON(results, fixedItems);
|
|
227
|
+
} else {
|
|
228
|
+
printHumanReadable(results, verbose, checkRunners, fixedItems);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return results.some((c) => c.status === "fail");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function buildDoctorCommand(
|
|
235
|
+
onResult: (hasFailures: boolean) => void,
|
|
236
|
+
checkRunners: Array<{ category: DoctorCategory; fn: DoctorCheckFn }>,
|
|
237
|
+
): Command {
|
|
165
238
|
return new Command("doctor")
|
|
166
239
|
.description("Run health checks on overstory setup")
|
|
167
240
|
.option("--json", "Output as JSON")
|
|
@@ -170,75 +243,22 @@ export function createDoctorCommand(options?: DoctorCommandOptions): Command {
|
|
|
170
243
|
.option("--fix", "Attempt to auto-fix issues")
|
|
171
244
|
.addHelpText(
|
|
172
245
|
"after",
|
|
173
|
-
"\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",
|
|
174
247
|
)
|
|
175
|
-
.action(
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const categoryFilter = opts.category;
|
|
180
|
-
const fix = opts.fix ?? false;
|
|
181
|
-
|
|
182
|
-
// Validate category filter if provided
|
|
183
|
-
if (categoryFilter !== undefined) {
|
|
184
|
-
const validCategories = ALL_CHECKS.map((c) => c.category);
|
|
185
|
-
if (!validCategories.includes(categoryFilter as DoctorCategory)) {
|
|
186
|
-
throw new ValidationError(
|
|
187
|
-
`Invalid category: ${categoryFilter}. Valid categories: ${validCategories.join(", ")}`,
|
|
188
|
-
{
|
|
189
|
-
field: "category",
|
|
190
|
-
value: categoryFilter,
|
|
191
|
-
},
|
|
192
|
-
);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const cwd = process.cwd();
|
|
197
|
-
const config = await loadConfig(cwd);
|
|
198
|
-
const overstoryDir = join(config.project.root, ".overstory");
|
|
199
|
-
|
|
200
|
-
// Filter checks by category if specified
|
|
201
|
-
const allChecks = options?.checkRunners ?? ALL_CHECKS;
|
|
202
|
-
const checksToRun = categoryFilter
|
|
203
|
-
? allChecks.filter((c) => c.category === categoryFilter)
|
|
204
|
-
: allChecks;
|
|
205
|
-
|
|
206
|
-
// Run all checks sequentially
|
|
207
|
-
let results: DoctorCheck[] = [];
|
|
208
|
-
for (const { fn } of checksToRun) {
|
|
209
|
-
const checkResults = await fn(config, overstoryDir);
|
|
210
|
-
results.push(...checkResults);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// Apply fixes if requested
|
|
214
|
-
let fixedItems: string[] | undefined;
|
|
215
|
-
if (fix) {
|
|
216
|
-
const applied = await applyFixes(results);
|
|
217
|
-
if (applied.length > 0) {
|
|
218
|
-
fixedItems = applied;
|
|
219
|
-
// Re-run all checks to get fresh results after fixes
|
|
220
|
-
results = [];
|
|
221
|
-
for (const { fn } of checksToRun) {
|
|
222
|
-
const checkResults = await fn(config, overstoryDir);
|
|
223
|
-
results.push(...checkResults);
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// Output results
|
|
229
|
-
if (json) {
|
|
230
|
-
printJSON(results, fixedItems);
|
|
231
|
-
} else {
|
|
232
|
-
printHumanReadable(results, verbose, allChecks, fixedItems);
|
|
233
|
-
}
|
|
248
|
+
.action(async (opts: DoctorActionOpts) => {
|
|
249
|
+
onResult(await runDoctorChecks(opts, checkRunners));
|
|
250
|
+
});
|
|
251
|
+
}
|
|
234
252
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
253
|
+
/**
|
|
254
|
+
* Create the Commander command for `overstory doctor`.
|
|
255
|
+
*/
|
|
256
|
+
export function createDoctorCommand(options?: DoctorCommandOptions): Command {
|
|
257
|
+
return buildDoctorCommand((hasFailures) => {
|
|
258
|
+
if (hasFailures) {
|
|
259
|
+
process.exitCode = 1;
|
|
260
|
+
}
|
|
261
|
+
}, options?.checkRunners ?? ALL_CHECKS);
|
|
242
262
|
}
|
|
243
263
|
|
|
244
264
|
/**
|
|
@@ -250,16 +270,15 @@ export async function doctorCommand(
|
|
|
250
270
|
args: string[],
|
|
251
271
|
options?: DoctorCommandOptions,
|
|
252
272
|
): Promise<number | undefined> {
|
|
253
|
-
|
|
273
|
+
let hasFailures = false;
|
|
274
|
+
const cmd = buildDoctorCommand((result) => {
|
|
275
|
+
hasFailures = result;
|
|
276
|
+
}, options?.checkRunners ?? ALL_CHECKS);
|
|
254
277
|
cmd.exitOverride();
|
|
255
278
|
|
|
256
|
-
const prevExitCode = process.exitCode as number | undefined;
|
|
257
|
-
process.exitCode = undefined;
|
|
258
|
-
|
|
259
279
|
try {
|
|
260
280
|
await cmd.parseAsync(args, { from: "user" });
|
|
261
281
|
} catch (err: unknown) {
|
|
262
|
-
process.exitCode = prevExitCode;
|
|
263
282
|
if (err && typeof err === "object" && "code" in err) {
|
|
264
283
|
const code = (err as { code: string }).code;
|
|
265
284
|
if (code === "commander.helpDisplayed" || code === "commander.version") {
|
|
@@ -269,7 +288,5 @@ export async function doctorCommand(
|
|
|
269
288
|
throw err;
|
|
270
289
|
}
|
|
271
290
|
|
|
272
|
-
|
|
273
|
-
process.exitCode = prevExitCode;
|
|
274
|
-
return exitCode;
|
|
291
|
+
return hasFailures ? 1 : undefined;
|
|
275
292
|
}
|
|
@@ -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
|
+
}
|