@soleri/core 9.7.2 → 9.9.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.
Files changed (91) hide show
  1. package/dist/brain/intelligence.d.ts.map +1 -1
  2. package/dist/brain/intelligence.js +11 -2
  3. package/dist/brain/intelligence.js.map +1 -1
  4. package/dist/brain/types.d.ts +1 -0
  5. package/dist/brain/types.d.ts.map +1 -1
  6. package/dist/enforcement/adapters/index.d.ts +15 -0
  7. package/dist/enforcement/adapters/index.d.ts.map +1 -1
  8. package/dist/enforcement/adapters/index.js +38 -0
  9. package/dist/enforcement/adapters/index.js.map +1 -1
  10. package/dist/enforcement/adapters/opencode.d.ts +21 -0
  11. package/dist/enforcement/adapters/opencode.d.ts.map +1 -0
  12. package/dist/enforcement/adapters/opencode.js +115 -0
  13. package/dist/enforcement/adapters/opencode.js.map +1 -0
  14. package/dist/index.d.ts +4 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +5 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/paths.d.ts +2 -0
  19. package/dist/paths.d.ts.map +1 -1
  20. package/dist/paths.js +4 -0
  21. package/dist/paths.js.map +1 -1
  22. package/dist/planning/evidence-collector.d.ts +2 -0
  23. package/dist/planning/evidence-collector.d.ts.map +1 -1
  24. package/dist/planning/evidence-collector.js +7 -2
  25. package/dist/planning/evidence-collector.js.map +1 -1
  26. package/dist/planning/gap-patterns.d.ts.map +1 -1
  27. package/dist/planning/gap-patterns.js +4 -1
  28. package/dist/planning/gap-patterns.js.map +1 -1
  29. package/dist/planning/plan-lifecycle.d.ts.map +1 -1
  30. package/dist/planning/plan-lifecycle.js +5 -0
  31. package/dist/planning/plan-lifecycle.js.map +1 -1
  32. package/dist/planning/planner-types.d.ts +2 -0
  33. package/dist/planning/planner-types.d.ts.map +1 -1
  34. package/dist/runtime/capture-ops.d.ts.map +1 -1
  35. package/dist/runtime/capture-ops.js +14 -6
  36. package/dist/runtime/capture-ops.js.map +1 -1
  37. package/dist/runtime/facades/curator-facade.d.ts.map +1 -1
  38. package/dist/runtime/facades/curator-facade.js +52 -4
  39. package/dist/runtime/facades/curator-facade.js.map +1 -1
  40. package/dist/runtime/orchestrate-ops.d.ts +12 -0
  41. package/dist/runtime/orchestrate-ops.d.ts.map +1 -1
  42. package/dist/runtime/orchestrate-ops.js +141 -1
  43. package/dist/runtime/orchestrate-ops.js.map +1 -1
  44. package/dist/runtime/quality-signals.d.ts +42 -0
  45. package/dist/runtime/quality-signals.d.ts.map +1 -0
  46. package/dist/runtime/quality-signals.js +124 -0
  47. package/dist/runtime/quality-signals.js.map +1 -0
  48. package/dist/skills/trust-classifier.js +1 -1
  49. package/dist/skills/trust-classifier.js.map +1 -1
  50. package/dist/vault/vault-markdown-sync.d.ts +5 -2
  51. package/dist/vault/vault-markdown-sync.d.ts.map +1 -1
  52. package/dist/vault/vault-markdown-sync.js +13 -2
  53. package/dist/vault/vault-markdown-sync.js.map +1 -1
  54. package/dist/workflows/index.d.ts +6 -0
  55. package/dist/workflows/index.d.ts.map +1 -0
  56. package/dist/workflows/index.js +5 -0
  57. package/dist/workflows/index.js.map +1 -0
  58. package/dist/workflows/workflow-loader.d.ts +83 -0
  59. package/dist/workflows/workflow-loader.d.ts.map +1 -0
  60. package/dist/workflows/workflow-loader.js +207 -0
  61. package/dist/workflows/workflow-loader.js.map +1 -0
  62. package/package.json +1 -1
  63. package/src/brain/intelligence.ts +15 -2
  64. package/src/brain/types.ts +1 -0
  65. package/src/enforcement/adapters/index.ts +45 -0
  66. package/src/enforcement/adapters/opencode.test.ts +406 -0
  67. package/src/enforcement/adapters/opencode.ts +153 -0
  68. package/src/index.ts +19 -0
  69. package/src/paths.ts +5 -0
  70. package/src/planning/evidence-collector.test.ts +95 -0
  71. package/src/planning/evidence-collector.ts +11 -0
  72. package/src/planning/gap-patterns.ts +7 -3
  73. package/src/planning/plan-lifecycle.test.ts +49 -0
  74. package/src/planning/plan-lifecycle.ts +5 -0
  75. package/src/planning/planner-types.ts +2 -0
  76. package/src/runtime/capture-ops.test.ts +58 -1
  77. package/src/runtime/capture-ops.ts +15 -4
  78. package/src/runtime/facades/curator-facade.test.ts +87 -9
  79. package/src/runtime/facades/curator-facade.ts +60 -4
  80. package/src/runtime/orchestrate-ops.test.ts +78 -1
  81. package/src/runtime/orchestrate-ops.ts +175 -1
  82. package/src/runtime/orchestrate-status-readiness.test.ts +162 -0
  83. package/src/runtime/quality-signals.test.ts +312 -0
  84. package/src/runtime/quality-signals.ts +169 -0
  85. package/src/skills/trust-classifier.ts +1 -1
  86. package/src/vault/vault-markdown-sync.test.ts +40 -0
  87. package/src/vault/vault-markdown-sync.ts +16 -3
  88. package/src/workflows/index.ts +12 -0
  89. package/src/workflows/orchestrate-integration.test.ts +166 -0
  90. package/src/workflows/workflow-loader.test.ts +149 -0
  91. package/src/workflows/workflow-loader.ts +238 -0
@@ -75,6 +75,20 @@ vi.mock('../planning/impact-analyzer.js', () => ({
75
75
  })),
76
76
  }));
77
77
 
78
+ vi.mock('../planning/evidence-collector.js', () => ({
79
+ collectGitEvidence: vi.fn().mockReturnValue({
80
+ planId: 'plan-1',
81
+ planObjective: 'test',
82
+ accuracy: 85,
83
+ evidenceSources: ['git'],
84
+ taskEvidence: [],
85
+ unplannedChanges: [],
86
+ missingWork: [],
87
+ verificationGaps: [],
88
+ summary: '0/0 tasks verified by git evidence',
89
+ }),
90
+ }));
91
+
78
92
  // ---------------------------------------------------------------------------
79
93
  // Mock runtime
80
94
  // ---------------------------------------------------------------------------
@@ -88,7 +102,9 @@ function mockRuntime(): AgentRuntime {
88
102
  stats: vi.fn().mockReturnValue({ totalEntries: 10, byDomain: {}, byType: {} }),
89
103
  captureMemory: vi.fn(),
90
104
  },
91
- brain: {},
105
+ brain: {
106
+ recordFeedback: vi.fn(),
107
+ },
92
108
  brainIntelligence: {
93
109
  recommend: vi.fn().mockReturnValue([]),
94
110
  lifecycle: vi.fn().mockReturnValue({ id: 'session-1' }),
@@ -318,6 +334,67 @@ describe('createOrchestrateOps', () => {
318
334
  );
319
335
  expect(result.session).toBeDefined();
320
336
  });
337
+
338
+ it('includes evidenceReport when completing a plan', async () => {
339
+ const op = findOp(ops, 'orchestrate_complete');
340
+ const result = (await op.handler({
341
+ planId: 'plan-1',
342
+ sessionId: 'session-1',
343
+ outcome: 'completed',
344
+ projectPath: '.',
345
+ })) as Record<string, unknown>;
346
+
347
+ expect(result).toHaveProperty('evidenceReport');
348
+ const report = result.evidenceReport as Record<string, unknown>;
349
+ expect(report.accuracy).toBe(85);
350
+ expect(report.evidenceSources).toEqual(['git']);
351
+ });
352
+
353
+ it('succeeds without blocking when git is unavailable', async () => {
354
+ const { collectGitEvidence } = await import('../planning/evidence-collector.js');
355
+ vi.mocked(collectGitEvidence).mockImplementationOnce(() => {
356
+ throw new Error('git not found');
357
+ });
358
+
359
+ const op = findOp(ops, 'orchestrate_complete');
360
+ const result = (await op.handler({
361
+ planId: 'plan-1',
362
+ sessionId: 'session-1',
363
+ outcome: 'completed',
364
+ })) as Record<string, unknown>;
365
+
366
+ // Should complete successfully without evidenceReport
367
+ expect(result).toHaveProperty('plan');
368
+ expect(result).toHaveProperty('session');
369
+ expect(result).not.toHaveProperty('evidenceReport');
370
+ });
371
+
372
+ it('adds warning when evidence accuracy is below 50%', async () => {
373
+ const { collectGitEvidence } = await import('../planning/evidence-collector.js');
374
+ vi.mocked(collectGitEvidence).mockReturnValueOnce({
375
+ planId: 'plan-1',
376
+ planObjective: 'test',
377
+ accuracy: 30,
378
+ evidenceSources: ['git'],
379
+ taskEvidence: [],
380
+ unplannedChanges: [],
381
+ missingWork: [],
382
+ verificationGaps: [],
383
+ summary: '0/2 tasks verified by git evidence',
384
+ });
385
+
386
+ const op = findOp(ops, 'orchestrate_complete');
387
+ const result = (await op.handler({
388
+ planId: 'plan-1',
389
+ sessionId: 'session-1',
390
+ outcome: 'completed',
391
+ })) as Record<string, unknown>;
392
+
393
+ expect(result).toHaveProperty('evidenceReport');
394
+ expect(result).toHaveProperty('warnings');
395
+ const warnings = result.warnings as string[];
396
+ expect(warnings.some((w) => w.includes('Low evidence accuracy (30%)'))).toBe(true);
397
+ });
321
398
  });
322
399
 
323
400
  // ─── orchestrate_status ───────────────────────────────────────
@@ -21,6 +21,8 @@ import { runEpilogue } from '../flows/epilogue.js';
21
21
  import type { OrchestrationPlan, ExecutionResult } from '../flows/types.js';
22
22
  import type { ContextHealthStatus } from './context-health.js';
23
23
  import type { OperatorSignals } from '../operator/operator-context-types.js';
24
+ import { loadAgentWorkflows, getWorkflowForIntent } from '../workflows/workflow-loader.js';
25
+ import type { WorkflowOverride } from '../workflows/workflow-loader.js';
24
26
  import {
25
27
  detectGitHubContext,
26
28
  findMatchingMilestone,
@@ -38,7 +40,10 @@ import {
38
40
  import { detectRationalizations } from '../planning/rationalization-detector.js';
39
41
  import { ImpactAnalyzer } from '../planning/impact-analyzer.js';
40
42
  import type { ImpactReport } from '../planning/impact-analyzer.js';
43
+ import { collectGitEvidence } from '../planning/evidence-collector.js';
44
+ import type { EvidenceReport } from '../planning/evidence-collector.js';
41
45
  import { recordPlanFeedback } from './plan-feedback-helper.js';
46
+ import { analyzeQualitySignals, captureQualitySignals } from './quality-signals.js';
42
47
 
43
48
  // ---------------------------------------------------------------------------
44
49
  // Intent detection — keyword-based mapping from prompt to intent
@@ -62,6 +67,70 @@ function detectIntent(prompt: string): string {
62
67
  return 'BUILD'; // default
63
68
  }
64
69
 
70
+ // ---------------------------------------------------------------------------
71
+ // Workflow override merge
72
+ // ---------------------------------------------------------------------------
73
+
74
+ /**
75
+ * Merge a workflow override into an OrchestrationPlan (mutates in place).
76
+ *
77
+ * - Gates: each workflow gate becomes a gate on the matching plan step
78
+ * (matched by phase → step id prefix). Unmatched gates are appended as
79
+ * new gate-only steps at the end.
80
+ * - Tools: workflow tools are merged into every step's `tools` array
81
+ * (deduped). This ensures the tools are available to the executor.
82
+ */
83
+ export function applyWorkflowOverride(plan: OrchestrationPlan, override: WorkflowOverride): void {
84
+ // Merge gates into plan steps
85
+ for (const gate of override.gates) {
86
+ // Try to find a step whose id starts with the gate phase
87
+ const matchingStep = plan.steps.find((s) =>
88
+ s.id.toLowerCase().startsWith(gate.phase.toLowerCase()),
89
+ );
90
+ if (matchingStep) {
91
+ // Attach/replace gate on the step
92
+ matchingStep.gate = {
93
+ type: 'GATE',
94
+ condition: gate.requirement,
95
+ onFail: { action: 'STOP', message: `Gate check failed: ${gate.check}` },
96
+ };
97
+ } else {
98
+ // No matching step — append a new gate-only step
99
+ plan.steps.push({
100
+ id: `workflow-gate-${gate.phase}`,
101
+ name: `${gate.phase} gate (${override.name})`,
102
+ tools: [],
103
+ parallel: false,
104
+ requires: [],
105
+ gate: {
106
+ type: 'GATE',
107
+ condition: gate.requirement,
108
+ onFail: { action: 'STOP', message: `Gate check failed: ${gate.check}` },
109
+ },
110
+ status: 'pending',
111
+ });
112
+ }
113
+ }
114
+
115
+ // Merge tools into plan steps (deduplicated)
116
+ if (override.tools.length > 0) {
117
+ for (const step of plan.steps) {
118
+ for (const tool of override.tools) {
119
+ if (!step.tools.includes(tool)) {
120
+ step.tools.push(tool);
121
+ }
122
+ }
123
+ }
124
+ // Update estimated tools count
125
+ plan.estimatedTools = plan.steps.reduce((acc, s) => acc + s.tools.length, 0);
126
+ }
127
+
128
+ // Add workflow info to warnings for visibility
129
+ plan.warnings.push(
130
+ `Workflow override "${override.name}" applied (${override.gates.length} gate(s), ${override.tools.length} tool(s)).`,
131
+ );
132
+ }
133
+
65
134
  // ---------------------------------------------------------------------------
66
135
  // In-memory plan store
67
136
  // ---------------------------------------------------------------------------
@@ -309,6 +378,23 @@ export function createOrchestrateOps(
309
378
  // 3. Build flow-engine plan
310
379
  const plan = await buildPlan(intent, agentId, projectPath, runtime, prompt);
311
380
 
381
+ // 3b. Merge workflow overrides (gates + tools) if agent has a matching workflow
382
+ let workflowApplied: string | undefined;
383
+ const agentDir = runtime.config.agentDir;
384
+ if (agentDir) {
385
+ try {
386
+ const workflowsDir = path.join(agentDir, 'workflows');
387
+ const agentWorkflows = loadAgentWorkflows(workflowsDir);
388
+ const workflowOverride = getWorkflowForIntent(agentWorkflows, intent);
389
+ if (workflowOverride) {
390
+ applyWorkflowOverride(plan, workflowOverride);
391
+ workflowApplied = workflowOverride.name;
392
+ }
393
+ } catch {
394
+ // Workflow loading failed — plan is still valid without overrides
395
+ }
396
+ }
397
+
312
398
  // 4. Store in planStore
313
399
  planStore.set(plan.planId, { plan, createdAt: Date.now() });
314
400
 
@@ -370,6 +456,7 @@ export function createOrchestrateOps(
370
456
  skippedCount: plan.skipped.length,
371
457
  warnings: plan.warnings,
372
458
  estimatedTools: plan.estimatedTools,
459
+ ...(workflowApplied ? { workflowOverride: workflowApplied } : {}),
373
460
  },
374
461
  };
375
462
  },
@@ -741,10 +828,30 @@ export function createOrchestrateOps(
741
828
  }
742
829
  }
743
830
 
831
+ const warnings: string[] = [];
832
+
833
+ // Evidence-based reconciliation: cross-reference plan tasks against git diff
834
+ let evidenceReport: EvidenceReport | null = null;
835
+ if (planObj && outcome === 'completed') {
836
+ try {
837
+ evidenceReport = collectGitEvidence(
838
+ planObj,
839
+ (params.projectPath as string) ?? '.',
840
+ 'main',
841
+ );
842
+ if (evidenceReport.accuracy < 50) {
843
+ warnings.push(
844
+ `Low evidence accuracy (${evidenceReport.accuracy}%) — plan tasks may not match git changes.`,
845
+ );
846
+ }
847
+ } catch {
848
+ // Evidence collection is best-effort — never blocks
849
+ }
850
+ }
851
+
744
852
  // Complete the planner plan (legacy lifecycle) — best-effort
745
853
  // The epilogue (brain session, knowledge extraction, flow epilogue) MUST run
746
854
  // even if plan transition fails (e.g. already completed, missing, invalid state).
747
- const warnings: string[] = [];
748
855
  let completedPlan;
749
856
  if (planObj && planId) {
750
857
  try {
@@ -788,6 +895,33 @@ export function createOrchestrateOps(
788
895
  }
789
896
  }
790
897
 
898
+ // Feed evidence accuracy into brain feedback — low accuracy signals poor pattern match
899
+ if (evidenceReport && planObj) {
900
+ try {
901
+ const evidenceAction = evidenceReport.accuracy < 50 ? 'dismissed' : 'accepted';
902
+ brain.recordFeedback(`plan-evidence:${planObj.objective}`, planObj.id, evidenceAction);
903
+ } catch {
904
+ // Evidence brain feedback is best-effort
905
+ }
906
+ }
907
+
908
+ // Quality signals: capture rework anti-patterns and clean-task feedback
909
+ if (evidenceReport) {
910
+ try {
911
+ const qualityAnalysis = analyzeQualitySignals(evidenceReport, planObj);
912
+ if (qualityAnalysis.antiPatterns.length > 0 || qualityAnalysis.cleanTasks.length > 0) {
913
+ captureQualitySignals(
914
+ qualityAnalysis,
915
+ vault,
916
+ brain,
917
+ planId ?? `direct-${Date.now()}`,
918
+ );
919
+ }
920
+ } catch {
921
+ // Quality signal capture is best-effort — never blocks completion
922
+ }
923
+ }
924
+
791
925
  // Extract knowledge — runs regardless of plan existence
792
926
  let extraction = null;
793
927
  try {
@@ -840,6 +974,7 @@ export function createOrchestrateOps(
840
974
  extraction,
841
975
  epilogue: epilogueResult,
842
976
  ...(impactReport ? { impactAnalysis: impactReport } : {}),
977
+ ...(evidenceReport ? { evidenceReport } : {}),
843
978
  ...(warnings.length > 0 ? { warnings } : {}),
844
979
  };
845
980
  },
@@ -890,6 +1025,44 @@ export function createOrchestrateOps(
890
1025
  createdAt: e.createdAt,
891
1026
  }));
892
1027
 
1028
+ // Compute readiness for the most recent active plan
1029
+ const TERMINAL_TASK_STATES = new Set(['completed', 'skipped', 'failed']);
1030
+ let readiness: {
1031
+ allTasksTerminal: boolean;
1032
+ terminalCount: number;
1033
+ totalCount: number;
1034
+ idleSince: number | null;
1035
+ } | null = null;
1036
+
1037
+ const executingPlans = activePlans.filter(
1038
+ (p: { status: string }) => p.status === 'executing',
1039
+ );
1040
+ if (executingPlans.length > 0) {
1041
+ const plan = executingPlans[0] as {
1042
+ tasks?: Array<{ status: string; completedAt?: number; startedAt?: number }>;
1043
+ updatedAt?: number;
1044
+ };
1045
+ const tasks = plan.tasks ?? [];
1046
+ const totalCount = tasks.length;
1047
+ const terminalCount = tasks.filter((t) => TERMINAL_TASK_STATES.has(t.status)).length;
1048
+ const allTasksTerminal = totalCount > 0 && terminalCount === totalCount;
1049
+
1050
+ // idleSince: the most recent completedAt among terminal tasks, or plan updatedAt
1051
+ let idleSince: number | null = null;
1052
+ if (totalCount > 0 && !allTasksTerminal) {
1053
+ const terminalTimestamps = tasks
1054
+ .filter((t) => TERMINAL_TASK_STATES.has(t.status) && t.completedAt)
1055
+ .map((t) => t.completedAt as number);
1056
+ if (terminalTimestamps.length > 0) {
1057
+ idleSince = Math.max(...terminalTimestamps);
1058
+ } else if (plan.updatedAt) {
1059
+ idleSince = plan.updatedAt;
1060
+ }
1061
+ }
1062
+
1063
+ readiness = { allTasksTerminal, terminalCount, totalCount, idleSince };
1064
+ }
1065
+
893
1066
  return {
894
1067
  activePlans,
895
1068
  sessionContext,
@@ -897,6 +1070,7 @@ export function createOrchestrateOps(
897
1070
  recommendations,
898
1071
  brainStats,
899
1072
  flowPlans,
1073
+ ...(readiness ? { readiness } : {}),
900
1074
  };
901
1075
  },
902
1076
  },
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Tests for orchestrate_status readiness field.
3
+ *
4
+ * Validates that orchestrate_status computes readiness
5
+ * based on the active plan's task states.
6
+ */
7
+
8
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
9
+ import { mkdirSync, rmSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { tmpdir } from 'node:os';
12
+ import { createOrchestrateOps } from './orchestrate-ops.js';
13
+ import { captureOps } from '../engine/test-helpers.js';
14
+ import { createAgentRuntime } from './runtime.js';
15
+ import type { AgentRuntime } from './types.js';
16
+
17
+ let runtime: AgentRuntime;
18
+ let tempDir: string;
19
+
20
+ beforeEach(() => {
21
+ tempDir = join(tmpdir(), `readiness-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
22
+ mkdirSync(tempDir, { recursive: true });
23
+ runtime = createAgentRuntime({
24
+ agentId: 'test-readiness',
25
+ vaultPath: ':memory:',
26
+ plansPath: join(tempDir, 'plans.json'),
27
+ });
28
+ });
29
+
30
+ afterEach(() => {
31
+ runtime.close();
32
+ rmSync(tempDir, { recursive: true, force: true });
33
+ });
34
+
35
+ /** Helper: call the orchestrate_status handler directly. */
36
+ async function callStatus(rt: AgentRuntime): Promise<Record<string, unknown>> {
37
+ const ops = captureOps(createOrchestrateOps(rt));
38
+ const op = ops.get('orchestrate_status')!;
39
+ return (await op.handler({})) as Record<string, unknown>;
40
+ }
41
+
42
+ /** Helper: create an executing plan with N tasks, return plan + task IDs. */
43
+ function createExecutingPlan(
44
+ rt: AgentRuntime,
45
+ tasks: Array<{ title: string; description: string }>,
46
+ ) {
47
+ const plan = rt.planner.create({
48
+ objective: 'Test plan',
49
+ scope: 'test',
50
+ decisions: [],
51
+ tasks: [],
52
+ });
53
+ rt.planner.approve(plan.id);
54
+ rt.planner.splitTasks(plan.id, tasks);
55
+ rt.planner.startExecution(plan.id);
56
+ const executing = rt.planner.get(plan.id)!;
57
+ return { planId: plan.id, tasks: executing.tasks };
58
+ }
59
+
60
+ describe('orchestrate_status readiness', () => {
61
+ it('returns no readiness when there are no executing plans', async () => {
62
+ const data = await callStatus(runtime);
63
+ expect(data.readiness).toBeUndefined();
64
+ });
65
+
66
+ it('returns readiness with allTasksTerminal=true when all tasks are done', async () => {
67
+ const { planId, tasks } = createExecutingPlan(runtime, [
68
+ { title: 'Task A', description: 'Do A' },
69
+ { title: 'Task B', description: 'Do B' },
70
+ ]);
71
+
72
+ for (const task of tasks) {
73
+ runtime.planner.updateTask(planId, task.id, 'completed');
74
+ }
75
+
76
+ const data = await callStatus(runtime);
77
+ const readiness = data.readiness as {
78
+ allTasksTerminal: boolean;
79
+ terminalCount: number;
80
+ totalCount: number;
81
+ idleSince: number | null;
82
+ };
83
+
84
+ expect(readiness).toBeDefined();
85
+ expect(readiness.allTasksTerminal).toBe(true);
86
+ expect(readiness.terminalCount).toBe(2);
87
+ expect(readiness.totalCount).toBe(2);
88
+ expect(readiness.idleSince).toBeNull();
89
+ });
90
+
91
+ it('returns readiness with mixed task states', async () => {
92
+ const { planId, tasks } = createExecutingPlan(runtime, [
93
+ { title: 'Task X', description: 'Do X' },
94
+ { title: 'Task Y', description: 'Do Y' },
95
+ { title: 'Task Z', description: 'Do Z' },
96
+ ]);
97
+
98
+ runtime.planner.updateTask(planId, tasks[0].id, 'completed');
99
+ runtime.planner.updateTask(planId, tasks[1].id, 'skipped');
100
+ // tasks[2] remains pending
101
+
102
+ const data = await callStatus(runtime);
103
+ const readiness = data.readiness as {
104
+ allTasksTerminal: boolean;
105
+ terminalCount: number;
106
+ totalCount: number;
107
+ idleSince: number | null;
108
+ };
109
+
110
+ expect(readiness).toBeDefined();
111
+ expect(readiness.allTasksTerminal).toBe(false);
112
+ expect(readiness.terminalCount).toBe(2);
113
+ expect(readiness.totalCount).toBe(3);
114
+ });
115
+
116
+ it('includes failed tasks in terminal count', async () => {
117
+ const { planId, tasks } = createExecutingPlan(runtime, [
118
+ { title: 'Task F', description: 'Fail' },
119
+ ]);
120
+
121
+ runtime.planner.updateTask(planId, tasks[0].id, 'failed');
122
+
123
+ const data = await callStatus(runtime);
124
+ const readiness = data.readiness as {
125
+ allTasksTerminal: boolean;
126
+ terminalCount: number;
127
+ totalCount: number;
128
+ idleSince: number | null;
129
+ };
130
+
131
+ expect(readiness).toBeDefined();
132
+ expect(readiness.allTasksTerminal).toBe(true);
133
+ expect(readiness.terminalCount).toBe(1);
134
+ expect(readiness.totalCount).toBe(1);
135
+ });
136
+
137
+ it('computes idleSince from last terminal task timestamp', async () => {
138
+ const { planId, tasks } = createExecutingPlan(runtime, [
139
+ { title: 'Done', description: 'Already done' },
140
+ { title: 'Pending', description: 'Still pending' },
141
+ ]);
142
+
143
+ runtime.planner.updateTask(planId, tasks[0].id, 'completed');
144
+ // tasks[1] remains pending
145
+
146
+ const data = await callStatus(runtime);
147
+ const readiness = data.readiness as {
148
+ allTasksTerminal: boolean;
149
+ terminalCount: number;
150
+ totalCount: number;
151
+ idleSince: number | null;
152
+ };
153
+
154
+ expect(readiness).toBeDefined();
155
+ expect(readiness.allTasksTerminal).toBe(false);
156
+ expect(readiness.terminalCount).toBe(1);
157
+ expect(readiness.totalCount).toBe(2);
158
+ // idleSince should be set (either from completedAt or updatedAt)
159
+ expect(readiness.idleSince).not.toBeNull();
160
+ expect(typeof readiness.idleSince).toBe('number');
161
+ });
162
+ });