@cleocode/core 2026.3.61 → 2026.3.63

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 (42) hide show
  1. package/dist/cleo.js +36 -1
  2. package/dist/cleo.js.map +1 -1
  3. package/dist/index.js +103 -25
  4. package/dist/index.js.map +3 -3
  5. package/dist/internal.d.ts +5 -3
  6. package/dist/internal.d.ts.map +1 -1
  7. package/dist/internal.js +4 -2
  8. package/dist/internal.js.map +1 -1
  9. package/dist/phases/deps.d.ts +1 -1
  10. package/dist/phases/deps.d.ts.map +1 -1
  11. package/dist/phases/deps.js +5 -2
  12. package/dist/phases/deps.js.map +1 -1
  13. package/dist/repair.js +43 -2
  14. package/dist/repair.js.map +1 -1
  15. package/dist/routing/capability-matrix.d.ts.map +1 -1
  16. package/dist/routing/capability-matrix.js +7 -0
  17. package/dist/routing/capability-matrix.js.map +1 -1
  18. package/dist/sequence/index.js +1 -1
  19. package/dist/sequence/index.js.map +1 -1
  20. package/dist/stats/index.d.ts.map +1 -1
  21. package/dist/stats/index.js +4 -2
  22. package/dist/stats/index.js.map +1 -1
  23. package/dist/store/brain-sqlite.d.ts.map +1 -1
  24. package/dist/store/sqlite.js +59 -5
  25. package/dist/store/sqlite.js.map +1 -1
  26. package/dist/system/backup.d.ts +15 -0
  27. package/dist/system/backup.d.ts.map +1 -1
  28. package/dist/system/backup.js +43 -1
  29. package/dist/system/backup.js.map +1 -1
  30. package/dist/tasks/add.d.ts.map +1 -1
  31. package/dist/tasks/add.js +66 -4
  32. package/dist/tasks/add.js.map +1 -1
  33. package/package.json +5 -5
  34. package/src/internal.ts +6 -3
  35. package/src/phases/deps.ts +5 -3
  36. package/src/routing/capability-matrix.ts +7 -0
  37. package/src/sequence/index.ts +1 -1
  38. package/src/stats/index.ts +4 -2
  39. package/src/store/brain-sqlite.ts +26 -0
  40. package/src/system/backup.ts +52 -1
  41. package/src/tasks/__tests__/add.test.ts +3 -1
  42. package/src/tasks/add.ts +66 -5
@@ -469,6 +469,13 @@ const CAPABILITY_MATRIX: OperationCapability[] = [
469
469
  mode: 'native',
470
470
  preferredChannel: 'either',
471
471
  },
472
+ {
473
+ domain: 'admin',
474
+ operation: 'backup',
475
+ gateway: 'query',
476
+ mode: 'native',
477
+ preferredChannel: 'either',
478
+ },
472
479
  {
473
480
  domain: 'admin',
474
481
  operation: 'backup',
@@ -168,7 +168,7 @@ export async function showSequence(cwd?: string): Promise<Record<string, unknown
168
168
  counter: seq.counter,
169
169
  lastId: seq.lastId,
170
170
  checksum: seq.checksum,
171
- nextId: `T${seq.counter + 1}`,
171
+ nextId: `T${String(seq.counter + 1).padStart(3, '0')}`,
172
172
  };
173
173
  }
174
174
 
@@ -149,8 +149,10 @@ export async function getProjectStats(
149
149
  )
150
150
  .get();
151
151
  archivedCompleted = archivedDoneRow?.c ?? 0;
152
- // totalCompleted = currently done (not yet archived) + archived-as-completed
153
- totalCompleted = (statusMap['done'] ?? 0) + archivedCompleted;
152
+ // totalCompleted: use audit log as SSoT (same source as completedInPeriod) to ensure
153
+ // the two metrics are consistent. DB-based status counts under-count because they miss
154
+ // tasks that were completed then cancelled, deleted, or archived with a non-default reason.
155
+ totalCompleted = entries.filter(isComplete).length;
154
156
  } catch {
155
157
  // fallback to audit_log counts if DB unavailable
156
158
  totalCreated = entries.filter(isCreate).length;
@@ -20,6 +20,7 @@ import { readMigrationFiles } from 'drizzle-orm/migrator';
20
20
  import type { NodeSQLiteDatabase } from 'drizzle-orm/node-sqlite';
21
21
  import { drizzle } from 'drizzle-orm/node-sqlite';
22
22
  import { migrate } from 'drizzle-orm/node-sqlite/migrator';
23
+ import { getLogger } from '../logger.js';
23
24
  import { getCleoDirAbsolute } from '../paths.js';
24
25
  import * as brainSchema from './brain-schema.js';
25
26
  import { isSqliteBusy, openNativeDatabase } from './sqlite.js';
@@ -119,6 +120,31 @@ function runBrainMigrations(
119
120
  }
120
121
  }
121
122
 
123
+ // Fix #65: Reconcile stale migration journal entries from older CLEO versions.
124
+ // Same pattern as tasks.db fix in sqlite.ts — see #63 for details.
125
+ if (tableExists(nativeDb, '__drizzle_migrations') && tableExists(nativeDb, 'brain_decisions')) {
126
+ const localMigrations = readMigrationFiles({ migrationsFolder });
127
+ const localHashes = new Set(localMigrations.map((m) => m.hash));
128
+ const dbEntries = nativeDb.prepare('SELECT hash FROM "__drizzle_migrations"').all() as Array<{
129
+ hash: string;
130
+ }>;
131
+ const hasOrphanedEntries = dbEntries.some((e) => !localHashes.has(e.hash));
132
+
133
+ if (hasOrphanedEntries) {
134
+ const log = getLogger('brain');
135
+ log.warn(
136
+ { orphaned: dbEntries.filter((e) => !localHashes.has(e.hash)).length },
137
+ 'Detected stale migration journal entries from a previous CLEO version. Reconciling brain.db.',
138
+ );
139
+ nativeDb.exec('DELETE FROM "__drizzle_migrations"');
140
+ for (const m of localMigrations) {
141
+ nativeDb.exec(
142
+ `INSERT INTO "__drizzle_migrations" ("hash", "created_at") VALUES ('${m.hash}', ${m.folderMillis})`,
143
+ );
144
+ }
145
+ }
146
+ }
147
+
122
148
  // Run pending migrations via drizzle-orm/node-sqlite/migrator (synchronous).
123
149
  const MAX_RETRIES = 5;
124
150
  const BASE_DELAY_MS = 100;
@@ -3,7 +3,7 @@
3
3
  * @task T4783
4
4
  */
5
5
 
6
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
8
  import { ExitCode } from '@cleocode/contracts';
9
9
  import { CleoError } from '../errors.js';
@@ -80,6 +80,57 @@ export function createBackup(
80
80
  return { backupId, path: backupDir, timestamp, type: btype, files: backedUp };
81
81
  }
82
82
 
83
+ /** A single backup entry returned by listSystemBackups. */
84
+ export interface BackupEntry {
85
+ backupId: string;
86
+ type: string;
87
+ timestamp: string;
88
+ note?: string;
89
+ files: string[];
90
+ }
91
+
92
+ /**
93
+ * List all available system backups (snapshot, safety, migration types).
94
+ * Reads `.meta.json` sidecar files written by createBackup.
95
+ * This is a pure read operation — it does not modify any files.
96
+ * @task T4783
97
+ */
98
+ export function listSystemBackups(projectRoot: string): BackupEntry[] {
99
+ const cleoDir = join(projectRoot, '.cleo');
100
+ const backupTypes = ['snapshot', 'safety', 'migration'];
101
+ const entries: BackupEntry[] = [];
102
+
103
+ for (const btype of backupTypes) {
104
+ const backupDir = join(cleoDir, 'backups', btype);
105
+ if (!existsSync(backupDir)) continue;
106
+ try {
107
+ const files = readdirSync(backupDir).filter((f) => f.endsWith('.meta.json'));
108
+ for (const metaFile of files) {
109
+ try {
110
+ const raw = readFileSync(join(backupDir, metaFile), 'utf-8');
111
+ const meta = JSON.parse(raw) as Partial<BackupEntry>;
112
+ if (meta.backupId && meta.timestamp) {
113
+ entries.push({
114
+ backupId: meta.backupId,
115
+ type: meta.type ?? btype,
116
+ timestamp: meta.timestamp,
117
+ note: meta.note,
118
+ files: meta.files ?? [],
119
+ });
120
+ }
121
+ } catch {
122
+ // skip malformed meta files
123
+ }
124
+ }
125
+ } catch {
126
+ // skip unreadable backup directories
127
+ }
128
+ }
129
+
130
+ // Sort newest first
131
+ return entries.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
132
+ }
133
+
83
134
  /** Restore from a backup. */
84
135
  export function restoreBackup(
85
136
  projectRoot: string,
@@ -286,7 +286,9 @@ describe('addTask (integration)', () => {
286
286
  accessor,
287
287
  );
288
288
  expect(result.dryRun).toBe(true);
289
- expect(result.task.id).toBe('T001');
289
+ // Dry run does not allocate a real sequence ID — the task is a preview only
290
+ expect(result.task.id).toBe('T???');
291
+ expect(result.task.title).toBe('Dry run task');
290
292
  });
291
293
 
292
294
  it('validates parent hierarchy', async () => {
package/src/tasks/add.ts CHANGED
@@ -645,6 +645,72 @@ export async function addTask(
645
645
  return { task: duplicate, duplicate: true };
646
646
  }
647
647
 
648
+ // Dry run: build a preview task without allocating a sequence ID or writing to the DB.
649
+ // Must be checked before allocateNextTaskId to avoid advancing the counter on no-op runs.
650
+ if (options.dryRun) {
651
+ const previewNow = new Date().toISOString();
652
+
653
+ // Resolve pipeline stage for the preview without any DB writes
654
+ let previewParentForStage: import('./pipeline-stage.js').ResolvedParent | null = null;
655
+ if (parentId) {
656
+ const previewParentTask = await dataAccessor.loadSingleTask(parentId);
657
+ previewParentForStage = previewParentTask
658
+ ? { pipelineStage: previewParentTask.pipelineStage, type: previewParentTask.type }
659
+ : null;
660
+ }
661
+ const previewPipelineStage = resolveDefaultPipelineStage({
662
+ explicitStage: options.pipelineStage,
663
+ taskType: taskType ?? null,
664
+ parentTask: previewParentForStage,
665
+ });
666
+ const previewPosition =
667
+ options.position !== undefined
668
+ ? options.position
669
+ : await dataAccessor.getNextPosition(parentId);
670
+
671
+ const previewTask: Task = {
672
+ id: 'T???',
673
+ title: options.title,
674
+ description: options.description,
675
+ status,
676
+ priority,
677
+ type: taskType,
678
+ parentId: parentId || null,
679
+ position: previewPosition,
680
+ positionVersion: 0,
681
+ size,
682
+ pipelineStage: previewPipelineStage,
683
+ createdAt: previewNow,
684
+ updatedAt: previewNow,
685
+ };
686
+ if (phase) previewTask.phase = phase;
687
+ if (options.labels?.length) previewTask.labels = options.labels.map((l) => l.trim());
688
+ if (options.files?.length) previewTask.files = options.files.map((f) => f.trim());
689
+ if (options.acceptance?.length)
690
+ previewTask.acceptance = options.acceptance.map((a) => a.trim());
691
+ if (options.depends?.length) previewTask.depends = options.depends.map((d) => d.trim());
692
+ if (options.notes) {
693
+ const previewNote = `${new Date()
694
+ .toISOString()
695
+ .replace('T', ' ')
696
+ .replace(/\.\d+Z$/, ' UTC')}: ${options.notes}`;
697
+ previewTask.notes = [previewNote];
698
+ }
699
+ if (status === 'blocked' && options.description) {
700
+ previewTask.blockedBy = options.description;
701
+ }
702
+ if (status === 'done') {
703
+ previewTask.completedAt = previewNow;
704
+ }
705
+ if (taskType !== 'epic') {
706
+ const verificationEnabledRaw = await getRawConfigValue('verification.enabled', cwd);
707
+ if (verificationEnabledRaw === true) {
708
+ previewTask.verification = buildDefaultVerification(previewNow);
709
+ }
710
+ }
711
+ return { task: previewTask, dryRun: true };
712
+ }
713
+
648
714
  const taskId = await allocateNextTaskId(cwd);
649
715
 
650
716
  const now = new Date().toISOString();
@@ -741,11 +807,6 @@ export async function addTask(
741
807
  }
742
808
  }
743
809
 
744
- // Dry run
745
- if (options.dryRun) {
746
- return { task, dryRun: true };
747
- }
748
-
749
810
  // Wrap all writes in a transaction for TOCTOU safety (T023)
750
811
  await dataAccessor.transaction(async (tx: TransactionAccessor) => {
751
812
  // Position shuffling via bulk SQL update (T025)