@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.
- package/dist/brain/intelligence.d.ts.map +1 -1
- package/dist/brain/intelligence.js +11 -2
- package/dist/brain/intelligence.js.map +1 -1
- package/dist/brain/types.d.ts +1 -0
- package/dist/brain/types.d.ts.map +1 -1
- package/dist/enforcement/adapters/index.d.ts +15 -0
- package/dist/enforcement/adapters/index.d.ts.map +1 -1
- package/dist/enforcement/adapters/index.js +38 -0
- package/dist/enforcement/adapters/index.js.map +1 -1
- package/dist/enforcement/adapters/opencode.d.ts +21 -0
- package/dist/enforcement/adapters/opencode.d.ts.map +1 -0
- package/dist/enforcement/adapters/opencode.js +115 -0
- package/dist/enforcement/adapters/opencode.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/paths.d.ts +2 -0
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +4 -0
- package/dist/paths.js.map +1 -1
- package/dist/planning/evidence-collector.d.ts +2 -0
- package/dist/planning/evidence-collector.d.ts.map +1 -1
- package/dist/planning/evidence-collector.js +7 -2
- package/dist/planning/evidence-collector.js.map +1 -1
- package/dist/planning/gap-patterns.d.ts.map +1 -1
- package/dist/planning/gap-patterns.js +4 -1
- package/dist/planning/gap-patterns.js.map +1 -1
- package/dist/planning/plan-lifecycle.d.ts.map +1 -1
- package/dist/planning/plan-lifecycle.js +5 -0
- package/dist/planning/plan-lifecycle.js.map +1 -1
- package/dist/planning/planner-types.d.ts +2 -0
- package/dist/planning/planner-types.d.ts.map +1 -1
- package/dist/runtime/capture-ops.d.ts.map +1 -1
- package/dist/runtime/capture-ops.js +14 -6
- package/dist/runtime/capture-ops.js.map +1 -1
- package/dist/runtime/facades/curator-facade.d.ts.map +1 -1
- package/dist/runtime/facades/curator-facade.js +52 -4
- package/dist/runtime/facades/curator-facade.js.map +1 -1
- package/dist/runtime/orchestrate-ops.d.ts +12 -0
- package/dist/runtime/orchestrate-ops.d.ts.map +1 -1
- package/dist/runtime/orchestrate-ops.js +141 -1
- package/dist/runtime/orchestrate-ops.js.map +1 -1
- package/dist/runtime/quality-signals.d.ts +42 -0
- package/dist/runtime/quality-signals.d.ts.map +1 -0
- package/dist/runtime/quality-signals.js +124 -0
- package/dist/runtime/quality-signals.js.map +1 -0
- package/dist/skills/trust-classifier.js +1 -1
- package/dist/skills/trust-classifier.js.map +1 -1
- package/dist/vault/vault-markdown-sync.d.ts +5 -2
- package/dist/vault/vault-markdown-sync.d.ts.map +1 -1
- package/dist/vault/vault-markdown-sync.js +13 -2
- package/dist/vault/vault-markdown-sync.js.map +1 -1
- package/dist/workflows/index.d.ts +6 -0
- package/dist/workflows/index.d.ts.map +1 -0
- package/dist/workflows/index.js +5 -0
- package/dist/workflows/index.js.map +1 -0
- package/dist/workflows/workflow-loader.d.ts +83 -0
- package/dist/workflows/workflow-loader.d.ts.map +1 -0
- package/dist/workflows/workflow-loader.js +207 -0
- package/dist/workflows/workflow-loader.js.map +1 -0
- package/package.json +1 -1
- package/src/brain/intelligence.ts +15 -2
- package/src/brain/types.ts +1 -0
- package/src/enforcement/adapters/index.ts +45 -0
- package/src/enforcement/adapters/opencode.test.ts +406 -0
- package/src/enforcement/adapters/opencode.ts +153 -0
- package/src/index.ts +19 -0
- package/src/paths.ts +5 -0
- package/src/planning/evidence-collector.test.ts +95 -0
- package/src/planning/evidence-collector.ts +11 -0
- package/src/planning/gap-patterns.ts +7 -3
- package/src/planning/plan-lifecycle.test.ts +49 -0
- package/src/planning/plan-lifecycle.ts +5 -0
- package/src/planning/planner-types.ts +2 -0
- package/src/runtime/capture-ops.test.ts +58 -1
- package/src/runtime/capture-ops.ts +15 -4
- package/src/runtime/facades/curator-facade.test.ts +87 -9
- package/src/runtime/facades/curator-facade.ts +60 -4
- package/src/runtime/orchestrate-ops.test.ts +78 -1
- package/src/runtime/orchestrate-ops.ts +175 -1
- package/src/runtime/orchestrate-status-readiness.test.ts +162 -0
- package/src/runtime/quality-signals.test.ts +312 -0
- package/src/runtime/quality-signals.ts +169 -0
- package/src/skills/trust-classifier.ts +1 -1
- package/src/vault/vault-markdown-sync.test.ts +40 -0
- package/src/vault/vault-markdown-sync.ts +16 -3
- package/src/workflows/index.ts +12 -0
- package/src/workflows/orchestrate-integration.test.ts +166 -0
- package/src/workflows/workflow-loader.test.ts +149 -0
- 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
|
+
});
|