@cleocode/core 2026.4.17 → 2026.4.19

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.
@@ -73,7 +73,11 @@ export function createSafetyBackup(dbPath: string): void {
73
73
  * Handles three scenarios:
74
74
  * 1. Tables exist but no __drizzle_migrations — bootstrap baseline as applied
75
75
  * 2. Journal has orphaned hashes (from older CLEO version) — clear and re-mark all as applied
76
- * 3. Journal is healthy no-op
76
+ * 3. Journal exists but is missing entries for migrations whose DDL has already been applied
77
+ * (e.g., ALTER TABLE ADD COLUMN ran but journal entry was never written — happens when
78
+ * migrations are cherry-picked from worktrees or the process crashes mid-migration).
79
+ * Auto-inserts the missing journal entry so Drizzle skips the migration instead of
80
+ * re-running ALTER TABLE and crashing on "duplicate column name".
77
81
  *
78
82
  * @param nativeDb - Native SQLite database handle
79
83
  * @param migrationsFolder - Path to the drizzle migrations folder
@@ -127,21 +131,117 @@ export function reconcileJournal(
127
131
  }
128
132
  }
129
133
  }
134
+
135
+ // Scenario 3: Journal exists but is missing entries for already-applied migrations.
136
+ // Detects migrations whose DDL columns already exist in the database but whose
137
+ // journal entry was never written (e.g., cherry-picked from a worktree, or process
138
+ // crashed after the ALTER TABLE succeeded but before the journal INSERT committed).
139
+ if (tableExists(nativeDb, '__drizzle_migrations') && tableExists(nativeDb, existenceTable)) {
140
+ const localMigrations = readMigrationFiles({ migrationsFolder });
141
+ const journalEntries = nativeDb
142
+ .prepare('SELECT hash FROM "__drizzle_migrations"')
143
+ .all() as Array<{ hash: string }>;
144
+ const journaledHashes = new Set(journalEntries.map((e) => e.hash));
145
+
146
+ for (const migration of localMigrations) {
147
+ if (journaledHashes.has(migration.hash)) continue;
148
+
149
+ // Parse the migration SQL for ALTER TABLE ... ADD COLUMN statements.
150
+ // drizzle's readMigrationFiles returns sql as string[] (one entry per
151
+ // statement-breakpoint-separated statement), so join them for regex scanning.
152
+ const alterColumnRegex = /ALTER\s+TABLE\s+[`"]?(\w+)[`"]?\s+ADD\s+COLUMN\s+[`"]?(\w+)[`"]?/gi;
153
+ const alterMatches: Array<{ table: string; column: string }> = [];
154
+ const sqlStatements = Array.isArray(migration.sql) ? migration.sql : [migration.sql ?? ''];
155
+ const fullSql = sqlStatements.join('\n');
156
+ for (const m of fullSql.matchAll(alterColumnRegex)) {
157
+ alterMatches.push({ table: m[1] as string, column: m[2] as string });
158
+ }
159
+
160
+ // Only auto-reconcile migrations that consist entirely of ALTER TABLE ADD COLUMN
161
+ // statements (and contain at least one). Pure CREATE INDEX / DROP INDEX migrations
162
+ // that have no journal entry are genuinely pending and must run normally.
163
+ if (alterMatches.length === 0) continue;
164
+
165
+ // Check whether all ADD COLUMN targets already exist in their tables.
166
+ const allColumnsExist = alterMatches.every(({ table, column }) => {
167
+ if (!tableExists(nativeDb, table)) return false;
168
+ const cols = nativeDb.prepare(`PRAGMA table_info(${table})`).all() as Array<{
169
+ name: string;
170
+ }>;
171
+ return cols.some((c) => c.name === column);
172
+ });
173
+
174
+ if (allColumnsExist) {
175
+ const log = getLogger(logSubsystem);
176
+ log.warn(
177
+ { migration: migration.name, columns: alterMatches },
178
+ `Detected partially-applied migration ${migration.name} — columns exist but journal entry missing. Auto-reconciling.`,
179
+ );
180
+ nativeDb.exec(
181
+ `INSERT INTO "__drizzle_migrations" ("hash", "created_at") VALUES ('${migration.hash}', ${migration.folderMillis})`,
182
+ );
183
+ }
184
+ }
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Check whether an error is a SQLite "duplicate column name" error.
190
+ *
191
+ * These are thrown when an ALTER TABLE ADD COLUMN statement is re-executed
192
+ * after the column was already added (Scenario 3 in reconcileJournal).
193
+ */
194
+ export function isDuplicateColumnError(err: unknown): boolean {
195
+ if (!(err instanceof Error)) return false;
196
+ return /duplicate column name/i.test(err.message);
130
197
  }
131
198
 
132
199
  /**
133
200
  * Run Drizzle migrations with SQLITE_BUSY retry and exponential backoff.
134
201
  *
202
+ * Also handles "duplicate column name" errors (Scenario 3): if Drizzle tries to
203
+ * re-apply a migration whose DDL columns already exist (journal entry missing),
204
+ * this function calls reconcileJournal again to insert the missing entry and
205
+ * retries migrate() once more. This is the belt-and-suspenders safety net for
206
+ * any partial migration that slips through the proactive reconcileJournal check.
207
+ *
135
208
  * @param db - Drizzle database instance
136
209
  * @param migrationsFolder - Path to the drizzle migrations folder
210
+ * @param nativeDb - Optional native SQLite handle for duplicate-column auto-reconcile
211
+ * @param existenceTable - Optional existence-check table name for auto-reconcile
212
+ * @param logSubsystem - Optional logger subsystem name for auto-reconcile warnings
137
213
  */
138
- // biome-ignore lint/suspicious/noExplicitAny: Drizzle's NodeSQLiteDatabase is generic — accepting any schema avoids coupling to a specific schema type
139
- export function migrateWithRetry(db: NodeSQLiteDatabase<any>, migrationsFolder: string): void {
214
+ export function migrateWithRetry(
215
+ // biome-ignore lint/suspicious/noExplicitAny: Drizzle's NodeSQLiteDatabase is generic — accepting any schema avoids coupling to a specific schema type
216
+ db: NodeSQLiteDatabase<any>,
217
+ migrationsFolder: string,
218
+ nativeDb?: DatabaseSync,
219
+ existenceTable?: string,
220
+ logSubsystem?: string,
221
+ ): void {
222
+ let duplicateColumnReconciled = false;
223
+
140
224
  for (let attempt = 1; attempt <= MAX_MIGRATION_RETRIES; attempt++) {
141
225
  try {
142
226
  migrate(db, { migrationsFolder });
143
227
  return;
144
228
  } catch (err) {
229
+ // Belt-and-suspenders: if Drizzle hits a duplicate column name error on
230
+ // the first attempt and we have the native DB handle, run Scenario 3
231
+ // reconcileJournal and retry once. This catches any partial migration that
232
+ // slipped through the proactive check run before migrateWithRetry.
233
+ if (
234
+ isDuplicateColumnError(err) &&
235
+ !duplicateColumnReconciled &&
236
+ nativeDb !== undefined &&
237
+ existenceTable !== undefined &&
238
+ logSubsystem !== undefined
239
+ ) {
240
+ duplicateColumnReconciled = true;
241
+ reconcileJournal(nativeDb, migrationsFolder, existenceTable, logSubsystem);
242
+ continue;
243
+ }
244
+
145
245
  if (!isSqliteBusy(err) || attempt === MAX_MIGRATION_RETRIES) {
146
246
  throw err;
147
247
  }
@@ -473,8 +473,10 @@ function runMigrations(nativeDb: DatabaseSync, db: NodeSQLiteDatabase<typeof sch
473
473
  // @see https://github.com/anthropics/cleo/issues/83
474
474
  ensureColumns(nativeDb, 'sessions', REQUIRED_SESSION_COLUMNS, 'sqlite');
475
475
 
476
- // Run pending migrations with SQLITE_BUSY retry
477
- migrateWithRetry(db, migrationsFolder);
476
+ // Run pending migrations with SQLITE_BUSY retry.
477
+ // Pass nativeDb + existenceTable so migrateWithRetry can auto-reconcile any
478
+ // partial migration (Scenario 3) that slips through the proactive check above.
479
+ migrateWithRetry(db, migrationsFolder, nativeDb, 'tasks', 'sqlite');
478
480
 
479
481
  // Defensive column safety net
480
482
  ensureColumns(nativeDb, 'tasks', REQUIRED_TASK_COLUMNS, 'sqlite');
package/src/tasks/add.ts CHANGED
@@ -502,9 +502,15 @@ export async function addTask(
502
502
  if (lifecycleMode === 'strict') {
503
503
  throw new CleoError(
504
504
  ExitCode.VALIDATION_ERROR,
505
- 'Tasks must have a parent (epic or task) in strict mode. Use --parent <epicId> or set lifecycle.mode to "advisory".',
505
+ 'Tasks must have a parent (epic or task) in strict mode. Use --parent <epicId>, --type epic for a root-level epic, or set lifecycle.mode to "advisory".',
506
506
  {
507
507
  fix: 'cleo add "Task title" --parent T### --acceptance "AC1|AC2|AC3"',
508
+ alternatives: [
509
+ {
510
+ action: 'Create as epic',
511
+ command: 'cleo add "Epic title" --type epic --priority high',
512
+ },
513
+ ],
508
514
  },
509
515
  );
510
516
  }