akm-cli 0.9.0-rc.2 → 0.9.0-rc.4

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 (115) hide show
  1. package/CHANGELOG.md +40 -12
  2. package/README.md +7 -8
  3. package/SECURITY.md +5 -3
  4. package/dist/akm +44 -33
  5. package/dist/akm-migrate-storage +44 -33
  6. package/dist/assets/backends/schtasks-template.xml +2 -1
  7. package/dist/assets/improve-strategies/catchup.json +3 -1
  8. package/dist/assets/improve-strategies/consolidate.json +3 -1
  9. package/dist/assets/improve-strategies/frequent.json +3 -1
  10. package/dist/assets/improve-strategies/graph-refresh.json +3 -1
  11. package/dist/assets/improve-strategies/memory-focus.json +4 -1
  12. package/dist/assets/improve-strategies/proactive-maintenance.json +1 -0
  13. package/dist/assets/improve-strategies/quick.json +3 -1
  14. package/dist/assets/improve-strategies/recombine-only.json +2 -0
  15. package/dist/assets/improve-strategies/reflect-distill.json +1 -0
  16. package/dist/assets/improve-strategies/synthesize.json +2 -0
  17. package/dist/assets/tasks/core/backup.yml +2 -2
  18. package/dist/cli/config-migrate.js +554 -26
  19. package/dist/cli.js +28 -11
  20. package/dist/commands/backup-cli.js +6 -4
  21. package/dist/commands/config-cli.js +10 -3
  22. package/dist/commands/feedback-cli.js +24 -7
  23. package/dist/commands/health/checks.js +94 -15
  24. package/dist/commands/health/html-report.js +23 -2
  25. package/dist/commands/health/improve-metrics.js +45 -6
  26. package/dist/commands/health/md-report.js +10 -1
  27. package/dist/commands/health/surfaces.js +2 -1
  28. package/dist/commands/health/windows.js +1 -1
  29. package/dist/commands/health.js +1 -1
  30. package/dist/commands/improve/anti-collapse.js +3 -3
  31. package/dist/commands/improve/collapse-detector.js +5 -5
  32. package/dist/commands/improve/consolidate.js +123 -66
  33. package/dist/commands/improve/distill/promote-memory.js +8 -6
  34. package/dist/commands/improve/distill/quality-gate.js +1 -1
  35. package/dist/commands/improve/distill-guards.js +1 -1
  36. package/dist/commands/improve/distill-promotion-policy.js +32 -26
  37. package/dist/commands/improve/distill.js +52 -36
  38. package/dist/commands/improve/eligibility.js +9 -8
  39. package/dist/commands/improve/extract-prompt.js +2 -2
  40. package/dist/commands/improve/extract.js +43 -11
  41. package/dist/commands/improve/improve-auto-accept.js +14 -28
  42. package/dist/commands/improve/improve-cli.js +4 -2
  43. package/dist/commands/improve/improve-strategies.js +2 -1
  44. package/dist/commands/improve/improve.js +49 -8
  45. package/dist/commands/improve/loop-stages.js +11 -6
  46. package/dist/commands/improve/preparation.js +51 -17
  47. package/dist/commands/improve/procedural.js +10 -6
  48. package/dist/commands/improve/recombine.js +31 -7
  49. package/dist/commands/improve/reflect.js +24 -14
  50. package/dist/commands/improve/salience.js +5 -1
  51. package/dist/commands/improve/source-identity.js +43 -0
  52. package/dist/commands/migrate-cli.js +36 -0
  53. package/dist/commands/mv-cli.js +647 -258
  54. package/dist/commands/proposal/drain.js +16 -4
  55. package/dist/commands/proposal/proposal-cli.js +3 -2
  56. package/dist/commands/proposal/proposal.js +16 -55
  57. package/dist/commands/proposal/repository.js +664 -58
  58. package/dist/commands/read/curate.js +4 -2
  59. package/dist/commands/read/knowledge.js +4 -1
  60. package/dist/commands/read/search.js +6 -2
  61. package/dist/commands/read/show.js +8 -7
  62. package/dist/commands/sources/self-update.js +156 -112
  63. package/dist/commands/sources/sources-cli.js +7 -2
  64. package/dist/commands/tasks/default-tasks.js +5 -5
  65. package/dist/commands/tasks/tasks-cli.js +7 -3
  66. package/dist/commands/tasks/tasks.js +261 -68
  67. package/dist/core/common.js +36 -3
  68. package/dist/core/config/config-io.js +2 -2
  69. package/dist/core/config/config-schema.js +15 -3
  70. package/dist/core/config/config.js +14 -13
  71. package/dist/core/file-lock.js +2 -2
  72. package/dist/core/improve-result.js +87 -7
  73. package/dist/core/migration-backup.js +816 -171
  74. package/dist/core/migration-operation.js +44 -0
  75. package/dist/core/state/migrations.js +4 -7
  76. package/dist/core/state-db.js +23 -7
  77. package/dist/core/write-source.js +86 -18
  78. package/dist/indexer/db/db.js +109 -53
  79. package/dist/indexer/index-writer-lock.js +38 -37
  80. package/dist/indexer/index-written-assets.js +73 -56
  81. package/dist/indexer/indexer.js +5 -1
  82. package/dist/indexer/search/search-source.js +2 -2
  83. package/dist/indexer/usage/usage-events.js +8 -2
  84. package/dist/integrations/agent/engine-resolution.js +14 -7
  85. package/dist/scripts/migrate-storage.js +887 -992
  86. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +494 -574
  87. package/dist/setup/detected-engines.js +7 -13
  88. package/dist/setup/engine-config.js +14 -4
  89. package/dist/setup/setup.js +1 -1
  90. package/dist/setup/steps/connection.js +1 -1
  91. package/dist/setup/steps/tasks.js +1 -1
  92. package/dist/sources/providers/git-stash.js +58 -21
  93. package/dist/sources/providers/git.js +1 -1
  94. package/dist/storage/engines/sqlite-migrations.js +138 -3
  95. package/dist/storage/repositories/events-repository.js +24 -0
  96. package/dist/storage/repositories/proposals-repository.js +14 -0
  97. package/dist/storage/repositories/task-history-repository.js +30 -3
  98. package/dist/tasks/backends/cron.js +71 -38
  99. package/dist/tasks/backends/exec-utils.js +55 -3
  100. package/dist/tasks/backends/launchd.js +241 -50
  101. package/dist/tasks/backends/schtasks.js +434 -59
  102. package/dist/tasks/command-executable.js +93 -0
  103. package/dist/tasks/embedded.js +2 -0
  104. package/dist/tasks/parser.js +142 -6
  105. package/dist/tasks/resolve-akm-bin.js +19 -4
  106. package/dist/tasks/runner.js +258 -93
  107. package/dist/tasks/schedule.js +108 -19
  108. package/dist/tasks/scheduler-invocation.js +86 -0
  109. package/dist/tasks/task-id.js +37 -0
  110. package/dist/workflows/db.js +24 -13
  111. package/dist/workflows/validator.js +24 -2
  112. package/docs/README.md +103 -0
  113. package/docs/migration/release-notes/0.9.0.md +28 -9
  114. package/docs/migration/v0.8-to-v0.9.md +186 -26
  115. package/package.json +2 -3
@@ -19,13 +19,15 @@ import { getTaskHistoryDir, getTaskLogDir } from "../../core/paths.js";
19
19
  import { commitWriteTargetBoundary, deleteAssetFromSource, resolveWriteTarget, writeAssetToSource, } from "../../core/write-source.js";
20
20
  import { resolveAssetPath } from "../../sources/resolve.js";
21
21
  import { backendNameForPlatform, selectBackend } from "../../tasks/backends/index.js";
22
+ import { findBareAkmExecutableIndex } from "../../tasks/command-executable.js";
22
23
  import { parseTaskDocument } from "../../tasks/parser.js";
23
24
  import { resolveAkmInvocation } from "../../tasks/resolve-akm-bin.js";
24
- import { exitCodeForStatus, readTaskHistory, runTask } from "../../tasks/runner.js";
25
+ import { exitCodeForStatus, INVALID_TASK_ATTEMPT_ID, readTaskHistory, recordTaskAttemptFailure, runTask, } from "../../tasks/runner.js";
25
26
  import { parseSchedule, SCHEDULE_SUPPORTED_SUBSET_HINT, translateToCron } from "../../tasks/schedule.js";
27
+ import { normaliseTaskId } from "../../tasks/task-id.js";
26
28
  import { validateTaskDocument } from "../../tasks/validator.js";
27
29
  import { resolveImproveStrategy } from "../improve/improve-strategies.js";
28
- export async function akmTasksAdd(input) {
30
+ export async function akmTasksAdd(input, deps = {}) {
29
31
  const id = normaliseTaskId(input.id);
30
32
  const hasCommand = input.command !== undefined &&
31
33
  input.command !== null &&
@@ -47,7 +49,6 @@ export async function akmTasksAdd(input) {
47
49
  const target = resolveTaskWriteTarget();
48
50
  const stashDir = target.source.path;
49
51
  const typeRoot = path.join(stashDir, "tasks");
50
- fs.mkdirSync(typeRoot, { recursive: true });
51
52
  const assetPath = resolveAssetPathFromName("task", typeRoot, id);
52
53
  if (!isWithin(assetPath, typeRoot)) {
53
54
  throw new UsageError(`Resolved task path escapes the stash: "${id}".`, "PATH_ESCAPE_VIOLATION");
@@ -73,25 +74,103 @@ export async function akmTasksAdd(input) {
73
74
  });
74
75
  const task = parseTaskDocument({ yaml, filePath: assetPath, id });
75
76
  await validateTaskDocument(task, { backend, stashDir });
77
+ const obsoleteReason = obsoleteBackupTaskReason(task);
78
+ if (obsoleteReason)
79
+ throw new UsageError(obsoleteReason, "INVALID_FLAG_VALUE");
76
80
  const ref = taskAssetRef(id);
77
- await writeAssetToSource(target.source, target.config, ref, yaml);
78
- // Install in the OS scheduler. If install fails after the file was written,
79
- // delete the file so the on-disk state never claims a task is registered
80
- // when it isn't.
81
+ const previousYaml = fs.existsSync(assetPath) ? fs.readFileSync(assetPath, "utf8") : undefined;
82
+ let previousTask;
83
+ let previousTaskError;
84
+ if (previousYaml !== undefined) {
85
+ try {
86
+ previousTask = parseTaskDocument({ yaml: previousYaml, filePath: assetPath, id });
87
+ }
88
+ catch (err) {
89
+ previousTaskError = err;
90
+ }
91
+ }
92
+ const sched = deps.backend ?? selectBackend();
93
+ const writeAsset = deps.writeAsset ?? writeAssetToSource;
94
+ const deleteAsset = deps.deleteAsset ?? deleteAssetFromSource;
95
+ const commitBoundary = deps.commitBoundary ?? commitWriteTargetBoundary;
96
+ const wasInstalled = previousYaml !== undefined && (await sched.list()).some((entry) => entry.id === id);
97
+ let sourceRestoreArmed = false;
98
+ let installSucceeded = false;
81
99
  try {
82
- const sched = selectBackend();
100
+ sourceRestoreArmed = true;
101
+ await writeAsset(target.source, target.config, ref, yaml);
83
102
  await sched.install(task);
103
+ installSucceeded = true;
104
+ commitBoundary(target, `Update task:${id}`);
84
105
  }
85
106
  catch (err) {
86
- try {
87
- await deleteAssetFromSource(target.source, target.config, ref);
107
+ const rollbackErrors = [];
108
+ let sourceRestored = false;
109
+ if (sourceRestoreArmed) {
110
+ try {
111
+ if (previousYaml === undefined) {
112
+ if (fs.existsSync(assetPath)) {
113
+ await deleteAsset(target.source, target.config, ref);
114
+ sourceRestored = true;
115
+ }
116
+ }
117
+ else {
118
+ await restoreTaskSourceBytes(writeAsset, target.source, target.config, ref, assetPath, previousYaml);
119
+ sourceRestored = true;
120
+ }
121
+ }
122
+ catch (rollbackError) {
123
+ rollbackErrors.push(rollbackError);
124
+ }
88
125
  }
89
- catch {
90
- /* ignore */
126
+ if (installSucceeded && !wasInstalled) {
127
+ try {
128
+ await sched.uninstall(id);
129
+ }
130
+ catch (rollbackError) {
131
+ rollbackErrors.push(rollbackError);
132
+ }
133
+ }
134
+ else if (installSucceeded && previousTask) {
135
+ try {
136
+ await sched.install(previousTask);
137
+ }
138
+ catch (rollbackError) {
139
+ rollbackErrors.push(rollbackError);
140
+ try {
141
+ if (typeof sched.setEnabled !== "function") {
142
+ throw new Error(`Scheduler backend "${sched.name}" cannot disable task "${id}".`);
143
+ }
144
+ await sched.setEnabled(id, false);
145
+ }
146
+ catch (disableError) {
147
+ rollbackErrors.push(disableError);
148
+ try {
149
+ await sched.uninstall(id);
150
+ }
151
+ catch (uninstallError) {
152
+ rollbackErrors.push(uninstallError);
153
+ }
154
+ }
155
+ }
156
+ }
157
+ else if (installSucceeded && wasInstalled) {
158
+ rollbackErrors.push(previousTaskError ?? new Error(`Prior task "${id}" could not be restored.`));
159
+ }
160
+ if (sourceRestored) {
161
+ try {
162
+ commitBoundary(target, `Restore task:${id}`);
163
+ }
164
+ catch (rollbackError) {
165
+ rollbackErrors.push(rollbackError);
166
+ }
167
+ }
168
+ if (rollbackErrors.length > 0) {
169
+ const message = err instanceof Error ? err.message : String(err);
170
+ throw new AggregateError([err, ...rollbackErrors], `${message}; rollback for task "${id}" was incomplete.`);
91
171
  }
92
172
  throw err;
93
173
  }
94
- commitWriteTargetBoundary(target, `Update task:${id}`);
95
174
  return {
96
175
  id,
97
176
  ref: `task:${id}`,
@@ -206,38 +285,68 @@ export async function akmTasksShow(id) {
206
285
  tags: task.tags,
207
286
  };
208
287
  }
209
- export async function akmTasksRemove(id) {
288
+ export async function akmTasksRemove(id, deps = {}) {
210
289
  const normalised = normaliseTaskId(id);
211
290
  const target = resolveTaskWriteTarget();
212
291
  const stashDir = target.source.path;
213
292
  const typeRoot = path.join(stashDir, "tasks");
214
293
  if (fs.existsSync(typeRoot))
215
294
  warnLegacyMdTaskFiles(typeRoot);
216
- await resolveAssetPath(stashDir, "task", normalised);
295
+ const filePath = await resolveAssetPath(stashDir, "task", normalised);
296
+ const yaml = fs.readFileSync(filePath, "utf8");
217
297
  const ref = taskAssetRef(normalised);
218
- const sched = selectBackend();
219
- let uninstallError;
220
- let deleteError;
298
+ const sched = deps.backend ?? selectBackend();
299
+ const writeAsset = deps.writeAsset ?? writeAssetToSource;
300
+ const deleteAsset = deps.deleteAsset ?? deleteAssetFromSource;
301
+ const commitBoundary = deps.commitBoundary ?? commitWriteTargetBoundary;
302
+ const wasInstalled = (await sched.list()).some((entry) => entry.id === normalised);
303
+ const previousTask = wasInstalled ? parseTaskDocument({ yaml, filePath, id: normalised }) : undefined;
304
+ let uninstallAttempted = false;
305
+ let deleteAttempted = false;
221
306
  try {
307
+ uninstallAttempted = true;
222
308
  await sched.uninstall(normalised);
309
+ deleteAttempted = true;
310
+ await deleteAsset(target.source, target.config, ref);
311
+ commitBoundary(target, `Remove task:${normalised}`);
223
312
  }
224
313
  catch (err) {
225
- uninstallError = err;
226
- }
227
- try {
228
- await deleteAssetFromSource(target.source, target.config, ref);
229
- }
230
- catch (err) {
231
- deleteError = err;
314
+ const rollbackErrors = [];
315
+ let sourceRestored = false;
316
+ if (deleteAttempted) {
317
+ try {
318
+ await restoreTaskSourceBytes(writeAsset, target.source, target.config, ref, filePath, yaml);
319
+ sourceRestored = true;
320
+ }
321
+ catch (rollbackError) {
322
+ rollbackErrors.push(rollbackError);
323
+ }
324
+ }
325
+ if (uninstallAttempted && previousTask) {
326
+ try {
327
+ await sched.install(previousTask);
328
+ }
329
+ catch (rollbackError) {
330
+ rollbackErrors.push(rollbackError);
331
+ }
332
+ }
333
+ if (sourceRestored) {
334
+ try {
335
+ commitBoundary(target, `Restore task:${normalised}`);
336
+ }
337
+ catch (rollbackError) {
338
+ rollbackErrors.push(rollbackError);
339
+ }
340
+ }
341
+ if (rollbackErrors.length > 0) {
342
+ const message = err instanceof Error ? err.message : String(err);
343
+ throw new AggregateError([err, ...rollbackErrors], `${message}; rollback for task "${normalised}" was incomplete.`);
344
+ }
345
+ throw err;
232
346
  }
233
- if (uninstallError !== undefined)
234
- throw uninstallError;
235
- if (deleteError !== undefined)
236
- throw deleteError;
237
- commitWriteTargetBoundary(target, `Remove task:${normalised}`);
238
347
  return { id: normalised, removed: true, backend: sched.name };
239
348
  }
240
- export async function akmTasksSetEnabled(id, enabled) {
349
+ export async function akmTasksSetEnabled(id, enabled, deps = {}) {
241
350
  const normalised = normaliseTaskId(id);
242
351
  const target = resolveTaskWriteTarget();
243
352
  const stashDir = target.source.path;
@@ -246,42 +355,111 @@ export async function akmTasksSetEnabled(id, enabled) {
246
355
  warnLegacyMdTaskFiles(typeRoot);
247
356
  const filePath = await resolveAssetPath(stashDir, "task", normalised);
248
357
  const yaml = fs.readFileSync(filePath, "utf8");
249
- // Parse before writing so stale v1 tasks are diagnosed without changing the
250
- // source file or its installed scheduler entry.
251
- parseTaskDocument({ yaml, filePath, id: normalised });
358
+ // Parse before writing so unsupported tasks are diagnosed without changing
359
+ // the source file or its installed scheduler entry.
360
+ const previousTask = parseTaskDocument({ yaml, filePath, id: normalised });
252
361
  const updated = setEnabledInYaml(yaml, enabled);
362
+ const task = parseTaskDocument({ yaml: updated, filePath, id: normalised });
363
+ const obsoleteReason = obsoleteBackupTaskReason(task);
364
+ if (obsoleteReason)
365
+ throw new UsageError(obsoleteReason, "INVALID_FLAG_VALUE");
253
366
  const ref = taskAssetRef(normalised);
254
- await writeAssetToSource(target.source, target.config, ref, updated);
255
- const sched = selectBackend();
367
+ const sched = deps.backend ?? selectBackend();
368
+ const writeAsset = deps.writeAsset ?? writeAssetToSource;
369
+ const commitBoundary = deps.commitBoundary ?? commitWriteTargetBoundary;
370
+ const wasInstalled = (await sched.list()).some((entry) => entry.id === normalised);
371
+ let sourceRestoreArmed = false;
372
+ let installSucceeded = false;
256
373
  try {
374
+ sourceRestoreArmed = true;
375
+ await writeAsset(target.source, target.config, ref, updated);
257
376
  // Reinstall from the (just-updated) definition rather than only toggling
258
377
  // the comment. A plain toggle leaves a stale schedule in place if the
259
378
  // .yml's `schedule:` changed while the task was disabled — re-enabling
260
379
  // would silently keep the old cron line. install() renders the block with
261
380
  // both the current schedule and the new enabled state, and is idempotent.
262
- const task = parseTaskDocument({ yaml: updated, filePath, id: normalised });
263
381
  await sched.install(task);
382
+ installSucceeded = true;
383
+ commitBoundary(target, `Update task:${normalised}`);
264
384
  }
265
385
  catch (err) {
266
- // Roll the file back so the YAML source-of-truth and the OS
267
- // scheduler don't diverge silently when the backend call fails.
268
- await writeAssetToSource(target.source, target.config, ref, yaml);
386
+ const rollbackErrors = [];
387
+ let sourceRestored = false;
388
+ if (sourceRestoreArmed) {
389
+ try {
390
+ await restoreTaskSourceBytes(writeAsset, target.source, target.config, ref, filePath, yaml);
391
+ sourceRestored = true;
392
+ }
393
+ catch (rollbackError) {
394
+ rollbackErrors.push(rollbackError);
395
+ }
396
+ }
397
+ if (installSucceeded) {
398
+ try {
399
+ if (wasInstalled)
400
+ await sched.install(previousTask);
401
+ else
402
+ await sched.uninstall(normalised);
403
+ }
404
+ catch (rollbackError) {
405
+ rollbackErrors.push(rollbackError);
406
+ }
407
+ }
408
+ if (sourceRestored) {
409
+ try {
410
+ commitBoundary(target, `Restore task:${normalised}`);
411
+ }
412
+ catch (rollbackError) {
413
+ rollbackErrors.push(rollbackError);
414
+ }
415
+ }
416
+ if (rollbackErrors.length > 0) {
417
+ const message = err instanceof Error ? err.message : String(err);
418
+ throw new AggregateError([err, ...rollbackErrors], `${message}; rollback for task "${normalised}" was incomplete.`);
419
+ }
269
420
  throw err;
270
421
  }
271
- commitWriteTargetBoundary(target, `Update task:${normalised}`);
272
422
  return { id: normalised, enabled, backend: sched.name };
273
423
  }
274
- export async function akmTasksRun(id) {
275
- const normalised = normaliseTaskId(id);
276
- const stashDir = resolveStashDir();
424
+ export async function akmTasksRun(id, options = {}) {
425
+ const startedAt = new Date();
426
+ let normalised;
427
+ try {
428
+ normalised = parseTaskRef(id).id;
429
+ }
430
+ catch (failure) {
431
+ recordTaskAttemptFailure({
432
+ taskId: INVALID_TASK_ATTEMPT_ID,
433
+ reason: "invalid_task_id",
434
+ failure,
435
+ startedAt,
436
+ });
437
+ throw failure;
438
+ }
439
+ let stashDir;
440
+ try {
441
+ stashDir = resolveStashDir();
442
+ }
443
+ catch (failure) {
444
+ recordTaskAttemptFailure({
445
+ taskId: normalised,
446
+ reason: "task_load_failed",
447
+ failure,
448
+ startedAt,
449
+ });
450
+ throw failure;
451
+ }
277
452
  const typeRoot = path.join(stashDir, "tasks");
278
453
  if (fs.existsSync(typeRoot))
279
454
  warnLegacyMdTaskFiles(typeRoot);
280
- const result = await runTask(normalised);
455
+ const result = await runTask(normalised, { stashDir, scheduled: options.scheduled === true });
456
+ const exitCode = result.status === "failed" && result.target.kind === "command" && result.detail?.exitCode === 78
457
+ ? 78
458
+ : exitCodeForStatus(result.status);
281
459
  return {
282
460
  ok: result.status === "completed" || result.status === "disabled",
283
461
  result,
284
- exitCode: exitCodeForStatus(result.status),
462
+ exitCode,
285
463
  };
286
464
  }
287
465
  export async function akmTasksHistory(input) {
@@ -314,7 +492,7 @@ export async function akmTasksSync(deps = {}) {
314
492
  .map((f) => f.slice(0, -4))
315
493
  : [];
316
494
  const sched = deps.backend ?? selectBackend();
317
- const backend = backendNameForPlatform();
495
+ const backend = sched.name;
318
496
  // Map id → installed signature so sync can detect schedule/enabled drift on
319
497
  // tasks that already exist in the scheduler, not just presence/absence.
320
498
  const present = new Map((await sched.list()).map((t) => [t.id, t.signature]));
@@ -327,16 +505,26 @@ export async function akmTasksSync(deps = {}) {
327
505
  let task;
328
506
  try {
329
507
  task = parseTaskDocument({ yaml: fs.readFileSync(filePath, "utf8"), filePath, id });
330
- }
331
- catch (err) {
332
- skipped.push({ id, reason: err instanceof Error ? err.message : String(err) });
333
- continue;
334
- }
335
- try {
336
508
  await validateTaskDocument(task, { backend, stashDir });
509
+ const obsoleteReason = obsoleteBackupTaskReason(task);
510
+ if (obsoleteReason)
511
+ throw new UsageError(obsoleteReason, "INVALID_FLAG_VALUE");
337
512
  }
338
513
  catch (err) {
339
514
  skipped.push({ id, reason: err instanceof Error ? err.message : String(err) });
515
+ if (present.has(id)) {
516
+ try {
517
+ await sched.setEnabled(id, false);
518
+ }
519
+ catch (disableError) {
520
+ try {
521
+ await sched.uninstall(id);
522
+ }
523
+ catch (uninstallError) {
524
+ throw new AggregateError([err, disableError, uninstallError], `Task "${id}" is invalid and its installed scheduler entry could not be disabled or removed.`);
525
+ }
526
+ }
527
+ }
340
528
  continue;
341
529
  }
342
530
  if (!present.has(id)) {
@@ -368,6 +556,19 @@ export async function akmTasksSync(deps = {}) {
368
556
  }
369
557
  return { installed, updated, removed, unchanged, skipped, backend: sched.name };
370
558
  }
559
+ function obsoleteBackupTaskReason(task) {
560
+ if (!task.enabled || task.target.kind !== "command")
561
+ return undefined;
562
+ const executableIndex = findBareAkmExecutableIndex(task.target.cmd);
563
+ if (executableIndex === undefined)
564
+ return undefined;
565
+ const args = task.target.cmd.slice(executableIndex + 1);
566
+ if (args.length !== 2 || args[0] !== "db" || args[1] !== "backups")
567
+ return undefined;
568
+ return (`Task "${task.id}" invokes obsolete \`akm db backups\`, which only listed legacy backups and is not a 0.9 backup command. ` +
569
+ "AKM will not install or enable it; sync will keep any existing scheduler entry disabled, and the task file is unchanged. " +
570
+ "Use `akm backup create --for 0.9.0` for an explicit migration recovery snapshot.");
571
+ }
371
572
  export async function akmTasksDoctor() {
372
573
  const warnings = [];
373
574
  let invocation = { argv: [], via: "unresolved" };
@@ -417,22 +618,14 @@ export async function akmTasksDoctor() {
417
618
  };
418
619
  }
419
620
  // ── helpers ─────────────────────────────────────────────────────────────────
420
- const VALID_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
421
- function normaliseTaskId(raw) {
422
- // Accept both .yml and .md suffixes from users so muscle memory from the
423
- // pre-0.8.0 markdown task format doesn't produce a confusing "task not found".
424
- const id = raw.trim().replace(/\.(yml|md)$/, "");
425
- if (!id) {
426
- throw new UsageError("Task id must be non-empty.", "MISSING_REQUIRED_ARGUMENT");
427
- }
428
- if (!VALID_ID_RE.test(id)) {
429
- throw new UsageError(`Task id "${id}" is invalid. Use letters, digits, dots, underscores, and dashes only.`, "INVALID_FLAG_VALUE");
430
- }
431
- return id;
432
- }
433
621
  function taskAssetRef(id) {
434
622
  return { type: "task", name: id };
435
623
  }
624
+ async function restoreTaskSourceBytes(writeAsset, source, config, ref, filePath, yaml) {
625
+ await writeAsset(source, config, ref, yaml);
626
+ // The normal write path adds a trailing newline; rollback restores the raw snapshot exactly.
627
+ fs.writeFileSync(filePath, yaml, "utf8");
628
+ }
436
629
  function resolveTaskWriteTarget() {
437
630
  return resolveWriteTarget(loadConfig());
438
631
  }
@@ -475,8 +668,8 @@ function isStaleTaskError(err) {
475
668
  return err instanceof UsageError && err.code === "TASK_SCHEMA_VERSION_UNSUPPORTED";
476
669
  }
477
670
  function warnStaleTaskFiles(ids) {
478
- process.stderr.write(`WARNING: ${ids.length} task file(s) use retired task schema v1 and were not loaded.\n` +
479
- ` Rewrite them with version: 2 and replace profile with engine. See docs/migration/v0.8-to-v0.9.md#task-assets.\n` +
671
+ process.stderr.write(`WARNING: ${ids.length} task file(s) use an unsupported task schema version and were not loaded.\n` +
672
+ ` Use version: 2. See docs/migration/v0.8-to-v0.9.md#engine-and-task-assets.\n` +
480
673
  ` Affected: ${ids.map((id) => `tasks/${id}.yml`).join(", ")}\n`);
481
674
  }
482
675
  function collectStaleTaskIds() {
@@ -27,6 +27,9 @@ export const ASSET_TYPES = Object.freeze([...getAssetTypes()]);
27
27
  export const ASSET_TYPE_SET = new Set(ASSET_TYPES);
28
28
  // ── Constants ───────────────────────────────────────────────────────────────
29
29
  export const IS_WINDOWS = process.platform === "win32";
30
+ export const MAX_CONFIG_FILE_BYTES = 1024 * 1024;
31
+ export const MAX_LOCAL_METADATA_BYTES = 1024 * 1024;
32
+ export const MAX_LOCK_METADATA_BYTES = 64 * 1024;
30
33
  export function isHttpUrl(value) {
31
34
  return !!value && /^https?:\/\//.test(value);
32
35
  }
@@ -58,6 +61,35 @@ export function isAssetType(type) {
58
61
  return Object.hasOwn(TYPE_DIRS, type);
59
62
  }
60
63
  // ── Utilities ───────────────────────────────────────────────────────────────
64
+ export function readTextFileDescriptorWithLimit(fd, maxBytes, label = "File", displayPath = "(open file)") {
65
+ const stat = fs.fstatSync(fd);
66
+ if (!stat.isFile())
67
+ throw new ConfigError(`${label} is not a regular file: ${displayPath}.`, "INVALID_CONFIG_FILE");
68
+ if (stat.size > maxBytes) {
69
+ throw new ConfigError(`${label} exceeds the ${maxBytes}-byte limit: ${displayPath}.`, "INVALID_CONFIG_FILE");
70
+ }
71
+ const buffer = Buffer.allocUnsafe(maxBytes + 1);
72
+ let total = 0;
73
+ while (total <= maxBytes) {
74
+ const bytesRead = fs.readSync(fd, buffer, total, maxBytes + 1 - total, null);
75
+ if (bytesRead === 0)
76
+ break;
77
+ total += bytesRead;
78
+ }
79
+ if (total > maxBytes) {
80
+ throw new ConfigError(`${label} exceeds the ${maxBytes}-byte limit: ${displayPath}.`, "INVALID_CONFIG_FILE");
81
+ }
82
+ return buffer.subarray(0, total).toString("utf8");
83
+ }
84
+ export function readTextFileWithLimit(filePath, maxBytes, label = "File") {
85
+ const fd = fs.openSync(filePath, "r");
86
+ try {
87
+ return readTextFileDescriptorWithLimit(fd, maxBytes, label, filePath);
88
+ }
89
+ finally {
90
+ fs.closeSync(fd);
91
+ }
92
+ }
61
93
  /**
62
94
  * Write content to a file atomically via a temp file + rename.
63
95
  * Prevents partial-write corruption on crash.
@@ -172,7 +204,7 @@ function isValidDirectory(dir) {
172
204
  function readStashDirFromConfig() {
173
205
  try {
174
206
  const configPath = getConfigPath();
175
- const text = fs.readFileSync(configPath, "utf8");
207
+ const text = readTextFileWithLimit(configPath, MAX_CONFIG_FILE_BYTES, "Config file");
176
208
  const raw = JSON.parse(text);
177
209
  if (typeof raw === "object" && raw !== null && typeof raw.stashDir === "string" && raw.stashDir.trim()) {
178
210
  return raw.stashDir.trim();
@@ -370,8 +402,9 @@ export async function readBodyWithByteCap(response, maxBytes = DEFAULT_RESPONSE_
370
402
  // No streaming body available (e.g., some mock environments). Fall
371
403
  // back to text() but still enforce the cap post-hoc.
372
404
  const text = await response.text();
373
- if (text.length > maxBytes)
374
- throw new ResponseTooLargeError(url, maxBytes, text.length);
405
+ const byteLength = Buffer.byteLength(text, "utf8");
406
+ if (byteLength > maxBytes)
407
+ throw new ResponseTooLargeError(url, maxBytes, byteLength);
375
408
  return text;
376
409
  }
377
410
  const reader = body.getReader();
@@ -15,7 +15,7 @@
15
15
  import fs from "node:fs";
16
16
  import path from "node:path";
17
17
  import { sleepSync } from "../../runtime.js";
18
- import { writeFileAtomic } from "../common.js";
18
+ import { MAX_CONFIG_FILE_BYTES, readTextFileWithLimit, writeFileAtomic } from "../common.js";
19
19
  import { ConfigError } from "../errors.js";
20
20
  import { createLockPayload, probeLock, reclaimStaleLock, releaseLock, tryAcquireLockSync } from "../file-lock.js";
21
21
  import { getCacheDir, getConfigDir } from "../paths.js";
@@ -25,7 +25,7 @@ import { getCacheDir, getConfigDir } from "../paths.js";
25
25
  */
26
26
  export function readConfigText(configPath) {
27
27
  try {
28
- return fs.readFileSync(configPath, "utf8");
28
+ return readTextFileWithLimit(configPath, MAX_CONFIG_FILE_BYTES, "Config file");
29
29
  }
30
30
  catch (err) {
31
31
  if (err.code === "ENOENT")
@@ -564,9 +564,9 @@ const ImproveProfileProcessesSchema = z
564
564
  // 0.8.0 removed the duplicated `feedbackDistillation` process key — it was
565
565
  // a thin wrapper around `processes.distill.enabled`. Keep the migration
566
566
  // hint so a stale config gets an actionable message rather than silently
567
- // doing nothing. All OTHER unknown process keys are tolerated (passthrough)
568
- // see the unknown-key policy in this file's header. Hard-rejecting them
569
- // turned benign cross-version skew into INVALID_CONFIG_FILE failures.
567
+ // doing nothing. Other unknown process keys remain preserved for
568
+ // cross-version compatibility, but an enabled one is rejected below because
569
+ // this version cannot execute it.
570
570
  if ("feedbackDistillation" in val) {
571
571
  ctx.addIssue({
572
572
  code: z.ZodIssueCode.custom,
@@ -574,6 +574,18 @@ const ImproveProfileProcessesSchema = z
574
574
  "It now controls both the orchestration gate and the LLM-call gate.",
575
575
  });
576
576
  }
577
+ for (const [name, process] of Object.entries(val)) {
578
+ if (!(name in IMPROVE_PROCESS_ENGINE_CAPABILITIES) &&
579
+ process !== null &&
580
+ typeof process === "object" &&
581
+ process.enabled === true) {
582
+ ctx.addIssue({
583
+ code: z.ZodIssueCode.custom,
584
+ path: [name],
585
+ message: `Unknown enabled improve process "${name}"`,
586
+ });
587
+ }
588
+ }
577
589
  });
578
590
  export const ImproveProfileConfigSchema = z
579
591
  .object({
@@ -4,7 +4,7 @@
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import { ConfigError } from "../errors.js";
7
- import { ensureMigrationBackupWithConfigLockHeld } from "../migration-backup.js";
7
+ import { assertNoPendingMigrationOperation } from "../migration-operation.js";
8
8
  import { acquireConfigLock, backupExistingConfig, parseConfigText, readConfigText, withConfigLock, writeConfigAtomic, } from "./config-io.js";
9
9
  import { AkmConfigSchema, CURRENT_CONFIG_VERSION } from "./config-schema.js";
10
10
  import { deepMergeConfig } from "./deep-merge.js";
@@ -68,6 +68,7 @@ export function resetConfigCache() {
68
68
  cachedConfig = undefined;
69
69
  }
70
70
  export function loadUserConfig() {
71
+ assertNoPendingMigrationOperation();
71
72
  const configPath = getConfigPath();
72
73
  let stat;
73
74
  try {
@@ -91,12 +92,12 @@ export function loadUserConfig() {
91
92
  }
92
93
  let text;
93
94
  try {
94
- text = fs.readFileSync(configPath, "utf8");
95
+ text = readConfigText(configPath);
95
96
  }
96
97
  catch (err) {
97
- if (err.code !== "ENOENT") {
98
- throw new ConfigError(`Unable to read config at ${configPath}: ${err instanceof Error ? err.message : String(err)}`, "INVALID_CONFIG_FILE");
99
- }
98
+ throw new ConfigError(`Unable to read config at ${configPath}: ${err instanceof Error ? err.message : String(err)}`, "INVALID_CONFIG_FILE");
99
+ }
100
+ if (text === undefined) {
100
101
  cachedConfig = undefined;
101
102
  return { ...DEFAULT_CONFIG };
102
103
  }
@@ -191,13 +192,14 @@ export function saveConfig(config) {
191
192
  saveConfigReal(currentConfig);
192
193
  }
193
194
  function saveConfigReal(config) {
195
+ assertNoPendingMigrationOperation();
194
196
  cachedConfig = undefined;
195
197
  const configPath = getConfigPath();
196
198
  const dir = path.dirname(configPath);
197
199
  fs.mkdirSync(dir, { recursive: true });
198
200
  withConfigLock(() => {
201
+ assertNoPendingMigrationOperation();
199
202
  const validated = validateCompleteConfig(config);
200
- ensureMigrationBackupWithConfigLockHeld();
201
203
  backupExistingConfig(configPath);
202
204
  writeConfigAtomic(configPath, sanitizeConfigForWrite(validated));
203
205
  });
@@ -212,12 +214,14 @@ export function validateCompleteConfig(config) {
212
214
  }
213
215
  /**
214
216
  * Mutate config under one fail-closed lock spanning read, merge, validation,
215
- * migration backup, ordinary backup, and atomic write.
217
+ * ordinary backup, and atomic write.
216
218
  */
217
219
  export function mutateConfig(mutate, options) {
220
+ assertNoPendingMigrationOperation();
218
221
  cachedConfig = undefined;
219
222
  const configPath = getConfigPath();
220
223
  return withConfigLock(() => {
224
+ assertNoPendingMigrationOperation();
221
225
  const text = readConfigText(configPath);
222
226
  if (text === undefined && options?.absentNoop) {
223
227
  return { config: { ...DEFAULT_CONFIG }, written: false };
@@ -227,7 +231,6 @@ export function mutateConfig(mutate, options) {
227
231
  if (mutated === current)
228
232
  return { config: current, written: false };
229
233
  const next = validateCompleteConfig({ ...mutated, configVersion: CURRENT_CONFIG_VERSION });
230
- ensureMigrationBackupWithConfigLockHeld();
231
234
  if (text !== undefined)
232
235
  backupExistingConfig(configPath);
233
236
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
@@ -241,18 +244,16 @@ export function mutateConfig(mutate, options) {
241
244
  * its stash, while preventing another config writer from racing the final save.
242
245
  */
243
246
  export async function mutateConfigWithPrecommit(mutate, precommit) {
247
+ assertNoPendingMigrationOperation();
244
248
  cachedConfig = undefined;
245
249
  const configPath = getConfigPath();
246
250
  const release = acquireConfigLock();
247
251
  try {
252
+ assertNoPendingMigrationOperation();
248
253
  const text = readConfigText(configPath);
249
254
  const current = text === undefined ? { ...DEFAULT_CONFIG } : parseAndValidateConfigText(text, configPath);
250
255
  const mutated = mutate(current);
251
256
  const next = validateCompleteConfig({ ...mutated, configVersion: CURRENT_CONFIG_VERSION });
252
- // Setup's pre-commit may initialize the stash. Prove the rebase and final
253
- // config are valid, then require the recovery snapshots before that first
254
- // external side effect.
255
- ensureMigrationBackupWithConfigLockHeld();
256
257
  if (text !== undefined)
257
258
  backupExistingConfig(configPath);
258
259
  const precommitResult = await precommit(next);
@@ -281,7 +282,7 @@ export async function mutateConfigWithPrecommit(mutate, precommit) {
281
282
  * --yes` with an `apiKey` field expected persistence and got a wiped config
282
283
  * with no feedback.
283
284
  */
284
- function sanitizeConfigForWrite(config) {
285
+ export function sanitizeConfigForWrite(config) {
285
286
  const sanitized = { ...config };
286
287
  const stripped = [];
287
288
  if (config.embedding?.apiKey !== undefined) {