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

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 (228) hide show
  1. package/CHANGELOG.md +223 -9
  2. package/SECURITY.md +23 -24
  3. package/dist/assets/help/help-improve.md +10 -10
  4. package/dist/assets/hints/cli-hints-full.md +44 -10
  5. package/dist/assets/hints/cli-hints-short.md +6 -2
  6. package/dist/assets/{profiles → improve-strategies}/default.json +1 -0
  7. package/dist/assets/{profiles → improve-strategies}/graph-refresh.json +1 -1
  8. package/dist/assets/{profiles → improve-strategies}/proactive-maintenance.json +2 -3
  9. package/dist/assets/{profiles → improve-strategies}/reflect-distill.json +3 -4
  10. package/dist/assets/stash-skeleton/README.md +28 -0
  11. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +6 -0
  12. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +6 -0
  13. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +12 -1
  14. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +11 -1
  15. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +6 -0
  16. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +9 -0
  17. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +7 -0
  18. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +7 -0
  19. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +6 -0
  20. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +98 -0
  21. package/dist/assets/stash-skeleton/facts/conventions/domains.md +63 -0
  22. package/dist/assets/stash-skeleton/facts/conventions/organization.md +127 -0
  23. package/dist/assets/tasks/core/backup.yml +1 -0
  24. package/dist/assets/tasks/core/extract.yml +1 -0
  25. package/dist/assets/tasks/core/improve.yml +1 -0
  26. package/dist/assets/tasks/core/index-refresh.yml +1 -0
  27. package/dist/assets/tasks/core/sync.yml +1 -0
  28. package/dist/assets/tasks/core/version-check.yml +1 -0
  29. package/dist/assets/tasks/graph-refresh-weekly.yml +4 -4
  30. package/dist/assets/templates/html/health.html +5 -1
  31. package/dist/cli/config-migrate.js +31 -138
  32. package/dist/cli/config-validate.js +10 -8
  33. package/dist/cli.js +48 -14
  34. package/dist/commands/agent/agent-dispatch.js +17 -16
  35. package/dist/commands/agent/agent-support.js +0 -24
  36. package/dist/commands/agent/contribute-cli.js +5 -15
  37. package/dist/commands/backup-cli.js +54 -0
  38. package/dist/commands/config-cli.js +45 -159
  39. package/dist/commands/env/secret.js +8 -5
  40. package/dist/commands/health/checks.js +130 -83
  41. package/dist/commands/health/html-report.js +4 -0
  42. package/dist/commands/health/improve-metrics.js +30 -32
  43. package/dist/commands/health/llm-usage.js +19 -19
  44. package/dist/commands/health/md-report.js +4 -0
  45. package/dist/commands/health/metrics.js +2 -1
  46. package/dist/commands/health/surfaces.js +5 -4
  47. package/dist/commands/health.js +1 -1
  48. package/dist/commands/improve/consolidate/chunking.js +2 -2
  49. package/dist/commands/improve/consolidate.js +28 -25
  50. package/dist/commands/improve/distill/promote-memory.js +5 -12
  51. package/dist/commands/improve/distill/quality-gate.js +5 -7
  52. package/dist/commands/improve/distill.js +16 -5
  53. package/dist/commands/improve/eligibility.js +22 -12
  54. package/dist/commands/improve/extract-cli.js +47 -19
  55. package/dist/commands/improve/extract.js +110 -62
  56. package/dist/commands/improve/improve-cli.js +38 -16
  57. package/dist/commands/improve/improve-result-file.js +30 -24
  58. package/dist/commands/improve/improve-strategies.js +137 -0
  59. package/dist/commands/improve/improve.js +60 -30
  60. package/dist/commands/improve/locks.js +66 -45
  61. package/dist/commands/improve/loop-stages.js +75 -33
  62. package/dist/commands/improve/memory/memory-belief.js +79 -7
  63. package/dist/commands/improve/memory/memory-contradiction-detect.js +12 -4
  64. package/dist/commands/improve/preparation.js +71 -73
  65. package/dist/commands/improve/procedural.js +3 -2
  66. package/dist/commands/improve/recombine.js +2 -1
  67. package/dist/commands/improve/reflect.js +119 -214
  68. package/dist/commands/improve/shared.js +11 -5
  69. package/dist/commands/lint/base-linter.js +152 -42
  70. package/dist/commands/mv-cli.js +809 -0
  71. package/dist/commands/proposal/proposal-cli.js +18 -8
  72. package/dist/commands/proposal/propose.js +64 -69
  73. package/dist/commands/read/knowledge.js +436 -4
  74. package/dist/commands/read/remember-cli.js +39 -2
  75. package/dist/commands/read/search-cli.js +6 -1
  76. package/dist/commands/registry-cli.js +29 -14
  77. package/dist/commands/remember.js +2 -0
  78. package/dist/commands/sources/init.js +13 -14
  79. package/dist/commands/sources/schema-repair.js +2 -4
  80. package/dist/commands/sources/source-add.js +62 -73
  81. package/dist/commands/sources/source-manage.js +50 -46
  82. package/dist/commands/sources/stash-cli.js +41 -4
  83. package/dist/commands/tasks/default-tasks.js +12 -12
  84. package/dist/commands/tasks/tasks-cli.js +7 -3
  85. package/dist/commands/tasks/tasks.js +113 -18
  86. package/dist/commands/wiki-cli.js +9 -10
  87. package/dist/core/asset/frontmatter.js +12 -2
  88. package/dist/core/common.js +5 -3
  89. package/dist/core/config/config-io.js +28 -17
  90. package/dist/core/config/config-schema.js +358 -66
  91. package/dist/core/config/config-types.js +3 -3
  92. package/dist/core/config/config-version.js +29 -0
  93. package/dist/core/config/config-walker.js +98 -27
  94. package/dist/core/config/config.js +132 -266
  95. package/dist/core/config/deep-merge.js +41 -0
  96. package/dist/core/config/engine-semantics.js +32 -0
  97. package/dist/core/errors.js +2 -2
  98. package/dist/core/extra-params.js +61 -0
  99. package/dist/core/file-lock.js +201 -56
  100. package/dist/core/improve-result.js +178 -0
  101. package/dist/core/maintenance-barrier.js +119 -0
  102. package/dist/core/migration-backup.js +416 -0
  103. package/dist/core/paths.js +3 -0
  104. package/dist/core/redaction.js +358 -0
  105. package/dist/core/state/migrations.js +17 -2
  106. package/dist/core/state-db.js +44 -1
  107. package/dist/indexer/db/db.js +116 -1
  108. package/dist/indexer/graph/graph-extraction.js +28 -16
  109. package/dist/indexer/index-writer-lock.js +31 -24
  110. package/dist/indexer/index-written-assets.js +15 -6
  111. package/dist/indexer/indexer.js +47 -2
  112. package/dist/indexer/passes/memory-inference.js +10 -6
  113. package/dist/indexer/passes/metadata.js +250 -0
  114. package/dist/indexer/search/db-search.js +111 -44
  115. package/dist/indexer/search/fts-query.js +41 -0
  116. package/dist/indexer/search/ranking-contributors.js +48 -0
  117. package/dist/indexer/search/ranking.js +36 -23
  118. package/dist/indexer/search/search-fields.js +11 -1
  119. package/dist/integrations/agent/builder-shared.js +7 -0
  120. package/dist/integrations/agent/builders.js +5 -52
  121. package/dist/integrations/agent/config.js +3 -143
  122. package/dist/integrations/agent/detect.js +17 -2
  123. package/dist/integrations/agent/engine-resolution.js +202 -0
  124. package/dist/integrations/agent/index.js +1 -2
  125. package/dist/integrations/agent/model-aliases.js +7 -2
  126. package/dist/integrations/agent/profiles.js +6 -99
  127. package/dist/integrations/agent/runner-dispatch.js +76 -13
  128. package/dist/integrations/agent/runner.js +76 -207
  129. package/dist/integrations/agent/spawn.js +4 -6
  130. package/dist/integrations/harnesses/aider/agent-builder.js +2 -3
  131. package/dist/integrations/harnesses/aider/index.js +0 -1
  132. package/dist/integrations/harnesses/amazonq/agent-builder.js +2 -3
  133. package/dist/integrations/harnesses/amazonq/index.js +0 -1
  134. package/dist/integrations/harnesses/claude/agent-builder.js +2 -3
  135. package/dist/integrations/harnesses/claude/index.js +0 -2
  136. package/dist/integrations/harnesses/codex/agent-builder.js +2 -3
  137. package/dist/integrations/harnesses/codex/index.js +0 -1
  138. package/dist/integrations/harnesses/copilot/agent-builder.js +2 -3
  139. package/dist/integrations/harnesses/copilot/index.js +0 -1
  140. package/dist/integrations/harnesses/gemini/agent-builder.js +2 -3
  141. package/dist/integrations/harnesses/gemini/index.js +0 -1
  142. package/dist/integrations/harnesses/index.js +1 -24
  143. package/dist/integrations/harnesses/opencode/agent-builder.js +2 -3
  144. package/dist/integrations/harnesses/opencode/index.js +0 -6
  145. package/dist/integrations/harnesses/opencode-sdk/harness.js +1 -6
  146. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +274 -191
  147. package/dist/integrations/harnesses/openhands/agent-builder.js +2 -3
  148. package/dist/integrations/harnesses/openhands/index.js +0 -1
  149. package/dist/integrations/harnesses/pi/agent-builder.js +2 -3
  150. package/dist/integrations/harnesses/pi/index.js +0 -1
  151. package/dist/integrations/harnesses/types.js +1 -32
  152. package/dist/integrations/lockfile.js +32 -21
  153. package/dist/llm/client.js +48 -14
  154. package/dist/llm/feature-gate.js +15 -47
  155. package/dist/llm/graph-extract.js +1 -1
  156. package/dist/llm/index-passes.js +8 -42
  157. package/dist/llm/memory-infer-impl.js +1 -1
  158. package/dist/llm/usage-persist.js +4 -0
  159. package/dist/llm/usage-telemetry.js +35 -5
  160. package/dist/output/shapes/helpers.js +2 -1
  161. package/dist/output/shapes/passthrough.js +2 -0
  162. package/dist/output/text/helpers.js +3 -1
  163. package/dist/schemas/akm-config.json +11013 -8600
  164. package/dist/schemas/akm-task.json +87 -0
  165. package/dist/schemas/akm-workflow.json +57 -13
  166. package/dist/scripts/migrate-storage.js +8591 -509
  167. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +8623 -484
  168. package/dist/setup/detected-engines.js +142 -0
  169. package/dist/setup/engine-config.js +89 -0
  170. package/dist/setup/setup.js +236 -132
  171. package/dist/setup/steps/connection.js +61 -32
  172. package/dist/setup/steps/platforms.js +4 -4
  173. package/dist/setup/steps.js +3 -2
  174. package/dist/storage/database.js +13 -1
  175. package/dist/storage/engines/sqlite-migrations.js +1 -0
  176. package/dist/storage/repositories/improve-runs-repository.js +5 -5
  177. package/dist/storage/repositories/task-history-repository.js +78 -0
  178. package/dist/storage/repositories/workflow-runs-repository.js +9 -8
  179. package/dist/tasks/parser.js +138 -52
  180. package/dist/tasks/runner.js +71 -75
  181. package/dist/tasks/schema.js +1 -1
  182. package/dist/tasks/validator.js +11 -6
  183. package/dist/wiki/wiki.js +9 -8
  184. package/dist/workflows/authoring/workflow-program-template.yaml +3 -3
  185. package/dist/workflows/concurrency-policy.js +15 -0
  186. package/dist/workflows/db.js +65 -13
  187. package/dist/workflows/exec/brief.js +18 -24
  188. package/dist/workflows/exec/frozen-judge.js +47 -0
  189. package/dist/workflows/exec/native-executor.js +124 -65
  190. package/dist/workflows/exec/report.js +93 -33
  191. package/dist/workflows/exec/run-workflow.js +38 -25
  192. package/dist/workflows/exec/scheduler.js +12 -41
  193. package/dist/workflows/exec/step-work.js +91 -35
  194. package/dist/workflows/ir/compile.js +13 -26
  195. package/dist/workflows/ir/freeze.js +243 -0
  196. package/dist/workflows/ir/plan-hash.js +40 -5
  197. package/dist/workflows/ir/schema.js +542 -1
  198. package/dist/workflows/parser.js +7 -0
  199. package/dist/workflows/program/parser.js +132 -23
  200. package/dist/workflows/program/project.js +3 -4
  201. package/dist/workflows/program/schema.js +2 -2
  202. package/dist/workflows/resource-limits.js +20 -0
  203. package/dist/workflows/runtime/plan-classifier.js +187 -0
  204. package/dist/workflows/runtime/runs.js +54 -47
  205. package/dist/workflows/runtime/workflow-asset-loader.js +0 -22
  206. package/dist/workflows/validator.js +25 -0
  207. package/docs/data-and-telemetry.md +2 -2
  208. package/docs/migration/release-notes/0.6.0.md +1 -1
  209. package/docs/migration/release-notes/0.7.0.md +5 -4
  210. package/docs/migration/v0.8-to-v0.9.md +401 -0
  211. package/package.json +4 -2
  212. package/schemas/akm-config.json +16638 -0
  213. package/schemas/akm-task.json +87 -0
  214. package/schemas/akm-workflow.json +372 -0
  215. package/dist/commands/improve/improve-profiles.js +0 -168
  216. package/dist/core/config/config-migration.js +0 -602
  217. package/dist/core/deep-merge.js +0 -38
  218. package/dist/llm/call-ai.js +0 -62
  219. package/dist/setup/legacy-config.js +0 -106
  220. package/docs/README.md +0 -104
  221. /package/dist/assets/{profiles → improve-strategies}/catchup.json +0 -0
  222. /package/dist/assets/{profiles → improve-strategies}/consolidate.json +0 -0
  223. /package/dist/assets/{profiles → improve-strategies}/frequent.json +0 -0
  224. /package/dist/assets/{profiles → improve-strategies}/memory-focus.json +0 -0
  225. /package/dist/assets/{profiles → improve-strategies}/quick.json +0 -0
  226. /package/dist/assets/{profiles → improve-strategies}/recombine-only.json +0 -0
  227. /package/dist/assets/{profiles → improve-strategies}/synthesize.json +0 -0
  228. /package/dist/assets/{profiles → improve-strategies}/thorough.json +0 -0
@@ -0,0 +1,61 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ export const EXTRA_PARAMS_PROTECTED_TOP_LEVEL_KEYS = [
5
+ "model",
6
+ "messages",
7
+ "temperature",
8
+ "maxtokens",
9
+ "responseformat",
10
+ "stream",
11
+ "streamoptions",
12
+ "enablethinking",
13
+ ];
14
+ export const EXTRA_PARAMS_CREDENTIAL_KEYS = [
15
+ "authorization",
16
+ "headers",
17
+ "apikey",
18
+ "token",
19
+ "password",
20
+ "secret",
21
+ "cookie",
22
+ "setcookie",
23
+ ];
24
+ const PROTECTED_TOP_LEVEL_KEYS = new Set(EXTRA_PARAMS_PROTECTED_TOP_LEVEL_KEYS);
25
+ const CREDENTIAL_KEYS = new Set(EXTRA_PARAMS_CREDENTIAL_KEYS);
26
+ export function normalizeExtraParamKey(key) {
27
+ return key.toLowerCase().replace(/[^a-z0-9]/g, "");
28
+ }
29
+ /** Validate provider extras without allowing them to override AKM fields or carry credentials. */
30
+ export function validateExtraParams(value) {
31
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
32
+ return [{ path: [], message: "must be an object" }];
33
+ }
34
+ const issues = [];
35
+ const visit = (entry, path) => {
36
+ if (Array.isArray(entry)) {
37
+ entry.forEach((child, index) => {
38
+ visit(child, [...path, index]);
39
+ });
40
+ return;
41
+ }
42
+ if (!entry || typeof entry !== "object")
43
+ return;
44
+ for (const [key, child] of Object.entries(entry)) {
45
+ const normalized = normalizeExtraParamKey(key);
46
+ if (path.length === 0 && PROTECTED_TOP_LEVEL_KEYS.has(normalized)) {
47
+ issues.push({ path: [key], message: `${key} is protected by AKM` });
48
+ }
49
+ if (CREDENTIAL_KEYS.has(normalized)) {
50
+ issues.push({ path: [...path, key], message: `${key} cannot carry credentials` });
51
+ }
52
+ visit(child, [...path, key]);
53
+ }
54
+ };
55
+ visit(value, []);
56
+ return issues;
57
+ }
58
+ export function formatExtraParamsIssue(label, issue) {
59
+ const suffix = issue.path.map((part) => (typeof part === "number" ? `[${part}]` : `.${part}`)).join("");
60
+ return `${label}${suffix} ${issue.message}`;
61
+ }
@@ -1,11 +1,114 @@
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 { randomUUID } from "node:crypto";
4
5
  import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { openDatabase } from "../storage/database.js";
5
8
  import { isProcessAlive } from "./common.js";
9
+ function readLockSnapshot(lockPath) {
10
+ let fd;
11
+ try {
12
+ fd = fs.openSync(lockPath, "r");
13
+ }
14
+ catch (err) {
15
+ if (err.code === "ENOENT")
16
+ return undefined;
17
+ throw err;
18
+ }
19
+ try {
20
+ const rawContent = fs.readFileSync(fd, "utf8");
21
+ const stat = fs.fstatSync(fd);
22
+ return {
23
+ rawContent,
24
+ identity: { dev: stat.dev, ino: stat.ino, size: stat.size, mtimeMs: stat.mtimeMs },
25
+ };
26
+ }
27
+ finally {
28
+ fs.closeSync(fd);
29
+ }
30
+ }
31
+ function sameIdentity(left, right) {
32
+ return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeMs === right.mtimeMs;
33
+ }
34
+ function operationMutexPath(lockPath) {
35
+ // `.sensitive` is an established non-asset suffix across stash walkers. The
36
+ // mutex may sit beside a secret/env lock and must never surface as an asset.
37
+ return path.join(path.dirname(lockPath), `.${path.basename(lockPath)}.operations.sensitive`);
38
+ }
39
+ /**
40
+ * Serialize every mutation of one canonical lock path. SQLite's write lock is
41
+ * released by the OS when a process dies, so this mutex needs no stale-owner
42
+ * deletion protocol (which would reproduce the same check/rename race it is
43
+ * meant to prevent).
44
+ */
45
+ function withLockOperationMutex(lockPath, run) {
46
+ const db = openDatabase(operationMutexPath(lockPath));
47
+ let began = false;
48
+ try {
49
+ db.exec("PRAGMA busy_timeout = 30000");
50
+ for (let attempt = 0; attempt < 5 && !began; attempt += 1) {
51
+ db.exec("BEGIN IMMEDIATE");
52
+ began = db.inTransaction;
53
+ if (!began)
54
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2 ** attempt);
55
+ }
56
+ if (!began)
57
+ throw new Error(`Could not acquire lock operation mutex for ${lockPath}.`);
58
+ const result = run();
59
+ db.exec("COMMIT");
60
+ began = false;
61
+ return result;
62
+ }
63
+ catch (error) {
64
+ if (began && db.inTransaction) {
65
+ try {
66
+ db.exec("ROLLBACK");
67
+ }
68
+ catch {
69
+ // Preserve the operation failure.
70
+ }
71
+ }
72
+ throw error;
73
+ }
74
+ finally {
75
+ db.close();
76
+ }
77
+ }
78
+ function tryAcquireLockRaw(lockPath, payload) {
79
+ try {
80
+ fs.writeFileSync(lockPath, payload, { flag: "wx" });
81
+ }
82
+ catch (err) {
83
+ if (err.code === "EEXIST")
84
+ return undefined;
85
+ throw err;
86
+ }
87
+ let snapshot;
88
+ try {
89
+ snapshot = readLockSnapshot(lockPath);
90
+ }
91
+ catch (error) {
92
+ releaseLockRaw(lockPath);
93
+ throw error;
94
+ }
95
+ if (!snapshot) {
96
+ releaseLockRaw(lockPath);
97
+ throw new Error(`Could not read newly acquired lock at ${lockPath}.`);
98
+ }
99
+ return { lockPath, ...snapshot };
100
+ }
101
+ function releaseLockRaw(lockPath) {
102
+ try {
103
+ fs.unlinkSync(lockPath);
104
+ }
105
+ catch {
106
+ // Sentinel already gone — fine.
107
+ }
108
+ }
6
109
  /**
7
110
  * Atomically create a sentinel at `lockPath` with `payload` as the body.
8
- * Returns true if we now own the lock, false if a sentinel already
111
+ * Returns an exact ownership handle if we now own the lock, or undefined if a sentinel already
9
112
  * exists (EEXIST). Throws any other error (permissions, missing parent
10
113
  * dir, etc.) — callers must ensure the parent directory exists.
11
114
  *
@@ -14,15 +117,11 @@ import { isProcessAlive } from "./common.js";
14
117
  * (improve.ts records pid + startedAt so audit can correlate runs).
15
118
  */
16
119
  export function tryAcquireLockSync(lockPath, payload) {
17
- try {
18
- fs.writeFileSync(lockPath, payload, { flag: "wx" });
19
- return true;
20
- }
21
- catch (err) {
22
- if (err.code === "EEXIST")
23
- return false;
24
- throw err;
25
- }
120
+ return withLockOperationMutex(lockPath, () => tryAcquireLockRaw(lockPath, payload));
121
+ }
122
+ /** Build a PID-bearing payload with a unique token for one acquisition attempt. */
123
+ export function createLockPayload(metadata = {}) {
124
+ return JSON.stringify({ ...metadata, pid: process.pid, lockId: randomUUID() });
26
125
  }
27
126
  /**
28
127
  * Inspect an existing sentinel at `lockPath` without modifying it.
@@ -36,70 +135,116 @@ export function tryAcquireLockSync(lockPath, payload) {
36
135
  * Does NOT remove the file. Callers decide recovery policy.
37
136
  */
38
137
  export function probeLock(lockPath, opts) {
39
- let rawContent;
40
- let ageMs;
41
- try {
42
- rawContent = fs.readFileSync(lockPath, "utf8");
43
- }
44
- catch (err) {
45
- if (err.code === "ENOENT")
46
- return { state: "absent" };
47
- return { state: "stale", reason: "unreadable" };
48
- }
138
+ let snapshot;
49
139
  try {
50
- const stat = fs.statSync(lockPath);
51
- ageMs = Date.now() - stat.mtimeMs;
140
+ snapshot = readLockSnapshot(lockPath);
52
141
  }
53
142
  catch {
54
- // Stat failed even though read succeeded — race-y removal in flight.
55
- return { state: "stale", reason: "unreadable", rawContent };
143
+ return { state: "stale", reason: "unreadable" };
56
144
  }
145
+ if (!snapshot)
146
+ return { state: "absent" };
147
+ const { rawContent, identity } = snapshot;
148
+ const ageMs = Date.now() - identity.mtimeMs;
57
149
  const holderPid = extractHolderPid(rawContent);
58
150
  if (holderPid === undefined) {
59
- return { state: "stale", reason: "invalid_pid", ageMs, rawContent };
151
+ return { state: "stale", reason: "invalid_pid", ageMs, rawContent, identity };
60
152
  }
61
153
  if (!isProcessAlive(holderPid)) {
62
- return { state: "stale", reason: "pid_dead", holderPid, ageMs, rawContent };
154
+ return { state: "stale", reason: "pid_dead", holderPid, ageMs, rawContent, identity };
63
155
  }
64
156
  if (opts?.staleAfterMs !== undefined && ageMs > opts.staleAfterMs) {
65
- return { state: "stale", reason: "age_exceeded", holderPid, ageMs, rawContent };
157
+ return { state: "stale", reason: "age_exceeded", holderPid, ageMs, rawContent, identity };
66
158
  }
67
- return { state: "held", holderPid, ageMs, rawContent };
159
+ return { state: "held", holderPid, ageMs, rawContent, identity };
68
160
  }
69
161
  /**
70
- * Remove a lock file. Idempotent silently ignores ENOENT. Used both to
71
- * reclaim stale locks (after probeLock returns `state: "stale"`) and to
72
- * release locks we own (after a successful tryAcquireLockSync).
162
+ * Revalidate and quarantine the probed sentinel while holding the same operation
163
+ * mutex used by acquisitions. A newer owner therefore cannot be renamed in the
164
+ * check/quarantine window, and a third contender cannot acquire until cleanup
165
+ * has completed.
73
166
  */
74
- export function releaseLock(lockPath) {
75
- try {
76
- fs.unlinkSync(lockPath);
77
- }
78
- catch {
79
- // Sentinel already gone — fine.
80
- }
167
+ export function reclaimStaleLock(lockPath, probe, options) {
168
+ if (probe.rawContent === undefined || probe.identity === undefined)
169
+ return false;
170
+ const expectedContent = probe.rawContent;
171
+ const expectedIdentity = probe.identity;
172
+ return withLockOperationMutex(lockPath, () => {
173
+ let current;
174
+ try {
175
+ current = readLockSnapshot(lockPath);
176
+ }
177
+ catch {
178
+ return false;
179
+ }
180
+ if (!current || current.rawContent !== expectedContent || !sameIdentity(current.identity, expectedIdentity)) {
181
+ return false;
182
+ }
183
+ const quarantinePath = `${lockPath}.stale-${process.pid}-${randomUUID()}`;
184
+ try {
185
+ fs.renameSync(lockPath, quarantinePath);
186
+ }
187
+ catch (err) {
188
+ if (err.code === "ENOENT")
189
+ return false;
190
+ throw err;
191
+ }
192
+ let quarantined;
193
+ try {
194
+ quarantined = readLockSnapshot(quarantinePath);
195
+ }
196
+ catch {
197
+ quarantined = undefined;
198
+ }
199
+ if (!quarantined ||
200
+ quarantined.rawContent !== expectedContent ||
201
+ !sameIdentity(quarantined.identity, expectedIdentity)) {
202
+ try {
203
+ // Restore without replacing a non-cooperating lock installed after quarantine.
204
+ fs.linkSync(quarantinePath, lockPath);
205
+ }
206
+ catch (err) {
207
+ if (err.code !== "EEXIST")
208
+ throw err;
209
+ }
210
+ releaseLockRaw(quarantinePath);
211
+ return false;
212
+ }
213
+ options?.afterQuarantineVerified?.();
214
+ try {
215
+ fs.unlinkSync(quarantinePath);
216
+ return true;
217
+ }
218
+ catch (err) {
219
+ if (err.code === "ENOENT")
220
+ return false;
221
+ throw err;
222
+ }
223
+ });
81
224
  }
82
225
  /**
83
- * Release a lock ONLY if it is still owned by `ownerPid`. Safe to call from a
84
- * `process.exit()` / `'exit'` handler as a backstop: `process.exit()` skips
85
- * `finally` blocks so the normal lock-release never runs on signal death
86
- * (SIGTERM/SIGINT) but it DOES fire `'exit'` listeners synchronously. Checking
87
- * ownership first means that if the lock was already released and re-acquired by
88
- * a different process, this leaves that process's lock intact (no cross-run
89
- * deletion / PID-reuse footgun). Synchronous so it is valid inside an exit handler.
226
+ * Release only the exact sentinel returned by `tryAcquireLockSync`. Content and
227
+ * file identity are revalidated while holding the acquisition operation mutex,
228
+ * so a stale holder cannot remove a successor. Synchronous and idempotent so it
229
+ * is safe in both `finally` blocks and process exit handlers.
90
230
  */
91
- export function releaseLockIfOwned(lockPath, ownerPid) {
92
- let rawContent;
93
- try {
94
- rawContent = fs.readFileSync(lockPath, "utf8");
95
- }
96
- catch {
97
- // Absent or unreadable — nothing of ours to release.
231
+ export function releaseLock(ownership) {
232
+ const { lockPath } = ownership;
233
+ if (!fs.existsSync(lockPath) && !fs.existsSync(operationMutexPath(lockPath)))
98
234
  return;
99
- }
100
- if (extractHolderPid(rawContent) === ownerPid) {
101
- releaseLock(lockPath);
102
- }
235
+ withLockOperationMutex(lockPath, () => {
236
+ let current;
237
+ try {
238
+ current = readLockSnapshot(lockPath);
239
+ }
240
+ catch {
241
+ // Absent or unreadable — nothing of ours to release.
242
+ return;
243
+ }
244
+ if (current && current.rawContent === ownership.rawContent && sameIdentity(current.identity, ownership.identity)) {
245
+ releaseLockRaw(lockPath);
246
+ }
247
+ });
103
248
  }
104
249
  /**
105
250
  * Extract a PID from a sentinel body. Accepts the two shapes used across
@@ -0,0 +1,178 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ const COMMON_FIELDS = [
5
+ "schemaVersion",
6
+ "ok",
7
+ "scope",
8
+ "dryRun",
9
+ "skipped",
10
+ "guidance",
11
+ "memorySummary",
12
+ "memoryCleanup",
13
+ "cyclesRun",
14
+ "plannedRefs",
15
+ "actions",
16
+ "distillSkipped",
17
+ "validationFailures",
18
+ "schemaRepairs",
19
+ "consolidation",
20
+ "extract",
21
+ "lintSummary",
22
+ "memoryIndexHealth",
23
+ "coverageGaps",
24
+ "evalCasesWritten",
25
+ "deadUrls",
26
+ "reflectsWithErrorContext",
27
+ "memoryInference",
28
+ "graphExtraction",
29
+ "memoryInferenceDurationMs",
30
+ "graphExtractionDurationMs",
31
+ "orphansPurged",
32
+ "proposalsExpired",
33
+ "reflectCooldownActions",
34
+ "reflectSkippedActions",
35
+ "reflectGuardRejectedActions",
36
+ "gateAutoAcceptedCount",
37
+ "gateAutoAcceptFailedCount",
38
+ "triage",
39
+ "proactiveMaintenance",
40
+ "recombination",
41
+ "proceduralCompilation",
42
+ "cycleMetrics",
43
+ "runId",
44
+ "sync",
45
+ "terminated",
46
+ ];
47
+ const V1_FIELDS = new Set([...COMMON_FIELDS, "profile", "profileFilteredRefs"]);
48
+ const V2_FIELDS = new Set([...COMMON_FIELDS, "strategy", "strategyFilteredRefs"]);
49
+ function fail(message) {
50
+ throw new Error(`invalid improve-result envelope: ${message}`);
51
+ }
52
+ function isRecord(value) {
53
+ return typeof value === "object" && value !== null && !Array.isArray(value);
54
+ }
55
+ function requireExactFields(value, allowed) {
56
+ const unknown = Object.keys(value).filter((key) => !allowed.has(key));
57
+ if (unknown.length > 0)
58
+ fail(`unknown field${unknown.length === 1 ? "" : "s"}: ${unknown.sort().join(", ")}`);
59
+ }
60
+ function validateCommon(value) {
61
+ if (typeof value.ok !== "boolean")
62
+ fail("ok must be a boolean");
63
+ if (typeof value.dryRun !== "boolean")
64
+ fail("dryRun must be a boolean");
65
+ if (!Array.isArray(value.plannedRefs))
66
+ fail("plannedRefs must be an array");
67
+ if (!isRecord(value.scope))
68
+ fail("scope must be an object");
69
+ requireExactFields(value.scope, new Set(["mode", "value"]));
70
+ if (value.scope.mode !== "all" && value.scope.mode !== "type" && value.scope.mode !== "ref") {
71
+ fail('scope.mode must be "all", "type", or "ref"');
72
+ }
73
+ if (value.scope.value !== undefined && typeof value.scope.value !== "string") {
74
+ fail("scope.value must be a string when present");
75
+ }
76
+ if (!isRecord(value.memorySummary))
77
+ fail("memorySummary must be an object");
78
+ requireExactFields(value.memorySummary, new Set(["eligible", "derived"]));
79
+ if (typeof value.memorySummary.eligible !== "number" || typeof value.memorySummary.derived !== "number") {
80
+ fail("memorySummary.eligible and memorySummary.derived must be numbers");
81
+ }
82
+ for (const field of [
83
+ "actions",
84
+ "validationFailures",
85
+ "schemaRepairs",
86
+ "extract",
87
+ "coverageGaps",
88
+ "deadUrls",
89
+ ]) {
90
+ if (value[field] !== undefined && !Array.isArray(value[field]))
91
+ fail(`${field} must be an array`);
92
+ }
93
+ for (const field of [
94
+ "cyclesRun",
95
+ "evalCasesWritten",
96
+ "reflectsWithErrorContext",
97
+ "memoryInferenceDurationMs",
98
+ "graphExtractionDurationMs",
99
+ "orphansPurged",
100
+ "proposalsExpired",
101
+ "reflectCooldownActions",
102
+ "reflectSkippedActions",
103
+ "reflectGuardRejectedActions",
104
+ "gateAutoAcceptedCount",
105
+ "gateAutoAcceptFailedCount",
106
+ ]) {
107
+ if (value[field] !== undefined && typeof value[field] !== "number")
108
+ fail(`${field} must be a number`);
109
+ }
110
+ for (const field of ["guidance", "runId"]) {
111
+ if (value[field] !== undefined && typeof value[field] !== "string")
112
+ fail(`${field} must be a string`);
113
+ }
114
+ for (const field of [
115
+ "skipped",
116
+ "memoryCleanup",
117
+ "distillSkipped",
118
+ "consolidation",
119
+ "lintSummary",
120
+ "memoryIndexHealth",
121
+ "memoryInference",
122
+ "graphExtraction",
123
+ "triage",
124
+ "proactiveMaintenance",
125
+ "recombination",
126
+ "proceduralCompilation",
127
+ "cycleMetrics",
128
+ "sync",
129
+ "terminated",
130
+ ]) {
131
+ if (value[field] !== undefined && !isRecord(value[field]))
132
+ fail(`${field} must be an object`);
133
+ }
134
+ }
135
+ /** Decode the persisted public v1/v2 contract without guessing across versions. */
136
+ export function decodeImproveResult(input) {
137
+ let parsed = input;
138
+ if (typeof input === "string") {
139
+ try {
140
+ parsed = JSON.parse(input);
141
+ }
142
+ catch {
143
+ fail("not valid JSON");
144
+ }
145
+ }
146
+ if (!isRecord(parsed))
147
+ fail("root must be an object");
148
+ if (parsed.schemaVersion === 1) {
149
+ requireExactFields(parsed, V1_FIELDS);
150
+ validateCommon(parsed);
151
+ if (parsed.profile !== undefined && typeof parsed.profile !== "string")
152
+ fail("profile must be a string");
153
+ if (parsed.profileFilteredRefs !== undefined && !Array.isArray(parsed.profileFilteredRefs)) {
154
+ fail("profileFilteredRefs must be an array");
155
+ }
156
+ return {
157
+ envelope: parsed,
158
+ strategy: null,
159
+ legacyProfile: typeof parsed.profile === "string" ? parsed.profile : null,
160
+ };
161
+ }
162
+ if (parsed.schemaVersion === 2) {
163
+ requireExactFields(parsed, V2_FIELDS);
164
+ validateCommon(parsed);
165
+ if (typeof parsed.strategy !== "string" || parsed.strategy.length === 0) {
166
+ fail("strategy must be a non-empty string");
167
+ }
168
+ if (parsed.strategyFilteredRefs !== undefined && !Array.isArray(parsed.strategyFilteredRefs)) {
169
+ fail("strategyFilteredRefs must be an array");
170
+ }
171
+ return {
172
+ envelope: parsed,
173
+ strategy: parsed.strategy,
174
+ legacyProfile: null,
175
+ };
176
+ }
177
+ fail(`unsupported schemaVersion: ${String(parsed.schemaVersion)}`);
178
+ }
@@ -0,0 +1,119 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ import { AsyncLocalStorage } from "node:async_hooks";
5
+ import { randomUUID } from "node:crypto";
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { ConfigError } from "./errors.js";
9
+ import { createLockPayload, probeLock, reclaimStaleLock, releaseLock, tryAcquireLockSync } from "./file-lock.js";
10
+ import { getMaintenanceBarrierPath } from "./paths.js";
11
+ const heldBarrierContext = new AsyncLocalStorage();
12
+ /**
13
+ * Serialize restore with the short critical section that creates every
14
+ * long-lived AKM lock or workflow lease. The long operation keeps its own
15
+ * lock/lease; this barrier is released immediately after acquisition.
16
+ */
17
+ export function tryAcquireMaintenanceBarrier() {
18
+ const lockPath = getMaintenanceBarrierPath();
19
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
20
+ for (let attempt = 0; attempt < 2; attempt += 1) {
21
+ const ownership = tryAcquireLockSync(lockPath, createLockPayload({ purpose: "maintenance-start" }));
22
+ if (ownership) {
23
+ return () => releaseLock(ownership);
24
+ }
25
+ const probe = probeLock(lockPath);
26
+ if (probe.state !== "stale" || !reclaimStaleLock(lockPath, probe))
27
+ return undefined;
28
+ }
29
+ return undefined;
30
+ }
31
+ export function acquireMaintenanceBarrier() {
32
+ const release = tryAcquireMaintenanceBarrier();
33
+ if (release)
34
+ return release;
35
+ throw new ConfigError(`AKM maintenance is in progress (barrier ${getMaintenanceBarrierPath()}); retry after it completes.`, "INVALID_CONFIG_FILE");
36
+ }
37
+ export function withMaintenanceStartBarrier(run) {
38
+ if (heldBarrierContext.getStore()?.active)
39
+ return run();
40
+ const release = acquireMaintenanceBarrier();
41
+ const ownership = { active: true };
42
+ try {
43
+ return heldBarrierContext.run(ownership, run);
44
+ }
45
+ finally {
46
+ ownership.active = false;
47
+ release();
48
+ }
49
+ }
50
+ async function acquireMaintenanceBarrierAsync() {
51
+ const deadline = Date.now() + 5_000;
52
+ while (true) {
53
+ const release = tryAcquireMaintenanceBarrier();
54
+ if (release)
55
+ return release;
56
+ if (Date.now() >= deadline)
57
+ return acquireMaintenanceBarrier();
58
+ await new Promise((resolve) => setTimeout(resolve, 5));
59
+ }
60
+ }
61
+ export async function withMaintenanceStartBarrierAsync(run) {
62
+ if (heldBarrierContext.getStore()?.active)
63
+ return run();
64
+ const release = await acquireMaintenanceBarrierAsync();
65
+ const ownership = { active: true };
66
+ try {
67
+ return await heldBarrierContext.run(ownership, run);
68
+ }
69
+ finally {
70
+ ownership.active = false;
71
+ release();
72
+ }
73
+ }
74
+ function withMaintenanceStartBarrierSyncWait(run) {
75
+ if (heldBarrierContext.getStore()?.active)
76
+ return run();
77
+ const deadline = Date.now() + 5_000;
78
+ let release = tryAcquireMaintenanceBarrier();
79
+ while (!release && Date.now() < deadline) {
80
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
81
+ release = tryAcquireMaintenanceBarrier();
82
+ }
83
+ if (!release)
84
+ release = acquireMaintenanceBarrier();
85
+ const ownership = { active: true };
86
+ try {
87
+ return heldBarrierContext.run(ownership, run);
88
+ }
89
+ finally {
90
+ ownership.active = false;
91
+ release();
92
+ }
93
+ }
94
+ /** Register a long-lived operation atomically with restore's blocker scan. */
95
+ export async function acquireMaintenanceActivity(name) {
96
+ return withMaintenanceStartBarrierAsync(async () => {
97
+ const directory = path.join(path.dirname(getMaintenanceBarrierPath()), "maintenance-activities");
98
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
99
+ const lockPath = path.join(directory, `${name}-${process.pid}-${randomUUID()}.lock`);
100
+ const ownership = tryAcquireLockSync(lockPath, createLockPayload({ purpose: name }));
101
+ if (!ownership) {
102
+ throw new ConfigError(`Could not register AKM maintenance activity at ${lockPath}.`, "INVALID_CONFIG_FILE");
103
+ }
104
+ return () => releaseLock(ownership);
105
+ });
106
+ }
107
+ /** Synchronous activity registration for synchronous database handle lifetimes. */
108
+ export function acquireMaintenanceActivitySync(name) {
109
+ return withMaintenanceStartBarrierSyncWait(() => {
110
+ const directory = path.join(path.dirname(getMaintenanceBarrierPath()), "maintenance-activities");
111
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
112
+ const lockPath = path.join(directory, `${name}-${process.pid}-${randomUUID()}.lock`);
113
+ const ownership = tryAcquireLockSync(lockPath, createLockPayload({ purpose: name }));
114
+ if (!ownership) {
115
+ throw new ConfigError(`Could not register AKM maintenance activity at ${lockPath}.`, "INVALID_CONFIG_FILE");
116
+ }
117
+ return () => releaseLock(ownership);
118
+ });
119
+ }