@cleocode/core 2026.4.45 → 2026.4.47
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/index.js +84 -77
- package/dist/index.js.map +3 -3
- package/dist/internal.d.ts +3 -3
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +128 -109
- package/dist/internal.js.map +3 -3
- package/dist/memory/brain-lifecycle.d.ts +13 -6
- package/dist/memory/brain-lifecycle.d.ts.map +1 -1
- package/dist/memory/brain-maintenance.d.ts +11 -0
- package/dist/memory/brain-maintenance.d.ts.map +1 -1
- package/dist/store/db-helpers.d.ts +8 -3
- package/dist/store/db-helpers.d.ts.map +1 -1
- package/dist/store/sqlite-data-accessor.d.ts.map +1 -1
- package/dist/tasks/complete.d.ts.map +1 -1
- package/package.json +18 -8
- package/src/internal.ts +16 -3
- package/src/memory/__tests__/brain-automation.test.ts +1 -0
- package/src/memory/__tests__/brain-lifecycle-tier-promotion.test.ts +367 -0
- package/src/memory/brain-lifecycle.ts +43 -32
- package/src/memory/brain-maintenance.ts +27 -2
- package/src/store/__tests__/db-helpers.test.ts +46 -6
- package/src/store/db-helpers.ts +25 -4
- package/src/store/sqlite-data-accessor.ts +2 -1
- package/src/tasks/__tests__/epic-auto-complete.test.ts +331 -0
- package/src/tasks/complete.ts +7 -4
|
@@ -381,14 +381,21 @@ export interface PromotionResult {
|
|
|
381
381
|
/**
|
|
382
382
|
* Run tier promotion for all memory tables.
|
|
383
383
|
*
|
|
384
|
-
* Promotion rules (per spec §1.1–§1.3):
|
|
385
|
-
* - short → medium:
|
|
386
|
-
*
|
|
387
|
-
*
|
|
384
|
+
* Promotion rules (per spec §1.1–§1.3, relaxed in T614):
|
|
385
|
+
* - short → medium:
|
|
386
|
+
* A. (citationCount >= 3 AND age > 24h) — citation-based track
|
|
387
|
+
* B. (qualityScore >= 0.7 AND age > 24h) — quality fast-track
|
|
388
|
+
* C. (verified = true AND age > 24h) — owner-verified track
|
|
389
|
+
* Note: `verified` is no longer a hard gate for routes A and B.
|
|
390
|
+
* Requiring verified=true on all paths caused all 235 short-tier observations
|
|
391
|
+
* to be permanently stuck (T614 bug).
|
|
392
|
+
* - medium → long:
|
|
393
|
+
* (citationCount >= 5 AND age > 7 days) OR (verified = true AND age > 7 days)
|
|
394
|
+
* Verified entries accelerate to long-tier without citation threshold.
|
|
388
395
|
*
|
|
389
396
|
* Eviction rules:
|
|
390
|
-
* - short-term entries older than
|
|
391
|
-
*
|
|
397
|
+
* - short-term entries older than 7 days with no promotion eligibility are
|
|
398
|
+
* soft-evicted (invalidAt = now).
|
|
392
399
|
* - long-term entries are NEVER auto-evicted.
|
|
393
400
|
*
|
|
394
401
|
* @param projectRoot - Project root directory for brain.db resolution
|
|
@@ -431,25 +438,30 @@ export async function runTierPromotion(projectRoot: string): Promise<PromotionRe
|
|
|
431
438
|
|
|
432
439
|
for (const { table, dateCol } of tables) {
|
|
433
440
|
// --- short → medium promotion ---
|
|
434
|
-
//
|
|
435
|
-
// A. citationCount >= 3 AND age > 24h
|
|
436
|
-
// B. quality_score >= 0.7 AND
|
|
441
|
+
// Three criteria (union — verified is no longer a hard gate for A/B paths):
|
|
442
|
+
// A. citationCount >= 3 AND age > 24h (citation track — no verified requirement)
|
|
443
|
+
// B. quality_score >= 0.7 AND age > 24h (quality fast-track — no verified requirement)
|
|
444
|
+
// C. verified = 1 AND age > 24h (owner-verified track)
|
|
437
445
|
interface TierRow {
|
|
438
446
|
id: string;
|
|
439
447
|
citation_count: number;
|
|
440
448
|
quality_score: number | null;
|
|
449
|
+
verified: number;
|
|
441
450
|
}
|
|
442
451
|
let shortToMedium: TierRow[] = [];
|
|
443
452
|
try {
|
|
444
453
|
shortToMedium = typedAll<TierRow>(
|
|
445
454
|
nativeDb.prepare(`
|
|
446
|
-
SELECT id, citation_count, quality_score
|
|
455
|
+
SELECT id, citation_count, quality_score, verified
|
|
447
456
|
FROM ${table}
|
|
448
457
|
WHERE memory_tier = 'short'
|
|
449
458
|
AND invalid_at IS NULL
|
|
450
459
|
AND ${dateCol} < ?
|
|
451
|
-
AND
|
|
452
|
-
|
|
460
|
+
AND (
|
|
461
|
+
citation_count >= 3
|
|
462
|
+
OR quality_score >= 0.7
|
|
463
|
+
OR verified = 1
|
|
464
|
+
)
|
|
453
465
|
`),
|
|
454
466
|
age24h,
|
|
455
467
|
);
|
|
@@ -463,33 +475,34 @@ export async function runTierPromotion(projectRoot: string): Promise<PromotionRe
|
|
|
463
475
|
nativeDb
|
|
464
476
|
.prepare(`UPDATE ${table} SET memory_tier = 'medium', updated_at = ? WHERE id = ?`)
|
|
465
477
|
.run(now, row.id);
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
});
|
|
478
|
+
let reason: string;
|
|
479
|
+
if (row.citation_count >= 3) {
|
|
480
|
+
reason = `citationCount=${row.citation_count} >= 3, age > 24h`;
|
|
481
|
+
} else if ((row.quality_score ?? 0) >= 0.7) {
|
|
482
|
+
reason = `qualityScore=${row.quality_score?.toFixed(2)} >= 0.70, age > 24h`;
|
|
483
|
+
} else {
|
|
484
|
+
reason = `verified=true, age > 24h`;
|
|
485
|
+
}
|
|
486
|
+
promoted.push({ id: row.id, table, fromTier: 'short', toTier: 'medium', reason });
|
|
476
487
|
} catch {
|
|
477
488
|
/* best-effort */
|
|
478
489
|
}
|
|
479
490
|
}
|
|
480
491
|
|
|
481
492
|
// --- medium → long promotion ---
|
|
493
|
+
// Two criteria (union — verified accelerates to long without citation threshold):
|
|
494
|
+
// A. citationCount >= 5 AND age > 7d
|
|
495
|
+
// B. verified = 1 AND age > 7d (owner-verified accelerated track)
|
|
482
496
|
let mediumToLong: TierRow[] = [];
|
|
483
497
|
try {
|
|
484
498
|
mediumToLong = typedAll<TierRow>(
|
|
485
499
|
nativeDb.prepare(`
|
|
486
|
-
SELECT id, citation_count, quality_score
|
|
500
|
+
SELECT id, citation_count, quality_score, verified
|
|
487
501
|
FROM ${table}
|
|
488
502
|
WHERE memory_tier = 'medium'
|
|
489
503
|
AND invalid_at IS NULL
|
|
490
504
|
AND ${dateCol} < ?
|
|
491
|
-
AND verified = 1
|
|
492
|
-
AND citation_count >= 5
|
|
505
|
+
AND (citation_count >= 5 OR verified = 1)
|
|
493
506
|
`),
|
|
494
507
|
age7d,
|
|
495
508
|
);
|
|
@@ -502,13 +515,11 @@ export async function runTierPromotion(projectRoot: string): Promise<PromotionRe
|
|
|
502
515
|
nativeDb
|
|
503
516
|
.prepare(`UPDATE ${table} SET memory_tier = 'long', updated_at = ? WHERE id = ?`)
|
|
504
517
|
.run(now, row.id);
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
reason: `citationCount=${row.citation_count} >= 5, verified, age > 7d`,
|
|
511
|
-
});
|
|
518
|
+
const reason =
|
|
519
|
+
row.citation_count >= 5
|
|
520
|
+
? `citationCount=${row.citation_count} >= 5, age > 7d`
|
|
521
|
+
: `verified=true, age > 7d`;
|
|
522
|
+
promoted.push({ id: row.id, table, fromTier: 'medium', toTier: 'long', reason });
|
|
512
523
|
} catch {
|
|
513
524
|
/* best-effort */
|
|
514
525
|
}
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { reconcileOrphanedRefs } from '../store/cross-db-cleanup.js';
|
|
23
|
-
import { applyTemporalDecay, consolidateMemories } from './brain-lifecycle.js';
|
|
23
|
+
import { applyTemporalDecay, consolidateMemories, runTierPromotion } from './brain-lifecycle.js';
|
|
24
24
|
import { populateEmbeddings } from './brain-retrieval.js';
|
|
25
25
|
|
|
26
26
|
// ============================================================================
|
|
@@ -61,6 +61,14 @@ export interface BrainMaintenanceEmbeddingsResult {
|
|
|
61
61
|
errors: number;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/** Tier promotion step result. */
|
|
65
|
+
export interface BrainMaintenanceTierPromotionResult {
|
|
66
|
+
/** Number of entries promoted (short→medium or medium→long). */
|
|
67
|
+
promoted: number;
|
|
68
|
+
/** Number of stale short-tier entries soft-evicted. */
|
|
69
|
+
evicted: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
64
72
|
/**
|
|
65
73
|
* Aggregated result from a full brain maintenance run.
|
|
66
74
|
*
|
|
@@ -74,6 +82,8 @@ export interface BrainMaintenanceResult {
|
|
|
74
82
|
consolidation: BrainMaintenanceConsolidationResult;
|
|
75
83
|
/** Results from the cross-DB orphaned reference reconciliation step. */
|
|
76
84
|
reconciliation: BrainMaintenanceReconciliationResult;
|
|
85
|
+
/** Results from the tier promotion step. */
|
|
86
|
+
tierPromotion: BrainMaintenanceTierPromotionResult;
|
|
77
87
|
/** Results from the embedding backfill step. */
|
|
78
88
|
embeddings: BrainMaintenanceEmbeddingsResult;
|
|
79
89
|
/** Total wall-clock duration of the maintenance run in milliseconds. */
|
|
@@ -93,6 +103,8 @@ export interface BrainMaintenanceOptions {
|
|
|
93
103
|
skipConsolidation?: boolean;
|
|
94
104
|
/** Skip the cross-DB orphaned reference reconciliation step. Default: false. */
|
|
95
105
|
skipReconciliation?: boolean;
|
|
106
|
+
/** Skip the tier promotion step (short→medium, medium→long). Default: false. */
|
|
107
|
+
skipTierPromotion?: boolean;
|
|
96
108
|
/** Skip the embedding backfill step. Default: false. */
|
|
97
109
|
skipEmbeddings?: boolean;
|
|
98
110
|
/**
|
|
@@ -146,6 +158,7 @@ export async function runBrainMaintenance(
|
|
|
146
158
|
skipDecay = false,
|
|
147
159
|
skipConsolidation = false,
|
|
148
160
|
skipReconciliation = false,
|
|
161
|
+
skipTierPromotion = false,
|
|
149
162
|
skipEmbeddings = false,
|
|
150
163
|
onProgress,
|
|
151
164
|
} = options ?? {};
|
|
@@ -160,6 +173,7 @@ export async function runBrainMaintenance(
|
|
|
160
173
|
observationsFixed: 0,
|
|
161
174
|
linksRemoved: 0,
|
|
162
175
|
};
|
|
176
|
+
const tierPromotionResult: BrainMaintenanceTierPromotionResult = { promoted: 0, evicted: 0 };
|
|
163
177
|
const embeddingsResult: BrainMaintenanceEmbeddingsResult = {
|
|
164
178
|
processed: 0,
|
|
165
179
|
skipped: 0,
|
|
@@ -193,7 +207,17 @@ export async function runBrainMaintenance(
|
|
|
193
207
|
onProgress?.('reconciliation', 1, 1);
|
|
194
208
|
}
|
|
195
209
|
|
|
196
|
-
// Step 4:
|
|
210
|
+
// Step 4: Tier promotion — promote short→medium and medium→long based on
|
|
211
|
+
// age, quality score, citation count, and verification status (T614).
|
|
212
|
+
if (!skipTierPromotion) {
|
|
213
|
+
onProgress?.('tier-promotion', 0, 1);
|
|
214
|
+
const raw = await runTierPromotion(projectRoot);
|
|
215
|
+
tierPromotionResult.promoted = raw.promoted.length;
|
|
216
|
+
tierPromotionResult.evicted = raw.evicted.length;
|
|
217
|
+
onProgress?.('tier-promotion', 1, 1);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Step 5: Embedding backfill (with per-item progress relay)
|
|
197
221
|
if (!skipEmbeddings) {
|
|
198
222
|
const raw = await populateEmbeddings(projectRoot, {
|
|
199
223
|
onProgress: (current, total) => {
|
|
@@ -209,6 +233,7 @@ export async function runBrainMaintenance(
|
|
|
209
233
|
decay: decayResult,
|
|
210
234
|
consolidation: consolidationResult,
|
|
211
235
|
reconciliation: reconciliationResult,
|
|
236
|
+
tierPromotion: tierPromotionResult,
|
|
212
237
|
embeddings: embeddingsResult,
|
|
213
238
|
duration: Date.now() - startTime,
|
|
214
239
|
};
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for db-helpers.ts — defensive orphan parent handling (T5034).
|
|
2
|
+
* Tests for db-helpers.ts — defensive orphan parent handling (T5034, T585).
|
|
3
3
|
*
|
|
4
4
|
* @task T5034
|
|
5
|
+
* @task T585
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
@@ -30,13 +31,47 @@ describe('upsertTask — orphan parent handling', () => {
|
|
|
30
31
|
await rm(tempDir, { recursive: true, force: true });
|
|
31
32
|
});
|
|
32
33
|
|
|
33
|
-
it('nulls out parentId when parent
|
|
34
|
+
it('nulls out parentId when allowOrphanParent=true and parent does not exist', async () => {
|
|
34
35
|
const { getDb } = await import('../sqlite.js');
|
|
35
36
|
const { upsertTask } = await import('../db-helpers.js');
|
|
36
37
|
const schema = await import('../tasks-schema.js');
|
|
37
38
|
const db = await getDb();
|
|
38
39
|
|
|
39
|
-
// Insert a child task with a non-existent parent
|
|
40
|
+
// Insert a child task with a non-existent parent using allowOrphanParent=true (bulk mode)
|
|
41
|
+
await upsertTask(
|
|
42
|
+
db,
|
|
43
|
+
{
|
|
44
|
+
id: 'T100',
|
|
45
|
+
title: 'Child task',
|
|
46
|
+
description: 'Has orphan parent ref',
|
|
47
|
+
status: 'pending',
|
|
48
|
+
priority: 'medium',
|
|
49
|
+
parentId: 'T999', // does NOT exist in DB
|
|
50
|
+
createdAt: new Date().toISOString(),
|
|
51
|
+
},
|
|
52
|
+
undefined,
|
|
53
|
+
true, // allowOrphanParent: silently null out for bulk/archive operations (T5034)
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// Verify the task was inserted with parentId = null
|
|
57
|
+
const rows = await db.select().from(schema.tasks).where(eq(schema.tasks.id, 'T100')).all();
|
|
58
|
+
|
|
59
|
+
expect(rows).toHaveLength(1);
|
|
60
|
+
expect(rows[0]!.parentId).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('preserves parentId (with warning) when allowOrphanParent=false and parent does not exist', async () => {
|
|
64
|
+
// In normal single-task write mode (default), upsertTask logs a warning but does NOT
|
|
65
|
+
// silently null out the parentId. This prevents data corruption for normal task creation
|
|
66
|
+
// while still surfacing the integrity issue. The FK constraint (disabled in tests) would
|
|
67
|
+
// reject the write in production if the parent truly does not exist.
|
|
68
|
+
const { getDb } = await import('../sqlite.js');
|
|
69
|
+
const { upsertTask } = await import('../db-helpers.js');
|
|
70
|
+
const schema = await import('../tasks-schema.js');
|
|
71
|
+
const db = await getDb();
|
|
72
|
+
|
|
73
|
+
// Insert a child task with a non-existent parent using default allowOrphanParent=false
|
|
74
|
+
// In VITEST, FK enforcement is OFF, so this will succeed without null-out.
|
|
40
75
|
await upsertTask(db, {
|
|
41
76
|
id: 'T100',
|
|
42
77
|
title: 'Child task',
|
|
@@ -47,11 +82,13 @@ describe('upsertTask — orphan parent handling', () => {
|
|
|
47
82
|
createdAt: new Date().toISOString(),
|
|
48
83
|
});
|
|
49
84
|
|
|
50
|
-
//
|
|
85
|
+
// With allowOrphanParent=false, the parentId is NOT nulled out — warning is logged.
|
|
86
|
+
// In test env FK is off, so T100 gets stored with parentId='T999' (not null).
|
|
51
87
|
const rows = await db.select().from(schema.tasks).where(eq(schema.tasks.id, 'T100')).all();
|
|
52
88
|
|
|
53
89
|
expect(rows).toHaveLength(1);
|
|
54
|
-
|
|
90
|
+
// parentId is preserved (not silently nulled), indicating the warning-only behavior
|
|
91
|
+
expect(rows[0]!.parentId).toBe('T999');
|
|
55
92
|
});
|
|
56
93
|
|
|
57
94
|
it('preserves parentId when parent task exists', async () => {
|
|
@@ -88,7 +125,9 @@ describe('upsertTask — orphan parent handling', () => {
|
|
|
88
125
|
expect(rows[0]!.parentId).toBe('T001');
|
|
89
126
|
});
|
|
90
127
|
|
|
91
|
-
it('handles archived task with orphan parent (T5034 regression)', async () => {
|
|
128
|
+
it('handles archived task with orphan parent using allowOrphanParent=true (T5034 regression)', async () => {
|
|
129
|
+
// Bulk archive operations pass allowOrphanParent=true to tolerate missing parents.
|
|
130
|
+
// This prevents FK violations when archiving tasks whose parents were deleted.
|
|
92
131
|
const { getDb } = await import('../sqlite.js');
|
|
93
132
|
const { upsertTask } = await import('../db-helpers.js');
|
|
94
133
|
const schema = await import('../tasks-schema.js');
|
|
@@ -110,6 +149,7 @@ describe('upsertTask — orphan parent handling', () => {
|
|
|
110
149
|
archivedAt: '2025-06-01T00:00:00Z',
|
|
111
150
|
archiveReason: 'completed',
|
|
112
151
|
},
|
|
152
|
+
true, // allowOrphanParent: bulk/archive mode silently nulls (T5034)
|
|
113
153
|
);
|
|
114
154
|
|
|
115
155
|
// Should succeed (not throw) and null out the parentId
|
package/src/store/db-helpers.ts
CHANGED
|
@@ -10,9 +10,12 @@
|
|
|
10
10
|
import type { Session, Task } from '@cleocode/contracts';
|
|
11
11
|
import { eq, inArray } from 'drizzle-orm';
|
|
12
12
|
import type { NodeSQLiteDatabase } from 'drizzle-orm/node-sqlite';
|
|
13
|
+
import { getLogger } from '../logger.js';
|
|
13
14
|
import type { NewTaskRow } from './tasks-schema.js';
|
|
14
15
|
import * as schema from './tasks-schema.js';
|
|
15
16
|
|
|
17
|
+
const log = getLogger('db-helpers');
|
|
18
|
+
|
|
16
19
|
/** Drizzle database instance type. */
|
|
17
20
|
type DrizzleDb = NodeSQLiteDatabase<typeof schema>;
|
|
18
21
|
|
|
@@ -27,15 +30,24 @@ export interface ArchiveFields {
|
|
|
27
30
|
* Upsert a single task row into the tasks table.
|
|
28
31
|
* Handles both active task upsert and archived task upsert via optional archiveFields.
|
|
29
32
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
33
|
+
* When `allowOrphanParent` is true (bulk/migration mode, T5034): silently nulls out
|
|
34
|
+
* parentId if the referenced parent does not exist, preventing FK violations.
|
|
35
|
+
* When false (normal single-task writes, default): logs a warning but still proceeds
|
|
36
|
+
* so that FK enforcement at the DB level provides the final safety net.
|
|
37
|
+
*
|
|
38
|
+
* Callers that perform bulk imports or archive restoration should pass
|
|
39
|
+
* `allowOrphanParent: true` to enable the lenient behavior.
|
|
32
40
|
*/
|
|
33
41
|
export async function upsertTask(
|
|
34
42
|
db: DrizzleDb,
|
|
35
43
|
row: NewTaskRow,
|
|
36
44
|
archiveFields?: ArchiveFields,
|
|
45
|
+
allowOrphanParent = false,
|
|
37
46
|
): Promise<void> {
|
|
38
|
-
//
|
|
47
|
+
// Validate parentId exists before writing (T5034, T585).
|
|
48
|
+
// In bulk/archive mode (allowOrphanParent=true) we silently null it out to
|
|
49
|
+
// avoid FK violations during migrations. In normal mode we log a warning so
|
|
50
|
+
// the data integrity issue surfaces without breaking the write.
|
|
39
51
|
if (row.parentId) {
|
|
40
52
|
const parent = await db
|
|
41
53
|
.select({ id: schema.tasks.id })
|
|
@@ -44,7 +56,16 @@ export async function upsertTask(
|
|
|
44
56
|
.limit(1)
|
|
45
57
|
.all();
|
|
46
58
|
if (parent.length === 0) {
|
|
47
|
-
|
|
59
|
+
if (allowOrphanParent) {
|
|
60
|
+
row = { ...row, parentId: null };
|
|
61
|
+
} else {
|
|
62
|
+
// Log a warning — the FK constraint will reject the write if enabled,
|
|
63
|
+
// or the task will be stored without a parent if FKs are off (test mode).
|
|
64
|
+
log.warn(
|
|
65
|
+
{ taskId: row.id, parentId: row.parentId },
|
|
66
|
+
'upsertTask: parentId references a non-existent task — parent relationship may be lost',
|
|
67
|
+
);
|
|
68
|
+
}
|
|
48
69
|
}
|
|
49
70
|
}
|
|
50
71
|
|
|
@@ -198,7 +198,8 @@ export async function createSqliteDataAccessor(cwd?: string): Promise<DataAccess
|
|
|
198
198
|
cycleTimeDays: taskAny.cycleTimeDays ?? null,
|
|
199
199
|
};
|
|
200
200
|
|
|
201
|
-
|
|
201
|
+
// allowOrphanParent=true: bulk archive writes tolerate missing parents (T5034)
|
|
202
|
+
await upsertTask(db, row, archiveFields, true);
|
|
202
203
|
depBatch.push({ taskId: task.id, deps: task.depends ?? [] });
|
|
203
204
|
}
|
|
204
205
|
|