akm-cli 0.9.7 → 0.9.8-beta.1

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 (117) hide show
  1. package/CHANGELOG.md +218 -0
  2. package/dist/commands/env/env.js +2 -21
  3. package/dist/commands/health/checks.js +0 -25
  4. package/dist/commands/health/improve-metrics.js +8 -34
  5. package/dist/commands/health/windows.js +0 -4
  6. package/dist/commands/health.js +1 -35
  7. package/dist/commands/improve/consolidate/eligibility.js +11 -5
  8. package/dist/commands/improve/extract.js +36 -32
  9. package/dist/commands/improve/memory/memory-belief.js +15 -5
  10. package/dist/commands/improve/memory/memory-contradiction-detect.js +16 -25
  11. package/dist/commands/improve/memory/memory-improve.js +7 -19
  12. package/dist/commands/improve/preparation.js +52 -36
  13. package/dist/commands/improve/reflect.js +13 -17
  14. package/dist/commands/lint/base-linter.js +42 -2
  15. package/dist/commands/lint/index.js +4 -5
  16. package/dist/commands/migrate/config-extra-params.js +61 -0
  17. package/dist/commands/migrate/dead-residue.js +113 -0
  18. package/dist/commands/migrate/stale-txn.js +49 -0
  19. package/dist/commands/migrate-cli.js +42 -1
  20. package/dist/commands/proposal/proposal.js +1 -21
  21. package/dist/commands/proposal/repository.js +0 -4
  22. package/dist/commands/proposal/validators/proposal-quality-validators.js +0 -6
  23. package/dist/commands/read/curate.js +4 -18
  24. package/dist/commands/read/show.js +2 -1
  25. package/dist/commands/sources/installed-stashes.js +5 -1
  26. package/dist/commands/sources/self-update.js +38 -1
  27. package/dist/commands/sources/sources-cli.js +17 -1
  28. package/dist/commands/tasks/tasks.js +0 -8
  29. package/dist/commands/url-checker.js +20 -28
  30. package/dist/core/adapter/adapters/agent-skills-adapter.js +2 -6
  31. package/dist/core/adapter/adapters/akm-adapter.js +2 -3
  32. package/dist/core/adapter/adapters/akm-lint.js +2 -20
  33. package/dist/core/adapter/adapters/akm-task-adapter.js +2 -6
  34. package/dist/core/adapter/adapters/akm-workflow-adapter.js +2 -6
  35. package/dist/core/adapter/adapters/dotenv-adapter.js +2 -22
  36. package/dist/core/adapter/adapters/generic-files-adapter.js +3 -9
  37. package/dist/core/adapter/adapters/llm-wiki-adapter.js +2 -6
  38. package/dist/core/adapter/adapters/okf-adapter.js +7 -18
  39. package/dist/core/adapter/adapters/shared.js +2 -0
  40. package/dist/core/adapter/adapters/tool-dir-shared.js +3 -9
  41. package/dist/core/adapter/adapters/website-snapshot-adapter.js +2 -6
  42. package/dist/core/adapter/validate-context.js +1 -3
  43. package/dist/core/asset/asset-placement.js +1 -3
  44. package/dist/core/asset/frontmatter.js +96 -0
  45. package/dist/core/common.js +66 -2
  46. package/dist/core/config/config-walker.js +6 -10
  47. package/dist/core/config/config.js +11 -8
  48. package/dist/core/extra-params.js +17 -13
  49. package/dist/core/improve-result.js +1 -3
  50. package/dist/core/json-schema.js +9 -11
  51. package/dist/core/state/migrations.js +52 -2
  52. package/dist/core/state-db.js +2 -1
  53. package/dist/execution/executable-identity.js +1 -3
  54. package/dist/execution/guarded-source.js +1 -6
  55. package/dist/indexer/bundle-identity-guard.js +6 -1
  56. package/dist/indexer/db/graph-db.js +139 -154
  57. package/dist/indexer/ensure-index.js +11 -19
  58. package/dist/indexer/graph/graph-boost.js +23 -34
  59. package/dist/indexer/graph/graph-extraction.js +12 -2
  60. package/dist/indexer/indexer.js +1 -1
  61. package/dist/indexer/lookup/adapter-concept-owner.js +12 -9
  62. package/dist/indexer/passes/memory-inference.js +7 -2
  63. package/dist/indexer/scan/drain-dir.js +2 -4
  64. package/dist/indexer/search/db-search.js +3 -3
  65. package/dist/indexer/search/fts-query.js +10 -15
  66. package/dist/indexer/search/search-source.js +0 -13
  67. package/dist/indexer/usage/usage-events.js +9 -1
  68. package/dist/indexer/walk/walker.js +11 -6
  69. package/dist/integrations/harnesses/claude/result-extractor.js +30 -15
  70. package/dist/integrations/harnesses/codex/result-extractor.js +43 -6
  71. package/dist/integrations/harnesses/copilot/result-extractor.js +39 -12
  72. package/dist/integrations/harnesses/gemini/result-extractor.js +40 -12
  73. package/dist/integrations/harnesses/index.js +0 -4
  74. package/dist/integrations/harnesses/openhands/result-extractor.js +51 -19
  75. package/dist/integrations/harnesses/pi/result-extractor.js +45 -12
  76. package/dist/integrations/lockfile.js +0 -14
  77. package/dist/integrations/session-logs/index.js +0 -81
  78. package/dist/llm/client.js +0 -12
  79. package/dist/llm/memory-infer.js +1 -3
  80. package/dist/llm/usage-telemetry.js +1 -3
  81. package/dist/registry/create-provider-registry.js +4 -0
  82. package/dist/registry/factory.js +4 -0
  83. package/dist/registry/resolve.js +23 -8
  84. package/dist/runtime.js +0 -12
  85. package/dist/scripts/akm-migrate-node.js +142 -244
  86. package/dist/scripts/akm-migrate.js +142 -244
  87. package/dist/setup/setup.js +6 -12
  88. package/dist/sources/providers/git-install.js +7 -2
  89. package/dist/sources/providers/tar-utils.js +1 -7
  90. package/dist/sources/snapshot-fetchers/content-extract.js +0 -17
  91. package/dist/storage/managed-db.js +20 -7
  92. package/dist/storage/repositories/events-repository.js +0 -81
  93. package/dist/storage/repositories/index-entries-repository.js +0 -17
  94. package/dist/storage/repositories/index-fts-repository.js +24 -30
  95. package/dist/storage/repositories/index-utility-repository.js +0 -57
  96. package/dist/storage/repositories/index-vec-repository.js +25 -27
  97. package/dist/storage/repositories/task-history-repository.js +9 -3
  98. package/dist/tasks/backends/launchd.js +8 -18
  99. package/dist/tasks/run/task-history.js +21 -31
  100. package/dist/tasks/scheduler-sync.js +1 -6
  101. package/dist/tasks/source/bounded-document.js +1 -14
  102. package/dist/workflows/exec/child-workflow.js +1 -1
  103. package/dist/workflows/exec/native-executor.js +2 -2
  104. package/dist/workflows/exec/step-work.js +5 -17
  105. package/dist/workflows/exec/worktree.js +40 -6
  106. package/dist/workflows/freeze/task-bindings.js +2 -4
  107. package/dist/workflows/ir/compile.js +3 -14
  108. package/dist/workflows/ir/schema-v4.js +4 -6
  109. package/dist/workflows/ir/schema.js +2 -5
  110. package/dist/workflows/parser.js +23 -26
  111. package/dist/workflows/source-files.js +8 -13
  112. package/dist/workflows/source-ir/schema.js +1 -19
  113. package/docs/migration/v0.9.0-troubleshooting.md +12 -2
  114. package/docs/reference/cli.md +2 -0
  115. package/docs/reference/workflow-schema.md +24 -0
  116. package/package.json +2 -5
  117. package/dist/workflows/source-ir/compare.js +0 -17
package/CHANGELOG.md CHANGED
@@ -4,6 +4,224 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
+ ## [0.9.8-beta.1] - 2026-09-01
8
+
9
+ A cleanup and stabilization release: deletion of machinery that policed the
10
+ codebase's shape rather than its behaviour, and — because auditing for that
11
+ machinery meant reading the code closely — a run of real defects it had been
12
+ sitting on top of.
13
+
14
+ Two security holes, two search-correctness bugs, a locale-dependent hash, a
15
+ deletion shield that failed open, and sixteen places that answered a failure
16
+ with a confident wrong answer instead of an error.
17
+
18
+ ### Fixed
19
+
20
+ - **Historical state migrations are reachable where akm cannot reinstall
21
+ itself** (#895). A migration flagged `historical-destructive` is refused
22
+ during an ordinary open — it needs a verified safety copy, taken under the
23
+ migration writer lock, so an unattended `akm index` can never quietly drop
24
+ operator state. That guard is correct and unchanged. Its *remedy* was not:
25
+ the only code path that admitted the migration ran as a post-install step of
26
+ `akm upgrade`, behind an npm install. Where akm is installed globally by a
27
+ container image and the runtime user is unprivileged, that install fails
28
+ `EACCES` and throws long before the migration is reached, so `akm index
29
+ --full` was blocked with no supported way out — and the two obvious
30
+ workarounds are both wrong (upgrading as root installs a *newer* akm than the
31
+ image ships, which `config-version-shim` then fails closed against; deleting
32
+ `state.db` destroys task history, proposals, and lessons metadata, none of
33
+ which is derived). New `akm upgrade --state-only` applies pending state
34
+ migrations and installs nothing. Nothing about the migration needed the
35
+ network, root, or a new binary; coupling it to one was the bug. The safety
36
+ copy is still taken — this changes who may request the migration, not what it
37
+ does.
38
+
39
+ - **Two security holes closed.** A failed `git ls-remote` made
40
+ `verifyClonedRevision` a no-op, silently skipping the R-011 post-clone
41
+ revision-integrity check — the guard against a compromised mirror — so any
42
+ network blip disabled it without a word. And `scanExtractedFiles`, the
43
+ post-extraction TOCTOU path-traversal rescan, returned silently when a
44
+ directory could not be read: it passed clean in exactly the race it exists
45
+ to catch. Both now fail loudly.
46
+
47
+ - **`akm curate --type` ignored the type filter.** A set `--type` bypassed
48
+ `selectCuratedStashHits` entirely and did a raw slice of the hits as
49
+ received, which neither filtered by type nor ranked by score — so
50
+ `--type command` could return a skill, in arbitrary order. It now narrows
51
+ the candidate pool and runs the full curation pipeline over it. The test
52
+ that should have caught this passed by accident, because the off-type hit
53
+ happened to sit past the result limit.
54
+
55
+ - **Curate silently dropped relevant results.** An undocumented floor excluded
56
+ any hit scoring below `max(0.35, leader * 0.7)`. Measured against a real
57
+ 3,265-memory bundle, one query went from one result to four once it was
58
+ removed — the suppressed hits were the ones actually matching. Ordering is
59
+ unchanged: results were already sorted before the floor ran, so it could
60
+ only ever hide the tail.
61
+
62
+ - **A bundle-audit hash depended on the machine's locale.** `canonicalJson`
63
+ in `installed-stashes.ts` sorted keys with `localeCompare` — ICU-dependent —
64
+ and fed the result to `sha256Hex`, so the same object could hash differently
65
+ on two machines. Now codepoint order, matching the other implementations.
66
+
67
+ - **A deletion shield failed open.** `isHotCapturedMemory` returned false when
68
+ it could not read or parse a memory, marking exactly the memories it failed
69
+ to inspect as fair game for consolidate to merge or delete. It now fails
70
+ closed.
71
+
72
+ - **Sixteen places returned a confident wrong answer instead of an error.**
73
+ The pattern (#791) recurred across search, graph, indexing, and scheduling:
74
+ a corrupt or locked index reported "no matches"; a failed inline rebuild
75
+ surfaced as "Index is empty. Run 'akm index'" at exit 0; `akm show` reported
76
+ "0 related files" for an unreadable index; a DB failure in
77
+ `listProposalsReadOnly` returned `[]`, so dedup could not tell "no prior
78
+ proposal" from "database unreadable" and could re-mint an already-rejected
79
+ one; a permission fault on the bundle directory told the user to run
80
+ `akm bundle create`; and `launchd`'s `list()` returned `[]` when the
81
+ LaunchAgents directory could not be read, so task reconciliation concluded
82
+ there was nothing to manage. In each case the failure now surfaces.
83
+
84
+ - **Drifted copies of shared helpers.** `asNonEmptyString` trimmed in two
85
+ places and not in four others, so a whitespace-only session id decoded to
86
+ `" "` in some subsystems and `undefined` in others. `isPlainObject` had two
87
+ incompatible definitions under one name; the loose form would accept a
88
+ `Date` or class instance as a plain record. Both consolidated, along with
89
+ `toPosix` (15 copies, whose "avoid an import cycle" justification was false
90
+ in every case), `isRecord` (11), `compareCodePoints` (8), and five more
91
+ families.
92
+
93
+ - **Pruning a memory no longer leaves dangling belief edges (#885).**
94
+ `writeContradictEdge` — the hardened, test-covered `contradictedBy` writer —
95
+ had no production caller; its docstring named three that did not exist. The
96
+ live pass used a private near-copy that had drifted on exactly the two
97
+ behaviours the original was hardened for: it read the key with
98
+ `Array.isArray` only, so a SCALAR edge (live data the indexer accepts and
99
+ lint never flags) read as "no edges" and was overwritten out of existence;
100
+ and it set `beliefState: "contradicted"` unconditionally, promoting an
101
+ `archived` memory back up the ranking. The tests for both behaviours were
102
+ guarding dead code. The copy is gone.
103
+
104
+ `persistBeliefStateTransition` is deliberately NOT routed through the shared
105
+ primitive: it is a state-transition writer that replaces the edge list
106
+ wholesale and can clear it, which an append-only, never-weaken primitive
107
+ cannot express.
108
+
109
+ - **`git worktree` operations no longer fail on a busy machine (#891).** The
110
+ module's internal `GIT_TIMEOUT_MS` was a flat 30s with no margin, so a
111
+ healthy `git worktree remove` on a loaded host returned
112
+ `{ removed: false, error: "timed out after 30000ms" }`. Anyone running a
113
+ workflow with `isolation: worktree` alongside other git activity could hit
114
+ spurious create failures, or have a clean worktree wrongly retained as
115
+ unremovable. Raised to 120s, matching the existing `GIT_PUSH_TIMEOUT_MS`
116
+ precedent. Note `scripts/test-integration.sh` had already raised *bun's*
117
+ per-test timeout for this reason, but that never touched the production
118
+ subprocess timeout, which fires first.
119
+
120
+ - **`akm health`'s dead-link check no longer reports an unearned all-clear
121
+ (#892).** `checkDeadUrls` capped at 20 URLs across the whole bundle, plus an
122
+ undocumented `slice(0, 3)` per entry, then the caller logged "URL check
123
+ complete (0 dead)". A bundle with thousands of links got a clean bill of
124
+ health after twenty were examined. Both caps removed; every URL is checked
125
+ and a failed request surfaces instead of being swallowed.
126
+
127
+ - **`akm lint --fix` no longer breaks on gitignored drafts (#887).** The doc
128
+ linter walked `docs/` with no gitignore awareness, so unpublished drafts
129
+ under `docs/.pending/` were held to the published-docs contract —
130
+ `bun run lint` failed locally while CI, which never has those files, stayed
131
+ green.
132
+
133
+ ### Changed
134
+
135
+ - **Search no longer truncates long queries (#892).** `MAX_LEXICAL_QUERY_TOKENS
136
+ = 16` silently dropped every token past the sixteenth, and tokens are
137
+ collected in order, so the discarded half was the tail — for
138
+ natural-language input, usually where the discriminating words are. It also
139
+ fed ranking, so token-overlap scoring ran on the truncated set too. It was
140
+ unexplained in the code and in the commit that introduced it, and unreachable
141
+ from any flag, config key, or environment variable. Removed: the planner
142
+ handles 10,000 tokens in 9ms, so no performance cliff was being protected.
143
+
144
+ - **Content and memory bodies are no longer silently truncated.**
145
+ `MAX_CONTENT_CHARS` (100k, duplicated across 8 adapters) cut indexed content
146
+ so the tail of a long document was unsearchable; `MAX_BODY_CHARS` (4000) cut
147
+ the text sent for memory inference, so on a large-context engine the model
148
+ saw a fraction of the input while the derived memory looked complete. Both
149
+ removed.
150
+
151
+ - **GitHub Actions are pinned to commit SHAs (#768).** All 29 `uses:` steps
152
+ across every workflow, with the tag preserved in a trailing comment.
153
+
154
+ - **Gated CI runs on schedule, dispatch, and candidate tags only.** The
155
+ `detect-changes` job that selected suites by regex-matching a PR diff is
156
+ gone — its path patterns had gone stale and still named test files this
157
+ release moved or deleted, so it was silently under-selecting suites. Release
158
+ evidence is unchanged; the checklist always required an exact-SHA dispatch.
159
+
160
+ - **`akm-eval` in CI is now a determinism check only.** Its score gates are
161
+ removed. Measured before cutting: the baseline scored a perfect 1.0 against
162
+ a 0.75 gate, and seven of nine case types never ran — CI has no LLM and no
163
+ run history, so everything the eval exists to measure was skipped while the
164
+ job reported green. The harness itself is unchanged and remains a genuine
165
+ quality signal when run against a real bundle.
166
+
167
+ ### Removed
168
+
169
+ - **`akm health --clean-dead-residue` and every other compensating shim.**
170
+ A special-purpose flag on `health` that deleted files existed because
171
+ migrations had not finished their own job. Removing a superseded layout IS
172
+ migration, so `akm migrate status` now reports it and `akm migrate apply`
173
+ removes it. Four more of the same class went the same way: the D8
174
+ `task_history` vocabulary is rewritten once by a state migration instead of
175
+ re-decided at three read sites; the legacy `extraParams` config lift happens
176
+ once in `migrate apply` instead of silently on every load forever (an
177
+ unmigrated config now fails closed, naming the command to run); retired
178
+ `type:slug` xrefs are rewritten by `lint --fix`; and stale transaction
179
+ journals are recovered by `migrate apply` rather than reported by an
180
+ advisory that pointed at documentation while the recovery function it needed
181
+ sat with zero callers.
182
+
183
+ - **Dead code with no consumers.** An empty `{}` type threaded through two
184
+ functions as a parameter neither read, with a `{}` placeholder at the call
185
+ site; unused helpers, imports, and parameters throughout.
186
+
187
+ - **14 architecture ratchets, 15 golden/snapshot suites, 12 characterization
188
+ suites.** Function-size and import-cycle ratchets that failed on refactors
189
+ harming nothing; byte-for-byte snapshots "fixed" by regenerating; tests that
190
+ pinned "what the code does today" by definition.
191
+ - **11 of 14 lint scripts (-3,988 lines).** `lint-tests-isolation` alone was
192
+ 717 lines standing in front of `src/core/paths.ts`, which already throws
193
+ `TEST_ISOLATION_MISSING` at runtime. Also removed: the `gen-config-schema
194
+ --check` gate (`build` regenerates the schema anyway, so what ships is always
195
+ current), and `lint-devto-posts` (322 lines that were wired to nothing and
196
+ duplicated checks `devto-cli` performs itself). The three kept catch
197
+ user-visible problems: secrets must route through the resolver, no dead refs
198
+ in shipped assets, no docs teaching commands that do not exist.
199
+ - **Both `MIN_TESTS` floors** — arbitrary numbers whose only job was to fail
200
+ the suite when test count dropped.
201
+ - **`release-workflow-syntax.yml`** — a workflow that ran actionlint over the
202
+ other workflows. A broken workflow file already fails at GitHub.
203
+ - **Dead `$STASH/.akm` residue is now reportable (#889).** `akm health` gains a
204
+ read-only `stash-dead-residue` advisory naming each stale path and its size;
205
+ deletion is gated behind an explicit `akm health --clean-dead-residue`. On a
206
+ real bundle 82% of `.akm` (135 MB) was pre-0.9.0 leftovers no code reads.
207
+
208
+ ### Testing
209
+
210
+ - Test isolation grandfather list drained 58 -> 2 (#785); the two retained
211
+ entries are the helper-definitions file and a meta-test of the guard itself.
212
+ - The test tree now has a stated unit/integration rule and ~250 files were
213
+ reclassified by evidence — real DB, network, or process spawn — rather than
214
+ by filename (#786).
215
+ - 31 status-only `not.toBe(0)` assertions replaced with exact exit codes and
216
+ machine-readable error codes, every value observed rather than inferred
217
+ (#787).
218
+ - Near-duplicate clusters drained (#788); real-server timing flakes removed or
219
+ made deterministic (#789).
220
+ - Kept deliberately: `registry-network-boundary.test.ts`, which enforces that
221
+ only `pinned-transport.ts` may import `node:http`/`node:https` — the control
222
+ that stops a raw request bypassing address-pinned fetching. No lint script
223
+ covers that boundary.
224
+
7
225
  ## [0.9.7] - 2026-08-31
8
226
 
9
227
  ### Added
@@ -48,28 +48,9 @@
48
48
  import fs from "node:fs";
49
49
  import path from "node:path";
50
50
  import dotenv from "dotenv";
51
- import { writeFileAtomic } from "../../core/common.js";
51
+ import { scanEnvKeyNames, writeFileAtomic } from "../../core/common.js";
52
52
  import { sensitiveMarkerPath } from "./marker-path.js";
53
- /** Matches a KEY=value assignment line, capturing only the key. */
54
- const ASSIGN_RE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;
55
- /** Scan lines and return KEY names in file order, without duplicates. */
56
- export function scanEnvKeyNames(text) {
57
- const keys = [];
58
- const seen = new Set();
59
- for (const line of text.split(/\r?\n/)) {
60
- const m = line.match(ASSIGN_RE);
61
- if (!m)
62
- continue;
63
- const key = m[1];
64
- if (!key)
65
- continue;
66
- if (seen.has(key))
67
- continue;
68
- seen.add(key);
69
- keys.push(key);
70
- }
71
- return keys;
72
- }
53
+ export { scanEnvKeyNames };
73
54
  /**
74
55
  * Read and return ONLY non-secret metadata: key names.
75
56
  *
@@ -706,29 +706,4 @@ export const HEALTH_CHECKS = [
706
706
  };
707
707
  },
708
708
  },
709
- {
710
- // Item 4: stale durable-transaction journals under $DATA/txn (stranded
711
- // recovery state seen twice in a real 0.9 migration) had zero health
712
- // visibility. `count`/`unreadable` already exclude journals younger than
713
- // the sweeper's grace period (a currently-running operation), so any
714
- // nonzero count here is a real leftover.
715
- name: "stale-txn-journals",
716
- channel: "advisory",
717
- run: (ctx) => {
718
- const s = ctx.staleTxnJournals;
719
- const warn = s.count > 0;
720
- const unreadablePart = s.unreadable > 0 ? `, ${s.unreadable} unreadable` : "";
721
- const agePart = s.oldestAgeMs !== null ? `, oldest ${Math.round(s.oldestAgeMs / 60000)}m old` : "";
722
- return {
723
- name: "stale-txn-journals",
724
- kind: "deterministic",
725
- status: warn ? "warn" : "pass",
726
- confidence: "high",
727
- message: warn
728
- ? `${s.count} stale transaction journal(s) found under ${s.dir}${unreadablePart}${agePart} — see docs/migration/v0.9.0-troubleshooting.md for journal reconciliation steps.`
729
- : "No stale transaction journals found.",
730
- evidence: { dir: s.dir, count: s.count, unreadable: s.unreadable, oldestAgeMs: s.oldestAgeMs },
731
- };
732
- },
733
- },
734
709
  ];
@@ -33,42 +33,16 @@ export function taskFailureDetail(row) {
33
33
  }
34
34
  }
35
35
  /**
36
- * D8 read-boundary predicate (spec docs/plans/specs/p1b-model-extraction.md
37
- * §5.3) for `akm health`'s `agentFailureRate`: true for a `task_history` row
38
- * that represents a prepared command (agent/LLM) result, across both
39
- * vocabularies. Mirrors src/tasks/run/task-history.ts's
40
- * `taskHistoryRowToResult` read mapping:
41
- * - NEW rows mark themselves with metadata `targetVocab: 2` and store
42
- * `target_kind: "command"` for the agent/LLM arm.
43
- * - LEGACY rows (no marker, written before P1b's F-2 re-code) stored
44
- * `target_kind: "prompt"` for the same arm — and `"command"` for the
45
- * UNRELATED native shell/script arm, which must NOT be counted here.
46
- * So an unmarked `"command"` row is a legacy shell/script run, not an
47
- * agent/LLM one; a marked `"command"` row (or an unmarked `"prompt"` row) is.
36
+ * `akm health`'s `agentFailureRate` predicate: true for a `task_history` row
37
+ * that represents a prepared command (agent/LLM) result. `target_kind` is
38
+ * read in the current (post-D8) vocabulary the
39
+ * `025-task-history-vocabulary-backfill` state migration rewrites every
40
+ * legacy-vocabulary row (which stored the agent/LLM arm as `"prompt"` and
41
+ * the native shell/script arm as `"command"`) before this ever runs against
42
+ * it, so a `"command"` row here is unambiguously the agent/LLM arm.
48
43
  */
49
44
  export function isAgentTaskHistoryRow(row) {
50
- // Check target_kind BEFORE decoding metadata: some rows in the wild
51
- // (e.g. improve-pipeline task_history rows, target_kind "improve") carry
52
- // metadata_json that predates the metadataVersion:2 shape entirely, and
53
- // decodeTaskHistoryMetadata throws on that — exactly like the pre-fix
54
- // `target_kind === "prompt"` filter, which never called it for a row this
55
- // function isn't going to count anyway. Only decode for the two target
56
- // kinds this predicate can return true for.
57
- if (row.target_kind !== "command" && row.target_kind !== "prompt")
58
- return false;
59
- // An undecodable metadata_json is by definition unmarked: pre-P1b rows can
60
- // carry shapes decodeTaskHistoryMetadata rejects, and `akm health` must
61
- // classify them as legacy rather than throw (round-3 review advisory).
62
- let marked = false;
63
- try {
64
- marked = decodeTaskHistoryMetadata(row.metadata_json).targetVocab === 2;
65
- }
66
- catch {
67
- marked = false;
68
- }
69
- if (row.target_kind === "command")
70
- return marked;
71
- return !marked;
45
+ return row.target_kind === "command";
72
46
  }
73
47
  function createUnknownImproveMetrics() {
74
48
  return {
@@ -148,10 +148,6 @@ export function buildWindowMetrics(db, stateDbPath, since, until, now = () => Da
148
148
  const failedTaskRows = taskRows.filter((row) => row.status === "failed");
149
149
  const activeRows = taskRows.filter((row) => row.status === "active" && row.completed_at === null);
150
150
  const stuckActiveRuns = activeRows.filter((row) => now() - new Date(row.started_at).getTime() > ACTIVE_RUN_WARN_MS).length;
151
- // D8 (spec §5.3): a marked "command" row or a legacy (unmarked) "prompt"
152
- // row is the agent/LLM arm; an unmarked "command" row is the legacy
153
- // native shell/script arm and must not be counted here (see
154
- // isAgentTaskHistoryRow's header comment for the full mapping).
155
151
  const agentRows = taskRows.filter((row) => isAgentTaskHistoryRow(row));
156
152
  const agentFailures = agentRows.filter((row) => {
157
153
  const detail = taskFailureDetail(row);
@@ -7,10 +7,9 @@ import { resolveStashDir } from "../core/common.js";
7
7
  import { loadConfig } from "../core/config/config.js";
8
8
  import { ConfigError, UsageError } from "../core/errors.js";
9
9
  import { readEvents } from "../core/events.js";
10
- import { listTxnJournalsTolerant, TXN_SWEEP_GRACE_MS } from "../core/fs-txn.js";
11
10
  import { openLogsDatabase } from "../core/logs-db.js";
12
11
  import { classifyPathAccess, describeInaccessiblePath } from "../core/path-access.js";
13
- import { getConfigPath, getDataDir, getDbPath, getStateDbPathInDataDir } from "../core/paths.js";
12
+ import { getConfigPath, getDbPath, getStateDbPathInDataDir } from "../core/paths.js";
14
13
  import { listExistingTableNames, openStateDatabase } from "../core/state-db.js";
15
14
  import { DURATION_UNITS, parseDuration, parseSinceToIso } from "../core/time.js";
16
15
  import { closeDatabase, openReadonlyExistingDatabase } from "../storage/repositories/index-connection.js";
@@ -150,37 +149,6 @@ function gatherTaskHistoryPhase(db, logsDb, since, stateDbPath, now) {
150
149
  agentFailureRate,
151
150
  };
152
151
  }
153
- /**
154
- * Item 4: leftover durable-transaction journals under `$DATA/txn` (stranded
155
- * recovery state seen twice in a real 0.9 migration) had zero health
156
- * visibility. Uses the tolerant scan variant so one corrupt journal.json is
157
- * counted rather than aborting the whole gather; a journal younger than
158
- * {@link TXN_SWEEP_GRACE_MS} is presumed to belong to a currently-running
159
- * operation and is not counted as stale. Best-effort: any unexpected error
160
- * (e.g. a permissions issue under `$DATA/txn`) degrades to "no signal" rather
161
- * than aborting the health report.
162
- */
163
- function gatherStaleTxnJournalsPhase(now) {
164
- const dir = path.join(getDataDir(), "txn");
165
- try {
166
- const { matches, unreadableMtimes } = listTxnJournalsTolerant(() => true);
167
- const nowMs = now();
168
- const staleAges = [
169
- ...matches.map((m) => nowMs - m.mtimeMs),
170
- ...unreadableMtimes.map((mtimeMs) => nowMs - mtimeMs),
171
- ].filter((ageMs) => ageMs >= TXN_SWEEP_GRACE_MS);
172
- const staleUnreadableCount = unreadableMtimes.filter((mtimeMs) => nowMs - mtimeMs >= TXN_SWEEP_GRACE_MS).length;
173
- return {
174
- dir,
175
- count: staleAges.length,
176
- oldestAgeMs: staleAges.length > 0 ? Math.max(...staleAges) : null,
177
- unreadable: staleUnreadableCount,
178
- };
179
- }
180
- catch {
181
- return { dir, count: 0, oldestAgeMs: null, unreadable: 0 };
182
- }
183
- }
184
152
  /**
185
153
  * Config fields the surfaces advisory needs. Best-effort: an unloadable
186
154
  * config leaves the field undefined and the caller falls back to a generic
@@ -512,7 +480,6 @@ export function akmHealth(options = {}) {
512
480
  try {
513
481
  const taskHistory = gatherTaskHistoryPhase(db, logsDb, since, stateDbPath, now);
514
482
  const { tableNames, missingTables, probe } = taskHistory;
515
- const staleTxnJournals = gatherStaleTxnJournalsPhase(now);
516
483
  const { egressConfigView } = gatherEgressConfigPhase();
517
484
  const { improveSummary } = gatherImproveSummaryPhase(db, stateDbPath, since, now);
518
485
  advisories.push(...gatherAncillaryAdvisories(db, stateDbPath, since, improveSummary, options, egressConfigView));
@@ -535,7 +502,6 @@ export function akmHealth(options = {}) {
535
502
  stuckActiveRuns: taskHistory.stuckActiveRuns,
536
503
  stuckActiveTasks: taskHistory.stuckActiveTasks,
537
504
  worstTaskFailRate: taskHistory.worstTaskFailRate,
538
- staleTxnJournals,
539
505
  sessionExtraction: improveSummary.sessionExtraction,
540
506
  autoAccept: improveSummary.autoAccept,
541
507
  engineProbes,
@@ -18,8 +18,8 @@ export function isConsolidationEligibleMemoryName(name) {
18
18
  *
19
19
  * Reads the file once per check; consolidate runs against ~10 memories per
20
20
  * chunk so the IO cost is trivial. Returns false on any read/parse error
21
- * (fail-safe: an unparseable file is treated as not-hot, but the broader
22
- * consolidate flow already guards against unparseable memories elsewhere).
21
+ * (fail-safe: an unreadable or unparseable file is treated as HOT protected
22
+ * because a deletion shield must not fail open; a missing file is not-hot).
23
23
  *
24
24
  * Defends against four observed defect classes (see
25
25
  * `memories/akm-improve-critical-review-2026-05-20`):
@@ -29,14 +29,20 @@ export function isConsolidationEligibleMemoryName(name) {
29
29
  * - Cascade deletes (LLM uses ref:X as `contradictedBy` for ref:Y then deletes both)
30
30
  */
31
31
  export function isHotCapturedMemory(filePath) {
32
+ if (!fs.existsSync(filePath))
33
+ return false;
32
34
  try {
33
- if (!fs.existsSync(filePath))
34
- return false;
35
35
  const content = fs.readFileSync(filePath, "utf8");
36
36
  const parsed = parseFrontmatter(content);
37
37
  return hasHotCaptureMode(parsed.data);
38
38
  }
39
39
  catch {
40
- return false;
40
+ // Fail CLOSED. This predicate is a deletion shield: "hot" memories are
41
+ // protected from consolidate's merge/delete. Returning false on a read or
42
+ // parse failure marked exactly the memories we could not inspect as fair
43
+ // game — the one direction a protection check must never fail. A missing
44
+ // file stays false (nothing to protect); an unreadable one is protected
45
+ // until someone can actually read it.
46
+ return true;
41
47
  }
42
48
  }
@@ -632,8 +632,40 @@ function unavailableExtractionResult(args) {
632
632
  ...args.notices,
633
633
  };
634
634
  }
635
+ // #561 — ADDITIVE session indexing. Generate + write the session asset
636
+ // (`sessions/<harness>/<id>.md`). FAIL-OPEN: any failure only returns a
637
+ // warning; it NEVER changes the proposal/skip outcome of extract. Returns the
638
+ // frontmatter fields to merge into the per-session result for state-db
639
+ // correlation. When disabled this makes NO LLM call and writes NOTHING.
640
+ async function maybeWriteSessionAsset(runCtx, session) {
641
+ const { stashDir, lease, sessionIndexing, dryRun } = runCtx;
642
+ const { data } = session.gate;
643
+ if (!sessionIndexing.enabled || dryRun)
644
+ return {};
645
+ if (!sessionMeetsDurationGate(data, sessionIndexing.minDurationMinutes))
646
+ return {};
647
+ try {
648
+ const result = await writeSessionAsset(data, stashDir, (summaryData) => sessionIndexing.generate(summaryData, lease));
649
+ if (result.written) {
650
+ // Write-path indexing (itself fail-open): standalone `akm extract`
651
+ // (session-end hook) has no post-loop reindex to pick this file up.
652
+ if (result.filePath)
653
+ await indexWrittenAssets(stashDir, [result.filePath]);
654
+ return {
655
+ ...(result.ref ? { sessionAssetRef: result.ref } : {}),
656
+ ...(result.logPath ? { sessionLogPath: result.logPath } : {}),
657
+ };
658
+ }
659
+ }
660
+ catch (err) {
661
+ if (err instanceof ConfigError)
662
+ throw err;
663
+ return { warning: `session asset write failed: ${err instanceof Error ? err.message : String(err)}` };
664
+ }
665
+ return {};
666
+ }
635
667
  async function processSession(runCtx, session) {
636
- const { harness, stashDir, config, llmRunner, lease, onNotices, getNotices, chat, ctx, eventsCtx, sourceRun, dryRun, timeoutMs, sessionIndexing, signal, standardsContext, } = runCtx;
668
+ const { harness, stashDir, config, llmRunner, lease, onNotices, getNotices, chat, ctx, eventsCtx, sourceRun, dryRun, timeoutMs, signal, standardsContext, } = runCtx;
637
669
  const { sessionRef, gate } = session;
638
670
  const warnings = [];
639
671
  const { data, filtered, contentHash } = gate;
@@ -645,36 +677,6 @@ async function processSession(runCtx, session) {
645
677
  inlineRefs: data.inlineRefs,
646
678
  ...(standardsContext.trim() ? { standardsContext } : {}),
647
679
  });
648
- // #561 — ADDITIVE session indexing. Generate + write the session asset
649
- // (`sessions/<harness>/<id>.md`). FAIL-OPEN: any failure only records a
650
- // warning; it NEVER changes the proposal/skip outcome of extract. Returns the
651
- // frontmatter fields to merge into the per-session result for state-db
652
- // correlation. When disabled this closure makes NO LLM call and writes NOTHING.
653
- const maybeWriteSessionAsset = async () => {
654
- if (!sessionIndexing.enabled || dryRun)
655
- return {};
656
- if (!sessionMeetsDurationGate(data, sessionIndexing.minDurationMinutes))
657
- return {};
658
- try {
659
- const result = await writeSessionAsset(data, stashDir, (summaryData) => sessionIndexing.generate(summaryData, lease));
660
- if (result.written) {
661
- // Write-path indexing (itself fail-open): standalone `akm extract`
662
- // (session-end hook) has no post-loop reindex to pick this file up.
663
- if (result.filePath)
664
- await indexWrittenAssets(stashDir, [result.filePath]);
665
- return {
666
- ...(result.ref ? { sessionAssetRef: result.ref } : {}),
667
- ...(result.logPath ? { sessionLogPath: result.logPath } : {}),
668
- };
669
- }
670
- }
671
- catch (err) {
672
- if (err instanceof ConfigError)
673
- throw err;
674
- warnings.push(`session asset write failed: ${err instanceof Error ? err.message : String(err)}`);
675
- }
676
- return {};
677
- };
678
680
  const extraction = await runSessionExtractionLlmCall({
679
681
  config,
680
682
  llmRunner,
@@ -708,7 +710,9 @@ async function processSession(runCtx, session) {
708
710
  const { payload } = extraction;
709
711
  const proposalIds = [];
710
712
  // Provenance refs are added only after the cited session asset exists.
711
- const sessionAsset = await maybeWriteSessionAsset();
713
+ const sessionAsset = await maybeWriteSessionAsset(runCtx, session);
714
+ if (sessionAsset.warning)
715
+ warnings.push(sessionAsset.warning);
712
716
  if (payload.candidates.length === 0) {
713
717
  appendEvent({
714
718
  eventType: "extract_invoked",
@@ -52,10 +52,17 @@ function readEdgeList(value) {
52
52
  * Write `contradictedBy` and `beliefState: contradicted` edges to a memory
53
53
  * file's frontmatter (C-3 / #382).
54
54
  *
55
- * This is the shared primitive used by:
56
- * - `akmConsolidate` when its LLM plan includes a `contradict` op
57
- * - `memory-contradiction-detect.ts` for the M-1 automated contradiction pass
58
- * - `resolveFamilyContradictions` in `memory-improve.ts` for SCC resolution
55
+ * The shared primitive for APPENDING one contradiction edge. Used by
56
+ * `memory-contradiction-detect.ts`'s automated contradiction pass.
57
+ *
58
+ * NOT used by `persistBeliefStateTransition` in `memory-improve.ts`, and that
59
+ * is deliberate (#885): the SCC resolver is a state-TRANSITION writer, not an
60
+ * edge appender. It replaces `contradictedBy` wholesale from recomputed
61
+ * `currentBeliefRefs`, deletes the key when a memory transitions away from
62
+ * `contradicted`, and moves a memory to any target state including back to
63
+ * active. An append-only primitive that never weakens a demotion cannot
64
+ * express those, and routing it through this would break the resolver's
65
+ * ability to clear an edge.
59
66
  *
60
67
  * Idempotent: if the `contradictedByRef` is already in `contradictedBy` AND
61
68
  * the file already carries the demotion state, the file is not rewritten. The
@@ -72,9 +79,12 @@ function readEdgeList(value) {
72
79
  *
73
80
  * @param filePath - Absolute path to the memory markdown file.
74
81
  * @param contradictedByRef - The ref that contradicts this memory.
82
+ * @returns `true` when the file was rewritten, `false` when the edge and the
83
+ * demotion were already present (the idempotent no-op). Callers count
84
+ * edges written from this.
75
85
  */
76
86
  export function writeContradictEdge(filePath, contradictedByRef) {
77
- mutateFrontmatter(filePath, (parsed) => {
87
+ return mutateFrontmatter(filePath, (parsed) => {
78
88
  const existing = readEdgeList(parsed.data.contradictedBy);
79
89
  const currentState = parsed.data.beliefState;
80
90
  const nextState = currentState === "archived" ? currentState : "contradicted";
@@ -17,8 +17,19 @@
17
17
  * 2. For each family, enumerate candidate pairs.
18
18
  * 3. For each pair, call the LLM to judge whether the two memories are in
19
19
  * direct factual conflict.
20
- * 4. For confirmed contradictions, write `contradictedBy` edges directly to
21
- * the losing memory's frontmatter (same mechanism as `persistBeliefStateTransition`).
20
+ * 4. For confirmed contradictions, append a `contradictedBy` edge to the
21
+ * losing memory's frontmatter via `writeContradictEdge`
22
+ * (`./memory-belief.ts`).
23
+ *
24
+ * That last step used to call a private near-copy of `writeContradictEdge`
25
+ * living in this file. The copy had drifted (#885): it read `contradictedBy`
26
+ * with `Array.isArray` only, so a SCALAR edge — live data the indexer accepts
27
+ * and lint never flags — read as "no edges" and was overwritten out of
28
+ * existence; and it set `beliefState: "contradicted"` unconditionally,
29
+ * promoting an `archived` memory back up (archived ranks BELOW contradicted).
30
+ * Both behaviors had tests, but the tests exercised the shared primitive,
31
+ * which nothing called — so they guarded dead code while the live path
32
+ * carried the bugs.
22
33
  *
23
34
  * # LLM Feature Gate
24
35
  *
@@ -36,12 +47,13 @@
36
47
  import fs from "node:fs";
37
48
  import path from "node:path";
38
49
  import contradictionJudgeTemplate from "../../../assets/prompts/contradiction-judge.md" with { type: "text" };
39
- import { mutateFrontmatter, parseFrontmatter } from "../../../core/asset/frontmatter.js";
50
+ import { parseFrontmatter } from "../../../core/asset/frontmatter.js";
40
51
  import { parseEmbeddedJsonResponse } from "../../../core/parse.js";
41
52
  import { disposeLoweredExecutionDispatchLease } from "../../../integrations/agent/execution-lowering.js";
42
53
  import { callStructured, preflightStructuredLlmRunner } from "../../../llm/structured-call.js";
43
54
  import { resolveImproveLlmExecution } from "../execution.js";
44
55
  import { isDerivedMemory, memoryIdentityRef, resolveParentRef } from "./derived-ref.js";
56
+ import { writeContradictEdge } from "./memory-belief.js";
45
57
  // ── Constants ────────────────────────────────────────────────────────────────
46
58
  /**
47
59
  * Minimum confidence required to write a contradiction edge. Below this
@@ -89,27 +101,6 @@ function toMemoryRef(memoriesDir, filePath) {
89
101
  return memoryIdentityRef(name);
90
102
  }
91
103
  // ── Edge writing ─────────────────────────────────────────────────────────────
92
- /**
93
- * Write a `contradictedBy` edge to the losing memory's frontmatter file.
94
- * Preserves all existing frontmatter keys; only adds/updates `contradictedBy`
95
- * and `beliefState: contradicted`.
96
- */
97
- /** Returns true if the edge was newly written, false if it already existed. */
98
- function writeContradictedByEdge(filePath, contradictedByRef) {
99
- return mutateFrontmatter(filePath, (parsed) => {
100
- const existing = Array.isArray(parsed.data.contradictedBy)
101
- ? parsed.data.contradictedBy
102
- : [];
103
- if (existing.includes(contradictedByRef))
104
- return null; // Edge already written.
105
- const updatedContradictedBy = [...new Set([...existing, contradictedByRef])].sort();
106
- return {
107
- ...parsed.data,
108
- contradictedBy: updatedContradictedBy,
109
- beliefState: "contradicted",
110
- };
111
- });
112
- }
113
104
  /**
114
105
  * Deterministically pick, for a confirmed-contradiction pair, the LOSER memory
115
106
  * that receives the single directed `contradictedBy` edge (SCC-resolved to
@@ -284,7 +275,7 @@ export async function detectAndWriteContradictions(stashDir, config, chat, strat
284
275
  // a 2-cycle that the SCC resolver refreshes back to active, erasing the
285
276
  // contradiction every run (see pickContradictionLoser).
286
277
  try {
287
- const wrote = writeContradictedByEdge(loser.filePath, winnerRef);
278
+ const wrote = writeContradictEdge(loser.filePath, winnerRef);
288
279
  result.edgesWritten += wrote ? 1 : 0;
289
280
  }
290
281
  catch (err) {