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

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 (88) hide show
  1. package/CHANGELOG.md +34 -8
  2. package/dist/assets/improve-strategies/catchup.json +3 -1
  3. package/dist/assets/improve-strategies/consolidate.json +3 -1
  4. package/dist/assets/improve-strategies/frequent.json +3 -1
  5. package/dist/assets/improve-strategies/graph-refresh.json +3 -1
  6. package/dist/assets/improve-strategies/memory-focus.json +4 -1
  7. package/dist/assets/improve-strategies/proactive-maintenance.json +1 -0
  8. package/dist/assets/improve-strategies/quick.json +3 -1
  9. package/dist/assets/improve-strategies/recombine-only.json +2 -0
  10. package/dist/assets/improve-strategies/reflect-distill.json +1 -0
  11. package/dist/assets/improve-strategies/synthesize.json +2 -0
  12. package/dist/assets/tasks/core/backup.yml +2 -2
  13. package/dist/cli/config-migrate.js +554 -26
  14. package/dist/cli.js +3 -1
  15. package/dist/commands/backup-cli.js +6 -4
  16. package/dist/commands/config-cli.js +10 -3
  17. package/dist/commands/feedback-cli.js +24 -7
  18. package/dist/commands/health/checks.js +94 -15
  19. package/dist/commands/health/surfaces.js +2 -1
  20. package/dist/commands/improve/anti-collapse.js +3 -3
  21. package/dist/commands/improve/collapse-detector.js +5 -5
  22. package/dist/commands/improve/consolidate.js +123 -66
  23. package/dist/commands/improve/distill/promote-memory.js +8 -6
  24. package/dist/commands/improve/distill/quality-gate.js +1 -1
  25. package/dist/commands/improve/distill-guards.js +1 -1
  26. package/dist/commands/improve/distill-promotion-policy.js +32 -26
  27. package/dist/commands/improve/distill.js +52 -36
  28. package/dist/commands/improve/eligibility.js +9 -8
  29. package/dist/commands/improve/extract-prompt.js +2 -2
  30. package/dist/commands/improve/extract.js +43 -11
  31. package/dist/commands/improve/improve-auto-accept.js +14 -28
  32. package/dist/commands/improve/improve-cli.js +4 -2
  33. package/dist/commands/improve/improve-strategies.js +2 -1
  34. package/dist/commands/improve/improve.js +49 -8
  35. package/dist/commands/improve/loop-stages.js +11 -6
  36. package/dist/commands/improve/preparation.js +51 -17
  37. package/dist/commands/improve/procedural.js +10 -6
  38. package/dist/commands/improve/recombine.js +31 -7
  39. package/dist/commands/improve/reflect.js +24 -14
  40. package/dist/commands/improve/salience.js +5 -1
  41. package/dist/commands/improve/source-identity.js +43 -0
  42. package/dist/commands/migrate-cli.js +36 -0
  43. package/dist/commands/mv-cli.js +647 -258
  44. package/dist/commands/proposal/drain.js +16 -4
  45. package/dist/commands/proposal/proposal-cli.js +3 -2
  46. package/dist/commands/proposal/proposal.js +16 -55
  47. package/dist/commands/proposal/repository.js +664 -58
  48. package/dist/commands/read/curate.js +4 -2
  49. package/dist/commands/read/knowledge.js +4 -1
  50. package/dist/commands/read/search.js +6 -2
  51. package/dist/commands/read/show.js +8 -7
  52. package/dist/commands/sources/self-update.js +156 -112
  53. package/dist/commands/sources/sources-cli.js +7 -2
  54. package/dist/core/common.js +36 -3
  55. package/dist/core/config/config-io.js +2 -2
  56. package/dist/core/config/config-schema.js +15 -3
  57. package/dist/core/config/config.js +14 -13
  58. package/dist/core/file-lock.js +2 -2
  59. package/dist/core/migration-backup.js +816 -171
  60. package/dist/core/migration-operation.js +44 -0
  61. package/dist/core/state/migrations.js +4 -7
  62. package/dist/core/state-db.js +22 -7
  63. package/dist/core/write-source.js +86 -18
  64. package/dist/indexer/db/db.js +109 -53
  65. package/dist/indexer/index-writer-lock.js +38 -37
  66. package/dist/indexer/index-written-assets.js +73 -56
  67. package/dist/indexer/indexer.js +5 -1
  68. package/dist/indexer/search/search-source.js +2 -2
  69. package/dist/indexer/usage/usage-events.js +8 -2
  70. package/dist/integrations/agent/engine-resolution.js +14 -7
  71. package/dist/scripts/migrate-storage.js +867 -990
  72. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +405 -566
  73. package/dist/setup/detected-engines.js +7 -13
  74. package/dist/setup/engine-config.js +14 -4
  75. package/dist/setup/setup.js +1 -1
  76. package/dist/setup/steps/connection.js +1 -1
  77. package/dist/setup/steps/tasks.js +1 -1
  78. package/dist/sources/providers/git-stash.js +58 -21
  79. package/dist/sources/providers/git.js +1 -1
  80. package/dist/storage/engines/sqlite-migrations.js +138 -3
  81. package/dist/storage/repositories/events-repository.js +24 -0
  82. package/dist/storage/repositories/proposals-repository.js +14 -0
  83. package/dist/tasks/embedded.js +2 -0
  84. package/dist/workflows/db.js +24 -13
  85. package/dist/workflows/validator.js +24 -2
  86. package/docs/migration/release-notes/0.9.0.md +24 -7
  87. package/docs/migration/v0.8-to-v0.9.md +124 -19
  88. package/package.json +1 -1
@@ -1,8 +1,6 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
- import { createHash } from "node:crypto";
5
- import { ConfigError } from "../core/errors.js";
6
4
  const CHAT_SUFFIX = "/chat/completions";
7
5
  export function normalizeChatCompletionsEndpoint(input) {
8
6
  const url = new URL(input);
@@ -84,19 +82,13 @@ function availableName(config, preferred, fingerprint) {
84
82
  return name;
85
83
  }
86
84
  const base = slug(preferred);
87
- const expectedKind = fingerprint.startsWith("llm:") ? "llm" : "agent";
88
- const baseEngine = config.engines?.[base];
89
- if (baseEngine && baseEngine.kind !== expectedKind) {
90
- throw new ConfigError(`Detected ${expectedKind} engine name ${JSON.stringify(base)} conflicts with configured ${baseEngine.kind} engine of the same name. Rename one engine before rerunning setup.`, "INVALID_CONFIG_FILE");
91
- }
92
85
  if (!config.engines?.[base])
93
86
  return base;
94
- const suffix = createHash("sha256").update(fingerprint).digest("hex").slice(0, 8);
95
- const candidate = `${base}-${suffix}`;
96
- const collision = config.engines?.[candidate];
97
- if (!collision || engineFingerprint(collision) === fingerprint)
98
- return candidate;
99
- throw new ConfigError(`Detected engine identity collision at ${JSON.stringify(candidate)}. Choose an explicit engine name before rerunning setup.`, "INVALID_CONFIG_FILE");
87
+ for (let suffix = 2;; suffix++) {
88
+ const candidate = `${base}-${suffix}`;
89
+ if (!config.engines?.[candidate])
90
+ return candidate;
91
+ }
100
92
  }
101
93
  /** Insert or reuse an LLM engine by canonical endpoint without modifying a match. */
102
94
  export function upsertDetectedLlmEngine(config, candidate) {
@@ -123,6 +115,8 @@ export function upsertDetectedLlmEngine(config, candidate) {
123
115
  (config.engines?.[currentDefault] && engineFingerprint(config.engines[currentDefault]) === fingerprint)) {
124
116
  defaults.llmEngine = name;
125
117
  }
118
+ if (!defaults.engine)
119
+ defaults.engine = defaults.llmEngine ?? name;
126
120
  return { config: { ...config, engines, defaults }, name, reused };
127
121
  }
128
122
  /** Insert or reuse an agent engine by canonical platform without modifying a match. */
@@ -35,6 +35,8 @@ export function writeLlmEngine(config, llm) {
35
35
  delete engines[name];
36
36
  const defaults = { ...(config.defaults ?? {}) };
37
37
  delete defaults.llmEngine;
38
+ if (defaults.engine === name)
39
+ delete defaults.engine;
38
40
  return { engines, defaults };
39
41
  }
40
42
  if (config.engines?.[name] && config.engines[name].kind !== "llm") {
@@ -54,7 +56,7 @@ export function writeLlmEngine(config, llm) {
54
56
  ...(capabilities?.structuredOutput !== undefined ? { supportsJsonSchema: capabilities.structuredOutput } : {}),
55
57
  },
56
58
  },
57
- defaults: { ...(config.defaults ?? {}), llmEngine: name },
59
+ defaults: { ...(config.defaults ?? {}), engine: config.defaults?.engine ?? name, llmEngine: name },
58
60
  };
59
61
  }
60
62
  /** Merge setup-selected agent engines without translating profile-shaped data. */
@@ -66,9 +68,17 @@ export function writeAgentEngines(config, selection) {
66
68
  }
67
69
  }
68
70
  const defaults = { ...(config.defaults ?? {}) };
69
- if (!selection?.default)
70
- delete defaults.engine;
71
- else
71
+ if (selection?.disabled) {
72
+ const currentDefault = defaults.engine;
73
+ if (currentDefault && config.engines?.[currentDefault]?.kind === "agent") {
74
+ const llmDefault = defaults.llmEngine;
75
+ if (llmDefault && config.engines?.[llmDefault]?.kind === "llm")
76
+ defaults.engine = llmDefault;
77
+ else
78
+ delete defaults.engine;
79
+ }
80
+ }
81
+ if (selection?.default)
72
82
  defaults.engine = selection.default;
73
83
  const engines = { ...(config.engines ?? {}), ...(selection?.engines ?? {}) };
74
84
  if (selection?.default && engines[selection.default] && engines[selection.default].kind !== "agent") {
@@ -430,7 +430,7 @@ export async function runSetupWizard(opts) {
430
430
  const registries = newConfig.registries;
431
431
  const allStashes = newConfig.sources ?? [];
432
432
  // Feature capability summary
433
- const agentConfigured = Boolean(agentConfig);
433
+ const agentConfigured = Boolean(agentConfig && !agentConfig.disabled);
434
434
  printCapabilitySummary(smallModelResult.skipped, agentConfigured);
435
435
  // Confirm before saving
436
436
  const effectiveRegistries = registries ?? DEFAULT_CONFIG.registries ?? [];
@@ -653,7 +653,7 @@ export async function stepAgentConnection(current, smallModel) {
653
653
  "",
654
654
  "You can configure this later with `akm setup`.",
655
655
  ].join("\n"), "Warning");
656
- return undefined;
656
+ return { disabled: true };
657
657
  }
658
658
  if (agentChoice === "same-connection") {
659
659
  if (smallModel.skipped || !smallModel.llm) {
@@ -56,7 +56,7 @@ const DEFAULT_SCHEDULED_TASKS_DEPS = {
56
56
  gitSync: saveGitStash,
57
57
  };
58
58
  export async function stepScheduledTasks(deps = DEFAULT_SCHEDULED_TASKS_DEPS) {
59
- const embedded = listEmbeddedTasks();
59
+ const embedded = listEmbeddedTasks().filter((task) => task.enabled);
60
60
  if (embedded.length === 0)
61
61
  return;
62
62
  // Snapshot current state so we can diff against the user's selection.
@@ -22,6 +22,27 @@ import { getCachePaths, parseGitRepoUrl } from "./git-provider.js";
22
22
  export function isGitBackedStash(stashDir) {
23
23
  return fs.existsSync(path.join(stashDir, ".git"));
24
24
  }
25
+ /** Return repo-relative dirty/staged paths without changing the index. */
26
+ export function listGitChangedPaths(repoDir) {
27
+ const result = runGit(["-C", repoDir, "status", "--porcelain", "-z", "--untracked-files=all"]);
28
+ if (result.status !== 0)
29
+ return [];
30
+ const records = result.stdout.split("\0");
31
+ const paths = [];
32
+ for (let i = 0; i < records.length; i++) {
33
+ const record = records[i];
34
+ if (!record)
35
+ continue;
36
+ const status = record.slice(0, 2);
37
+ paths.push(record.slice(3));
38
+ if (status.includes("R") || status.includes("C")) {
39
+ const previousPath = records[++i];
40
+ if (previousPath)
41
+ paths.push(previousPath);
42
+ }
43
+ }
44
+ return paths;
45
+ }
25
46
  /**
26
47
  * Resolve the writable-override flag for an end-of-run / `akm sync` commit on
27
48
  * the primary stash. Returns `true` when the root config explicitly marks the
@@ -110,16 +131,25 @@ export function saveGitStash(name, message, writableOverride, options) {
110
131
  // 2. Managed pathspecs (TYPE_DIRS values + `.akm`) that exist on disk —
111
132
  // stages everything akm owns and, by construction, never stages non-akm
112
133
  // WIP. This preserves the #476 protection WITHOUT refusing.
113
- // 3. Last-resort `git add -A`ONLY when neither an explicit list nor any
114
- // managed pathspec can be resolved. This is the maintainer-approved
115
- // "or all files if we cannot determine the exact file list" fallback and
116
- // is the one (rare) path that could include unrelated non-akm files.
117
- if (!stageScopedChanges(repoDir, options?.paths)) {
134
+ // 3. No resolvable managed pathno commit. Broad staging is never safe
135
+ // because it can absorb unrelated work already present in the index.
136
+ const staged = stageScopedChanges(repoDir, options?.paths);
137
+ if (!staged.ok) {
118
138
  throw new Error(`git add failed while staging akm changes in ${repoDir}`);
119
139
  }
140
+ if (staged.pathspecs && staged.pathspecs.length === 0) {
141
+ return { committed: false, pushed: false, skipped: false, output: "nothing to commit" };
142
+ }
120
143
  // Nothing actually staged → don't create an empty commit. This happens when
121
144
  // only non-akm files were dirty (precedence 2 staged nothing).
122
- const stagedResult = runGit(["-C", repoDir, "diff", "--cached", "--quiet"]);
145
+ const stagedResult = runGit([
146
+ "-C",
147
+ repoDir,
148
+ "diff",
149
+ "--cached",
150
+ "--quiet",
151
+ ...(staged.pathspecs ? ["--", ...staged.pathspecs] : []),
152
+ ]);
123
153
  if (stagedResult.status === 0) {
124
154
  return { committed: false, pushed: false, skipped: false, output: "nothing to commit" };
125
155
  }
@@ -135,6 +165,7 @@ export function saveGitStash(name, message, writableOverride, options) {
135
165
  "commit",
136
166
  "-m",
137
167
  commitMessage,
168
+ ...(staged.pathspecs ? ["--only", "--", ...staged.pathspecs] : []),
138
169
  ]);
139
170
  if (commitResult.status !== 0) {
140
171
  throw new Error(`git commit failed: ${commitResult.stderr?.trim() || "unknown error"}`);
@@ -161,34 +192,40 @@ export function saveGitStash(name, message, writableOverride, options) {
161
192
  }
162
193
  /**
163
194
  * Stage akm's changes in `repoDir` using the scoped-staging precedence
164
- * documented at the call site (#476). Returns `false` only when a `git add`
165
- * subprocess fails; returns `true` otherwise (including when nothing matched —
166
- * the caller then detects "nothing staged" via `git diff --cached --quiet`).
195
+ * documented at the call site (#476). The returned pathspecs are reduced to
196
+ * exact staged files so `git commit --only` cannot absorb unrelated index state.
167
197
  *
168
198
  * @param paths Optional explicit repo-relative paths akm wrote this run. When
169
199
  * provided and non-empty, exactly those are staged (chunked to stay under
170
200
  * argv length limits). Otherwise we fall back to the managed pathspecs, and
171
- * finally to `git add -A` only if no managed pathspec exists on disk.
201
+ * skip the commit when no managed pathspec exists on disk.
172
202
  */
173
203
  function stageScopedChanges(repoDir, paths) {
204
+ if (paths !== undefined && paths.length === 0)
205
+ return { ok: true, pathspecs: [] };
174
206
  // Precedence 1: explicit modified-file list.
175
207
  const explicit = (paths ?? []).filter((p) => typeof p === "string" && p.length > 0);
176
208
  if (explicit.length > 0) {
177
- return addPathspecsChunked(repoDir, explicit);
209
+ const ok = addPathspecsChunked(repoDir, explicit);
210
+ return { ok, pathspecs: ok ? listStagedPaths(repoDir, explicit) : [] };
178
211
  }
179
212
  // Precedence 2: managed pathspecs that exist on disk (TYPE_DIRS + `.akm`).
180
213
  const managed = [...Object.values(TYPE_DIRS), ".akm"].filter((dir) => fs.existsSync(path.join(repoDir, dir)));
181
214
  if (managed.length > 0) {
182
- return addPathspecsChunked(repoDir, managed);
183
- }
184
- // Precedence 3 (last resort): nothing akm-managed resolved on disk. Stage
185
- // everything. This is the ONLY branch that can include unrelated non-akm
186
- // files it is the explicit, maintainer-approved "or all files if we cannot
187
- // determine the exact file list" fallback and should be rare/never in
188
- // practice (a git-backed stash always has at least one managed subtree once
189
- // akm has written to it).
190
- const addAll = runGit(["-C", repoDir, "add", "-A"]);
191
- return addAll.status === 0;
215
+ const ok = addPathspecsChunked(repoDir, managed);
216
+ return { ok, pathspecs: ok ? listStagedPaths(repoDir, managed) : [] };
217
+ }
218
+ // No managed target means there is no safe commit scope.
219
+ return { ok: true, pathspecs: [] };
220
+ }
221
+ function listStagedPaths(repoDir, pathspecs) {
222
+ const result = runGit(["-C", repoDir, "diff", "--cached", "--name-only", "--", ...pathspecs]);
223
+ if (result.status !== 0)
224
+ return [];
225
+ return result.stdout
226
+ .split("\n")
227
+ .map((value) => value.trim())
228
+ .filter(Boolean);
192
229
  }
193
230
  /**
194
231
  * Run `git add -- <pathspec>...` in chunks so a very large path list never
@@ -10,4 +10,4 @@
10
10
  // keeps importing from a single module namespace.
11
11
  export { classifyCloneFailure, cloneRepo, syncRegistryGitRef } from "./git-install.js";
12
12
  export { ensureGitMirror, GitSourceProvider, getCachePaths, parseGitRepoUrl, syncMirroredRepo, } from "./git-provider.js";
13
- export { isGitBackedStash, resolveWritableOverride, saveGitStash, } from "./git-stash.js";
13
+ export { isGitBackedStash, listGitChangedPaths, resolveWritableOverride, saveGitStash, } from "./git-stash.js";
@@ -1,6 +1,115 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * Shared SQLite migration engine.
6
+ *
7
+ * state.db (`src/core/state-db.ts`) and workflow.db (`src/workflows/db.ts`)
8
+ * both evolve their schema through an identical, idempotent, transaction-per-
9
+ * migration runner backed by a `schema_migrations` ledger. The two runners
10
+ * were byte-identical except for ONE line: workflow.db must back-fill a
11
+ * `schema_migrations` row for pre-versioning databases before evaluating the
12
+ * migration list (see `bootstrapPreVersioningDb` in workflows/db.ts).
13
+ *
14
+ * This module factors that runner out once. Each DB module supplies only its
15
+ * own `MIGRATIONS` array; workflow.db additionally passes a `bootstrap` hook.
16
+ *
17
+ * Migration-safety contract:
18
+ * - `id` is permanent and must never be reused.
19
+ * - `up` must be idempotent (use IF NOT EXISTS, INSERT OR IGNORE, etc.).
20
+ * - `up` must not DROP any table that holds durable (non-regenerable) data.
21
+ * - `up` must not RENAME or change the type of an existing column.
22
+ * - To add a column: use `ALTER TABLE … ADD COLUMN … DEFAULT …`.
23
+ * - Applied IDs must be an exact ordered prefix of the registry.
24
+ * - Released migration bodies are sealed by SHA-256 in the same ledger.
25
+ */
26
+ import crypto from "node:crypto";
27
+ export function migrationChecksum(migration) {
28
+ return crypto.createHash("sha256").update(migration.id).update("\0").update(migration.up).digest("hex");
29
+ }
30
+ function migrationsTableExists(db) {
31
+ return !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'").get();
32
+ }
33
+ function ledgerHasChecksum(db) {
34
+ return db.prepare("PRAGMA table_info(schema_migrations)").all().some((column) => column.name === "checksum");
35
+ }
36
+ export function inspectMigrationLedger(db, migrations) {
37
+ const registryIds = migrations.map((migration) => migration.id);
38
+ if (new Set(registryIds).size !== registryIds.length) {
39
+ return {
40
+ status: "inconsistent",
41
+ migrationIds: [],
42
+ checksums: [],
43
+ detail: "migration registry contains duplicate IDs",
44
+ };
45
+ }
46
+ if (!migrationsTableExists(db))
47
+ return { status: registryIds.length === 0 ? "current" : "old", migrationIds: [], checksums: [] };
48
+ const hasChecksum = ledgerHasChecksum(db);
49
+ const rows = db
50
+ .prepare(`SELECT id${hasChecksum ? ", checksum" : ""} FROM schema_migrations ORDER BY rowid`)
51
+ .all();
52
+ const migrationIds = rows.map((row) => row.id);
53
+ const checksums = rows.map((row) => row.checksum ?? null);
54
+ for (let index = 0; index < rows.length; index += 1) {
55
+ const expected = migrations[index];
56
+ const row = rows[index];
57
+ if (!expected) {
58
+ return { status: "newer", migrationIds, checksums, detail: `unknown migration ID ${row.id}` };
59
+ }
60
+ if (row.id !== expected.id) {
61
+ const knownLater = registryIds.includes(row.id);
62
+ return {
63
+ status: knownLater ? "inconsistent" : "newer",
64
+ migrationIds,
65
+ checksums,
66
+ detail: knownLater
67
+ ? `migration ledger is not an exact ordered prefix at position ${index + 1}`
68
+ : `unknown migration ID ${row.id}`,
69
+ };
70
+ }
71
+ const expectedChecksum = migrationChecksum(expected);
72
+ if (row.checksum && row.checksum !== expectedChecksum) {
73
+ return {
74
+ status: "inconsistent",
75
+ migrationIds,
76
+ checksums,
77
+ detail: `migration ${row.id} checksum does not match the released migration body`,
78
+ };
79
+ }
80
+ }
81
+ const missingChecksum = checksums.indexOf(null);
82
+ if (missingChecksum >= 0) {
83
+ return {
84
+ status: "old",
85
+ migrationIds,
86
+ checksums,
87
+ detail: `migration ${migrationIds[missingChecksum]} has not been sealed with a checksum`,
88
+ };
89
+ }
90
+ return {
91
+ status: rows.length === migrations.length ? "current" : "old",
92
+ migrationIds,
93
+ checksums,
94
+ };
95
+ }
96
+ export function assertMigrationLedger(db, migrations) {
97
+ const state = inspectMigrationLedger(db, migrations);
98
+ if (state.status === "newer") {
99
+ throw new Error(`Refusing to open a database with a newer migration ledger: ${state.detail}.`);
100
+ }
101
+ if (state.status === "inconsistent") {
102
+ throw new Error(`Refusing a database whose migrations are not an exact ordered prefix: ${state.detail}.`);
103
+ }
104
+ return state;
105
+ }
106
+ export function assertCurrentMigrationLedger(db, migrations) {
107
+ const state = assertMigrationLedger(db, migrations);
108
+ if (state.status !== "current") {
109
+ throw new Error(`Refusing to open an obsolete writable schema; run \`akm migrate apply\`: ${state.detail ?? "pending migrations"}.`);
110
+ }
111
+ return state;
112
+ }
4
113
  /**
5
114
  * Create the migrations ledger table if it does not exist. Must be called
6
115
  * unconditionally on every open so a fresh database bootstraps correctly.
@@ -9,9 +118,12 @@ export function ensureMigrationsTable(db) {
9
118
  db.exec(`
10
119
  CREATE TABLE IF NOT EXISTS schema_migrations (
11
120
  id TEXT PRIMARY KEY,
12
- applied_at TEXT NOT NULL DEFAULT (datetime('now'))
121
+ applied_at TEXT NOT NULL DEFAULT (datetime('now')),
122
+ checksum TEXT
13
123
  );
14
124
  `);
125
+ if (!ledgerHasChecksum(db))
126
+ db.exec("ALTER TABLE schema_migrations ADD COLUMN checksum TEXT");
15
127
  }
16
128
  /**
17
129
  * Apply every pending migration, one transaction per migration.
@@ -27,9 +139,32 @@ export function ensureMigrationsTable(db) {
27
139
  * @param opts Optional `bootstrap` hook (see {@link RunMigrationsOptions}).
28
140
  */
29
141
  export function runMigrations(db, migrations, opts) {
142
+ if (opts?.applyPending === false) {
143
+ assertMigrationLedger(db, migrations);
144
+ return;
145
+ }
146
+ if (migrationsTableExists(db))
147
+ assertMigrationLedger(db, migrations);
30
148
  ensureMigrationsTable(db);
31
149
  opts?.bootstrap?.(db);
32
- const appliedRows = db.prepare("SELECT id FROM schema_migrations").all();
150
+ const ledger = assertMigrationLedger(db, migrations);
151
+ db.transaction(() => {
152
+ const update = db.prepare("UPDATE schema_migrations SET checksum = ? WHERE id = ? AND checksum IS NULL");
153
+ for (let index = 0; index < ledger.migrationIds.length; index += 1) {
154
+ update.run(migrationChecksum(migrations[index]), ledger.migrationIds[index]);
155
+ }
156
+ if (opts?.generationMarker) {
157
+ db.exec(`
158
+ CREATE TABLE IF NOT EXISTS akm_migration_generation (
159
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
160
+ operation_id TEXT NOT NULL,
161
+ phase TEXT NOT NULL
162
+ )
163
+ `);
164
+ db.prepare("INSERT INTO akm_migration_generation(singleton, operation_id, phase) VALUES (1, ?, ?) ON CONFLICT(singleton) DO UPDATE SET operation_id=excluded.operation_id, phase=excluded.phase").run(opts.generationMarker.operationId, opts.generationMarker.phase);
165
+ }
166
+ })();
167
+ const appliedRows = db.prepare("SELECT id FROM schema_migrations ORDER BY rowid").all();
33
168
  const applied = new Set(appliedRows.map((r) => r.id));
34
169
  for (const migration of migrations) {
35
170
  if (applied.has(migration.id))
@@ -37,7 +172,7 @@ export function runMigrations(db, migrations, opts) {
37
172
  opts?.beforeMigration?.(migration);
38
173
  db.transaction(() => {
39
174
  db.exec(migration.up);
40
- db.prepare("INSERT INTO schema_migrations (id) VALUES (?)").run(migration.id);
175
+ db.prepare("INSERT INTO schema_migrations (id, checksum) VALUES (?, ?)").run(migration.id, migrationChecksum(migration));
41
176
  })();
42
177
  }
43
178
  }
@@ -51,6 +51,30 @@ export function insertEvent(db, input) {
51
51
  return undefined;
52
52
  }
53
53
  }
54
+ /** Strict idempotent event insert for durable mutation journals. */
55
+ export function insertEventOnce(db, input) {
56
+ const rows = db
57
+ .prepare("SELECT id, metadata_json FROM events WHERE event_type = ? AND ref IS ? ORDER BY id")
58
+ .all(input.eventType, input.ref ?? null);
59
+ for (const row of rows) {
60
+ try {
61
+ const metadata = JSON.parse(row.metadata_json);
62
+ if (metadata[input.idempotencyMetadataKey ?? "proposalTransactionId"] === input.idempotencyKey)
63
+ return row.id;
64
+ }
65
+ catch {
66
+ // Corrupt unrelated event metadata cannot satisfy this idempotency key.
67
+ }
68
+ }
69
+ const result = db
70
+ .prepare(`INSERT INTO events (event_type, ts, ref, metadata_json)
71
+ VALUES (?, ?, ?, ?)
72
+ RETURNING id`)
73
+ .get(input.eventType, input.ts, input.ref ?? null, JSON.stringify(input.metadata));
74
+ if (!result)
75
+ throw new Error(`Failed to persist ${input.eventType} event.`);
76
+ return result.id;
77
+ }
54
78
  /**
55
79
  * Read events from the database matching the filter. Returns events in
56
80
  * ascending id order so consumers can process them in emission order.
@@ -37,6 +37,10 @@ export function proposalRowToProposal(row) {
37
37
  ...(typeof meta.confidence === "number" ? { confidence: meta.confidence } : {}),
38
38
  ...(meta.gateDecision !== undefined ? { gateDecision: meta.gateDecision } : {}),
39
39
  ...(typeof meta.backupContent === "string" ? { backupContent: meta.backupContent } : {}),
40
+ ...(typeof meta.acceptedContentHash === "string" ? { acceptedContentHash: meta.acceptedContentHash } : {}),
41
+ ...(meta.acceptedTarget !== undefined ? { acceptedTarget: meta.acceptedTarget } : {}),
42
+ ...(meta.legacyAcceptedTargetDerived === true ? { legacyAcceptedTargetDerived: true } : {}),
43
+ ...(meta.legacyAcceptedAssetWasAbsent === true ? { legacyAcceptedAssetWasAbsent: true } : {}),
40
44
  ...(typeof meta.eligibilitySource === "string"
41
45
  ? { eligibilitySource: meta.eligibilitySource }
42
46
  : {}),
@@ -59,6 +63,16 @@ export function proposalToRowValues(proposal, stashDir) {
59
63
  metaObj.gateDecision = proposal.gateDecision;
60
64
  if (proposal.backupContent !== undefined)
61
65
  metaObj.backupContent = proposal.backupContent;
66
+ if (proposal.acceptedContentHash !== undefined)
67
+ metaObj.acceptedContentHash = proposal.acceptedContentHash;
68
+ if (proposal.acceptedTarget !== undefined)
69
+ metaObj.acceptedTarget = proposal.acceptedTarget;
70
+ if (proposal.legacyAcceptedTargetDerived !== undefined) {
71
+ metaObj.legacyAcceptedTargetDerived = proposal.legacyAcceptedTargetDerived;
72
+ }
73
+ if (proposal.legacyAcceptedAssetWasAbsent !== undefined) {
74
+ metaObj.legacyAcceptedAssetWasAbsent = proposal.legacyAcceptedAssetWasAbsent;
75
+ }
62
76
  if (proposal.eligibilitySource !== undefined)
63
77
  metaObj.eligibilitySource = proposal.eligibilitySource;
64
78
  return {
@@ -58,12 +58,14 @@ export function listEmbeddedTasks() {
58
58
  const command = typeof doc.command === "string" ? doc.command : "";
59
59
  const schedule = typeof doc.schedule === "string" ? doc.schedule : "";
60
60
  const description = typeof doc.description === "string" ? doc.description : "";
61
+ const enabled = doc.enabled !== false;
61
62
  tasks.push({
62
63
  id,
63
64
  label: `core/${id}`,
64
65
  command,
65
66
  schedule,
66
67
  description,
68
+ enabled,
67
69
  yaml,
68
70
  });
69
71
  }
@@ -4,10 +4,10 @@
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import { acquireMaintenanceActivitySync } from "../core/maintenance-barrier.js";
7
- import { ensureMigrationBackup, getMigrationBackupDir } from "../core/migration-backup.js";
7
+ import { assertNoPendingMigrationOperation } from "../core/migration-operation.js";
8
8
  import { getWorkflowDbPath } from "../core/paths.js";
9
9
  import { openDatabase } from "../storage/database.js";
10
- import { runMigrations as runSqliteMigrations } from "../storage/engines/sqlite-migrations.js";
10
+ import { assertCurrentMigrationLedger, assertMigrationLedger, runMigrations as runSqliteMigrations, } from "../storage/engines/sqlite-migrations.js";
11
11
  import { applyStandardPragmas } from "../storage/sqlite-pragmas.js";
12
12
  /**
13
13
  * workflow.db — Durable SQLite database for workflow run state.
@@ -45,24 +45,37 @@ import { applyStandardPragmas } from "../storage/sqlite-pragmas.js";
45
45
  // ── Public API ───────────────────────────────────────────────────────────────
46
46
  export function openWorkflowDatabase(dbPath = getWorkflowDbPath()) {
47
47
  const isCanonical = path.resolve(dbPath) === path.resolve(getWorkflowDbPath());
48
+ if (isCanonical)
49
+ assertNoPendingMigrationOperation();
48
50
  const releaseActivity = isCanonical ? acquireMaintenanceActivitySync("workflow-db") : undefined;
49
51
  let db;
50
52
  try {
51
- // Preserve an originally absent workflow.db in the recovery manifest. SQLite
52
- // creates the file at open time, so the cutover bundle must exist first.
53
- if (isCanonical && !fs.existsSync(getMigrationBackupDir()))
54
- ensureMigrationBackup();
53
+ if (isCanonical)
54
+ assertNoPendingMigrationOperation();
55
+ const existed = fs.existsSync(dbPath);
55
56
  const dir = path.dirname(dbPath);
56
57
  if (!fs.existsSync(dir)) {
57
58
  fs.mkdirSync(dir, { recursive: true });
58
59
  }
60
+ if (existed) {
61
+ const preflight = openDatabase(dbPath, { readonly: true });
62
+ try {
63
+ if (isCanonical)
64
+ assertCurrentMigrationLedger(preflight, WORKFLOW_MIGRATIONS);
65
+ else
66
+ assertMigrationLedger(preflight, WORKFLOW_MIGRATIONS);
67
+ }
68
+ finally {
69
+ preflight.close();
70
+ }
71
+ }
59
72
  db = openDatabase(dbPath);
60
73
  // #589: 30 s busy timeout, matching index.db / state.db. Without it the
61
74
  // default is 0 ms, so any concurrent writer fails immediately with
62
75
  // SQLITE_BUSY. #628: journal_mode is configurable via AKM_SQLITE_JOURNAL_MODE.
63
76
  applyStandardPragmas(db, { dataDir: dir });
64
77
  ensureBaseSchema(db);
65
- runMigrations(db, { ensureCutoverBackup: isCanonical });
78
+ runMigrations(db, { applyPending: !(isCanonical && existed) });
66
79
  if (!releaseActivity)
67
80
  return db;
68
81
  const openedDb = db;
@@ -156,7 +169,7 @@ function ensureBaseSchema(db) {
156
169
  * All workflow.db migrations in application order. New migrations are
157
170
  * APPENDED — never inserted in the middle or reordered.
158
171
  */
159
- const MIGRATIONS = [
172
+ export const WORKFLOW_MIGRATIONS = [
160
173
  // ── Migration 001 — add scope_key column ────────────────────────────────────
161
174
  //
162
175
  // Adds the `scope_key` column to `workflow_runs` so runs can be partitioned
@@ -392,11 +405,9 @@ function bootstrapPreVersioningDb(db) {
392
405
  * Called automatically by {@link openWorkflowDatabase}.
393
406
  */
394
407
  export function runMigrations(db, options) {
395
- runSqliteMigrations(db, MIGRATIONS, {
408
+ runSqliteMigrations(db, WORKFLOW_MIGRATIONS, {
396
409
  bootstrap: bootstrapPreVersioningDb,
397
- beforeMigration(migration) {
398
- if (options?.ensureCutoverBackup && migration.id === "010-ir-v3-engine")
399
- ensureMigrationBackup();
400
- },
410
+ applyPending: options?.applyPending,
411
+ generationMarker: options?.generationMarker,
401
412
  });
402
413
  }
@@ -8,15 +8,37 @@
8
8
  * the whole document or the raw frontmatter at once: duplicate step IDs,
9
9
  * step-id format, and the frontmatter key whitelist.
10
10
  */
11
+ import { parseAssetRef, refToString } from "../core/asset/asset-ref.js";
11
12
  import { utf8Bytes, WORKFLOW_MAX_INSTRUCTION_BYTES, WORKFLOW_MAX_PARAMS, WORKFLOW_MAX_STEPS } from "./resource-limits.js";
12
13
  const STEP_ID_REGEX = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
13
- const ALLOWED_FRONTMATTER_KEYS = new Set(["description", "tags", "params", "name", "updated", "when_to_use"]);
14
+ const ALLOWED_FRONTMATTER_KEYS = new Set(["description", "tags", "params", "name", "updated", "when_to_use", "xrefs"]);
14
15
  export function runSemanticChecks(draft, frontmatterData, frontmatterEndLine, errors) {
15
16
  checkFrontmatterKeys(frontmatterData, frontmatterEndLine, errors);
17
+ checkXrefs(frontmatterData.xrefs, frontmatterEndLine, errors);
16
18
  checkStepIdFormat(draft, errors);
17
19
  checkDuplicateStepIds(draft, errors);
18
20
  checkResourceLimits(draft, errors);
19
21
  }
22
+ function checkXrefs(value, line, errors) {
23
+ if (value === undefined)
24
+ return;
25
+ if (!Array.isArray(value)) {
26
+ errors.push({ line, message: 'Workflow frontmatter "xrefs" must be an array of canonical asset refs.' });
27
+ return;
28
+ }
29
+ for (const ref of value) {
30
+ try {
31
+ if (typeof ref !== "string" || refToString(parseAssetRef(ref)) !== ref)
32
+ throw new Error("non-canonical ref");
33
+ }
34
+ catch {
35
+ errors.push({
36
+ line,
37
+ message: `Workflow frontmatter "xrefs" contains an invalid or non-canonical ref: ${String(ref)}.`,
38
+ });
39
+ }
40
+ }
41
+ }
20
42
  function checkResourceLimits(draft, errors) {
21
43
  if (draft.steps.length > WORKFLOW_MAX_STEPS) {
22
44
  errors.push({ line: 1, message: `Workflow must contain at most ${WORKFLOW_MAX_STEPS} steps.` });
@@ -39,7 +61,7 @@ function checkFrontmatterKeys(data, fmEndLine, errors) {
39
61
  continue;
40
62
  errors.push({
41
63
  line: fmEndLine,
42
- message: `Workflow frontmatter "${key}" is not supported. Use only: description, tags, params, name, updated, when_to_use.`,
64
+ message: `Workflow frontmatter "${key}" is not supported. Use only: description, tags, params, name, updated, when_to_use, xrefs.`,
43
65
  });
44
66
  }
45
67
  }