@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
|
@@ -340,6 +340,101 @@ describe('collectGitEvidence', () => {
|
|
|
340
340
|
});
|
|
341
341
|
});
|
|
342
342
|
|
|
343
|
+
describe('collectGitEvidence — rework tracking', () => {
|
|
344
|
+
it('includes fixIterations in task evidence when present', () => {
|
|
345
|
+
mockExecFileSync
|
|
346
|
+
.mockReturnValueOnce('feature/auth\n')
|
|
347
|
+
.mockReturnValueOnce('M\tsrc/auth/middleware.ts\n');
|
|
348
|
+
|
|
349
|
+
const plan = makePlan({
|
|
350
|
+
tasks: [
|
|
351
|
+
{
|
|
352
|
+
id: 'task-1',
|
|
353
|
+
title: 'Add auth middleware',
|
|
354
|
+
description: 'Auth middleware',
|
|
355
|
+
status: 'completed',
|
|
356
|
+
fixIterations: 2,
|
|
357
|
+
updatedAt: Date.now(),
|
|
358
|
+
},
|
|
359
|
+
],
|
|
360
|
+
});
|
|
361
|
+
const report = collectGitEvidence(plan, '/project', 'main');
|
|
362
|
+
|
|
363
|
+
expect(report.taskEvidence[0].fixIterations).toBe(2);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('omits fixIterations when task has zero rework', () => {
|
|
367
|
+
mockExecFileSync
|
|
368
|
+
.mockReturnValueOnce('feature/auth\n')
|
|
369
|
+
.mockReturnValueOnce('M\tsrc/auth/middleware.ts\n');
|
|
370
|
+
|
|
371
|
+
const plan = makePlan({
|
|
372
|
+
tasks: [
|
|
373
|
+
{
|
|
374
|
+
id: 'task-1',
|
|
375
|
+
title: 'Add auth middleware',
|
|
376
|
+
description: 'Auth middleware',
|
|
377
|
+
status: 'completed',
|
|
378
|
+
updatedAt: Date.now(),
|
|
379
|
+
},
|
|
380
|
+
],
|
|
381
|
+
});
|
|
382
|
+
const report = collectGitEvidence(plan, '/project', 'main');
|
|
383
|
+
|
|
384
|
+
expect(report.taskEvidence[0].fixIterations).toBeUndefined();
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('includes rework count in summary when tasks were reworked', () => {
|
|
388
|
+
mockExecFileSync
|
|
389
|
+
.mockReturnValueOnce('feature/auth\n')
|
|
390
|
+
.mockReturnValueOnce('M\tsrc/auth/middleware.ts\nM\tsrc/auth/login.ts\n');
|
|
391
|
+
|
|
392
|
+
const plan = makePlan({
|
|
393
|
+
tasks: [
|
|
394
|
+
{
|
|
395
|
+
id: 'task-1',
|
|
396
|
+
title: 'Add auth middleware',
|
|
397
|
+
description: 'Auth middleware',
|
|
398
|
+
status: 'completed',
|
|
399
|
+
fixIterations: 1,
|
|
400
|
+
updatedAt: Date.now(),
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
id: 'task-2',
|
|
404
|
+
title: 'Add login endpoint',
|
|
405
|
+
description: 'Login endpoint',
|
|
406
|
+
status: 'completed',
|
|
407
|
+
updatedAt: Date.now(),
|
|
408
|
+
},
|
|
409
|
+
],
|
|
410
|
+
});
|
|
411
|
+
const report = collectGitEvidence(plan, '/project', 'main');
|
|
412
|
+
|
|
413
|
+
expect(report.summary).toContain('1 required rework');
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it('does not include rework in summary when no tasks were reworked', () => {
|
|
417
|
+
mockExecFileSync
|
|
418
|
+
.mockReturnValueOnce('feature/auth\n')
|
|
419
|
+
.mockReturnValueOnce('M\tsrc/auth/middleware.ts\n');
|
|
420
|
+
|
|
421
|
+
const plan = makePlan({
|
|
422
|
+
tasks: [
|
|
423
|
+
{
|
|
424
|
+
id: 'task-1',
|
|
425
|
+
title: 'Add auth middleware',
|
|
426
|
+
description: 'Auth middleware',
|
|
427
|
+
status: 'completed',
|
|
428
|
+
updatedAt: Date.now(),
|
|
429
|
+
},
|
|
430
|
+
],
|
|
431
|
+
});
|
|
432
|
+
const report = collectGitEvidence(plan, '/project', 'main');
|
|
433
|
+
|
|
434
|
+
expect(report.summary).not.toContain('rework');
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
|
|
343
438
|
describe('collectVerificationGaps', () => {
|
|
344
439
|
it('returns empty array when no tasks have verification', () => {
|
|
345
440
|
const tasks: PlanTask[] = [makeTask()];
|
|
@@ -19,6 +19,8 @@ export interface GitTaskEvidence {
|
|
|
19
19
|
plannedStatus: string;
|
|
20
20
|
matchedFiles: FileChange[];
|
|
21
21
|
verdict: 'DONE' | 'PARTIAL' | 'MISSING' | 'SKIPPED';
|
|
22
|
+
/** Number of rework cycles this task went through (0 = first pass). */
|
|
23
|
+
fixIterations?: number;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export interface UnplannedChange {
|
|
@@ -72,6 +74,8 @@ export function collectGitEvidence(
|
|
|
72
74
|
plannedStatus: task.status,
|
|
73
75
|
matchedFiles: matches,
|
|
74
76
|
verdict,
|
|
77
|
+
...(task.fixIterations !== undefined &&
|
|
78
|
+
task.fixIterations > 0 && { fixIterations: task.fixIterations }),
|
|
75
79
|
});
|
|
76
80
|
}
|
|
77
81
|
|
|
@@ -93,12 +97,17 @@ export function collectGitEvidence(
|
|
|
93
97
|
? Math.round(((doneTasks + partialTasks * 0.5 + skippedTasks * 0.25) / totalTasks) * 100)
|
|
94
98
|
: 100;
|
|
95
99
|
|
|
100
|
+
const reworkedTasks = taskEvidence.filter(
|
|
101
|
+
(te) => te.fixIterations && te.fixIterations > 0,
|
|
102
|
+
).length;
|
|
103
|
+
|
|
96
104
|
const summary = buildSummary(
|
|
97
105
|
totalTasks,
|
|
98
106
|
doneTasks,
|
|
99
107
|
partialTasks,
|
|
100
108
|
missingWork.length,
|
|
101
109
|
unplannedChanges.length,
|
|
110
|
+
reworkedTasks,
|
|
102
111
|
);
|
|
103
112
|
|
|
104
113
|
const verificationGaps = collectVerificationGaps(plan.tasks, taskEvidence);
|
|
@@ -284,11 +293,13 @@ function buildSummary(
|
|
|
284
293
|
partial: number,
|
|
285
294
|
missing: number,
|
|
286
295
|
unplanned: number,
|
|
296
|
+
reworked: number = 0,
|
|
287
297
|
): string {
|
|
288
298
|
const parts: string[] = [];
|
|
289
299
|
parts.push(`${done}/${total} tasks verified by git evidence`);
|
|
290
300
|
if (partial > 0) parts.push(`${partial} partially done`);
|
|
291
301
|
if (missing > 0) parts.push(`${missing} with no file evidence`);
|
|
292
302
|
if (unplanned > 0) parts.push(`${unplanned} unplanned file changes`);
|
|
303
|
+
if (reworked > 0) parts.push(`${reworked} required rework`);
|
|
293
304
|
return parts.join(', ');
|
|
294
305
|
}
|
|
@@ -172,12 +172,16 @@ export function analyzeStructure(plan: Plan): PlanGap[] {
|
|
|
172
172
|
}
|
|
173
173
|
|
|
174
174
|
if (plan.tasks.length === 0) {
|
|
175
|
+
const hasApproachSteps =
|
|
176
|
+
plan.approach && /(?:step\s+\d|task\s+\d|\d\.\s|\d\)\s)/i.test(plan.approach);
|
|
175
177
|
gaps.push(
|
|
176
178
|
gap(
|
|
177
|
-
'critical',
|
|
179
|
+
hasApproachSteps ? 'major' : 'critical',
|
|
178
180
|
'structure',
|
|
179
|
-
|
|
180
|
-
|
|
181
|
+
hasApproachSteps
|
|
182
|
+
? 'Plan has no tasks but approach contains steps. Use `addTasks` in `plan_iterate` or pass `tasks` in `create_plan` to promote them.'
|
|
183
|
+
: 'Plan has no tasks.',
|
|
184
|
+
'Add tasks via `create_plan` (tasks param) or `plan_iterate` (addTasks param).',
|
|
181
185
|
'tasks',
|
|
182
186
|
'no_tasks',
|
|
183
187
|
),
|
|
@@ -342,6 +342,55 @@ describe('plan-lifecycle', () => {
|
|
|
342
342
|
expect(task.completedAt).toBeGreaterThan(0);
|
|
343
343
|
expect(task.status).toBe('failed');
|
|
344
344
|
});
|
|
345
|
+
|
|
346
|
+
it('increments fixIterations on completed → in_progress rework', () => {
|
|
347
|
+
const task = makeTask();
|
|
348
|
+
applyTaskStatusUpdate(task, 'in_progress');
|
|
349
|
+
applyTaskStatusUpdate(task, 'completed');
|
|
350
|
+
expect(task.fixIterations).toBeUndefined();
|
|
351
|
+
// Rework: send back from completed to in_progress
|
|
352
|
+
applyTaskStatusUpdate(task, 'in_progress');
|
|
353
|
+
expect(task.fixIterations).toBe(1);
|
|
354
|
+
expect(task.completedAt).toBeUndefined();
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('increments fixIterations on failed → in_progress rework', () => {
|
|
358
|
+
const task = makeTask();
|
|
359
|
+
applyTaskStatusUpdate(task, 'in_progress');
|
|
360
|
+
applyTaskStatusUpdate(task, 'failed');
|
|
361
|
+
// Rework from failed
|
|
362
|
+
applyTaskStatusUpdate(task, 'in_progress');
|
|
363
|
+
expect(task.fixIterations).toBe(1);
|
|
364
|
+
expect(task.completedAt).toBeUndefined();
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('accumulates fixIterations across multiple rework cycles', () => {
|
|
368
|
+
const task = makeTask();
|
|
369
|
+
applyTaskStatusUpdate(task, 'in_progress');
|
|
370
|
+
applyTaskStatusUpdate(task, 'completed');
|
|
371
|
+
applyTaskStatusUpdate(task, 'in_progress'); // rework 1
|
|
372
|
+
applyTaskStatusUpdate(task, 'completed');
|
|
373
|
+
applyTaskStatusUpdate(task, 'in_progress'); // rework 2
|
|
374
|
+
expect(task.fixIterations).toBe(2);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('does not increment fixIterations on pending → in_progress', () => {
|
|
378
|
+
const task = makeTask();
|
|
379
|
+
applyTaskStatusUpdate(task, 'in_progress');
|
|
380
|
+
expect(task.fixIterations).toBeUndefined();
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('resets completedAt on rework but preserves startedAt', () => {
|
|
384
|
+
const task = makeTask();
|
|
385
|
+
applyTaskStatusUpdate(task, 'in_progress');
|
|
386
|
+
const originalStartedAt = task.startedAt;
|
|
387
|
+
applyTaskStatusUpdate(task, 'completed');
|
|
388
|
+
expect(task.completedAt).toBeGreaterThan(0);
|
|
389
|
+
// Rework
|
|
390
|
+
applyTaskStatusUpdate(task, 'in_progress');
|
|
391
|
+
expect(task.completedAt).toBeUndefined();
|
|
392
|
+
expect(task.startedAt).toBe(originalStartedAt);
|
|
393
|
+
});
|
|
345
394
|
});
|
|
346
395
|
|
|
347
396
|
describe('createPlanObject', () => {
|
|
@@ -338,6 +338,11 @@ export function applyIteration(plan: Plan, changes: IterateChanges): number {
|
|
|
338
338
|
*/
|
|
339
339
|
export function applyTaskStatusUpdate(task: PlanTask, status: TaskStatus): void {
|
|
340
340
|
const now = Date.now();
|
|
341
|
+
// Rework detection: completed/failed → in_progress means a fix iteration
|
|
342
|
+
if (status === 'in_progress' && (task.status === 'completed' || task.status === 'failed')) {
|
|
343
|
+
task.fixIterations = (task.fixIterations ?? 0) + 1;
|
|
344
|
+
task.completedAt = undefined;
|
|
345
|
+
}
|
|
341
346
|
if (status === 'in_progress' && !task.startedAt) task.startedAt = now;
|
|
342
347
|
if (status === 'completed' || status === 'skipped' || status === 'failed') {
|
|
343
348
|
task.completedAt = now;
|
|
@@ -88,6 +88,8 @@ export interface PlanTask {
|
|
|
88
88
|
deliverables?: TaskDeliverable[];
|
|
89
89
|
/** Verification findings for tasks that modify existing code. Advisory only. */
|
|
90
90
|
verification?: TaskVerification;
|
|
91
|
+
/** Number of times this task was sent back for rework (completed → in_progress). */
|
|
92
|
+
fixIterations?: number;
|
|
91
93
|
updatedAt: number;
|
|
92
94
|
}
|
|
93
95
|
|
|
@@ -14,12 +14,14 @@ vi.mock('../vault/scope-detector.js', () => ({
|
|
|
14
14
|
})),
|
|
15
15
|
}));
|
|
16
16
|
|
|
17
|
+
const mockSyncEntryToMarkdown = vi.fn(() => Promise.resolve({ written: true }));
|
|
17
18
|
vi.mock('../vault/vault-markdown-sync.js', () => ({
|
|
18
|
-
syncEntryToMarkdown:
|
|
19
|
+
syncEntryToMarkdown: (...args: unknown[]) => mockSyncEntryToMarkdown(...args),
|
|
19
20
|
}));
|
|
20
21
|
|
|
21
22
|
vi.mock('../paths.js', () => ({
|
|
22
23
|
agentKnowledgeDir: vi.fn(() => '/mock/knowledge'),
|
|
24
|
+
projectKnowledgeDir: vi.fn((p: string) => `/mock/project/${p}/knowledge`),
|
|
23
25
|
}));
|
|
24
26
|
|
|
25
27
|
// ─── Mock Runtime Factory ──────────────────────────────────────────────
|
|
@@ -213,6 +215,32 @@ describe('createCaptureOps', () => {
|
|
|
213
215
|
const results = result.results as Array<Record<string, unknown>>;
|
|
214
216
|
expect((results[0].scope as Record<string, unknown>).tier).toBe('agent');
|
|
215
217
|
});
|
|
218
|
+
|
|
219
|
+
it('triggers markdown file write on successful capture', async () => {
|
|
220
|
+
mockSyncEntryToMarkdown.mockClear();
|
|
221
|
+
await findOp(ops, 'capture_knowledge').handler({
|
|
222
|
+
entries: [
|
|
223
|
+
{ type: 'pattern', domain: 'testing', title: 'Sync Test', description: 'test', tags: [] },
|
|
224
|
+
],
|
|
225
|
+
});
|
|
226
|
+
// fire-and-forget: allow microtask to flush
|
|
227
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
228
|
+
expect(mockSyncEntryToMarkdown).toHaveBeenCalledWith(
|
|
229
|
+
expect.objectContaining({ domain: 'testing', title: 'Sync Test' }),
|
|
230
|
+
'/mock/knowledge',
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('does not block capture response when sync fails', async () => {
|
|
235
|
+
mockSyncEntryToMarkdown.mockClear();
|
|
236
|
+
mockSyncEntryToMarkdown.mockRejectedValueOnce(new Error('disk full'));
|
|
237
|
+
const result = (await findOp(ops, 'capture_knowledge').handler({
|
|
238
|
+
entries: [{ type: 'pattern', domain: 'a', title: 'A', description: 'a', tags: [] }],
|
|
239
|
+
})) as Record<string, unknown>;
|
|
240
|
+
// Capture should still succeed despite sync error
|
|
241
|
+
expect(result.captured).toBe(1);
|
|
242
|
+
expect(result.rejected).toBe(0);
|
|
243
|
+
});
|
|
216
244
|
});
|
|
217
245
|
|
|
218
246
|
describe('capture_quick', () => {
|
|
@@ -323,6 +351,35 @@ describe('createCaptureOps', () => {
|
|
|
323
351
|
expect(result.captured).toBe(false);
|
|
324
352
|
expect((result.governance as Record<string, unknown>).action).toBe('error');
|
|
325
353
|
});
|
|
354
|
+
|
|
355
|
+
it('triggers markdown file write on successful capture', async () => {
|
|
356
|
+
mockSyncEntryToMarkdown.mockClear();
|
|
357
|
+
await findOp(ops, 'capture_quick').handler({
|
|
358
|
+
type: 'pattern',
|
|
359
|
+
domain: 'testing',
|
|
360
|
+
title: 'Quick Sync Test',
|
|
361
|
+
description: 'quick test',
|
|
362
|
+
});
|
|
363
|
+
// fire-and-forget: allow microtask to flush
|
|
364
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
365
|
+
expect(mockSyncEntryToMarkdown).toHaveBeenCalledWith(
|
|
366
|
+
expect.objectContaining({ domain: 'testing', title: 'Quick Sync Test' }),
|
|
367
|
+
'/mock/knowledge',
|
|
368
|
+
);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('does not block capture response when sync fails', async () => {
|
|
372
|
+
mockSyncEntryToMarkdown.mockClear();
|
|
373
|
+
mockSyncEntryToMarkdown.mockRejectedValueOnce(new Error('disk full'));
|
|
374
|
+
const result = (await findOp(ops, 'capture_quick').handler({
|
|
375
|
+
type: 'pattern',
|
|
376
|
+
domain: 'testing',
|
|
377
|
+
title: 'Quick Fail',
|
|
378
|
+
description: 'test',
|
|
379
|
+
})) as Record<string, unknown>;
|
|
380
|
+
// Capture should still succeed despite sync error
|
|
381
|
+
expect(result.captured).toBe(true);
|
|
382
|
+
});
|
|
326
383
|
});
|
|
327
384
|
|
|
328
385
|
describe('search_intelligent', () => {
|
|
@@ -11,7 +11,7 @@ import type { AgentRuntime } from './types.js';
|
|
|
11
11
|
import { detectScope } from '../vault/scope-detector.js';
|
|
12
12
|
import type { ScopeTier, ScopeDetectionResult } from '../vault/scope-detector.js';
|
|
13
13
|
import { syncEntryToMarkdown } from '../vault/vault-markdown-sync.js';
|
|
14
|
-
import { agentKnowledgeDir } from '../paths.js';
|
|
14
|
+
import { agentKnowledgeDir, projectKnowledgeDir } from '../paths.js';
|
|
15
15
|
import type { IntelligenceEntry } from '../intelligence/types.js';
|
|
16
16
|
|
|
17
17
|
/**
|
|
@@ -189,6 +189,7 @@ export function createCaptureOps(runtime: AgentRuntime): OpDefinition[] {
|
|
|
189
189
|
origin: 'user',
|
|
190
190
|
},
|
|
191
191
|
config.agentId,
|
|
192
|
+
projectPath,
|
|
192
193
|
);
|
|
193
194
|
}
|
|
194
195
|
} catch (err) {
|
|
@@ -417,6 +418,7 @@ export function createCaptureOps(runtime: AgentRuntime): OpDefinition[] {
|
|
|
417
418
|
origin: 'user',
|
|
418
419
|
},
|
|
419
420
|
config.agentId,
|
|
421
|
+
projectPath,
|
|
420
422
|
);
|
|
421
423
|
return result;
|
|
422
424
|
} catch (err) {
|
|
@@ -629,9 +631,18 @@ function mapType(type: string): 'pattern' | 'anti-pattern' | 'rule' | 'playbook'
|
|
|
629
631
|
}
|
|
630
632
|
|
|
631
633
|
/** Fire-and-forget markdown sync — never blocks capture, logs errors silently. */
|
|
632
|
-
function fireAndForgetSync(entry: IntelligenceEntry, agentId: string): void {
|
|
633
|
-
|
|
634
|
-
|
|
634
|
+
function fireAndForgetSync(entry: IntelligenceEntry, agentId: string, projectPath?: string): void {
|
|
635
|
+
// Always sync to agent home dir
|
|
636
|
+
const agentDir = agentKnowledgeDir(agentId);
|
|
637
|
+
syncEntryToMarkdown(entry, agentDir).catch(() => {
|
|
635
638
|
/* non-blocking — markdown sync is best-effort */
|
|
636
639
|
});
|
|
640
|
+
|
|
641
|
+
// Also sync to project-local knowledge dir if a real project path is provided
|
|
642
|
+
if (projectPath && projectPath !== '.') {
|
|
643
|
+
const projDir = projectKnowledgeDir(projectPath);
|
|
644
|
+
syncEntryToMarkdown(entry, projDir).catch(() => {
|
|
645
|
+
/* non-blocking — markdown sync is best-effort */
|
|
646
|
+
});
|
|
647
|
+
}
|
|
637
648
|
}
|
|
@@ -3,24 +3,43 @@ import { createCuratorFacadeOps } from './curator-facade.js';
|
|
|
3
3
|
import type { OpDefinition } from '../../facades/types.js';
|
|
4
4
|
import type { AgentRuntime } from '../types.js';
|
|
5
5
|
|
|
6
|
+
interface MockLinkManager {
|
|
7
|
+
backfillLinks: ReturnType<typeof vi.fn>;
|
|
8
|
+
getOrphans: ReturnType<typeof vi.fn>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function getLinkManager(rt: AgentRuntime): MockLinkManager {
|
|
12
|
+
return (rt as unknown as { linkManager: MockLinkManager }).linkManager;
|
|
13
|
+
}
|
|
14
|
+
|
|
6
15
|
function mockRuntime(): AgentRuntime {
|
|
7
16
|
return {
|
|
8
17
|
curator: {
|
|
9
|
-
getStatus: vi
|
|
18
|
+
getStatus: vi
|
|
19
|
+
.fn()
|
|
20
|
+
.mockReturnValue({ initialized: true, entriesGroomed: 5, tables: { entries: 100 } }),
|
|
10
21
|
detectDuplicates: vi.fn().mockReturnValue([]),
|
|
11
22
|
detectContradictions: vi.fn(),
|
|
12
23
|
getContradictions: vi.fn().mockReturnValue([]),
|
|
13
24
|
resolveContradiction: vi.fn().mockReturnValue({ resolved: true }),
|
|
14
25
|
groomEntry: vi.fn().mockReturnValue({ groomed: true }),
|
|
15
26
|
groomAll: vi.fn().mockReturnValue({ groomed: 10 }),
|
|
16
|
-
consolidate: vi.fn().mockReturnValue({ duplicates: 0, stale: 0 }),
|
|
17
|
-
healthAudit: vi.fn().mockReturnValue({
|
|
27
|
+
consolidate: vi.fn().mockReturnValue({ duplicates: 0, stale: 0, durationMs: 10 }),
|
|
28
|
+
healthAudit: vi.fn().mockReturnValue({
|
|
29
|
+
score: 85,
|
|
30
|
+
metrics: { coverage: 1, freshness: 1, quality: 1, tagHealth: 1 },
|
|
31
|
+
recommendations: [],
|
|
32
|
+
}),
|
|
18
33
|
getVersionHistory: vi.fn().mockReturnValue([]),
|
|
19
34
|
recordSnapshot: vi.fn().mockReturnValue({ recorded: true }),
|
|
20
35
|
getQueueStats: vi.fn().mockReturnValue({ total: 20, groomed: 15 }),
|
|
21
36
|
enrichMetadata: vi.fn().mockReturnValue({ enriched: true }),
|
|
22
37
|
detectContradictionsHybrid: vi.fn().mockReturnValue([]),
|
|
23
38
|
},
|
|
39
|
+
linkManager: {
|
|
40
|
+
backfillLinks: vi.fn().mockReturnValue({ processed: 5, linksCreated: 3, durationMs: 50 }),
|
|
41
|
+
getOrphans: vi.fn().mockReturnValue([]),
|
|
42
|
+
},
|
|
24
43
|
jobQueue: {
|
|
25
44
|
enqueue: vi.fn().mockImplementation((_type, _params) => `job-${Date.now()}`),
|
|
26
45
|
getStats: vi.fn().mockReturnValue({ pending: 0, running: 0 }),
|
|
@@ -91,7 +110,7 @@ describe('createCuratorFacadeOps', () => {
|
|
|
91
110
|
describe('curator_status', () => {
|
|
92
111
|
it('returns curator status', async () => {
|
|
93
112
|
const result = await findOp(ops, 'curator_status').handler({});
|
|
94
|
-
expect(result).toEqual({ initialized: true, entriesGroomed: 5 });
|
|
113
|
+
expect(result).toEqual({ initialized: true, entriesGroomed: 5, tables: { entries: 100 } });
|
|
95
114
|
});
|
|
96
115
|
});
|
|
97
116
|
|
|
@@ -168,15 +187,18 @@ describe('createCuratorFacadeOps', () => {
|
|
|
168
187
|
});
|
|
169
188
|
|
|
170
189
|
describe('curator_consolidate', () => {
|
|
171
|
-
it('consolidates with default params', async () => {
|
|
190
|
+
it('consolidates with default params and includes linksCreated', async () => {
|
|
172
191
|
const result = await findOp(ops, 'curator_consolidate').handler({});
|
|
173
|
-
expect(result).toEqual({ duplicates: 0, stale: 0 });
|
|
192
|
+
expect(result).toEqual({ duplicates: 0, stale: 0, durationMs: 10, linksCreated: 3 });
|
|
174
193
|
expect(runtime.curator.consolidate).toHaveBeenCalledWith({
|
|
175
194
|
dryRun: undefined,
|
|
176
195
|
staleDaysThreshold: undefined,
|
|
177
196
|
duplicateThreshold: undefined,
|
|
178
197
|
contradictionThreshold: undefined,
|
|
179
198
|
});
|
|
199
|
+
expect(getLinkManager(runtime).backfillLinks).toHaveBeenCalledWith({
|
|
200
|
+
dryRun: undefined,
|
|
201
|
+
});
|
|
180
202
|
});
|
|
181
203
|
|
|
182
204
|
it('consolidates with custom params', async () => {
|
|
@@ -193,12 +215,68 @@ describe('createCuratorFacadeOps', () => {
|
|
|
193
215
|
contradictionThreshold: 0.3,
|
|
194
216
|
});
|
|
195
217
|
});
|
|
218
|
+
|
|
219
|
+
it('returns linksCreated: 0 when linkManager throws', async () => {
|
|
220
|
+
const lm = getLinkManager(runtime);
|
|
221
|
+
vi.mocked(lm.backfillLinks).mockImplementation(() => {
|
|
222
|
+
throw new Error('link module unavailable');
|
|
223
|
+
});
|
|
224
|
+
const result = (await findOp(ops, 'curator_consolidate').handler({})) as Record<
|
|
225
|
+
string,
|
|
226
|
+
unknown
|
|
227
|
+
>;
|
|
228
|
+
expect(result.linksCreated).toBe(0);
|
|
229
|
+
});
|
|
196
230
|
});
|
|
197
231
|
|
|
198
232
|
describe('curator_health_audit', () => {
|
|
199
|
-
it('returns audit result', async () => {
|
|
200
|
-
const result = await findOp(ops, 'curator_health_audit').handler({})
|
|
201
|
-
|
|
233
|
+
it('returns audit result with orphan metrics', async () => {
|
|
234
|
+
const result = (await findOp(ops, 'curator_health_audit').handler({})) as Record<
|
|
235
|
+
string,
|
|
236
|
+
unknown
|
|
237
|
+
>;
|
|
238
|
+
expect(result.score).toBe(85);
|
|
239
|
+
expect(result.orphanCount).toBe(0);
|
|
240
|
+
expect(result.orphanPercentage).toBe(0);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('reduces quality when orphan percentage > 10%', async () => {
|
|
244
|
+
const lm = getLinkManager(runtime);
|
|
245
|
+
// 15 orphans out of 100 entries = 15%
|
|
246
|
+
vi.mocked(lm.getOrphans).mockReturnValue(
|
|
247
|
+
Array.from({ length: 15 }, (_, i) => ({
|
|
248
|
+
id: `orphan-${i}`,
|
|
249
|
+
title: `o${i}`,
|
|
250
|
+
type: 'pattern',
|
|
251
|
+
domain: 'test',
|
|
252
|
+
})),
|
|
253
|
+
);
|
|
254
|
+
const result = (await findOp(ops, 'curator_health_audit').handler({})) as Record<
|
|
255
|
+
string,
|
|
256
|
+
unknown
|
|
257
|
+
>;
|
|
258
|
+
expect(result.orphanCount).toBe(15);
|
|
259
|
+
expect(result.orphanPercentage).toBe(15);
|
|
260
|
+
const metrics = result.metrics as Record<string, number>;
|
|
261
|
+
expect(metrics.quality).toBe(0.7); // 1 * 0.7
|
|
262
|
+
expect(
|
|
263
|
+
(result.recommendations as string[]).some(
|
|
264
|
+
(r: string) => r.includes('orphan entries') && r.includes('no links'),
|
|
265
|
+
),
|
|
266
|
+
).toBe(true);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('succeeds when linkManager throws', async () => {
|
|
270
|
+
const lm = getLinkManager(runtime);
|
|
271
|
+
vi.mocked(lm.getOrphans).mockImplementation(() => {
|
|
272
|
+
throw new Error('link module unavailable');
|
|
273
|
+
});
|
|
274
|
+
const result = (await findOp(ops, 'curator_health_audit').handler({})) as Record<
|
|
275
|
+
string,
|
|
276
|
+
unknown
|
|
277
|
+
>;
|
|
278
|
+
expect(result.orphanCount).toBe(0);
|
|
279
|
+
expect(result.orphanPercentage).toBe(0);
|
|
202
280
|
});
|
|
203
281
|
});
|
|
204
282
|
|
|
@@ -108,7 +108,7 @@ export function createCuratorFacadeOps(runtime: AgentRuntime): OpDefinition[] {
|
|
|
108
108
|
{
|
|
109
109
|
name: 'curator_consolidate',
|
|
110
110
|
description:
|
|
111
|
-
'Consolidate vault — find duplicates, stale entries, contradictions. Dry-run by default.',
|
|
111
|
+
'Consolidate vault — find duplicates, stale entries, contradictions, and backfill Zettelkasten links for orphan entries. Dry-run by default.',
|
|
112
112
|
auth: 'write',
|
|
113
113
|
schema: z.object({
|
|
114
114
|
dryRun: z.boolean().optional().describe('Default true. Set false to apply mutations.'),
|
|
@@ -126,21 +126,77 @@ export function createCuratorFacadeOps(runtime: AgentRuntime): OpDefinition[] {
|
|
|
126
126
|
.describe('Contradiction threshold. Default 0.4.'),
|
|
127
127
|
}),
|
|
128
128
|
handler: async (params) => {
|
|
129
|
-
|
|
129
|
+
const result = curator.consolidate({
|
|
130
130
|
dryRun: params.dryRun as boolean | undefined,
|
|
131
131
|
staleDaysThreshold: params.staleDaysThreshold as number | undefined,
|
|
132
132
|
duplicateThreshold: params.duplicateThreshold as number | undefined,
|
|
133
133
|
contradictionThreshold: params.contradictionThreshold as number | undefined,
|
|
134
134
|
});
|
|
135
|
+
|
|
136
|
+
// Backfill Zettelkasten links for orphan entries
|
|
137
|
+
let linksCreated = 0;
|
|
138
|
+
try {
|
|
139
|
+
const { linkManager } = runtime;
|
|
140
|
+
if (linkManager) {
|
|
141
|
+
const backfillResult = linkManager.backfillLinks({
|
|
142
|
+
dryRun: params.dryRun as boolean | undefined,
|
|
143
|
+
});
|
|
144
|
+
linksCreated = backfillResult.linksCreated;
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
// Link module unavailable — degrade gracefully
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { ...result, linksCreated };
|
|
135
151
|
},
|
|
136
152
|
},
|
|
137
153
|
{
|
|
138
154
|
name: 'curator_health_audit',
|
|
139
155
|
description:
|
|
140
|
-
'Audit vault health — score (0-100), coverage, freshness, quality, tag health, recommendations.',
|
|
156
|
+
'Audit vault health — score (0-100), coverage, freshness, quality, tag health, orphan count, recommendations.',
|
|
141
157
|
auth: 'read',
|
|
142
158
|
handler: async () => {
|
|
143
|
-
|
|
159
|
+
const result = curator.healthAudit();
|
|
160
|
+
|
|
161
|
+
// Enrich with orphan statistics from link manager
|
|
162
|
+
let orphanCount = 0;
|
|
163
|
+
let orphanPercentage = 0;
|
|
164
|
+
try {
|
|
165
|
+
const { linkManager } = runtime;
|
|
166
|
+
if (linkManager) {
|
|
167
|
+
// getOrphans returns up to limit entries; use a high limit to count all
|
|
168
|
+
const orphans = linkManager.getOrphans(10000);
|
|
169
|
+
orphanCount = orphans.length;
|
|
170
|
+
// Compute percentage against total entries via curator status
|
|
171
|
+
const status = curator.getStatus();
|
|
172
|
+
const totalEntries = Object.values(status.tables).reduce(
|
|
173
|
+
(sum, count) => sum + count,
|
|
174
|
+
0,
|
|
175
|
+
);
|
|
176
|
+
orphanPercentage =
|
|
177
|
+
totalEntries > 0 ? Math.round((orphanCount / totalEntries) * 100) : 0;
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
// Link module unavailable — degrade gracefully
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Apply quality penalty if orphan percentage > 10%
|
|
184
|
+
const metrics = { ...result.metrics };
|
|
185
|
+
const recommendations = [...result.recommendations];
|
|
186
|
+
if (orphanPercentage > 10) {
|
|
187
|
+
metrics.quality = Math.round(metrics.quality * 0.7 * 100) / 100;
|
|
188
|
+
recommendations.push(
|
|
189
|
+
`${orphanCount} orphan entries (${orphanPercentage}%) have no links — run consolidation to backfill.`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
...result,
|
|
195
|
+
metrics,
|
|
196
|
+
recommendations,
|
|
197
|
+
orphanCount,
|
|
198
|
+
orphanPercentage,
|
|
199
|
+
};
|
|
144
200
|
},
|
|
145
201
|
},
|
|
146
202
|
|