akm-cli 0.9.0-rc.0 → 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 (262) hide show
  1. package/CHANGELOG.md +339 -2
  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/prompts/workflow-unit-preamble.md +26 -0
  11. package/dist/assets/stash-skeleton/README.md +28 -0
  12. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +6 -0
  13. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +6 -0
  14. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +12 -1
  15. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +11 -1
  16. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +6 -0
  17. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +9 -0
  18. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +7 -0
  19. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +7 -0
  20. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +6 -0
  21. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +98 -0
  22. package/dist/assets/stash-skeleton/facts/conventions/domains.md +63 -0
  23. package/dist/assets/stash-skeleton/facts/conventions/organization.md +127 -0
  24. package/dist/assets/tasks/core/backup.yml +1 -0
  25. package/dist/assets/tasks/core/extract.yml +1 -0
  26. package/dist/assets/tasks/core/improve.yml +1 -0
  27. package/dist/assets/tasks/core/index-refresh.yml +1 -0
  28. package/dist/assets/tasks/core/sync.yml +1 -0
  29. package/dist/assets/tasks/core/version-check.yml +1 -0
  30. package/dist/assets/tasks/graph-refresh-weekly.yml +4 -4
  31. package/dist/assets/templates/html/health.html +5 -1
  32. package/dist/cli/config-migrate.js +31 -138
  33. package/dist/cli/config-validate.js +10 -8
  34. package/dist/cli.js +48 -14
  35. package/dist/commands/agent/agent-dispatch.js +17 -16
  36. package/dist/commands/agent/agent-support.js +0 -24
  37. package/dist/commands/agent/contribute-cli.js +5 -15
  38. package/dist/commands/backup-cli.js +54 -0
  39. package/dist/commands/config-cli.js +45 -159
  40. package/dist/commands/env/env-binding.js +95 -0
  41. package/dist/commands/env/env-cli.js +8 -65
  42. package/dist/commands/env/secret.js +8 -5
  43. package/dist/commands/health/checks.js +130 -83
  44. package/dist/commands/health/html-report.js +4 -0
  45. package/dist/commands/health/improve-metrics.js +30 -32
  46. package/dist/commands/health/llm-usage.js +19 -19
  47. package/dist/commands/health/md-report.js +4 -0
  48. package/dist/commands/health/metrics.js +2 -1
  49. package/dist/commands/health/surfaces.js +5 -4
  50. package/dist/commands/health.js +1 -1
  51. package/dist/commands/improve/consolidate/chunking.js +2 -2
  52. package/dist/commands/improve/consolidate.js +28 -25
  53. package/dist/commands/improve/distill/promote-memory.js +5 -12
  54. package/dist/commands/improve/distill/quality-gate.js +5 -7
  55. package/dist/commands/improve/distill.js +16 -5
  56. package/dist/commands/improve/eligibility.js +22 -12
  57. package/dist/commands/improve/extract-cli.js +47 -19
  58. package/dist/commands/improve/extract.js +110 -62
  59. package/dist/commands/improve/improve-cli.js +38 -16
  60. package/dist/commands/improve/improve-result-file.js +30 -24
  61. package/dist/commands/improve/improve-strategies.js +137 -0
  62. package/dist/commands/improve/improve.js +60 -30
  63. package/dist/commands/improve/locks.js +66 -45
  64. package/dist/commands/improve/loop-stages.js +75 -33
  65. package/dist/commands/improve/memory/memory-belief.js +79 -7
  66. package/dist/commands/improve/memory/memory-contradiction-detect.js +12 -4
  67. package/dist/commands/improve/preparation.js +71 -73
  68. package/dist/commands/improve/procedural.js +3 -2
  69. package/dist/commands/improve/recombine.js +2 -1
  70. package/dist/commands/improve/reflect.js +119 -214
  71. package/dist/commands/improve/shared.js +11 -5
  72. package/dist/commands/lint/base-linter.js +152 -42
  73. package/dist/commands/mv-cli.js +809 -0
  74. package/dist/commands/proposal/proposal-cli.js +18 -8
  75. package/dist/commands/proposal/propose.js +64 -69
  76. package/dist/commands/read/knowledge.js +436 -4
  77. package/dist/commands/read/remember-cli.js +39 -2
  78. package/dist/commands/read/search-cli.js +6 -1
  79. package/dist/commands/registry-cli.js +29 -14
  80. package/dist/commands/remember.js +2 -0
  81. package/dist/commands/sources/init.js +13 -14
  82. package/dist/commands/sources/migration-help.js +7 -4
  83. package/dist/commands/sources/schema-repair.js +2 -4
  84. package/dist/commands/sources/source-add.js +62 -73
  85. package/dist/commands/sources/source-manage.js +50 -46
  86. package/dist/commands/sources/stash-cli.js +41 -4
  87. package/dist/commands/tasks/default-tasks.js +12 -12
  88. package/dist/commands/tasks/tasks-cli.js +7 -3
  89. package/dist/commands/tasks/tasks.js +113 -18
  90. package/dist/commands/wiki-cli.js +9 -10
  91. package/dist/commands/workflow-cli.js +276 -12
  92. package/dist/core/asset/asset-spec.js +58 -1
  93. package/dist/core/asset/frontmatter.js +12 -2
  94. package/dist/core/common.js +5 -3
  95. package/dist/core/config/config-io.js +28 -17
  96. package/dist/core/config/config-schema.js +379 -66
  97. package/dist/core/config/config-types.js +3 -3
  98. package/dist/core/config/config-version.js +29 -0
  99. package/dist/core/config/config-walker.js +98 -27
  100. package/dist/core/config/config.js +132 -266
  101. package/dist/core/config/deep-merge.js +41 -0
  102. package/dist/core/config/engine-semantics.js +32 -0
  103. package/dist/core/errors.js +2 -2
  104. package/dist/core/extra-params.js +61 -0
  105. package/dist/core/file-lock.js +201 -56
  106. package/dist/core/improve-result.js +178 -0
  107. package/dist/core/json-schema.js +142 -0
  108. package/dist/core/maintenance-barrier.js +119 -0
  109. package/dist/core/migration-backup.js +416 -0
  110. package/dist/core/paths.js +3 -0
  111. package/dist/core/redaction.js +358 -0
  112. package/dist/core/state/migrations.js +17 -2
  113. package/dist/core/state-db.js +44 -1
  114. package/dist/indexer/db/db.js +118 -2
  115. package/dist/indexer/graph/graph-extraction.js +28 -16
  116. package/dist/indexer/index-writer-lock.js +31 -24
  117. package/dist/indexer/index-written-assets.js +15 -6
  118. package/dist/indexer/indexer.js +47 -2
  119. package/dist/indexer/passes/memory-inference.js +10 -6
  120. package/dist/indexer/passes/metadata.js +250 -0
  121. package/dist/indexer/search/db-search.js +111 -44
  122. package/dist/indexer/search/fts-query.js +41 -0
  123. package/dist/indexer/search/ranking-contributors.js +48 -0
  124. package/dist/indexer/search/ranking.js +36 -23
  125. package/dist/indexer/search/search-fields.js +11 -1
  126. package/dist/indexer/walk/matchers.js +39 -0
  127. package/dist/integrations/agent/builder-shared.js +7 -0
  128. package/dist/integrations/agent/builders.js +5 -50
  129. package/dist/integrations/agent/config.js +3 -143
  130. package/dist/integrations/agent/detect.js +17 -2
  131. package/dist/integrations/agent/engine-resolution.js +202 -0
  132. package/dist/integrations/agent/index.js +1 -2
  133. package/dist/integrations/agent/model-aliases.js +16 -2
  134. package/dist/integrations/agent/profiles.js +36 -62
  135. package/dist/integrations/agent/runner-dispatch.js +91 -4
  136. package/dist/integrations/agent/runner.js +76 -207
  137. package/dist/integrations/agent/spawn.js +141 -20
  138. package/dist/integrations/harnesses/aider/agent-builder.js +112 -0
  139. package/dist/integrations/harnesses/aider/index.js +57 -0
  140. package/dist/integrations/harnesses/aider/result-extractor.js +53 -0
  141. package/dist/integrations/harnesses/amazonq/agent-builder.js +152 -0
  142. package/dist/integrations/harnesses/amazonq/index.js +58 -0
  143. package/dist/integrations/harnesses/amazonq/result-extractor.js +48 -0
  144. package/dist/integrations/harnesses/claude/agent-builder.js +46 -8
  145. package/dist/integrations/harnesses/claude/index.js +25 -25
  146. package/dist/integrations/harnesses/claude/result-extractor.js +52 -0
  147. package/dist/integrations/harnesses/codex/agent-builder.js +136 -0
  148. package/dist/integrations/harnesses/codex/index.js +62 -0
  149. package/dist/integrations/harnesses/codex/result-extractor.js +73 -0
  150. package/dist/integrations/harnesses/copilot/agent-builder.js +121 -0
  151. package/dist/integrations/harnesses/copilot/index.js +59 -0
  152. package/dist/integrations/harnesses/copilot/result-extractor.js +151 -0
  153. package/dist/integrations/harnesses/gemini/agent-builder.js +120 -0
  154. package/dist/integrations/harnesses/gemini/index.js +59 -0
  155. package/dist/integrations/harnesses/gemini/result-extractor.js +121 -0
  156. package/dist/integrations/harnesses/index.js +27 -28
  157. package/dist/integrations/harnesses/opencode/agent-builder.js +2 -3
  158. package/dist/integrations/harnesses/opencode/index.js +15 -22
  159. package/dist/integrations/harnesses/opencode-sdk/harness.js +60 -0
  160. package/dist/integrations/harnesses/opencode-sdk/index.js +8 -32
  161. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +681 -108
  162. package/dist/integrations/harnesses/openhands/agent-builder.js +125 -0
  163. package/dist/integrations/harnesses/openhands/index.js +57 -0
  164. package/dist/integrations/harnesses/openhands/result-extractor.js +103 -0
  165. package/dist/integrations/harnesses/pi/agent-builder.js +103 -0
  166. package/dist/integrations/harnesses/pi/index.js +57 -0
  167. package/dist/integrations/harnesses/pi/result-extractor.js +135 -0
  168. package/dist/integrations/harnesses/types.js +8 -32
  169. package/dist/integrations/lockfile.js +32 -21
  170. package/dist/integrations/session-logs/index.js +24 -11
  171. package/dist/llm/client.js +48 -14
  172. package/dist/llm/feature-gate.js +15 -47
  173. package/dist/llm/graph-extract.js +1 -1
  174. package/dist/llm/index-passes.js +8 -42
  175. package/dist/llm/memory-infer-impl.js +1 -1
  176. package/dist/llm/usage-persist.js +4 -0
  177. package/dist/llm/usage-telemetry.js +35 -5
  178. package/dist/output/renderers.js +3 -2
  179. package/dist/output/shapes/helpers.js +2 -1
  180. package/dist/output/shapes/passthrough.js +6 -0
  181. package/dist/output/text/helpers.js +215 -2
  182. package/dist/output/text/workflow.js +3 -1
  183. package/dist/schemas/akm-config.json +16638 -0
  184. package/dist/schemas/akm-task.json +87 -0
  185. package/dist/schemas/akm-workflow.json +372 -0
  186. package/dist/scripts/migrate-storage.js +10944 -8801
  187. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +9247 -350
  188. package/dist/setup/detected-engines.js +142 -0
  189. package/dist/setup/engine-config.js +89 -0
  190. package/dist/setup/setup.js +236 -132
  191. package/dist/setup/steps/connection.js +61 -32
  192. package/dist/setup/steps/platforms.js +4 -4
  193. package/dist/setup/steps.js +3 -2
  194. package/dist/storage/database.js +13 -1
  195. package/dist/storage/engines/sqlite-migrations.js +1 -0
  196. package/dist/storage/repositories/improve-runs-repository.js +5 -5
  197. package/dist/storage/repositories/task-history-repository.js +78 -0
  198. package/dist/storage/repositories/workflow-runs-repository.js +190 -1
  199. package/dist/tasks/parser.js +138 -52
  200. package/dist/tasks/runner.js +71 -75
  201. package/dist/tasks/schema.js +1 -1
  202. package/dist/tasks/validator.js +11 -6
  203. package/dist/text-import-hook.mjs +1 -1
  204. package/dist/wiki/wiki.js +9 -8
  205. package/dist/workflows/authoring/authoring.js +123 -10
  206. package/dist/workflows/authoring/workflow-program-template.yaml +31 -0
  207. package/dist/workflows/cli.js +4 -0
  208. package/dist/workflows/concurrency-policy.js +15 -0
  209. package/dist/workflows/db.js +200 -13
  210. package/dist/workflows/exec/brief.js +478 -0
  211. package/dist/workflows/exec/frozen-judge.js +47 -0
  212. package/dist/workflows/exec/native-executor.js +1034 -0
  213. package/dist/workflows/exec/param-secrets.js +115 -0
  214. package/dist/workflows/exec/report.js +1355 -0
  215. package/dist/workflows/exec/run-workflow.js +609 -0
  216. package/dist/workflows/exec/scheduler.js +71 -0
  217. package/dist/workflows/exec/step-work.js +1212 -0
  218. package/dist/workflows/exec/unit-writer.js +23 -0
  219. package/dist/workflows/exec/watch.js +116 -0
  220. package/dist/workflows/exec/worktree.js +171 -0
  221. package/dist/workflows/ir/compile.js +375 -0
  222. package/dist/workflows/ir/freeze.js +243 -0
  223. package/dist/workflows/ir/params.js +54 -0
  224. package/dist/workflows/ir/plan-hash.js +68 -0
  225. package/dist/workflows/ir/schema.js +545 -0
  226. package/dist/workflows/parser.js +10 -1
  227. package/dist/workflows/program/expressions.js +369 -0
  228. package/dist/workflows/program/parser.js +869 -0
  229. package/dist/workflows/program/project.js +104 -0
  230. package/dist/workflows/program/schema.js +54 -0
  231. package/dist/workflows/renderer.js +82 -5
  232. package/dist/workflows/resource-limits.js +20 -0
  233. package/dist/workflows/runtime/agent-identity.js +59 -14
  234. package/dist/workflows/runtime/plan-classifier.js +187 -0
  235. package/dist/workflows/runtime/runs.js +246 -69
  236. package/dist/workflows/runtime/unit-checkin.js +45 -0
  237. package/dist/workflows/runtime/workflow-asset-loader.js +42 -1
  238. package/dist/workflows/validate-summary.js +24 -3
  239. package/dist/workflows/validator.js +26 -1
  240. package/docs/data-and-telemetry.md +4 -3
  241. package/docs/migration/release-notes/0.6.0.md +1 -1
  242. package/docs/migration/release-notes/0.7.0.md +5 -4
  243. package/docs/migration/release-notes/0.9.0-beta.60.md +19 -0
  244. package/docs/migration/v0.8-to-v0.9.md +401 -0
  245. package/package.json +4 -2
  246. package/schemas/akm-config.json +16638 -0
  247. package/schemas/akm-task.json +87 -0
  248. package/schemas/akm-workflow.json +372 -0
  249. package/dist/commands/improve/improve-profiles.js +0 -168
  250. package/dist/core/config/config-migration.js +0 -602
  251. package/dist/core/deep-merge.js +0 -38
  252. package/dist/llm/call-ai.js +0 -62
  253. package/dist/setup/legacy-config.js +0 -106
  254. package/docs/README.md +0 -104
  255. /package/dist/assets/{profiles → improve-strategies}/catchup.json +0 -0
  256. /package/dist/assets/{profiles → improve-strategies}/consolidate.json +0 -0
  257. /package/dist/assets/{profiles → improve-strategies}/frequent.json +0 -0
  258. /package/dist/assets/{profiles → improve-strategies}/memory-focus.json +0 -0
  259. /package/dist/assets/{profiles → improve-strategies}/quick.json +0 -0
  260. /package/dist/assets/{profiles → improve-strategies}/recombine-only.json +0 -0
  261. /package/dist/assets/{profiles → improve-strategies}/synthesize.json +0 -0
  262. /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,142 @@
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
+ /**
5
+ * Structural JSON-Schema-subset validator (orchestration plan P1).
6
+ *
7
+ * The workflow engine's structured-output normalization needs to validate
8
+ * unit results against the author-declared unit `output` schema on any harness —
9
+ * including ones with no native schema support. Pulling in a full
10
+ * draft-2020-12 validator is deliberately avoided (dependency surface); this
11
+ * module implements the bounded subset that covers the schemas workflow
12
+ * authors actually write:
13
+ *
14
+ * Supported: `type` (string | string[] — string, number, integer, boolean,
15
+ * object, array, null), `properties`, `required`, `items`,
16
+ * `additionalProperties: false`, `enum` (primitives), `minItems`,
17
+ * `maxItems`, `minLength`, `maxLength`, `minimum`, `maximum`.
18
+ *
19
+ * Ignored (permissive): `$ref`, `allOf`/`anyOf`/`oneOf`/`not`, `pattern`,
20
+ * `format`, and every other keyword. Unknown keywords never throw — a
21
+ * schema using them simply constrains less. Callers needing full JSON
22
+ * Schema semantics should validate downstream.
23
+ *
24
+ * Returns a flat list of human-readable error strings (empty = valid), each
25
+ * prefixed with a JSON-pointer-ish path — the shape `runStructured`'s
26
+ * corrective-feedback builder wants.
27
+ */
28
+ export function validateJsonSchemaSubset(value, schema) {
29
+ const errors = [];
30
+ validateNode(value, schema, "$", errors);
31
+ return errors;
32
+ }
33
+ function typeOf(value) {
34
+ if (value === null)
35
+ return "null";
36
+ if (Array.isArray(value))
37
+ return "array";
38
+ switch (typeof value) {
39
+ case "string":
40
+ return "string";
41
+ case "boolean":
42
+ return "boolean";
43
+ case "number":
44
+ return Number.isInteger(value) ? "integer" : "number";
45
+ default:
46
+ return "object";
47
+ }
48
+ }
49
+ function matchesType(actual, expected) {
50
+ if (expected === actual)
51
+ return true;
52
+ // JSON Schema: every integer is also a number.
53
+ return expected === "number" && actual === "integer";
54
+ }
55
+ function validateNode(value, schema, path, errors) {
56
+ const actual = typeOf(value);
57
+ const declared = schema.type;
58
+ if (typeof declared === "string" || Array.isArray(declared)) {
59
+ const expected = (Array.isArray(declared) ? declared : [declared]).filter((t) => typeof t === "string");
60
+ if (expected.length > 0 && !expected.some((t) => matchesType(actual, t))) {
61
+ errors.push(`${path}: expected type ${expected.join(" | ")}, got ${actual}`);
62
+ return; // type mismatch makes the remaining constraints meaningless
63
+ }
64
+ }
65
+ if (Array.isArray(schema.enum) && schema.enum.length > 0) {
66
+ const allowed = schema.enum;
67
+ if (!allowed.some((candidate) => candidate === value)) {
68
+ errors.push(`${path}: value ${JSON.stringify(value)} is not one of ${JSON.stringify(allowed)}`);
69
+ return;
70
+ }
71
+ }
72
+ if (actual === "string" && typeof value === "string") {
73
+ if (typeof schema.minLength === "number" && value.length < schema.minLength) {
74
+ errors.push(`${path}: string shorter than minLength ${schema.minLength}`);
75
+ }
76
+ if (typeof schema.maxLength === "number" && value.length > schema.maxLength) {
77
+ errors.push(`${path}: string longer than maxLength ${schema.maxLength}`);
78
+ }
79
+ return;
80
+ }
81
+ if ((actual === "number" || actual === "integer") && typeof value === "number") {
82
+ if (typeof schema.minimum === "number" && value < schema.minimum) {
83
+ errors.push(`${path}: ${value} is below minimum ${schema.minimum}`);
84
+ }
85
+ if (typeof schema.maximum === "number" && value > schema.maximum) {
86
+ errors.push(`${path}: ${value} is above maximum ${schema.maximum}`);
87
+ }
88
+ return;
89
+ }
90
+ if (actual === "array" && Array.isArray(value)) {
91
+ if (typeof schema.minItems === "number" && value.length < schema.minItems) {
92
+ errors.push(`${path}: array has fewer than minItems ${schema.minItems}`);
93
+ }
94
+ if (typeof schema.maxItems === "number" && value.length > schema.maxItems) {
95
+ errors.push(`${path}: array has more than maxItems ${schema.maxItems}`);
96
+ }
97
+ const items = schema.items;
98
+ if (items && typeof items === "object" && !Array.isArray(items)) {
99
+ value.forEach((element, index) => {
100
+ validateNode(element, items, `${path}[${index}]`, errors);
101
+ });
102
+ }
103
+ return;
104
+ }
105
+ if (actual === "object" && typeof value === "object" && value !== null) {
106
+ const record = value;
107
+ const properties = schema.properties && typeof schema.properties === "object" && !Array.isArray(schema.properties)
108
+ ? schema.properties
109
+ : undefined;
110
+ if (Array.isArray(schema.required)) {
111
+ for (const key of schema.required) {
112
+ // `Object.hasOwn`, not `key in record`: a required key satisfied only by
113
+ // an inherited prototype member (e.g. "toString", "constructor") is NOT
114
+ // present on the value itself, so `{}` must fail `required: ["toString"]`.
115
+ if (typeof key === "string" && !Object.hasOwn(record, key)) {
116
+ errors.push(`${path}: missing required property "${key}"`);
117
+ }
118
+ }
119
+ }
120
+ if (properties) {
121
+ for (const [key, propSchema] of Object.entries(properties)) {
122
+ if (!Object.hasOwn(record, key))
123
+ continue;
124
+ if (propSchema && typeof propSchema === "object" && !Array.isArray(propSchema)) {
125
+ validateNode(record[key], propSchema, `${path}.${key}`, errors);
126
+ }
127
+ }
128
+ }
129
+ // `additionalProperties: false` closes the object to exactly its declared
130
+ // `properties`. This MUST run even when no `properties` object is present:
131
+ // `{ type: "object", additionalProperties: false }` admits only `{}`. Use
132
+ // `Object.hasOwn` so an inherited key name (e.g. "toString") on the empty
133
+ // property set is not mistaken for a declared property.
134
+ if (schema.additionalProperties === false) {
135
+ for (const key of Object.keys(record)) {
136
+ if (!properties || !Object.hasOwn(properties, key)) {
137
+ errors.push(`${path}: unexpected property "${key}" (additionalProperties: false)`);
138
+ }
139
+ }
140
+ }
141
+ }
142
+ }