akm-cli 0.9.17-alpha.2 → 0.9.17-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (343) hide show
  1. package/CHANGELOG.md +756 -0
  2. package/dist/akm +94 -196
  3. package/dist/cli/shared.js +6 -2
  4. package/dist/cli.js +22 -9
  5. package/dist/commands/agent/agent-dispatch.js +1 -1
  6. package/dist/commands/command/command-execution.js +24 -62
  7. package/dist/commands/feedback-cli.js +0 -1
  8. package/dist/commands/health/accept-rate.js +2 -2
  9. package/dist/commands/health/checks.js +30 -75
  10. package/dist/commands/health/config-skew.js +38 -0
  11. package/dist/commands/health/egress.js +54 -0
  12. package/dist/commands/health/html-report.js +0 -38
  13. package/dist/commands/health/improve-metrics.js +123 -562
  14. package/dist/commands/health/plugin-staleness.js +53 -3
  15. package/dist/commands/health/renderers.js +12 -4
  16. package/dist/commands/health/report-view-model.js +11 -106
  17. package/dist/commands/health/types-improve.js +4 -19
  18. package/dist/commands/health/windows.js +64 -73
  19. package/dist/commands/health.js +122 -143
  20. package/dist/commands/improve/consolidate/chunking.js +25 -100
  21. package/dist/commands/improve/consolidate/sanitize.js +54 -149
  22. package/dist/commands/improve/consolidate.js +538 -1075
  23. package/dist/commands/improve/content-hash.js +16 -24
  24. package/dist/commands/improve/distill/content-repair.js +18 -100
  25. package/dist/commands/improve/distill-guards.js +20 -81
  26. package/dist/commands/improve/distill-promotion-policy.js +23 -243
  27. package/dist/commands/improve/distill.js +608 -1075
  28. package/dist/commands/improve/eligibility.js +126 -400
  29. package/dist/commands/improve/execution.js +3 -5
  30. package/dist/commands/improve/extract.js +487 -1046
  31. package/dist/commands/improve/feedback-valence.js +0 -25
  32. package/dist/commands/improve/improve-cli.js +29 -166
  33. package/dist/commands/improve/improve-result-file.js +10 -66
  34. package/dist/commands/improve/improve-strategies.js +12 -7
  35. package/dist/commands/improve/improve-usage-report.js +18 -64
  36. package/dist/commands/improve/improve.js +443 -1063
  37. package/dist/commands/improve/ledger.js +114 -0
  38. package/dist/commands/improve/locks.js +2 -8
  39. package/dist/commands/improve/loop-stages.js +459 -1172
  40. package/dist/commands/improve/memory/derived-ref.js +12 -77
  41. package/dist/commands/improve/memory/memory-belief.js +14 -118
  42. package/dist/commands/improve/memory/memory-improve.js +4 -3
  43. package/dist/commands/improve/outcome-loop.js +28 -156
  44. package/dist/commands/improve/planner.js +5 -10
  45. package/dist/commands/improve/preparation.js +851 -2339
  46. package/dist/commands/improve/proactive-maintenance.js +34 -101
  47. package/dist/commands/improve/reflect-noise.js +104 -280
  48. package/dist/commands/improve/reflect.js +621 -1367
  49. package/dist/commands/improve/salience.js +46 -232
  50. package/dist/commands/improve/session-asset.js +19 -100
  51. package/dist/commands/improve/stage.js +323 -0
  52. package/dist/commands/proposal/drain.js +251 -644
  53. package/dist/commands/proposal/proposal-cli.js +3 -18
  54. package/dist/commands/proposal/proposal-types.js +20 -41
  55. package/dist/commands/proposal/proposal.js +1 -2
  56. package/dist/commands/proposal/propose.js +134 -160
  57. package/dist/commands/proposal/repository.js +502 -1487
  58. package/dist/commands/proposal/validators/proposal-quality-validators.js +71 -174
  59. package/dist/commands/proposal/validators/proposal-validators.js +1 -1
  60. package/dist/commands/proposal/validators/proposals.js +13 -89
  61. package/dist/commands/read/curate.js +63 -413
  62. package/dist/commands/read/search-cli.js +16 -33
  63. package/dist/commands/read/search.js +17 -23
  64. package/dist/commands/read/show.js +2 -13
  65. package/dist/commands/sources/bundle-cli.js +25 -2
  66. package/dist/commands/sources/bundle-config-ops.js +7 -0
  67. package/dist/commands/sources/dangerous-env-audit.js +1 -2
  68. package/dist/commands/sources/info.js +2 -11
  69. package/dist/commands/sources/installed-stashes.js +197 -746
  70. package/dist/commands/sources/schema-repair.js +98 -129
  71. package/dist/commands/sources/source-add.js +62 -12
  72. package/dist/commands/sources/stash-cli.js +1 -1
  73. package/dist/commands/tasks/explain.js +10 -13
  74. package/dist/commands/tasks/tasks-cli.js +9 -8
  75. package/dist/commands/tasks/tasks.js +326 -930
  76. package/dist/commands/tasks/validate.js +42 -21
  77. package/dist/commands/workflow/plan.js +22 -29
  78. package/dist/commands/workflow-cli.js +4 -4
  79. package/dist/core/adapter/adapters/akm-adapter.js +0 -1
  80. package/dist/core/adapter/adapters/akm-lint.js +2 -3
  81. package/dist/core/adapter/adapters/akm-metadata.js +11 -12
  82. package/dist/core/adapter/adapters/akm-workflow-adapter.js +1 -1
  83. package/dist/core/adapter/execution-source.js +17 -29
  84. package/dist/core/asset/resolve-ref.js +1 -1
  85. package/dist/core/bundle-id.js +42 -5
  86. package/dist/core/bundle-rename.js +291 -0
  87. package/dist/core/config/config-io.js +1 -2
  88. package/dist/core/config/config-schema.js +1 -33
  89. package/dist/core/config/config-walker.js +1 -1
  90. package/dist/core/config/config.js +163 -68
  91. package/dist/core/config/legacy-source-shape-shim.js +38 -9
  92. package/dist/core/config/schema/embedding.js +20 -5
  93. package/dist/core/config/schema/engines.js +5 -0
  94. package/dist/core/config/schema/execution.js +1 -1
  95. package/dist/core/config/schema/experimental.js +1 -1
  96. package/dist/core/config/schema/improve-processes.js +21 -95
  97. package/dist/core/config/schema/improve.js +4 -42
  98. package/dist/core/config/schema/scheduler.js +12 -12
  99. package/dist/core/config/schema/search.js +6 -22
  100. package/dist/core/env-secret-ref.js +0 -1
  101. package/dist/core/errors.js +8 -9
  102. package/dist/core/file-lock.js +76 -173
  103. package/dist/core/logs-db.js +2 -2
  104. package/dist/core/paths.js +0 -27
  105. package/dist/core/redaction.js +109 -2
  106. package/dist/core/run-lock.js +2 -5
  107. package/dist/core/spawn-env.js +1 -1
  108. package/dist/core/state/migrations.js +108 -61
  109. package/dist/core/state-db-scope.js +2 -4
  110. package/dist/core/state-db.js +126 -692
  111. package/dist/core/type-presentation.js +1 -9
  112. package/dist/core/write-source.js +293 -1012
  113. package/dist/execution/input-contract.js +1 -1
  114. package/dist/execution/resolved-request.js +135 -689
  115. package/dist/execution/source.js +63 -257
  116. package/dist/execution/target-ref.js +1 -1
  117. package/dist/indexer/bundle-identity-guard.js +2 -2
  118. package/dist/indexer/db/graph-db.js +106 -46
  119. package/dist/indexer/ensure-index.js +44 -85
  120. package/dist/indexer/graph/graph-extraction.js +340 -562
  121. package/dist/indexer/graph/graph-related.js +130 -0
  122. package/dist/indexer/index-rebuild-lock.js +3 -11
  123. package/dist/indexer/index-writer-lock.js +8 -17
  124. package/dist/indexer/index-written-assets.js +139 -151
  125. package/dist/indexer/indexer.js +524 -846
  126. package/dist/indexer/materialize-embeddings.js +60 -397
  127. package/dist/indexer/passes/memory-inference.js +81 -90
  128. package/dist/indexer/passes/metadata.js +132 -200
  129. package/dist/indexer/read-preflight.js +0 -7
  130. package/dist/indexer/scan/doc-to-entry.js +1 -3
  131. package/dist/indexer/scan/drain-dir.js +1 -1
  132. package/dist/indexer/search/db-search.js +181 -590
  133. package/dist/indexer/search/fts-query.js +30 -41
  134. package/dist/indexer/search/ranking.js +28 -154
  135. package/dist/indexer/search/search-attribution.js +12 -32
  136. package/dist/indexer/search/search-fields.js +11 -15
  137. package/dist/indexer/search/search-hit-enrichers.js +54 -85
  138. package/dist/indexer/search/search-source.js +1 -4
  139. package/dist/indexer/usage/usage-events.js +2 -7
  140. package/dist/integrations/agent/engine-fallback.js +23 -40
  141. package/dist/integrations/agent/engine-resolution.js +93 -183
  142. package/dist/integrations/agent/execution.js +507 -0
  143. package/dist/integrations/agent/model-map.js +28 -156
  144. package/dist/integrations/agent/request-lowering.js +66 -141
  145. package/dist/integrations/agent/runner-dispatch.js +143 -321
  146. package/dist/integrations/agent/runner.js +54 -14
  147. package/dist/integrations/lockfile.js +53 -101
  148. package/dist/llm/embedders/deterministic.js +2 -3
  149. package/dist/llm/embedders/profile.js +71 -0
  150. package/dist/llm/embedders/remote.js +10 -15
  151. package/dist/llm/graph-extract.js +3 -12
  152. package/dist/llm/index-passes.js +3 -5
  153. package/dist/llm/memory-infer.js +1 -2
  154. package/dist/llm/metadata-enhance.js +1 -2
  155. package/dist/llm/structured-call.js +5 -24
  156. package/dist/output/generic-render.js +23 -11
  157. package/dist/output/html-render.js +13 -10
  158. package/dist/output/render-registry.js +3 -32
  159. package/dist/output/shapes/helpers.js +2 -34
  160. package/dist/output/shapes/passthrough.js +1 -9
  161. package/dist/{indexer/search/ranking-types.js → output/text/bundle-rename.js} +4 -1
  162. package/dist/output/text/command-format.js +60 -23
  163. package/dist/output/text/helpers.js +1 -1
  164. package/dist/output/text/migrate.js +5 -14
  165. package/dist/output/text/proposal-format.js +1 -2
  166. package/dist/output/text/workflow-format.js +0 -32
  167. package/dist/output/text.js +2 -0
  168. package/dist/registry/factory.js +4 -19
  169. package/dist/registry/network.js +66 -220
  170. package/dist/registry/providers/index.js +0 -2
  171. package/dist/registry/providers/skills-sh.js +3 -14
  172. package/dist/registry/providers/static-index.js +24 -26
  173. package/dist/registry/resolve.js +55 -131
  174. package/dist/scripts/akm-migrate-node.js +43937 -93313
  175. package/dist/scripts/akm-migrate.js +43697 -93071
  176. package/dist/setup/registry-stash-loader.js +4 -13
  177. package/dist/setup/semantic-assets.js +3 -44
  178. package/dist/setup/setup.js +1 -1
  179. package/dist/setup/steps/tasks.js +25 -15
  180. package/dist/sources/provider-factory.js +17 -18
  181. package/dist/sources/providers/filesystem.js +2 -3
  182. package/dist/sources/providers/git-install.js +7 -1
  183. package/dist/sources/providers/git-provider.js +0 -3
  184. package/dist/sources/providers/git-stash.js +0 -17
  185. package/dist/sources/providers/npm.js +2 -4
  186. package/dist/sources/providers/provider-utils.js +5 -10
  187. package/dist/sources/providers/website.js +0 -2
  188. package/dist/sources/snapshot-fetchers/website-ingest.js +1 -1
  189. package/dist/sources/website-url.js +2 -2
  190. package/dist/storage/database.js +9 -35
  191. package/dist/storage/repositories/improve-ledger-repository.js +168 -0
  192. package/dist/storage/repositories/index-connection.js +34 -70
  193. package/dist/storage/repositories/index-entries-repository.js +69 -111
  194. package/dist/storage/repositories/index-entry-mapper.js +1 -2
  195. package/dist/storage/repositories/index-entry-schema.js +83 -269
  196. package/dist/storage/repositories/index-fts-repository.js +86 -256
  197. package/dist/storage/repositories/index-llm-cache-repository.js +17 -0
  198. package/dist/storage/repositories/index-meta-repository.js +6 -4
  199. package/dist/storage/repositories/index-schema.js +192 -220
  200. package/dist/storage/repositories/index-utility-repository.js +8 -29
  201. package/dist/storage/repositories/index-vec-repository.js +133 -414
  202. package/dist/storage/repositories/outcome-repository.js +2 -1
  203. package/dist/storage/repositories/proposals-repository.js +35 -0
  204. package/dist/storage/repositories/registry-index-cache-repository.js +100 -0
  205. package/dist/storage/repositories/task-history-repository.js +26 -4
  206. package/dist/storage/repositories/workflow-runs-repository.js +53 -244
  207. package/dist/storage/sqlite-migrations.js +136 -0
  208. package/dist/storage/sqlite-pragmas.js +11 -9
  209. package/dist/storage/sqlite-transaction.js +170 -0
  210. package/dist/storage/state-db-integrity.js +34 -27
  211. package/dist/tasks/activation-config.js +134 -62
  212. package/dist/tasks/backends/cron.js +129 -277
  213. package/dist/tasks/backends/exec-utils.js +2 -5
  214. package/dist/tasks/backends/launchd.js +125 -745
  215. package/dist/tasks/backends/schtasks.js +101 -620
  216. package/dist/tasks/prepare/prepare-support.js +5 -15
  217. package/dist/tasks/prepare/prepare.js +0 -2
  218. package/dist/tasks/resolve-akm-bin.js +20 -79
  219. package/dist/tasks/run/attempt-lifecycle.js +0 -1
  220. package/dist/tasks/scheduler-binding.js +18 -238
  221. package/dist/tasks/scheduler-invocation.js +52 -52
  222. package/dist/tasks/scheduler-lock.js +53 -0
  223. package/dist/tasks/scheduler-sync.js +363 -679
  224. package/dist/tasks/source/parse-task-source.js +160 -10
  225. package/dist/tasks/source/task-source-v3-frozen.js +3 -4
  226. package/dist/tasks/source/task-to-v4.js +2 -2
  227. package/dist/workflows/authoring/authoring.js +3 -12
  228. package/dist/workflows/compile.js +211 -0
  229. package/dist/workflows/concurrency-policy.js +13 -74
  230. package/dist/workflows/exec/child-invocation.js +3 -17
  231. package/dist/workflows/exec/child-workflow.js +32 -141
  232. package/dist/workflows/exec/dispatch-redaction.js +13 -53
  233. package/dist/workflows/exec/environment.js +98 -0
  234. package/dist/workflows/exec/exec-unit.js +33 -140
  235. package/dist/workflows/exec/frozen-judge.js +7 -59
  236. package/dist/workflows/exec/native-executor.js +82 -341
  237. package/dist/workflows/exec/param-secrets.js +29 -47
  238. package/dist/workflows/exec/run-workflow.js +154 -387
  239. package/dist/workflows/exec/scheduler.js +9 -36
  240. package/dist/workflows/exec/step-work.js +127 -430
  241. package/dist/workflows/exec/unit-dispatch.js +11 -63
  242. package/dist/workflows/exec/unit-writer.js +8 -52
  243. package/dist/workflows/exec/worktree.js +39 -273
  244. package/dist/workflows/freeze/child-output-references.js +4 -15
  245. package/dist/workflows/freeze/environment.js +99 -92
  246. package/dist/workflows/freeze/freeze.js +172 -0
  247. package/dist/workflows/freeze/step-values.js +19 -21
  248. package/dist/workflows/freeze/targets/child-workflow.js +23 -92
  249. package/dist/workflows/freeze/targets/command.js +10 -33
  250. package/dist/workflows/freeze/targets/script.js +5 -12
  251. package/dist/workflows/freeze/targets/shell.js +3 -6
  252. package/dist/workflows/freeze/targets/task.js +25 -80
  253. package/dist/workflows/freeze/task-bindings.js +20 -67
  254. package/dist/workflows/{source-ir/github-yaml.js → github-yaml.js} +88 -206
  255. package/dist/workflows/ir/params.js +6 -51
  256. package/dist/workflows/ir/plan-hash.js +2 -34
  257. package/dist/workflows/parser.js +140 -43
  258. package/dist/{commands/improve/consolidate/types.js → workflows/plan.js} +2 -1
  259. package/dist/workflows/renderer.js +36 -69
  260. package/dist/workflows/resource-limits.js +12 -120
  261. package/dist/workflows/runtime/agent-identity.js +8 -40
  262. package/dist/workflows/runtime/run-outputs.js +3 -6
  263. package/dist/workflows/runtime/run-plan.js +316 -0
  264. package/dist/workflows/runtime/runs.js +48 -200
  265. package/dist/workflows/runtime/workflow-asset-loader.js +24 -57
  266. package/dist/workflows/{source-ir/semantics.js → source-semantics.js} +16 -20
  267. package/dist/workflows/validate-summary.js +2 -7
  268. package/docs/integration/bundling-akm.md +49 -42
  269. package/docs/migration/README.md +1 -0
  270. package/docs/migration/release-notes/0.9.17.md +41 -0
  271. package/docs/migration/v0.9.1-to-v0.9.2.md +19 -7
  272. package/docs/reference/cli.md +182 -125
  273. package/docs/reference/configuration.md +49 -56
  274. package/docs/reference/data-and-telemetry.md +19 -20
  275. package/docs/reference/tasks.md +86 -38
  276. package/docs/reference/workflow-schema.md +14 -18
  277. package/docs/reference/workflows.md +6 -9
  278. package/package.json +1 -1
  279. package/schemas/akm-config.json +87 -406
  280. package/dist/commands/health/advisories.js +0 -150
  281. package/dist/commands/health/metrics.js +0 -329
  282. package/dist/commands/health/surfaces.js +0 -102
  283. package/dist/commands/improve/anti-collapse.js +0 -83
  284. package/dist/commands/improve/collapse-detector.js +0 -432
  285. package/dist/commands/improve/consolidate/eligibility.js +0 -48
  286. package/dist/commands/improve/consolidate/merge.js +0 -146
  287. package/dist/commands/improve/distill/promote-memory.js +0 -329
  288. package/dist/commands/improve/distill/quality-gate.js +0 -500
  289. package/dist/commands/improve/memory/memory-contradiction-detect.js +0 -291
  290. package/dist/commands/improve/proposal-envelope.js +0 -31
  291. package/dist/commands/improve/run-context.js +0 -123
  292. package/dist/commands/improve/shared.js +0 -21
  293. package/dist/commands/improve/source-identity.js +0 -28
  294. package/dist/commands/improve/triage.js +0 -96
  295. package/dist/commands/proposal/drain-policies.js +0 -151
  296. package/dist/commands/sources/update-transaction.js +0 -220
  297. package/dist/core/action-contributors.js +0 -28
  298. package/dist/core/config/config-version-shim.js +0 -101
  299. package/dist/core/config/retired-experimental-keys-shim.js +0 -62
  300. package/dist/core/fs-txn.js +0 -405
  301. package/dist/core/lexical-score.js +0 -25
  302. package/dist/core/maintenance-barrier.js +0 -167
  303. package/dist/execution/executable-identity.js +0 -105
  304. package/dist/execution/guarded-source.js +0 -427
  305. package/dist/indexer/graph/graph-boost.js +0 -427
  306. package/dist/indexer/graph/graph-dedup.js +0 -95
  307. package/dist/indexer/search/name-match.js +0 -35
  308. package/dist/indexer/search/ranking-contributors.js +0 -515
  309. package/dist/indexer/walk/project-context.js +0 -192
  310. package/dist/integrations/agent/execution-cascade.js +0 -566
  311. package/dist/integrations/agent/execution-definitions.js +0 -202
  312. package/dist/integrations/agent/execution-lowering.js +0 -841
  313. package/dist/integrations/agent/execution-preparation.js +0 -98
  314. package/dist/integrations/agent/inline-execution.js +0 -74
  315. package/dist/registry/create-provider-registry.js +0 -29
  316. package/dist/registry/pinned-request-helper.js +0 -247
  317. package/dist/registry/pinned-transport.js +0 -717
  318. package/dist/sources/providers/index.js +0 -14
  319. package/dist/storage/engines/sqlite-migrations.js +0 -271
  320. package/dist/storage/repositories/canaries-repository.js +0 -107
  321. package/dist/storage/repositories/embedding-salvage-repository.js +0 -184
  322. package/dist/storage/repositories/registry-cache.js +0 -113
  323. package/dist/tasks/scheduler-sync-preview.js +0 -52
  324. package/dist/workflows/freeze/resolve-steps.js +0 -86
  325. package/dist/workflows/freeze/source-freeze.js +0 -64
  326. package/dist/workflows/ir/compile.js +0 -321
  327. package/dist/workflows/ir/environment-v4.js +0 -330
  328. package/dist/workflows/ir/freeze-v4.js +0 -153
  329. package/dist/workflows/ir/schema-v4.js +0 -745
  330. package/dist/workflows/ir/schema.js +0 -354
  331. package/dist/workflows/program/schema.js +0 -78
  332. package/dist/workflows/runtime/checkin.js +0 -57
  333. package/dist/workflows/runtime/plan-classifier.js +0 -196
  334. package/dist/workflows/runtime/unit-checkin.js +0 -45
  335. package/dist/workflows/runtime/unit-phases.js +0 -20
  336. package/dist/workflows/schema.js +0 -4
  337. package/dist/workflows/source-ir/compile.js +0 -200
  338. package/dist/workflows/source-ir/program.js +0 -50
  339. package/dist/workflows/source-ir/result.js +0 -26
  340. package/dist/workflows/source-ir/schema.js +0 -786
  341. package/dist/workflows/source-ir/triggers.js +0 -79
  342. package/dist/workflows/source-ir/uses.js +0 -40
  343. package/dist/workflows/validator.js +0 -60
@@ -1,102 +1,38 @@
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 { bundleRefToString, isBundleSlug, parseBundleRef } from "../core/asset/asset-ref.js";
5
- import { cloneExecutionJson, cloneExecutionJsonObject, } from "./json.js";
6
- import { assertSnapshotKeys, snapshotStrictRecord } from "./record.js";
4
+ import { cloneExecutionJson, cloneExecutionJsonObject } from "./json.js";
7
5
  export const EXECUTION_SOURCE_SCHEMA_VERSION = 1;
8
6
  /** Current internal adapter identifiers are lowercase kebab-case registry keys. */
9
7
  export const EXECUTION_ADAPTER_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
10
- const CONTROL_OR_LINE_SEPARATOR_PATTERN = /[\p{Cc}\p{Zl}\p{Zp}]/u;
11
- const FORMAT_CHARACTER_PATTERN = /\p{Cf}/u;
12
- const WINDOWS_DRIVE_PREFIX_PATTERN = /^[A-Za-z]:/;
13
- const RESERVED_EXTENSION_OWNERS = new Set(["__proto__", "constructor", "prototype", "tostring"]);
14
- const renderedSourceBrand = Symbol("akm.adapter-rendered-execution-source");
15
- const renderedSourceInstances = new WeakSet();
16
8
  function requireRecord(value, path) {
17
- return snapshotStrictRecord(value, path);
18
- }
19
- function assertOnlyKeys(value, allowed, path) {
20
- assertSnapshotKeys(value, allowed, path);
21
- }
22
- function validateExtensionOwner(owner) {
23
- const normalized = owner.toLowerCase();
24
- if (!EXECUTION_ADAPTER_ID_PATTERN.test(owner) || RESERVED_EXTENSION_OWNERS.has(normalized)) {
25
- throw new TypeError(`extension owner must be a canonical, non-reserved adapter identifier: ${owner}`);
26
- }
27
- }
28
- function frozenNullPrototypeMap(entries) {
29
- const out = Object.create(null);
30
- for (const [key, value] of entries) {
31
- Object.defineProperty(out, key, { value, enumerable: true, configurable: false, writable: false });
9
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
10
+ throw new TypeError(`${path} must be an object`);
32
11
  }
33
- return Object.freeze(out);
34
- }
35
- function cloneExtensionEntry(value, index) {
36
- const path = `extension entry ${index}`;
37
- const cloned = cloneExecutionJson(value, path);
38
- if (!Array.isArray(cloned) || cloned.length !== 2) {
39
- throw new TypeError(`${path} must be a two-element [owner, values] array`);
40
- }
41
- const [owner, values] = cloned;
42
- if (typeof owner !== "string")
43
- throw new TypeError(`${path} owner must be a string`);
44
- if (values === null || Array.isArray(values) || typeof values !== "object") {
45
- throw new TypeError(`${path} values must be a JSON object`);
46
- }
47
- return [owner, values];
12
+ return value;
48
13
  }
49
14
  export function createAdapterExtensions(first, second, ...rest) {
50
- const rawEntries = typeof first === "string"
15
+ const entries = typeof first === "string"
51
16
  ? [[first, second]]
52
17
  : [first, ...(second === undefined ? [] : [second]), ...rest];
53
- const seen = new Set();
54
- const cloned = [];
55
- for (const [index, rawEntry] of rawEntries.entries()) {
56
- const [owner, values] = cloneExtensionEntry(rawEntry, index);
57
- validateExtensionOwner(owner);
58
- if (seen.has(owner))
59
- throw new TypeError(`duplicate extension owner: ${owner}`);
60
- seen.add(owner);
61
- cloned.push([owner, cloneExecutionJsonObject(values, `extensions.${owner}`)]);
62
- }
63
- return frozenNullPrototypeMap(cloned);
18
+ const out = {};
19
+ for (const [owner, values] of entries)
20
+ out[owner] = cloneExecutionJsonObject(values, `extensions.${owner}`);
21
+ return Object.freeze(out);
64
22
  }
65
23
  export function cloneAdapterExtensions(value, path) {
66
- const record = requireRecord(value, path);
67
- const entries = Object.entries(record).map(([owner, fields]) => [owner, cloneExecutionJsonObject(fields, `${path}.${owner}`)]);
68
- const [first, ...rest] = entries;
69
- return first ? createAdapterExtensions(first, ...rest) : frozenNullPrototypeMap([]);
70
- }
71
- function requireCanonicalString(value, path) {
72
- if (typeof value !== "string" ||
73
- value.length === 0 ||
74
- !isWellFormedUnicode(value) ||
75
- value.normalize("NFC") !== value) {
76
- throw new TypeError(`${path} must be a non-empty NFC string`);
77
- }
78
- if (hasUnsafeIdentityCharacter(value)) {
79
- throw new TypeError(`${path} must not contain Unicode control or dangerous format characters`);
24
+ const out = {};
25
+ for (const [owner, fields] of Object.entries(requireRecord(value, path))) {
26
+ out[owner] = cloneExecutionJsonObject(fields, `${path}.${owner}`);
80
27
  }
81
- return value;
28
+ return Object.freeze(out);
82
29
  }
83
- /**
84
- * Validate a selector/layer identifier that may cross the durable execution
85
- * boundary. Keep this shared with the cascade planner so resume cannot admit a
86
- * selector the live planner would reject.
87
- */
30
+ /** A selector or layer identifier that may cross the durable execution boundary. */
88
31
  export function requireStableExecutionSelector(value, path) {
89
- let selector;
90
- try {
91
- selector = requireCanonicalString(value, path);
32
+ if (typeof value !== "string" || value.length === 0 || value.trim() !== value) {
33
+ throw new TypeError(`${path} must be a non-empty stable identifier`);
92
34
  }
93
- catch (cause) {
94
- throw new TypeError(`${path} must be a non-empty stable NFC identifier`, { cause });
95
- }
96
- if (selector.length > 512 || selector.trim() !== selector) {
97
- throw new TypeError(`${path} must be a non-empty stable NFC identifier`);
98
- }
99
- return selector;
35
+ return value;
100
36
  }
101
37
  export function isPortableExecutionAgentSelector(value) {
102
38
  return /^(?:[^/]+\/\/)?agents\/[^/]/.test(value);
@@ -104,118 +40,61 @@ export function isPortableExecutionAgentSelector(value) {
104
40
  export function executionPersonaMatchesSelector(selector, personaRef) {
105
41
  return selector.includes("//") ? selector === personaRef : personaRef.endsWith(`//${selector}`);
106
42
  }
107
- function hasUnsafeIdentityCharacter(value) {
108
- for (const character of value) {
109
- if (CONTROL_OR_LINE_SEPARATOR_PATTERN.test(character))
110
- return true;
111
- // Join controls are meaningful in emoji and several writing systems. All
112
- // other format controls are rejected because they can conceal identity.
113
- if (FORMAT_CHARACTER_PATTERN.test(character) && character !== "\u200C" && character !== "\u200D")
114
- return true;
115
- }
116
- return false;
117
- }
118
- function isWellFormedUnicode(value) {
119
- for (let index = 0; index < value.length; index += 1) {
120
- const code = value.charCodeAt(index);
121
- if (code >= 0xd800 && code <= 0xdbff) {
122
- const next = value.charCodeAt(index + 1);
123
- if (!(next >= 0xdc00 && next <= 0xdfff))
124
- return false;
125
- index += 1;
126
- }
127
- else if (code >= 0xdc00 && code <= 0xdfff) {
128
- return false;
129
- }
130
- }
131
- return true;
132
- }
133
- function validateCanonicalIdentity(input, path) {
134
- assertOnlyKeys(input, ["ref", "bundle", "adapter", "file", "hash"], path);
135
- const ref = requireCanonicalString(input.ref, `${path}.ref`);
136
- const bundle = requireCanonicalString(input.bundle, `${path}.bundle`);
137
- const adapter = requireCanonicalString(input.adapter, `${path}.adapter`);
138
- const file = requireCanonicalString(input.file, `${path}.file`);
139
- const hash = requireCanonicalString(input.hash, `${path}.hash`);
140
- let parsed;
141
- try {
142
- parsed = parseBundleRef(ref);
143
- }
144
- catch (cause) {
145
- throw new TypeError(`${path}.ref is not a canonical bundle ref`, { cause });
146
- }
147
- if (parsed.bundle === undefined ||
148
- parsed.bundle !== bundle ||
149
- parsed.fragment !== undefined ||
150
- !isBundleSlug(bundle) ||
151
- bundleRefToString(parsed) !== ref) {
152
- throw new TypeError(`${path}.ref must round-trip as the same fully-qualified bundle ref without a fragment`);
153
- }
154
- if (!EXECUTION_ADAPTER_ID_PATTERN.test(adapter)) {
155
- throw new TypeError(`${path}.adapter must use the current lowercase kebab-case adapter identifier grammar`);
156
- }
157
- const segments = file.split("/");
158
- if (file.startsWith("/") ||
159
- WINDOWS_DRIVE_PREFIX_PATTERN.test(file) ||
160
- file.includes("\\") ||
161
- segments.some((segment) => !segment || segment === "." || segment === "..")) {
162
- throw new TypeError(`${path}.file must be a normalized relative POSIX path`);
43
+ const IDENTITY_KEYS = ["ref", "bundle", "adapter", "file", "hash"];
44
+ function identityFrom(value, path) {
45
+ const record = requireRecord(value, path);
46
+ const out = {};
47
+ for (const key of IDENTITY_KEYS) {
48
+ const field = record[key];
49
+ if (typeof field !== "string" || field.length === 0)
50
+ throw new TypeError(`${path}.${key} must be a non-empty string`);
51
+ out[key] = field;
163
52
  }
164
- if (!/^[a-f0-9]{64}$/.test(hash))
165
- throw new TypeError(`${path}.hash must be a SHA-256 hex digest`);
166
- return { ref, bundle, adapter, file, hash };
53
+ return Object.freeze(out);
167
54
  }
168
- /** Validate, clone, and freeze an identity produced by an adapter renderer. */
55
+ /** Copy and freeze an identity produced by an adapter renderer. */
169
56
  export function createExecutionSourceIdentity(input) {
170
- return Object.freeze(validateCanonicalIdentity(requireRecord(input, "execution source identity"), "execution source identity"));
57
+ return identityFrom(input, "execution source identity");
171
58
  }
172
- /** Validate and freeze a source identity read back from a frozen request. */
59
+ /** Read a source identity back from a frozen request. */
173
60
  export function decodeExecutionSourceIdentity(value, path = "execution source identity") {
174
- return Object.freeze(validateCanonicalIdentity(requireRecord(value, path), path));
61
+ return identityFrom(value, path);
175
62
  }
63
+ /** Type-check the common frontmatter defaults; unknown keys are ignored. */
176
64
  export function cloneUnresolvedExecutionDefaults(input, path = "execution source defaults") {
177
65
  const record = requireRecord(input, path);
178
- assertOnlyKeys(record, [
179
- "agent",
180
- "engine",
181
- "model",
182
- "inference",
183
- "outputSchema",
184
- "tools",
185
- "timeout",
186
- "workspace",
187
- "environment",
188
- "runtime",
189
- ], path);
190
- const json = { ...cloneExecutionJsonObject(record, path) };
66
+ const present = (key) => Object.hasOwn(record, key) && record[key] !== undefined;
67
+ const out = {};
191
68
  for (const key of ["agent", "engine", "model", "workspace"]) {
192
- const value = json[key];
193
- if (value !== undefined && value !== null && typeof value !== "string") {
69
+ if (!present(key))
70
+ continue;
71
+ const value = record[key];
72
+ if (value !== null && typeof value !== "string")
194
73
  throw new TypeError(`${path}.${key} must be a string or null`);
195
- }
74
+ out[key] = value;
196
75
  }
197
- const timeout = json.timeout;
198
- if (timeout !== undefined && timeout !== null && typeof timeout !== "string" && typeof timeout !== "number") {
199
- throw new TypeError(`${path}.timeout must be a string, number, or null`);
76
+ if (present("timeout")) {
77
+ const timeout = record.timeout;
78
+ if (timeout !== null && typeof timeout !== "string" && typeof timeout !== "number") {
79
+ throw new TypeError(`${path}.timeout must be a string, number, or null`);
80
+ }
81
+ out.timeout = timeout;
200
82
  }
201
83
  for (const key of ["inference", "outputSchema", "runtime"]) {
202
- const value = json[key];
203
- if (value !== undefined && value !== null && (Array.isArray(value) || typeof value !== "object")) {
204
- throw new TypeError(`${path}.${key} must be an object or null`);
205
- }
84
+ if (!present(key))
85
+ continue;
86
+ out[key] = record[key] === null ? null : cloneExecutionJsonObject(record[key], `${path}.${key}`);
206
87
  }
207
- const environment = json.environment;
208
- if (environment !== undefined && environment !== null) {
209
- if (Array.isArray(environment) || typeof environment !== "object") {
210
- throw new TypeError(`${path}.environment must be an object or null`);
211
- }
212
- if (Object.values(environment).some((value) => typeof value !== "string")) {
88
+ if (present("environment")) {
89
+ const environment = record.environment === null ? null : cloneExecutionJsonObject(record.environment, `${path}.environment`);
90
+ if (environment !== null && Object.values(environment).some((value) => typeof value !== "string")) {
213
91
  throw new TypeError(`${path}.environment values must be strings`);
214
92
  }
93
+ out.environment = environment;
215
94
  }
216
- if (Object.hasOwn(json, "tools"))
217
- json.tools = cloneToolSelection(json.tools, `${path}.tools`);
218
- return Object.freeze(json);
95
+ if (present("tools"))
96
+ out.tools = cloneToolSelection(record.tools, `${path}.tools`);
97
+ return Object.freeze(out);
219
98
  }
220
99
  export function cloneToolSelection(value, path = "tools") {
221
100
  if (value === null || typeof value === "string")
@@ -229,92 +108,19 @@ export function cloneToolSelection(value, path = "tools") {
229
108
  return cloneExecutionJsonObject(cloned, path);
230
109
  }
231
110
  export function createAdapterRenderedExecutionSource(input) {
232
- const record = requireRecord(input, "adapter-rendered execution source");
233
- assertOnlyKeys(record, ["kind", "content", "identity", "defaults", "extensions"], "adapter-rendered execution source");
234
- const kind = record.kind;
235
- const content = record.content;
111
+ const { kind, content } = input;
236
112
  if (kind !== "command" && kind !== "persona")
237
113
  throw new TypeError("execution source kind is invalid");
238
114
  if (typeof content !== "string")
239
115
  throw new TypeError("execution source content must be a string");
240
- const defaults = Object.hasOwn(record, "defaults") ? record.defaults : {};
241
- if (defaults === undefined) {
242
- throw new TypeError("execution source defaults must be omitted or be an object");
243
- }
244
- const source = {
116
+ return Object.freeze({
245
117
  schemaVersion: EXECUTION_SOURCE_SCHEMA_VERSION,
246
118
  kind,
247
119
  content,
248
- defaults: cloneUnresolvedExecutionDefaults(defaults),
249
- identity: createExecutionSourceIdentity(record.identity),
250
- };
251
- if (Object.hasOwn(record, "extensions")) {
252
- source.extensions = cloneAdapterExtensions(record.extensions, "execution source extensions");
253
- }
254
- Object.defineProperty(source, renderedSourceBrand, { value: true, enumerable: false });
255
- const frozen = Object.freeze(source);
256
- renderedSourceInstances.add(frozen);
257
- return frozen;
258
- }
259
- function isAdapterRenderedSource(value, kind) {
260
- if (typeof value !== "object" || value === null)
261
- return false;
262
- if (!renderedSourceInstances.has(value))
263
- return false;
264
- if (Object.getPrototypeOf(value) !== Object.prototype || !Object.isFrozen(value))
265
- return false;
266
- if (!Object.hasOwn(value, renderedSourceBrand))
267
- return false;
268
- const required = new Set(["schemaVersion", "kind", "content", "defaults", "identity"]);
269
- const optional = new Set(["extensions"]);
270
- for (const key of Reflect.ownKeys(value)) {
271
- if (key === renderedSourceBrand)
272
- continue;
273
- if (typeof key !== "string" || (!required.delete(key) && !optional.has(key)))
274
- return false;
275
- const descriptor = Object.getOwnPropertyDescriptor(value, key);
276
- if (!descriptor || !("value" in descriptor) || !descriptor.enumerable)
277
- return false;
278
- }
279
- if (required.size > 0)
280
- return false;
281
- const brand = Object.getOwnPropertyDescriptor(value, renderedSourceBrand);
282
- const source = value;
283
- if (brand?.value !== true ||
284
- brand.enumerable !== false ||
285
- brand.configurable !== false ||
286
- brand.writable !== false ||
287
- source.schemaVersion !== EXECUTION_SOURCE_SCHEMA_VERSION ||
288
- source.kind !== kind ||
289
- typeof source.content !== "string" ||
290
- typeof source.defaults !== "object" ||
291
- source.defaults === null ||
292
- !Object.isFrozen(source.defaults) ||
293
- typeof source.identity !== "object" ||
294
- source.identity === null ||
295
- Object.getPrototypeOf(source.identity) !== Object.prototype ||
296
- !Object.isFrozen(source.identity)) {
297
- return false;
298
- }
299
- if (Object.hasOwn(source, "extensions")) {
300
- if (typeof source.extensions !== "object" ||
301
- source.extensions === null ||
302
- Object.getPrototypeOf(source.extensions) !== null ||
303
- !Object.isFrozen(source.extensions)) {
304
- return false;
305
- }
306
- }
307
- try {
308
- decodeExecutionSourceIdentity(source.identity);
309
- }
310
- catch {
311
- return false;
312
- }
313
- return true;
314
- }
315
- export function isAdapterRenderedCommandSource(value) {
316
- return isAdapterRenderedSource(value, "command");
317
- }
318
- export function isAdapterRenderedPersonaSource(value) {
319
- return isAdapterRenderedSource(value, "persona");
120
+ defaults: cloneUnresolvedExecutionDefaults(input.defaults ?? {}),
121
+ identity: createExecutionSourceIdentity(input.identity),
122
+ ...(input.extensions
123
+ ? { extensions: cloneAdapterExtensions(input.extensions, "execution source extensions") }
124
+ : {}),
125
+ });
320
126
  }
@@ -23,7 +23,7 @@
23
23
  * Explicit non-goals (binding, spec §4.1): no GitHub locator grammar, no
24
24
  * `akm/command` builtin special case, no resolution, no filesystem access, no
25
25
  * guessing. Callers layer builtin detection on top (see
26
- * `classifyWorkflowSourceUses` in src/workflows/source-ir/uses.ts).
26
+ * `classifyWorkflowStepUses` in src/workflows/source-semantics.ts).
27
27
  */
28
28
  import { bundleRefToString, parseBundleRef } from "../core/asset/asset-ref.js";
29
29
  import { UsageError } from "../core/errors.js";
@@ -34,7 +34,7 @@ import { rethrowIfTestIsolationError } from "../core/errors.js";
34
34
  import { getDbPath } from "../core/paths.js";
35
35
  import { warn } from "../core/warn.js";
36
36
  import { closeDatabase, openReadonlyExistingDatabase } from "../storage/repositories/index-connection.js";
37
- import { isCanonicalIndexGeneration } from "../storage/repositories/index-entry-schema.js";
37
+ import { hasCurrentEntriesTable } from "../storage/repositories/index-entry-schema.js";
38
38
  let guardSettled = false;
39
39
  /** TEST-ONLY: re-arm the once-per-process guard between cases. */
40
40
  export function resetBundleIdentityGuardForTests() {
@@ -49,7 +49,7 @@ function indexedBundlePaths(dbPath) {
49
49
  db = openReadonlyExistingDatabase(dbPath);
50
50
  if (!db)
51
51
  return undefined;
52
- if (!isCanonicalIndexGeneration(db))
52
+ if (!hasCurrentEntriesTable(db))
53
53
  return undefined;
54
54
  return db
55
55
  .prepare("SELECT DISTINCT bundle_id AS bundleId, file_path AS filePath FROM entries " +
@@ -29,16 +29,93 @@ function uniqueSorted(values) {
29
29
  function normalizeEntity(value) {
30
30
  return value.trim().toLowerCase();
31
31
  }
32
+ /** Child rows joined to their file row, the same join the loaders read through. */
33
+ const STORED_ENTITIES = `graph_file_entities e
34
+ JOIN graph_files gf ON gf.stash_root = e.stash_root AND gf.file_path = e.file_path AND gf.body_hash = e.body_hash
35
+ WHERE gf.stash_root = ?`;
36
+ const STORED_RELATIONS = `graph_file_relations r
37
+ JOIN graph_files gf ON gf.stash_root = r.stash_root AND gf.file_path = r.file_path AND gf.body_hash = r.body_hash
38
+ WHERE gf.stash_root = ?`;
39
+ /** One comparable key for a file's extraction: its entities and relations, in order. */
40
+ function extractionKey(entities, relations) {
41
+ return JSON.stringify([entities, relations.map((r) => [r.from, r.to, r.type ?? null, r.confidence ?? null])]);
42
+ }
43
+ const EMPTY_EXTRACTION_KEY = extractionKey([], []);
44
+ /** The extraction key of every stored file under a root that has child rows. */
45
+ function readStoredExtractionKeys(db, stashRoot) {
46
+ const entityRows = db
47
+ .prepare(`SELECT e.file_path AS file_path, e.entity AS entity FROM ${STORED_ENTITIES} ORDER BY e.file_path, e.entity_order`)
48
+ .all(stashRoot);
49
+ const relationRows = db
50
+ .prepare(`SELECT r.file_path AS file_path, r.from_entity AS from_entity, r.to_entity AS to_entity,
51
+ r.relation_type AS relation_type, r.confidence AS confidence
52
+ FROM ${STORED_RELATIONS} ORDER BY r.file_path, r.relation_order`)
53
+ .all(stashRoot);
54
+ const byPath = new Map();
55
+ const bucket = (filePath) => {
56
+ let entry = byPath.get(filePath);
57
+ if (!entry) {
58
+ entry = { entities: [], relations: [] };
59
+ byPath.set(filePath, entry);
60
+ }
61
+ return entry;
62
+ };
63
+ for (const row of entityRows)
64
+ bucket(row.file_path).entities.push(row.entity);
65
+ for (const row of relationRows) {
66
+ bucket(row.file_path).relations.push({
67
+ from: row.from_entity,
68
+ to: row.to_entity,
69
+ ...(row.relation_type !== null ? { type: row.relation_type } : {}),
70
+ ...(row.confidence !== null ? { confidence: row.confidence } : {}),
71
+ });
72
+ }
73
+ return new Map([...byPath].map(([filePath, stored]) => [filePath, extractionKey(stored.entities, stored.relations)]));
74
+ }
75
+ function roundMetric(value) {
76
+ return Number(value.toFixed(4));
77
+ }
78
+ /**
79
+ * The graph_meta counts, derived from the stored rows of one root. Each field
80
+ * has one meaning (see {@link GraphQualityTelemetry}): files are graph_files
81
+ * rows, entities are distinct case-folded names, relations are distinct
82
+ * case-folded (from, to, type) triples.
83
+ */
84
+ function readStoredGraphQuality(db, stashRoot) {
85
+ const count = (sql) => db.prepare(sql).get(stashRoot).n;
86
+ const storedFiles = count("SELECT COUNT(*) AS n FROM graph_files WHERE stash_root = ?");
87
+ const filesWithEntities = count(`SELECT COUNT(DISTINCT gf.file_path) AS n FROM ${STORED_ENTITIES}`);
88
+ const entityCount = count(`SELECT COUNT(DISTINCT e.entity_norm) AS n FROM ${STORED_ENTITIES}`);
89
+ const relationCount = count(`SELECT COUNT(*) AS n FROM (
90
+ SELECT DISTINCT r.from_entity_norm, r.to_entity_norm, lower(coalesce(r.relation_type, '')) FROM ${STORED_RELATIONS}
91
+ )`);
92
+ const maxEdges = entityCount > 1 ? (entityCount * (entityCount - 1)) / 2 : 0;
93
+ return {
94
+ consideredFiles: storedFiles,
95
+ extractedFiles: filesWithEntities,
96
+ entityCount,
97
+ relationCount,
98
+ extractionCoverage: storedFiles > 0 ? roundMetric(filesWithEntities / storedFiles) : 0,
99
+ density: maxEdges > 0 ? roundMetric(relationCount / maxEdges) : 0,
100
+ };
101
+ }
32
102
  /**
33
103
  * Persist (or update) a graph snapshot for a stash root.
34
104
  *
35
105
  * #624-P1: keyed on (stash_root, file_path, body_hash) — NOT entries.id. Graph
36
106
  * rows are self-keyed by path, so they survive an entries delete + reinsert
37
- * (a reindex) when body_hash is unchanged. Unchanged files (matching body_hash)
38
- * only have their file-meta refreshed; files whose body_hash changed have their
39
- * old row + child rows deleted and the new content inserted; files in DB but
40
- * absent from the new snapshot are deleted. There is no entry_id resolution and
41
- * no orphan-skip — a graph file no longer needs a matching entries row.
107
+ * (a reindex) when body_hash is unchanged. A file whose body_hash is unchanged
108
+ * keeps its row; its entity and relation rows are rewritten only when they
109
+ * differ from the snapshot's (a re-extraction of the same body, e.g. after a
110
+ * model or prompt change or a failed first attempt, must land). Files whose
111
+ * body_hash changed have their old row + child rows deleted and the new content
112
+ * inserted; files in DB but absent from the new snapshot are deleted. There is
113
+ * no entry_id resolution and no orphan-skip — a graph file no longer needs a
114
+ * matching entries row.
115
+ *
116
+ * graph_meta records the snapshot's schema version, time and run telemetry;
117
+ * its counts are derived from the rows as stored after the write, never from
118
+ * the caller's in-memory graph.
42
119
  */
43
120
  export function replaceStoredGraph(db, graph) {
44
121
  const upsertMeta = db.prepare(`INSERT INTO graph_meta (
@@ -94,16 +171,15 @@ export function replaceStoredGraph(db, graph) {
94
171
  const insertRelation = db.prepare(`INSERT INTO graph_file_relations (
95
172
  stash_root, file_path, body_hash, relation_order, from_entity_norm, from_entity, to_entity_norm, to_entity, relation_type, confidence
96
173
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
97
- const quality = graph.quality;
98
174
  const telemetry = graph.telemetry;
99
175
  db.transaction(() => {
100
- upsertMeta.run(graph.stashRoot, graph.schemaVersion, graph.generatedAt, quality?.consideredFiles ?? graph.files.length, quality?.extractedFiles ?? graph.files.length, quality?.entityCount ?? graph.entities?.length ?? 0, quality?.relationCount ?? graph.relations?.length ?? 0, quality?.extractionCoverage ?? 0, quality?.density ?? 0, telemetry?.extractorId ?? null, telemetry?.extractionRunId ?? null, telemetry?.model ?? null, telemetry?.promptVersion ?? null, telemetry?.batchSize ?? null, telemetry?.cacheHits ?? 0, telemetry?.cacheMisses ?? 0, telemetry?.truncationCount ?? 0, telemetry?.failureCount ?? 0);
101
176
  // Build a snapshot of existing rows for incremental compare. The unique
102
177
  // index idx_graph_files_path guarantees at most one row per file_path.
103
178
  const existingRows = selectExisting.all(graph.stashRoot);
104
179
  const existingByPath = new Map();
105
180
  for (const row of existingRows)
106
181
  existingByPath.set(row.file_path, row);
182
+ const storedKeys = readStoredExtractionKeys(db, graph.stashRoot);
107
183
  const presentPaths = new Set();
108
184
  for (const [fileOrder, node] of graph.files.entries()) {
109
185
  // body_hash is part of the PK; default to a sentinel for inputs (test
@@ -112,22 +188,32 @@ export function replaceStoredGraph(db, graph) {
112
188
  // correct behaviour for "unknown" bodies. Distinct files in one stash
113
189
  // are still keyed apart by file_path, so the empty sentinel is safe.
114
190
  const bodyHash = node.bodyHash && node.bodyHash.length > 0 ? node.bodyHash : "";
191
+ const status = node.status ?? (node.entities.length > 0 ? "extracted" : "empty");
192
+ const reason = node.reason ?? (node.entities.length > 0 ? "none" : "no_graph_content");
193
+ const runId = node.extractionRunId ?? telemetry?.extractionRunId ?? null;
115
194
  presentPaths.add(node.path);
116
195
  const existing = existingByPath.get(node.path);
117
196
  if (existing && existing.body_hash === bodyHash) {
118
- // Body unchanged — only fix up file_order/confidence in case they drifted.
119
- updateFileMeta.run(fileOrder, node.type, node.confidence ?? null, node.status ?? (node.entities.length > 0 ? "extracted" : "empty"), node.reason ?? (node.entities.length > 0 ? "none" : "no_graph_content"), node.extractionRunId ?? telemetry?.extractionRunId ?? null, graph.stashRoot, node.path, bodyHash);
120
- continue;
197
+ // Body unchanged — refresh the file meta, and rewrite the child rows
198
+ // only when this snapshot's extraction differs from the stored one.
199
+ updateFileMeta.run(fileOrder, node.type, node.confidence ?? null, status, reason, runId, graph.stashRoot, node.path, bodyHash);
200
+ const storedKey = storedKeys.get(node.path) ?? EMPTY_EXTRACTION_KEY;
201
+ if (storedKey === extractionKey(node.entities, node.relations))
202
+ continue;
203
+ deleteEntities.run(graph.stashRoot, node.path, bodyHash);
204
+ deleteRelations.run(graph.stashRoot, node.path, bodyHash);
121
205
  }
122
- if (existing) {
123
- // Stale row (different body_hash for this path). Delete the old row by
124
- // its OLD body_hash; child rows cascade, but explicit DELETE keeps the
125
- // order deterministic and is safe regardless of the FK pragma.
126
- deleteEntities.run(graph.stashRoot, existing.file_path, existing.body_hash);
127
- deleteRelations.run(graph.stashRoot, existing.file_path, existing.body_hash);
128
- deleteFile.run(graph.stashRoot, existing.file_path, existing.body_hash);
206
+ else {
207
+ if (existing) {
208
+ // Stale row (different body_hash for this path). Delete the old row by
209
+ // its OLD body_hash; child rows cascade, but explicit DELETE keeps the
210
+ // order deterministic and is safe regardless of the FK pragma.
211
+ deleteEntities.run(graph.stashRoot, existing.file_path, existing.body_hash);
212
+ deleteRelations.run(graph.stashRoot, existing.file_path, existing.body_hash);
213
+ deleteFile.run(graph.stashRoot, existing.file_path, existing.body_hash);
214
+ }
215
+ insertFile.run(graph.stashRoot, node.path, fileOrder, node.type, bodyHash, node.confidence ?? null, status, reason, runId);
129
216
  }
130
- insertFile.run(graph.stashRoot, node.path, fileOrder, node.type, bodyHash, node.confidence ?? null, node.status ?? (node.entities.length > 0 ? "extracted" : "empty"), node.reason ?? (node.entities.length > 0 ? "none" : "no_graph_content"), node.extractionRunId ?? telemetry?.extractionRunId ?? null);
131
217
  for (const [entityOrder, entity] of node.entities.entries()) {
132
218
  insertEntity.run(graph.stashRoot, node.path, bodyHash, entityOrder, normalizeEntity(entity), entity);
133
219
  }
@@ -144,6 +230,8 @@ export function replaceStoredGraph(db, graph) {
144
230
  deleteFile.run(graph.stashRoot, row.file_path, row.body_hash);
145
231
  }
146
232
  }
233
+ const quality = readStoredGraphQuality(db, graph.stashRoot);
234
+ upsertMeta.run(graph.stashRoot, graph.schemaVersion, graph.generatedAt, quality.consideredFiles, quality.extractedFiles, quality.entityCount, quality.relationCount, quality.extractionCoverage, quality.density, telemetry?.extractorId ?? null, telemetry?.extractionRunId ?? null, telemetry?.model ?? null, telemetry?.promptVersion ?? null, telemetry?.batchSize ?? null, telemetry?.cacheHits ?? 0, telemetry?.cacheMisses ?? 0, telemetry?.truncationCount ?? 0, telemetry?.failureCount ?? 0);
147
235
  })();
148
236
  }
149
237
  export function deleteStoredGraph(db, stashPath) {
@@ -191,34 +279,6 @@ export function enqueueGraphExtraction(db, stashRoot, filePath, bodyHash, priori
191
279
  rethrowIfTestIsolationError(err);
192
280
  }
193
281
  }
194
- /**
195
- * #624-P3 — drain up to `limit` queued files for a stash, highest-priority
196
- * first (then oldest queued_at). The returned rows are DELETED from the queue
197
- * in the SAME transaction (SELECT-then-DELETE-by-PK), so a drain is exactly
198
- * once. Tolerant of a missing table / db error (returns []), but never masks
199
- * the bun-test isolation guard.
200
- */
201
- export function drainExtractionQueue(db, stashRoot, limit) {
202
- try {
203
- return db.transaction(() => {
204
- const rows = db
205
- .prepare(`SELECT file_path, body_hash, priority
206
- FROM graph_extraction_queue
207
- WHERE stash_root = ?
208
- ORDER BY priority DESC, queued_at ASC
209
- LIMIT ?`)
210
- .all(stashRoot, limit);
211
- const del = db.prepare("DELETE FROM graph_extraction_queue WHERE stash_root = ? AND file_path = ?");
212
- for (const row of rows)
213
- del.run(stashRoot, row.file_path);
214
- return rows.map((row) => ({ filePath: row.file_path, bodyHash: row.body_hash, priority: row.priority }));
215
- })();
216
- }
217
- catch (err) {
218
- rethrowIfTestIsolationError(err);
219
- return [];
220
- }
221
- }
222
282
  /** Read queued graph work without claiming or deleting it. */
223
283
  export function peekExtractionQueue(db, stashRoot, limit) {
224
284
  try {