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
@@ -14,7 +14,7 @@
14
14
  * `mergeCurateSearchResponses`) by importing them directly.
15
15
  */
16
16
  import fs from "node:fs";
17
- import { parseAssetRef } from "../../core/asset/asset-ref.js";
17
+ import { parseAssetRef, refToString } from "../../core/asset/asset-ref.js";
18
18
  import { parseFrontmatter } from "../../core/asset/frontmatter.js";
19
19
  import { getIndexPassConfig, loadConfig } from "../../core/config/config.js";
20
20
  import { rethrowIfTestIsolationError, UsageError } from "../../core/errors.js";
@@ -78,10 +78,12 @@ function logCurateEvent(query, result, eventSource = "user") {
78
78
  for (const item of result.items) {
79
79
  if (!("ref" in item) || typeof item.ref !== "string")
80
80
  continue;
81
+ const parsed = parseAssetRef(item.ref);
82
+ const itemOrigin = "origin" in item && typeof item.origin === "string" ? item.origin : undefined;
81
83
  insertUsageEvent(db, {
82
84
  event_type: "curate",
83
85
  query,
84
- entry_ref: item.ref,
86
+ entry_ref: refToString({ ...parsed, ...(parsed.origin || !itemOrigin ? {} : { origin: itemOrigin }) }),
85
87
  source: eventSource,
86
88
  });
87
89
  }
@@ -21,7 +21,7 @@ import { loadConfig } from "../../core/config/config.js";
21
21
  import { UsageError } from "../../core/errors.js";
22
22
  import { resolveStashStandards } from "../../core/standards/resolve-stash-standards.js";
23
23
  import { warn } from "../../core/warn.js";
24
- import { commitWriteTargetBoundary, formatRefForMessage, resolveWriteTarget, writeAssetToSource, } from "../../core/write-source.js";
24
+ import { commitWriteTargetBoundary, formatRefForMessage, recordWriteTargetPath, resolveWriteTarget, writeAssetToSource, } from "../../core/write-source.js";
25
25
  import { indexWrittenAssets } from "../../indexer/index-written-assets.js";
26
26
  import { resolveSourceEntries } from "../../indexer/search/search-source.js";
27
27
  import { fetchWebsiteMarkdownSnapshot, shouldAllowPrivateWebsiteUrlForTests } from "../../sources/website-ingest.js";
@@ -528,6 +528,9 @@ export async function writeMarkdownAsset(options) {
528
528
  // Degrade to the same applied:false report the non-writable path uses.
529
529
  try {
530
530
  writeSupersededEdge(item.filePath, result.ref);
531
+ if (path.resolve(item.stashRoot) === path.resolve(source.path)) {
532
+ recordWriteTargetPath(source, item.filePath);
533
+ }
531
534
  }
532
535
  catch (error) {
533
536
  const reason = `demotion failed: ${error instanceof Error ? error.message : String(error)}`;
@@ -11,6 +11,7 @@
11
11
  * a registry search behind `--source registry|both`, and log a usage event.
12
12
  * Provider `search()` methods do not exist.
13
13
  */
14
+ import { parseAssetRef, refToString } from "../../core/asset/asset-ref.js";
14
15
  import { loadConfig } from "../../core/config/config.js";
15
16
  import { rethrowIfTestIsolationError, UsageError } from "../../core/errors.js";
16
17
  import { appendEvent } from "../../core/events.js";
@@ -191,8 +192,11 @@ function resolveEntryIds(db, hits) {
191
192
  for (const hit of hits) {
192
193
  try {
193
194
  const entryId = getEntryIdByFilePath(db, hit.path);
194
- if (entryId !== undefined)
195
- results.push({ entryId, ref: hit.ref });
195
+ if (entryId !== undefined) {
196
+ const parsed = parseAssetRef(hit.ref);
197
+ const origin = parsed.origin ?? hit.origin ?? undefined;
198
+ results.push({ entryId, ref: refToString({ ...parsed, ...(origin ? { origin } : {}) }) });
199
+ }
196
200
  }
197
201
  catch {
198
202
  /* skip unresolvable */
@@ -19,14 +19,14 @@
19
19
  import fs from "node:fs";
20
20
  import path from "node:path";
21
21
  import { findCittyTopLevelCommandIndex } from "../../cli/parse-args.js";
22
- import { parseAssetRef } from "../../core/asset/asset-ref.js";
22
+ import { parseAssetRef, refToString } from "../../core/asset/asset-ref.js";
23
23
  import { parseFrontmatter } from "../../core/asset/frontmatter.js";
24
24
  import { META_DIR, parseMetaRef, resolveMetaFilePath } from "../../core/asset/stash-meta.js";
25
25
  import { asNonEmptyString } from "../../core/common.js";
26
26
  import { getIndexPassConfig, loadConfig } from "../../core/config/config.js";
27
27
  import { NotFoundError, rethrowIfTestIsolationError, UsageError } from "../../core/errors.js";
28
28
  import { appendEvent, readEvents } from "../../core/events.js";
29
- import { closeDatabase, computeBodyHash, findEntryIdByRef, openExistingDatabase } from "../../indexer/db/db.js";
29
+ import { closeDatabase, computeBodyHash, findEntryIdByRef, getEntryIdByFilePath, openExistingDatabase, } from "../../indexer/db/db.js";
30
30
  import { hasGraphData } from "../../indexer/db/graph-db.js";
31
31
  import { listRelatedPathsForFile } from "../../indexer/graph/graph-boost.js";
32
32
  import { extractGraphForSingleFile } from "../../indexer/graph/graph-extraction.js";
@@ -160,7 +160,7 @@ export async function akmShowUnified(input) {
160
160
  }
161
161
  // Count prior shows of this ref before logging the current one.
162
162
  const priorShowCount = recentShowCount(ref);
163
- logShowEvent(ref, input.eventSource);
163
+ logShowEvent(ref, input.eventSource, result.path, result.origin);
164
164
  if (priorShowCount >= 2) {
165
165
  // Agent has shown this same asset 3+ times — inject a loop-break hint.
166
166
  result.showLoopWarning = priorShowCount + 1;
@@ -257,11 +257,12 @@ function recentShowCount(ref) {
257
257
  return 0;
258
258
  }
259
259
  }
260
- function logShowEvent(ref, eventSource = "user") {
260
+ function logShowEvent(ref, eventSource = "user", filePath, origin) {
261
261
  // Emit a structured event to events.jsonl so workflow-trace consumers
262
262
  // detect akm show invocations without relying on stdout scraping.
263
263
  const parsed = parseAssetRef(ref);
264
- appendEvent({ eventType: "show", ref, metadata: { type: parsed.type, name: parsed.name } });
264
+ const eventRef = refToString({ ...parsed, ...(parsed.origin || !origin ? {} : { origin }) });
265
+ appendEvent({ eventType: "show", ref: eventRef, metadata: { type: parsed.type, name: parsed.name } });
265
266
  // Detect if this show is a selection from a recent search result.
266
267
  try {
267
268
  // D7: bound the query to the last 60 s so we never scan unbounded history
@@ -295,8 +296,8 @@ function logShowEvent(ref, eventSource = "user") {
295
296
  withIndexDb((db) => {
296
297
  insertUsageEvent(db, {
297
298
  event_type: "show",
298
- entry_ref: ref,
299
- entry_id: findEntryIdByRef(db, ref),
299
+ entry_ref: eventRef,
300
+ entry_id: filePath ? getEntryIdByFilePath(db, filePath) : findEntryIdByRef(db, eventRef),
300
301
  source: eventSource,
301
302
  });
302
303
  }, { busyTimeoutMs: TELEMETRY_BUSY_TIMEOUT_MS });
@@ -5,7 +5,7 @@ import * as childProcess from "node:child_process";
5
5
  import { createHash } from "node:crypto";
6
6
  import fs from "node:fs";
7
7
  import path from "node:path";
8
- import { fetchWithRetry, IS_WINDOWS } from "../../core/common.js";
8
+ import { fetchWithRetry, IS_WINDOWS, ResponseTooLargeError, readBodyWithByteCap } from "../../core/common.js";
9
9
  import { warn } from "../../core/warn.js";
10
10
  import { githubHeaders } from "../../integrations/github.js";
11
11
  import { getDirname, mainPath, semverOrder } from "../../runtime.js";
@@ -14,6 +14,61 @@ const DEFAULT_PACKAGE_NAME = "akm-cli";
14
14
  const NODE_MODULES_SEGMENT = "/node_modules/";
15
15
  const BUN_GLOBAL_INSTALL_PATTERN = /(^|\/)\.bun\/(?:[^/]+\/)+node_modules\//;
16
16
  const PNPM_GLOBAL_INSTALL_PATTERN = /(^|\/)(?:pnpm\/global|\.pnpm-global)(?:\/\d+)?\/node_modules\//;
17
+ const MIGRATION_CONTRACT_VERSION = "0.9.0-rc.0";
18
+ const MAX_BINARY_DOWNLOAD_BYTES = 256 * 1024 * 1024;
19
+ const MAX_CHECKSUM_METADATA_BYTES = 1024 * 1024;
20
+ export function defaultMigrationCommand(preparedConfigPath) {
21
+ const configArgs = preparedConfigPath ? ["--config", preparedConfigPath] : [];
22
+ return {
23
+ preflight: (akmBin) => runRequiredCommand(akmBin, ["migrate", "status"], "Migration preflight"),
24
+ stagedPreflight: (akmBin) => runRequiredCommand(akmBin, ["migrate", "status", ...configArgs], "Staged migration preflight"),
25
+ apply: (akmBin) => runRequiredCommand(akmBin, ["migrate", "apply", ...configArgs], "Migration apply"),
26
+ };
27
+ }
28
+ async function streamResponseToFile(response, destination, maxBytes) {
29
+ const declaredText = response.headers.get("content-length");
30
+ if (declaredText) {
31
+ const declared = Number(declaredText);
32
+ if (Number.isFinite(declared) && declared > maxBytes) {
33
+ await response.body?.cancel().catch(() => undefined);
34
+ throw new ResponseTooLargeError(response.url || "binary download", maxBytes, declared);
35
+ }
36
+ }
37
+ if (!response.body)
38
+ throw new Error("Binary download response did not provide a streaming body.");
39
+ const fd = fs.openSync(destination, "w", 0o600);
40
+ const reader = response.body.getReader();
41
+ const hash = createHash("sha256");
42
+ let byteSize = 0;
43
+ try {
44
+ while (true) {
45
+ const { done, value } = await reader.read();
46
+ if (done)
47
+ break;
48
+ if (!value)
49
+ continue;
50
+ byteSize += value.byteLength;
51
+ if (byteSize > maxBytes) {
52
+ await reader.cancel().catch(() => undefined);
53
+ throw new ResponseTooLargeError(response.url || "binary download", maxBytes, byteSize);
54
+ }
55
+ hash.update(value);
56
+ let written = 0;
57
+ while (written < value.byteLength)
58
+ written += fs.writeSync(fd, value, written, value.byteLength - written);
59
+ }
60
+ fs.fdatasyncSync(fd);
61
+ return { byteSize, sha256: hash.digest("hex") };
62
+ }
63
+ catch (error) {
64
+ removeFileBestEffort(destination);
65
+ throw error;
66
+ }
67
+ finally {
68
+ reader.releaseLock();
69
+ fs.closeSync(fd);
70
+ }
71
+ }
17
72
  /** Read live runtime signals. */
18
73
  export function getInstallSignals() {
19
74
  return {
@@ -72,7 +127,7 @@ export async function checkForUpdate(currentVersion) {
72
127
  if (!response.ok) {
73
128
  throw new Error(`Failed to check for updates: ${response.status} ${response.statusText}`);
74
129
  }
75
- const release = (await response.json());
130
+ const release = JSON.parse(await readBodyWithByteCap(response, MAX_CHECKSUM_METADATA_BYTES));
76
131
  const latestTag = release.tag_name ?? "";
77
132
  const latestVersion = latestTag.replace(/^v/, "");
78
133
  return {
@@ -82,10 +137,11 @@ export async function checkForUpdate(currentVersion) {
82
137
  installMethod,
83
138
  };
84
139
  }
85
- export async function performUpgrade(check, opts) {
140
+ export async function performUpgrade(check, opts, dependencies) {
86
141
  const { currentVersion, latestVersion, installMethod } = check;
87
142
  const force = opts?.force === true;
88
143
  const skipPostUpgrade = opts?.skipPostUpgrade === true;
144
+ const migration = dependencies?.migration ?? defaultMigrationCommand(opts?.migrationConfig);
89
145
  // All install methods can short-circuit here unless the user explicitly forces an upgrade.
90
146
  if (!check.updateAvailable && !force) {
91
147
  return {
@@ -96,11 +152,19 @@ export async function performUpgrade(check, opts) {
96
152
  message: `akm v${currentVersion} is already the latest version`,
97
153
  };
98
154
  }
155
+ if (latestVersion &&
156
+ semverOrder(currentVersion, MIGRATION_CONTRACT_VERSION) < 0 &&
157
+ semverOrder(latestVersion, MIGRATION_CONTRACT_VERSION) >= 0) {
158
+ throw new Error("AKM 0.8 does not implement the migrate command or the --migration-config upgrade contract, so self-update cannot safely cross into 0.9. " +
159
+ "Prepare the 0.9 config and an independent backup, install or stage the 0.9 binary manually, then use the new binary to run: " +
160
+ "akm migrate apply --config <prepared-0.9.json>. See docs/migration/v0.8-to-v0.9.md.");
161
+ }
99
162
  const packageManagerCommand = getPackageManagerUpgradeCommand(installMethod);
100
163
  if (packageManagerCommand) {
101
164
  if (!latestVersion) {
102
165
  throw new Error("Unable to determine latest version from GitHub releases. Check https://github.com/itlackey/akm/releases");
103
166
  }
167
+ migration.preflight("akm");
104
168
  const result = childProcess.spawnSync(packageManagerCommand.command, packageManagerCommand.args, {
105
169
  encoding: "utf8",
106
170
  env: process.env,
@@ -113,6 +177,7 @@ export async function performUpgrade(check, opts) {
113
177
  const details = (result.stderr ?? "").trim() || (result.stdout ?? "").trim() || `exit code ${result.status}`;
114
178
  throw new Error(`Failed to upgrade akm via ${installMethod}: ${details}\nRun manually: ${packageManagerCommand.displayCommand}`);
115
179
  }
180
+ migration.apply("akm");
116
181
  return {
117
182
  currentVersion,
118
183
  newVersion: latestVersion,
@@ -139,12 +204,34 @@ export async function performUpgrade(check, opts) {
139
204
  const binaryName = getAkmBinaryName();
140
205
  const binaryUrl = `https://github.com/${REPO}/releases/download/${tag}/${binaryName}`;
141
206
  const checksumsUrl = `https://github.com/${REPO}/releases/download/${tag}/checksums.txt`;
207
+ const execPath = dependencies?.execPath ?? process.execPath;
208
+ const execDir = path.dirname(execPath);
209
+ const execName = path.basename(execPath);
210
+ const stagedPath = IS_WINDOWS
211
+ ? path.join(execDir, `.${execName}.new.${process.pid}`)
212
+ : path.join(execDir, `.${execName}.tmp.${process.pid}`);
213
+ const backupPath = `${execPath}${IS_WINDOWS ? ".old" : ".bak"}`;
142
214
  // Download binary
143
215
  const binaryResponse = await fetchWithRetry(binaryUrl);
144
216
  if (!binaryResponse.ok) {
145
217
  throw new Error(`Failed to download binary: ${binaryResponse.status} ${binaryResponse.statusText}`);
146
218
  }
147
- const binaryData = new Uint8Array(await binaryResponse.arrayBuffer());
219
+ let downloaded;
220
+ try {
221
+ downloaded = await streamResponseToFile(binaryResponse, stagedPath, MAX_BINARY_DOWNLOAD_BYTES);
222
+ if (!IS_WINDOWS)
223
+ fs.chmodSync(stagedPath, 0o755);
224
+ }
225
+ catch (err) {
226
+ removeFileBestEffort(stagedPath);
227
+ const code = err.code;
228
+ if (code === "EACCES" || code === "EPERM") {
229
+ throw new Error(`Permission denied writing to ${execDir}.\n` +
230
+ `${IS_WINDOWS ? "Try running as Administrator." : "Run: sudo akm upgrade"}\n` +
231
+ `Or re-run the install script: curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | bash`);
232
+ }
233
+ throw err;
234
+ }
148
235
  // Download and verify checksum (mandatory — upgrade is blocked if checksums cannot be fetched)
149
236
  let checksumVerified = false;
150
237
  const skipChecksum = opts?.skipChecksum === true;
@@ -160,10 +247,10 @@ export async function performUpgrade(check, opts) {
160
247
  }
161
248
  }
162
249
  else {
163
- const checksumsText = await checksumsResponse.text();
250
+ const checksumsText = await readBodyWithByteCap(checksumsResponse, MAX_CHECKSUM_METADATA_BYTES);
164
251
  const expectedHash = parseChecksumForFile(checksumsText, binaryName);
165
252
  if (expectedHash) {
166
- const actualHash = createHash("sha256").update(binaryData).digest("hex");
253
+ const actualHash = downloaded.sha256;
167
254
  if (actualHash !== expectedHash) {
168
255
  throw new Error(`Checksum mismatch for ${binaryName}.\n` + `Expected: ${expectedHash}\n` + `Got: ${actualHash}`);
169
256
  }
@@ -183,6 +270,7 @@ export async function performUpgrade(check, opts) {
183
270
  catch (err) {
184
271
  if (err instanceof Error &&
185
272
  (err.message.includes("Checksum mismatch") || err.message.includes("Checksum verification failed"))) {
273
+ removeFileBestEffort(stagedPath);
186
274
  throw err;
187
275
  }
188
276
  // Network or parse failure
@@ -190,99 +278,46 @@ export async function performUpgrade(check, opts) {
190
278
  warn(`WARNING: Could not fetch or parse checksums: ${err instanceof Error ? err.message : String(err)}. Proceeding because --skip-checksum was provided.`);
191
279
  }
192
280
  else {
281
+ removeFileBestEffort(stagedPath);
193
282
  throw new Error(`Checksum verification failed: ${err instanceof Error ? err.message : String(err)}. ` +
194
283
  `Use --skip-checksum to bypass (not recommended).`);
195
284
  }
196
285
  }
197
- const execPath = process.execPath;
198
- const execDir = path.dirname(execPath);
199
- const execName = path.basename(execPath);
200
- if (IS_WINDOWS) {
201
- // Windows: rename running exe, write new one, clean up old on success
202
- const oldPath = `${execPath}.old`;
203
- try {
204
- fs.renameSync(execPath, oldPath);
205
- }
206
- catch (err) {
207
- const code = err.code;
208
- if (code === "EPERM" || code === "EACCES") {
209
- throw new Error(`Permission denied. Cannot rename ${execPath}.\n` +
210
- `Try running as Administrator, or re-download from https://github.com/${REPO}/releases`);
211
- }
212
- const detail = err instanceof Error ? err.message : String(err);
213
- throw new Error(`Failed to rename ${execPath}: ${detail}`);
214
- }
215
- try {
216
- fs.writeFileSync(execPath, binaryData);
217
- }
218
- catch (err) {
219
- // Restore from old
220
- fs.renameSync(oldPath, execPath);
221
- throw err;
222
- }
223
- // Best-effort cleanup of .old
224
- try {
225
- fs.unlinkSync(oldPath);
226
- }
227
- catch {
228
- // Windows may lock the old exe — it will be cleaned up on next startup or manually
229
- }
286
+ try {
287
+ migration.preflight(execPath);
288
+ migration.stagedPreflight(stagedPath);
230
289
  }
231
- else {
232
- // Unix: write to temp file, chmod +x, atomic rename
233
- const tmpPath = path.join(execDir, `.${execName}.tmp.${process.pid}`);
234
- const bakPath = `${execPath}.bak`;
235
- try {
236
- fs.writeFileSync(tmpPath, binaryData);
237
- fs.chmodSync(tmpPath, 0o755);
238
- }
239
- catch (err) {
240
- // Clean up temp file on failure
241
- try {
242
- fs.unlinkSync(tmpPath);
243
- }
244
- catch {
245
- /* ignore */
246
- }
247
- const code = err.code;
248
- if (code === "EACCES" || code === "EPERM") {
249
- throw new Error(`Permission denied writing to ${execDir}.\n` +
250
- `Run: sudo akm upgrade\n` +
251
- `Or re-run the install script: curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | bash`);
252
- }
253
- throw err;
254
- }
255
- // Backup current, then atomic rename
256
- try {
257
- fs.copyFileSync(execPath, bakPath);
258
- fs.renameSync(tmpPath, execPath);
259
- }
260
- catch (err) {
261
- // Restore from backup if rename failed
262
- try {
263
- fs.unlinkSync(tmpPath);
264
- }
265
- catch {
266
- /* ignore */
267
- }
268
- try {
269
- if (fs.existsSync(bakPath) && !fs.existsSync(execPath)) {
270
- fs.renameSync(bakPath, execPath);
271
- }
272
- }
273
- catch {
274
- /* ignore */
275
- }
276
- throw err;
277
- }
278
- // Cleanup backup
279
- try {
280
- fs.unlinkSync(bakPath);
281
- }
282
- catch {
283
- /* ignore */
290
+ catch (err) {
291
+ removeFileBestEffort(stagedPath);
292
+ throw err;
293
+ }
294
+ if (fs.existsSync(backupPath)) {
295
+ removeFileBestEffort(stagedPath);
296
+ throw new Error(`Refusing to overwrite retained previous binary at ${backupPath}.`);
297
+ }
298
+ try {
299
+ if (IS_WINDOWS)
300
+ fs.renameSync(execPath, backupPath);
301
+ else
302
+ fs.copyFileSync(execPath, backupPath);
303
+ fs.renameSync(stagedPath, execPath);
304
+ }
305
+ catch (err) {
306
+ removeFileBestEffort(stagedPath);
307
+ if (!fs.existsSync(execPath) && fs.existsSync(backupPath)) {
308
+ fs.renameSync(backupPath, execPath);
284
309
  }
310
+ throw err;
311
+ }
312
+ try {
313
+ migration.apply(execPath);
314
+ }
315
+ catch (err) {
316
+ const detail = err instanceof Error ? err.message : String(err);
317
+ throw new Error(`Migration apply failed; the new binary remains installed and the previous binary retained at ${backupPath}: ${detail}`);
285
318
  }
319
+ // Keep the previous binary available until migration apply has completed.
320
+ removeFileBestEffort(backupPath);
286
321
  return {
287
322
  currentVersion,
288
323
  newVersion: latestVersion,
@@ -290,32 +325,19 @@ export async function performUpgrade(check, opts) {
290
325
  installMethod,
291
326
  binaryPath: execPath,
292
327
  checksumVerified,
293
- // For binary installs, the new binary now lives at execPath; spawn it
294
- // directly so the post-upgrade work runs against the new code.
295
328
  postUpgrade: runPostUpgradeTasks(execPath, { skip: skipPostUpgrade }),
296
329
  };
297
330
  }
298
331
  /**
299
- * Run the post-upgrade tasks against the *new* binary as a child process.
300
- *
301
- * Why a child process: the running akm process still has the old code in
302
- * memory. Calling loadConfig()/akmIndex() in-process would use the old
303
- * implementations and miss any DB_VERSION / config-key changes the new
304
- * release introduces.
305
- *
306
- * The new binary's `akm index` does the work for us:
307
- * 1. loadConfig() runs at startup — auto-migrates legacy `stashes` →
308
- * `sources` if the on-disk config still uses the old key.
309
- * 2. ensureSchema() converges index.db forward via its idempotent baseline
310
- * schema + additive migrations (no destructive rebuild).
311
- * 3. The full reindex repopulates entries + workflow_documents + FTS.
332
+ * Rebuild the derived index after the explicit migration command succeeds.
333
+ * Migration and indexing are intentionally separate lifecycle steps.
312
334
  */
313
335
  function runPostUpgradeTasks(akmBin, opts) {
314
336
  if (opts.skip) {
315
337
  return {
316
338
  ok: true,
317
339
  skipped: true,
318
- message: "Skipped post-upgrade tasks. Run `akm index` manually to migrate config and rebuild the index.",
340
+ message: "Migration completed; skipped the index rebuild. Run `akm index` manually to rebuild the index.",
319
341
  };
320
342
  }
321
343
  try {
@@ -328,7 +350,7 @@ function runPostUpgradeTasks(akmBin, opts) {
328
350
  return {
329
351
  ok: false,
330
352
  skipped: false,
331
- message: `Post-upgrade tasks could not start: ${result.error.message}. Run \`akm index\` manually.`,
353
+ message: `Migration completed, but the index rebuild could not start: ${result.error.message}. Run \`akm index\` manually.`,
332
354
  };
333
355
  }
334
356
  if (result.status !== 0) {
@@ -337,14 +359,14 @@ function runPostUpgradeTasks(akmBin, opts) {
337
359
  ok: false,
338
360
  skipped: false,
339
361
  exitCode: result.status,
340
- message: `Post-upgrade \`akm index\` failed (${detail}). Run \`akm index\` manually.`,
362
+ message: `Migration completed, but post-upgrade \`akm index\` failed (${detail}). Run \`akm index\` manually.`,
341
363
  };
342
364
  }
343
365
  return {
344
366
  ok: true,
345
367
  skipped: false,
346
368
  exitCode: 0,
347
- message: "Config migrated (if needed) and index rebuilt against the new binary.",
369
+ message: "Migration completed and the index was rebuilt against the new binary.",
348
370
  };
349
371
  }
350
372
  catch (err) {
@@ -352,10 +374,32 @@ function runPostUpgradeTasks(akmBin, opts) {
352
374
  return {
353
375
  ok: false,
354
376
  skipped: false,
355
- message: `Post-upgrade tasks failed: ${detail}. Run \`akm index\` manually.`,
377
+ message: `Migration completed, but the index rebuild failed: ${detail}. Run \`akm index\` manually.`,
356
378
  };
357
379
  }
358
380
  }
381
+ function runRequiredCommand(akmBin, args, label) {
382
+ const result = childProcess.spawnSync(akmBin, args, {
383
+ encoding: "utf8",
384
+ env: process.env,
385
+ stdio: "pipe",
386
+ });
387
+ if (result.error) {
388
+ throw new Error(`${label} could not start: ${result.error.message}`);
389
+ }
390
+ if (result.status !== 0) {
391
+ const detail = (result.stderr ?? "").trim() || (result.stdout ?? "").trim() || `exit code ${result.status}`;
392
+ throw new Error(`${label} failed (${detail}).`);
393
+ }
394
+ }
395
+ function removeFileBestEffort(filePath) {
396
+ try {
397
+ fs.unlinkSync(filePath);
398
+ }
399
+ catch {
400
+ // Cleanup is best-effort; the primary operation determines success.
401
+ }
402
+ }
359
403
  function parseChecksumForFile(checksumsText, filename) {
360
404
  for (const line of checksumsText.split("\n")) {
361
405
  const trimmed = line.trim();
@@ -115,9 +115,13 @@ export const upgradeCommand = defineJsonCommand({
115
115
  },
116
116
  "skip-post-upgrade": {
117
117
  type: "boolean",
118
- description: "Skip the post-upgrade `akm index` rebuild (config auto-migration still runs on next `akm` invocation)",
118
+ description: "Skip the post-upgrade index rebuild (migration preflight and apply still run)",
119
119
  default: false,
120
120
  },
121
+ "migration-config": {
122
+ type: "string",
123
+ description: "For 0.9+ upgrades, pass an operator-prepared config only to the new binary's migration apply",
124
+ },
121
125
  },
122
126
  async run({ args }) {
123
127
  const check = await checkForUpdate(pkgVersion);
@@ -127,7 +131,8 @@ export const upgradeCommand = defineJsonCommand({
127
131
  }
128
132
  const skipChecksum = args["skip-checksum"];
129
133
  const skipPostUpgrade = args["skip-post-upgrade"];
130
- const result = await performUpgrade(check, { force: args.force, skipChecksum, skipPostUpgrade });
134
+ const migrationConfig = args["migration-config"];
135
+ const result = await performUpgrade(check, { force: args.force, skipChecksum, skipPostUpgrade, migrationConfig });
131
136
  output("upgrade", result);
132
137
  },
133
138
  });
@@ -67,12 +67,12 @@ export const DEFAULT_IMPROVE_TASKS = [
67
67
  },
68
68
  ];
69
69
  /**
70
- * A schedule for the manual catch-up task. The scheduler requires a valid
71
- * cron expression even when the task is registered disabled, so we give the
72
- * unscheduled task a nominal far-future-ish cadence and leave it disabled
73
- * the documented entry point is `akm tasks run akm-improve-catchup`.
70
+ * A schedule for the manual catch-up task. The scheduler requires a portable
71
+ * expression even when the task is registered disabled, so we give it a
72
+ * nominal daily cadence and leave it disabled. The documented entry point is
73
+ * `akm tasks run akm-improve-catchup`.
74
74
  */
75
- const MANUAL_TASK_NOMINAL_SCHEDULE = "0 4 1 1 *"; // 04:00 on Jan 1 — never effectively fires
75
+ const MANUAL_TASK_NOMINAL_SCHEDULE = "0 4 * * *";
76
76
  const DEFAULT_DEPS = {
77
77
  list: akmTasksList,
78
78
  add: akmTasksAdd,
@@ -136,11 +136,15 @@ const tasksRunCommand = defineCommand({
136
136
  name: "run",
137
137
  description: "Execute a task now (this is what cron / launchd / schtasks invoke at the scheduled time)",
138
138
  },
139
- args: { id: { type: "positional", description: "Task id", required: true } },
139
+ args: {
140
+ id: { type: "positional", description: "Task id", required: true },
141
+ scheduled: { type: "boolean", description: "Internal marker for scheduler-generated runs", default: false },
142
+ },
140
143
  async run({ args }) {
141
144
  await runWithJsonErrors(async () => {
142
- const { id } = parseTaskRef(args.id);
143
- const envelope = await akmTasksRun(id);
145
+ const envelope = await akmTasksRun(args.id, {
146
+ scheduled: args.scheduled === true,
147
+ });
144
148
  output("tasks-run", envelope);
145
149
  if (envelope.exitCode !== 0)
146
150
  process.exit(envelope.exitCode);