akm-cli 0.9.0-beta.5 → 0.9.0-beta.51

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 (221) hide show
  1. package/CHANGELOG.md +711 -0
  2. package/README.md +12 -4
  3. package/dist/akm +38 -0
  4. package/dist/akm-migrate-storage +38 -0
  5. package/dist/assets/profiles/default.json +9 -4
  6. package/dist/assets/profiles/frequent.json +1 -1
  7. package/dist/assets/profiles/memory-focus.json +1 -1
  8. package/dist/assets/profiles/quick.json +1 -1
  9. package/dist/assets/profiles/synthesize.json +15 -0
  10. package/dist/assets/profiles/thorough.json +1 -1
  11. package/dist/assets/prompts/consolidate-system.md +23 -0
  12. package/dist/assets/prompts/contradiction-judge.md +33 -0
  13. package/dist/assets/prompts/distill-knowledge-system.md +22 -0
  14. package/dist/assets/prompts/distill-lesson-system.md +36 -0
  15. package/dist/assets/prompts/extract-session.md +6 -2
  16. package/dist/assets/prompts/graph-extract-system.md +1 -0
  17. package/dist/assets/prompts/graph-extract-user-prompt.md +1 -1
  18. package/dist/assets/prompts/memory-infer-system.md +1 -0
  19. package/dist/assets/prompts/memory-infer-user.md +5 -0
  20. package/dist/assets/prompts/metadata-enhance-system.md +1 -0
  21. package/dist/assets/prompts/procedural-system.md +44 -0
  22. package/dist/assets/prompts/recombine-system.md +40 -0
  23. package/dist/assets/prompts/staleness-detect-system.md +6 -0
  24. package/dist/assets/prompts/validate-summary-judge.md +1 -0
  25. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +38 -0
  26. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +38 -0
  27. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +39 -0
  28. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +40 -0
  29. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +43 -0
  30. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +38 -0
  31. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +43 -0
  32. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +40 -0
  33. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +43 -0
  34. package/dist/assets/templates/html/health.html +281 -111
  35. package/dist/assets/wiki/ingest-workflow-template.md +38 -10
  36. package/dist/cli/parse-args.js +46 -1
  37. package/dist/cli/shared.js +28 -0
  38. package/dist/cli.js +27 -11
  39. package/dist/commands/agent/agent-dispatch.js +2 -2
  40. package/dist/commands/agent/agent-support.js +0 -7
  41. package/dist/commands/agent/contribute-cli.js +17 -4
  42. package/dist/commands/config-cli.js +18 -2
  43. package/dist/commands/env/child-env.js +47 -0
  44. package/dist/commands/env/env-cli.js +33 -26
  45. package/dist/commands/env/secret-cli.js +36 -22
  46. package/dist/commands/feedback-cli.js +15 -6
  47. package/dist/commands/graph/graph-cli.js +5 -13
  48. package/dist/commands/graph/graph.js +76 -72
  49. package/dist/commands/health/checks.js +49 -1
  50. package/dist/commands/health/html-report.js +422 -80
  51. package/dist/commands/health.js +386 -9
  52. package/dist/commands/improve/calibration.js +161 -0
  53. package/dist/commands/improve/consolidate/chunking.js +141 -0
  54. package/dist/commands/improve/consolidate/eligibility.js +81 -0
  55. package/dist/commands/improve/consolidate/merge.js +145 -0
  56. package/dist/commands/improve/consolidate/sanitize.js +231 -0
  57. package/dist/commands/{lint.js → improve/consolidate/types.js} +1 -1
  58. package/dist/commands/improve/consolidate.js +635 -660
  59. package/dist/commands/improve/dedup.js +482 -0
  60. package/dist/commands/improve/distill.js +159 -69
  61. package/dist/commands/improve/eligibility.js +434 -0
  62. package/dist/commands/improve/encoding-salience.js +205 -0
  63. package/dist/commands/improve/extract-cli.js +124 -2
  64. package/dist/commands/improve/extract-prompt.js +39 -2
  65. package/dist/commands/improve/extract-watch.js +140 -0
  66. package/dist/commands/improve/extract.js +389 -40
  67. package/dist/commands/improve/feedback-valence.js +54 -0
  68. package/dist/commands/improve/homeostatic.js +467 -0
  69. package/dist/commands/improve/improve-auto-accept.js +138 -7
  70. package/dist/commands/improve/improve-cli.js +36 -61
  71. package/dist/commands/improve/improve-profiles.js +14 -0
  72. package/dist/commands/improve/improve-result-file.js +14 -25
  73. package/dist/commands/improve/improve-session.js +58 -0
  74. package/dist/commands/improve/improve.js +485 -2498
  75. package/dist/commands/improve/locks.js +154 -0
  76. package/dist/commands/improve/loop-stages.js +1083 -0
  77. package/dist/commands/improve/memory/memory-contradiction-detect.js +23 -28
  78. package/dist/commands/improve/outcome-loop.js +256 -0
  79. package/dist/commands/improve/preparation.js +1966 -0
  80. package/dist/commands/improve/proactive-maintenance.js +115 -0
  81. package/dist/commands/improve/procedural.js +418 -0
  82. package/dist/commands/improve/recombine.js +850 -0
  83. package/dist/commands/improve/reflect-noise.js +0 -0
  84. package/dist/commands/improve/reflect.js +183 -40
  85. package/dist/commands/improve/salience.js +438 -0
  86. package/dist/commands/improve/triage.js +93 -0
  87. package/dist/commands/lint/agent-linter.js +19 -24
  88. package/dist/commands/lint/base-linter.js +173 -60
  89. package/dist/commands/lint/command-linter.js +19 -24
  90. package/dist/commands/lint/env-key-rules.js +38 -1
  91. package/dist/commands/lint/fact-linter.js +39 -0
  92. package/dist/commands/lint/index.js +31 -13
  93. package/dist/commands/lint/memory-linter.js +1 -1
  94. package/dist/commands/lint/registry.js +7 -2
  95. package/dist/commands/lint/task-linter.js +3 -3
  96. package/dist/commands/lint/workflow-linter.js +26 -1
  97. package/dist/commands/proposal/drain-policies.js +5 -0
  98. package/dist/commands/proposal/drain.js +43 -50
  99. package/dist/commands/proposal/proposal-cli.js +21 -31
  100. package/dist/commands/proposal/proposal.js +5 -0
  101. package/dist/commands/proposal/propose.js +7 -2
  102. package/dist/commands/proposal/validators/proposal-quality-validators.js +9 -8
  103. package/dist/commands/proposal/validators/proposals.js +189 -63
  104. package/dist/commands/read/curate.js +414 -94
  105. package/dist/commands/read/knowledge.js +6 -3
  106. package/dist/commands/read/search-cli.js +9 -4
  107. package/dist/commands/read/search.js +10 -6
  108. package/dist/commands/read/show.js +86 -7
  109. package/dist/commands/sources/init.js +49 -17
  110. package/dist/commands/sources/installed-stashes.js +11 -3
  111. package/dist/commands/sources/schema-repair.js +43 -45
  112. package/dist/commands/sources/self-update.js +2 -2
  113. package/dist/commands/sources/source-add.js +7 -3
  114. package/dist/commands/sources/stash-cli.js +28 -40
  115. package/dist/commands/sources/stash-skeleton.js +23 -8
  116. package/dist/commands/tasks/tasks-cli.js +19 -27
  117. package/dist/commands/tasks/tasks.js +39 -11
  118. package/dist/commands/wiki-cli.js +21 -35
  119. package/dist/core/asset/asset-registry.js +3 -1
  120. package/dist/core/asset/asset-spec.js +18 -2
  121. package/dist/core/asset/frontmatter.js +166 -167
  122. package/dist/core/asset/markdown.js +8 -0
  123. package/dist/core/authoring-rules.js +92 -0
  124. package/dist/core/common.js +0 -5
  125. package/dist/core/config/config-migration.js +12 -11
  126. package/dist/core/config/config-schema.js +340 -56
  127. package/dist/core/config/config-types.js +3 -3
  128. package/dist/core/config/config.js +28 -7
  129. package/dist/core/events.js +3 -7
  130. package/dist/core/improve-types.js +11 -8
  131. package/dist/core/logs-db.js +10 -66
  132. package/dist/core/parse.js +36 -16
  133. package/dist/core/paths.js +3 -0
  134. package/dist/core/standards/resolve-standards-context.js +87 -0
  135. package/dist/core/standards/resolve-stash-standards.js +99 -0
  136. package/dist/core/standards/resolve-type-conventions.js +66 -0
  137. package/dist/core/state/migrations.js +714 -0
  138. package/dist/core/state-db.js +525 -474
  139. package/dist/indexer/db/db.js +439 -247
  140. package/dist/indexer/db/graph-db.js +129 -86
  141. package/dist/indexer/ensure-index.js +152 -17
  142. package/dist/indexer/graph/graph-boost.js +51 -41
  143. package/dist/indexer/graph/graph-extraction.js +218 -4
  144. package/dist/indexer/index-writer-lock.js +99 -0
  145. package/dist/indexer/indexer.js +123 -221
  146. package/dist/indexer/passes/dir-staleness.js +114 -0
  147. package/dist/indexer/passes/memory-inference.js +13 -5
  148. package/dist/indexer/passes/staleness-detect.js +2 -5
  149. package/dist/indexer/search/db-search.js +19 -6
  150. package/dist/indexer/search/ranking-contributors.js +22 -0
  151. package/dist/indexer/search/ranking.js +4 -0
  152. package/dist/indexer/search/search-source.js +17 -18
  153. package/dist/indexer/search/semantic-status.js +4 -0
  154. package/dist/indexer/walk/matchers.js +9 -0
  155. package/dist/integrations/agent/config.js +6 -53
  156. package/dist/integrations/agent/index.js +2 -18
  157. package/dist/integrations/agent/prompts.js +75 -9
  158. package/dist/integrations/agent/runner-dispatch.js +59 -0
  159. package/dist/integrations/harnesses/claude/session-log.js +11 -1
  160. package/dist/integrations/harnesses/index.js +2 -3
  161. package/dist/integrations/harnesses/opencode/session-log.js +173 -3
  162. package/dist/integrations/harnesses/opencode-sdk/index.js +2 -2
  163. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +0 -2
  164. package/dist/integrations/session-logs/index.js +16 -0
  165. package/dist/llm/client.js +45 -15
  166. package/dist/llm/embedder.js +42 -3
  167. package/dist/llm/embedders/deterministic.js +66 -0
  168. package/dist/llm/embedders/local.js +66 -2
  169. package/dist/llm/feature-gate.js +8 -4
  170. package/dist/llm/graph-extract.js +67 -44
  171. package/dist/llm/memory-infer-impl.js +138 -0
  172. package/dist/llm/memory-infer.js +1 -127
  173. package/dist/llm/metadata-enhance.js +44 -31
  174. package/dist/llm/structured-call.js +49 -0
  175. package/dist/migrate-storage-node.mjs +8 -0
  176. package/dist/output/context.js +5 -5
  177. package/dist/output/renderers.js +74 -2
  178. package/dist/output/shapes/curate.js +14 -2
  179. package/dist/output/shapes/passthrough.js +0 -1
  180. package/dist/output/text/helpers.js +16 -1
  181. package/dist/registry/providers/skills-sh.js +21 -147
  182. package/dist/registry/providers/static-index.js +15 -157
  183. package/dist/registry/resolve.js +22 -9
  184. package/dist/runtime.js +25 -1
  185. package/dist/scripts/migrate-storage.js +2617 -1961
  186. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +759 -510
  187. package/dist/setup/setup.js +29 -8
  188. package/dist/sources/include.js +6 -2
  189. package/dist/sources/providers/filesystem.js +0 -1
  190. package/dist/sources/providers/git-install.js +210 -0
  191. package/dist/sources/providers/git-provider.js +234 -0
  192. package/dist/sources/providers/git-stash.js +248 -0
  193. package/dist/sources/providers/git.js +10 -661
  194. package/dist/sources/providers/npm.js +2 -6
  195. package/dist/sources/providers/provider-utils.js +13 -7
  196. package/dist/sources/providers/sync-from-ref.js +9 -1
  197. package/dist/sources/providers/tar-utils.js +16 -8
  198. package/dist/sources/providers/website.js +9 -5
  199. package/dist/sources/website-ingest.js +187 -29
  200. package/dist/sources/wiki-fetchers/registry.js +53 -0
  201. package/dist/sources/wiki-fetchers/youtube.js +239 -0
  202. package/dist/storage/database.js +45 -10
  203. package/dist/storage/managed-db.js +82 -0
  204. package/dist/storage/repositories/registry-cache.js +92 -0
  205. package/dist/storage/sqlite-pragmas.js +146 -0
  206. package/dist/tasks/backends/cron.js +1 -1
  207. package/dist/tasks/backends/launchd.js +1 -1
  208. package/dist/tasks/backends/schtasks.js +1 -1
  209. package/dist/tasks/{resolveAkmBin.js → resolve-akm-bin.js} +2 -2
  210. package/dist/tasks/runner.js +5 -13
  211. package/dist/text-import-hook.mjs +0 -0
  212. package/dist/wiki/wiki.js +37 -0
  213. package/dist/workflows/db.js +3 -4
  214. package/dist/workflows/runtime/runs.js +1 -117
  215. package/dist/workflows/runtime/workflow-asset-loader.js +125 -0
  216. package/dist/workflows/validate-summary.js +2 -7
  217. package/docs/data-and-telemetry.md +3 -2
  218. package/docs/migration/release-notes/0.9.0.md +39 -0
  219. package/package.json +13 -11
  220. package/dist/commands/db-cli.js +0 -23
  221. package/dist/indexer/db/db-backup.js +0 -376
@@ -23,12 +23,16 @@
23
23
  * descriptionQualityValidator passes — same pattern as the
24
24
  * consolidate-writer fix.
25
25
  */
26
+ import fs from "node:fs";
27
+ import path from "node:path";
26
28
  import { assembleAsset } from "../../core/asset/asset-serialize.js";
27
29
  import { resolveStashDir, timestampForFilename } from "../../core/common.js";
28
30
  import { getDefaultLlmConfig, loadConfig } from "../../core/config/config.js";
29
31
  import { ConfigError, UsageError } from "../../core/errors.js";
30
32
  import { appendEvent } from "../../core/events.js";
31
- import { getExtractedSessionsMap, openStateDatabase, shouldSkipAlreadyExtractedSession, upsertExtractedSession, } from "../../core/state-db.js";
33
+ import { probeLock, releaseLock, tryAcquireLockSync } from "../../core/file-lock.js";
34
+ import { resolveStashStandards } from "../../core/standards/resolve-stash-standards.js";
35
+ import { getExtractedSessionsMap, getLastExtractRunAt, getStateDbPath, openStateDatabase, shouldSkipAlreadyExtractedSession, upsertExtractedSession, withStateDb, } from "../../core/state-db.js";
32
36
  import { repairTruncatedDescription } from "../../core/text-truncation.js";
33
37
  import { warn } from "../../core/warn.js";
34
38
  import { resolveImproveProcessRunnerFromProfile, runnerIsLlm } from "../../integrations/agent/runner.js";
@@ -36,10 +40,15 @@ import { normalizeHarnessId } from "../../integrations/harnesses/index.js";
36
40
  import { getAvailableHarnesses } from "../../integrations/session-logs/index.js";
37
41
  import { preFilterSession } from "../../integrations/session-logs/pre-filter.js";
38
42
  import { chatCompletion } from "../../llm/client.js";
39
- import { isLlmFeatureEnabled, tryLlmFeature } from "../../llm/feature-gate.js";
43
+ import { embed } from "../../llm/embedder.js";
44
+ import { tryLlmFeature } from "../../llm/feature-gate.js";
45
+ import { sha256Hex } from "../../runtime.js";
40
46
  import { createProposal, isProposalSkipped } from "../proposal/validators/proposals.js";
41
47
  import { buildExtractPrompt, EXTRACT_JSON_SCHEMA, parseExtractPayload } from "./extract-prompt.js";
48
+ import { applySchemaSimilarityPenalty, buildHotProbationFrontmatter, loadDerivedLayerEmbeddings, } from "./homeostatic.js";
49
+ import { resolveProcessEnabled } from "./improve-profiles.js";
42
50
  import { buildSessionSummaryPrompt, parseSessionSummary, SESSION_SUMMARY_JSON_SCHEMA, sessionMeetsDurationGate, writeSessionAsset, } from "./session-asset.js";
51
+ import { resolveTriageConfig, scoreSessionTriage } from "./triage.js";
43
52
  /** Default minimum session duration (minutes) for session indexing (#561). */
44
53
  const DEFAULT_MIN_SESSION_DURATION_MINUTES = 5;
45
54
  /**
@@ -50,6 +59,82 @@ const DEFAULT_MIN_SESSION_DURATION_MINUTES = 5;
50
59
  * (0 chars, journal files) are safe to skip.
51
60
  */
52
61
  const DEFAULT_MIN_CONTENT_CHARS = 10;
62
+ /**
63
+ * Default cap on NEW sessions the extract pass will LLM-process in a single run
64
+ * (`processes.extract.maxSessionsPerRun` overrides; `0` disables). Bounds per-run
65
+ * wall time + token spend so a backlog of accumulated sessions can't run a single
66
+ * pass past its scheduled-task timeout. Overflow sessions stay unseen and are
67
+ * processed by subsequent runs, so coverage is preserved — just spread out.
68
+ */
69
+ const DEFAULT_MAX_SESSIONS_PER_RUN = 25;
70
+ /**
71
+ * Floor for the default discovery window (48h). When no explicit `--since` /
72
+ * `defaultSince` is configured, discovery looks back to the LAST recorded
73
+ * extract run for the harness (so an intermittently-online host that was off for
74
+ * days still rediscovers sessions that ended during the gap), but never LESS
75
+ * than this — looking back less than the prior window could drop a session that
76
+ * a previous run deferred via `maxSessionsPerRun`. Widening is free of redundant
77
+ * LLM cost: the content-hash ledger skips unchanged sessions with zero LLM calls.
78
+ */
79
+ const DEFAULT_SINCE_FLOOR_MS = 48 * 60 * 60 * 1000;
80
+ /**
81
+ * Staleness window for the per-session extract lock. A single session's
82
+ * processing is bounded by the per-session LLM timeout (default 60s) plus the
83
+ * session-summary call, so a lock older than this must belong to a crashed
84
+ * holder and is safe to reclaim.
85
+ */
86
+ const EXTRACT_SESSION_LOCK_STALE_MS = 5 * 60 * 1000;
87
+ /**
88
+ * Resolve the discovery `sinceMs` cutoff when no explicit `since`/`defaultSince`
89
+ * is set: the later of (last recorded extract run for this harness) and
90
+ * (now − 48h). See {@link DEFAULT_SINCE_FLOOR_MS}. Best-effort — any state.db
91
+ * error falls back to the 48h floor.
92
+ */
93
+ function resolveDefaultSinceMs(harnessName, now, opts) {
94
+ const floor = now - DEFAULT_SINCE_FLOOR_MS;
95
+ if (opts.skipTracking)
96
+ return floor;
97
+ try {
98
+ return withStateDb((db) => {
99
+ const lastRun = getLastExtractRunAt(db, harnessName);
100
+ return lastRun != null ? Math.min(lastRun, floor) : floor;
101
+ }, { path: opts.stateDbPath, borrowed: opts.stateDb });
102
+ }
103
+ catch {
104
+ return floor;
105
+ }
106
+ }
107
+ /** Filesystem-safe per-session lock path, co-located with the state.db. */
108
+ function getExtractSessionLockPath(harness, sessionId, stateDbPath) {
109
+ const safe = `${harness}-${sessionId}`.replace(/[^A-Za-z0-9._-]/g, "_");
110
+ return path.join(path.dirname(stateDbPath), "extract-locks", `extract-${safe}.lock`);
111
+ }
112
+ /**
113
+ * Try to claim the per-session extract lock so a concurrent extract (e.g. a
114
+ * session-end hook firing `--session-id` while the hourly improve pass runs
115
+ * discovery) cannot double-process the SAME session — duplicate LLM spend and
116
+ * near-duplicate proposals. Reclaims a stale lock (dead holder PID or age past
117
+ * {@link EXTRACT_SESSION_LOCK_STALE_MS}). Returns false when another LIVE run
118
+ * holds it — the caller then skips the session without any LLM call. Best-effort:
119
+ * any filesystem error resolves to `true` (proceed) so locking never blocks
120
+ * extraction outright.
121
+ */
122
+ function acquireExtractSessionLock(lockPath) {
123
+ try {
124
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
125
+ if (tryAcquireLockSync(lockPath, String(process.pid)))
126
+ return true;
127
+ const probe = probeLock(lockPath, { staleAfterMs: EXTRACT_SESSION_LOCK_STALE_MS });
128
+ if (probe.state === "held")
129
+ return false;
130
+ // absent (released between attempt + probe) or stale → reclaim and retry once.
131
+ releaseLock(lockPath);
132
+ return tryAcquireLockSync(lockPath, String(process.pid));
133
+ }
134
+ catch {
135
+ return true;
136
+ }
137
+ }
53
138
  // ── Helpers ──────────────────────────────────────────────────────────────────
54
139
  /**
55
140
  * Parse a since-string into an absolute ms-epoch cutoff. Accepts:
@@ -112,9 +197,43 @@ function buildCandidateProposal(candidate, sourceRef) {
112
197
  if (candidate.type === "lesson" && candidate.when_to_use) {
113
198
  fm.when_to_use = candidate.when_to_use;
114
199
  }
200
+ // #615 WS-0: preserve ordered-action + outcome data in frontmatter so the data
201
+ // survives even if source transcripts are not re-extractable later. The
202
+ // procedural-compilation feature (detection/compilation) is deferred to 0.10+.
203
+ if (candidate.orderedActions && candidate.orderedActions.length > 0) {
204
+ fm.orderedActions = candidate.orderedActions;
205
+ if (candidate.outcomeData) {
206
+ fm.outcomeData = candidate.outcomeData;
207
+ }
208
+ }
115
209
  const content = assembleAsset(fm, candidate.body);
116
210
  return { ref, content, description };
117
211
  }
212
+ /**
213
+ * Canonicalize a session's content into a single deterministic string for
214
+ * hashing (#602). Each event is rendered `<role>\n<text>` and events are joined
215
+ * with a NUL-delimited separator (`\n\0\n`) so event boundaries cannot be forged
216
+ * by text that itself contains newlines.
217
+ *
218
+ * The input is the RAW `data.events` stream — NOT the pre-filtered / truncated
219
+ * set — so the hash is stable across `maxTotalChars` (and any other pre-filter)
220
+ * config changes: changing config must NEVER change the hash (idempotency AC).
221
+ * `inlineRefs` and ref metadata (title, startedAt/endedAt timestamps) are
222
+ * deliberately EXCLUDED so clock/title churn (and an agent adding an inline
223
+ * `akm remember` mid-session) does not change the hash.
224
+ */
225
+ function canonicalizeSessionContent(data) {
226
+ return data.events.map((e) => `${e.role ?? "unknown"}\n${e.text}`).join("\n\0\n");
227
+ }
228
+ /**
229
+ * sha256 (hex) of the normalized session content (#602). This is the byte-exact,
230
+ * clock-independent skip authority that replaced the old `session_ended_at`
231
+ * timestamp comparison. See {@link canonicalizeSessionContent} for exactly what
232
+ * is (and is not) hashed.
233
+ */
234
+ export function hashSessionContent(data) {
235
+ return sha256Hex(canonicalizeSessionContent(data));
236
+ }
118
237
  /**
119
238
  * Process one session through the full pipeline: read → pre-filter → LLM →
120
239
  * parse → createProposal-per-candidate. Returns the per-session result.
@@ -123,7 +242,21 @@ function buildCandidateProposal(candidate, sourceRef) {
123
242
  * proposal validation failure) the session result records a warning and
124
243
  * keeps going — one session's bad luck never aborts a multi-session run.
125
244
  */
126
- async function processSession(harness, sessionRef, stashDir, config, llmConfig, chat, ctx, sourceRun, dryRun, timeoutMs, maxTotalChars, minContentChars, sessionIndexing) {
245
+ async function processSession(harness, sessionRef, stashDir, config, llmConfig, chat, ctx, sourceRun, dryRun, timeoutMs, maxTotalChars, minContentChars,
246
+ // #626 — pre-LLM heuristic triage gate. Default-off (enabled:false) takes the
247
+ // exact pre-change path (no scorer call, no new skipReason).
248
+ // #641 — proceduralAwareFloor is opt-in, DEFAULT OFF.
249
+ triage, sessionIndexing, schemaSimilarityCtx,
250
+ // #602 — already-extracted skip moved INSIDE processSession: the content hash
251
+ // can only be computed after readSession, so the skip decision lives here. The
252
+ // prior row + bypass flags are threaded in from the caller. Skipping here still
253
+ // costs ZERO LLM calls (the expensive resource #602 protects); only the cheap
254
+ // file read is incurred.
255
+ prior, force,
256
+ // Stash authoring standards (convention/meta fact bodies) for non-wiki
257
+ // output. Resolved ONCE per run by the caller and threaded in so facts are
258
+ // not re-read per session. Empty string when none exist.
259
+ standardsContext) {
127
260
  const warnings = [];
128
261
  let data;
129
262
  try {
@@ -141,6 +274,27 @@ async function processSession(harness, sessionRef, stashDir, config, llmConfig,
141
274
  skipReason: "read_failed",
142
275
  };
143
276
  }
277
+ // #602 — content-hash skip. Computed on the RAW event stream immediately after
278
+ // a successful read, BEFORE the pre-filter / minContentChars / triage gates, so
279
+ // an unchanged session never reaches the LLM. Hash-based ⇒ clock-independent
280
+ // (immune to the Jun 11-12 timestamp double-extract/over-throttle bug). The skip
281
+ // applies UNIFORMLY — including explicit `--session-id` targeting (so a
282
+ // session-end hook firing `extract --session-id <id>` is idempotent). ONLY
283
+ // `--force` overrides it to re-extract a previously-extracted session.
284
+ const contentHash = hashSessionContent(data);
285
+ if (!force && shouldSkipAlreadyExtractedSession(prior, contentHash)) {
286
+ return {
287
+ sessionId: sessionRef.sessionId,
288
+ harness: harness.name,
289
+ candidateCount: 0,
290
+ proposalIds: [],
291
+ preFilter: { inputCount: 0, outputCount: 0, truncatedCount: 0 },
292
+ warnings: [`already extracted (content unchanged) at ${prior?.processed_at}; pass --force to re-process`],
293
+ skipped: true,
294
+ skipReason: "already_extracted",
295
+ contentHash,
296
+ };
297
+ }
144
298
  const filtered = preFilterSession(data, {
145
299
  ...(typeof maxTotalChars === "number" ? { maxTotalChars } : {}),
146
300
  });
@@ -167,9 +321,42 @@ async function processSession(harness, sessionRef, stashDir, config, llmConfig,
167
321
  warnings: [],
168
322
  skipped: true,
169
323
  skipReason: "too_short",
324
+ contentHash,
170
325
  };
171
326
  }
172
- const prompt = buildExtractPrompt({ data, events: filtered.events, inlineRefs: data.inlineRefs });
327
+ // #626 pre-LLM heuristic triage gate. Runs AFTER minContentChars + the
328
+ // already-extracted skip check (both in the caller / above), BEFORE the
329
+ // extraction prompt and the session-asset write. When the session scores below
330
+ // the configured threshold we triage it out: no chat() call, no session asset,
331
+ // no proposals. Pure-heuristic — zero added LLM cost. Default-off → skipped.
332
+ if (triage.enabled) {
333
+ const t = scoreSessionTriage(data, triage.minScore, {
334
+ proceduralAwareFloor: triage.proceduralAwareFloor,
335
+ });
336
+ if (!t.pass) {
337
+ return {
338
+ sessionId: sessionRef.sessionId,
339
+ harness: harness.name,
340
+ candidateCount: 0,
341
+ proposalIds: [],
342
+ preFilter: {
343
+ inputCount: filtered.stats.inputCount,
344
+ outputCount: filtered.stats.outputCount,
345
+ truncatedCount: filtered.stats.truncatedCount,
346
+ },
347
+ warnings: [],
348
+ skipped: true,
349
+ skipReason: "triaged_out",
350
+ contentHash,
351
+ };
352
+ }
353
+ }
354
+ const prompt = buildExtractPrompt({
355
+ data,
356
+ events: filtered.events,
357
+ inlineRefs: data.inlineRefs,
358
+ ...(standardsContext.trim() ? { standardsContext } : {}),
359
+ });
173
360
  // #561 — ADDITIVE session indexing. Generate + write the session asset
174
361
  // (`sessions/<harness>/<id>.md`). FAIL-OPEN: any failure only records a
175
362
  // warning; it NEVER changes the proposal/skip outcome of extract. Returns the
@@ -217,6 +404,7 @@ async function processSession(harness, sessionRef, stashDir, config, llmConfig,
217
404
  warnings: ["session_extraction feature returned empty (disabled / timeout / error)"],
218
405
  skipped: true,
219
406
  skipReason: "llm_unavailable",
407
+ contentHash,
220
408
  };
221
409
  }
222
410
  const payload = parseExtractPayload(llmRaw);
@@ -248,15 +436,34 @@ async function processSession(harness, sessionRef, stashDir, config, llmConfig,
248
436
  truncatedCount: filtered.stats.truncatedCount,
249
437
  },
250
438
  warnings,
439
+ contentHash,
251
440
  ...sessionAsset,
252
441
  };
253
442
  }
443
+ // WS-3b step 0c: hot-probation intake buffer (#604).
444
+ // When enabled, system-generated extractions enter captureMode: hot-probation
445
+ // so they spend ONE consolidation cycle in probation before the deterministic
446
+ // dedup+quality pass promotes them. Default OFF.
447
+ const hotProbationEnabled = config.profiles?.improve?.default?.processes?.extract?.hotProbation
448
+ ?.enabled === true;
254
449
  for (const candidate of payload.candidates) {
255
450
  if (dryRun) {
256
451
  proposalIds.push(`dry-run:${candidate.type}:${candidate.name}`);
257
452
  continue;
258
453
  }
259
454
  try {
455
+ // WS-3b Step-0b: schema-similarity intake gate. When enabled and the
456
+ // candidate is a lesson/knowledge whose body embedding is within ε of an
457
+ // existing derived-layer node, down-prioritize by multiplying confidence by
458
+ // the penalty. PARITY: schemaSimilarityCtx is null when the flag is off →
459
+ // applySchemaSimilarityPenalty returns the original confidence untouched and
460
+ // never embeds. (Logic lives in homeostatic.ts so it is unit-testable.)
461
+ const gateResult = await applySchemaSimilarityPenalty(candidate, schemaSimilarityCtx, (text) => schemaSimilarityCtx?.embedFn
462
+ ? schemaSimilarityCtx.embedFn(text)
463
+ : embed(text, schemaSimilarityCtx?.embeddingConfig));
464
+ const effectiveConfidence = gateResult.effectiveConfidence;
465
+ if (gateResult.warning)
466
+ warn(gateResult.warning);
260
467
  const { ref, content, description } = buildCandidateProposal(candidate, sessionRef);
261
468
  const result = createProposal(stashDir, {
262
469
  ref,
@@ -267,9 +474,22 @@ async function processSession(harness, sessionRef, stashDir, config, llmConfig,
267
474
  frontmatter: {
268
475
  description,
269
476
  ...(candidate.when_to_use ? { when_to_use: candidate.when_to_use } : {}),
270
- ...(typeof candidate.confidence === "number" ? { confidence: candidate.confidence } : {}),
477
+ ...(effectiveConfidence !== undefined ? { confidence: effectiveConfidence } : {}),
271
478
  sources: [`session:${sessionRef.harness}:${sessionRef.sessionId}`],
272
479
  evidence: candidate.evidence,
480
+ // #615 WS-0: mirror ordered-action + outcome data in the proposal
481
+ // frontmatter record so downstream tooling can read it without
482
+ // re-parsing the content body. Omitted when not present.
483
+ ...(candidate.orderedActions && candidate.orderedActions.length > 0
484
+ ? { orderedActions: candidate.orderedActions }
485
+ : {}),
486
+ ...(candidate.outcomeData ? { outcomeData: candidate.outcomeData } : {}),
487
+ // WS-3b step 0c: tag system-generated extractions as hot-probation
488
+ // when the feature is enabled. The consolidation pass will exclude
489
+ // them from the LLM merge pool until the intake dedup+quality pass
490
+ // runs against them. User-explicit `akm remember` (captureMode: hot)
491
+ // is unaffected — this only applies to extract-generated proposals.
492
+ ...(hotProbationEnabled ? buildHotProbationFrontmatter() : {}),
273
493
  },
274
494
  },
275
495
  }, ctx);
@@ -310,6 +530,7 @@ async function processSession(harness, sessionRef, stashDir, config, llmConfig,
310
530
  truncatedCount: filtered.stats.truncatedCount,
311
531
  },
312
532
  warnings,
533
+ contentHash,
313
534
  ...sessionAsset,
314
535
  };
315
536
  }
@@ -323,13 +544,24 @@ export async function akmExtract(options) {
323
544
  const stashDir = options.stashDir ?? resolveStashDir();
324
545
  const dryRun = options.dryRun ?? false;
325
546
  const sourceRun = options.sourceRun ?? `extract-${timestampForFilename()}`;
326
- // Read the per-process extract config from the active improve profile. Matches
327
- // the pattern reflect/distill/consolidate use: `profiles.improve.<active>.processes.extract`.
328
- // Only the `default` improve profile is consulted here extract isn't invoked
329
- // with a profile flag yet (parity item for a future change).
330
- const extractProcess = config.profiles?.improve?.default?.processes?.extract;
547
+ // Read the per-process extract config + enabled-state from the ACTIVE improve
548
+ // profile when `akmImprove` threaded it; otherwise fall back to the `default`
549
+ // profile (the explicit `akm extract` path, which has no active profile). This
550
+ // is what stops a non-default profile's `extract.enabled` from being silently
551
+ // overridden by the default profile and vice-versa.
552
+ const activeProfile = options.improveProfile;
553
+ const extractProcess = activeProfile
554
+ ? activeProfile.processes?.extract
555
+ : config.profiles?.improve?.default?.processes?.extract;
556
+ // The `extract.enabled` process toggle gates extract as a STAGE of `akm improve`
557
+ // (the activeProfile path) — consistent with #593/#594 where the active profile,
558
+ // not `default`, is the source of truth. An EXPLICIT `akm extract` invocation
559
+ // (no activeProfile) is a direct user/cron action and always runs; gating it on
560
+ // the default improve profile's stage toggle was a footgun — dropping extract
561
+ // from the daily improve profile would silently disable the standalone command.
562
+ const extractEnabled = activeProfile ? resolveProcessEnabled("extract", activeProfile) : true;
331
563
  // Feature-gate early so we get a clean "skipped because disabled" envelope.
332
- if (!isLlmFeatureEnabled(config, "session_extraction")) {
564
+ if (!extractEnabled) {
333
565
  return {
334
566
  schemaVersion: 1,
335
567
  ok: true,
@@ -375,8 +607,17 @@ export async function akmExtract(options) {
375
607
  // #595/#596 — minimum raw session size; sessions below it skip the LLM call
376
608
  // entirely. Set `processes.extract.minContentChars: 0` to disable the gate.
377
609
  const minContentChars = typeof extractProcess?.minContentChars === "number" ? extractProcess.minContentChars : DEFAULT_MIN_CONTENT_CHARS;
610
+ // Cap on NEW sessions LLM-processed per run; 0 disables. Absent = default.
611
+ // Bounds per-run wall time / LLM cost so a backlog can't push a run past its
612
+ // task timeout — the overflow stays unseen and is picked up by later runs.
613
+ const maxSessionsPerRun = typeof extractProcess?.maxSessionsPerRun === "number"
614
+ ? extractProcess.maxSessionsPerRun
615
+ : DEFAULT_MAX_SESSIONS_PER_RUN;
378
616
  // Default discovery window — process config can override the built-in 24h.
379
617
  const effectiveSince = options.since ?? extractProcess?.defaultSince;
618
+ // #626 — resolve the triage gate config once per run. Default-off → the
619
+ // per-session path never calls the scorer and emits no telemetry.
620
+ const triage = resolveTriageConfig(extractProcess);
380
621
  // #561 — resolve session-indexing config. Default ON: we only reach this code
381
622
  // when `session_extraction` is enabled AND an LLM is configured (both checked
382
623
  // above), so defaulting on costs nothing offline (the summary call fails open)
@@ -465,7 +706,16 @@ export async function akmExtract(options) {
465
706
  candidates = [target];
466
707
  }
467
708
  else {
468
- const sinceMs = parseSinceArg(effectiveSince);
709
+ // No explicit `--since`/`defaultSince` → default to "since the last run"
710
+ // (floored at 48h) so an intermittently-online host doesn't lose sessions
711
+ // that ended while it was off. See {@link resolveDefaultSinceMs}.
712
+ const sinceMs = effectiveSince
713
+ ? parseSinceArg(effectiveSince)
714
+ : resolveDefaultSinceMs(harness.name, startMs, {
715
+ ...(options.stateDb ? { stateDb: options.stateDb } : {}),
716
+ ...(options.stateDbPath ? { stateDbPath: options.stateDbPath } : {}),
717
+ ...(options.skipTracking ? { skipTracking: options.skipTracking } : {}),
718
+ });
469
719
  candidates = harness.listSessions({
470
720
  sinceMs,
471
721
  ...(options.location ? { location: options.location } : {}),
@@ -474,6 +724,10 @@ export async function akmExtract(options) {
474
724
  const sessions = [];
475
725
  let processedCount = 0;
476
726
  let skippedCount = 0;
727
+ // #626 — per-run triage aggregation counters (counts-only telemetry, AC4).
728
+ let triageEvaluated = 0;
729
+ let triagePassed = 0;
730
+ let triagedOut = 0;
477
731
  const allProposalIds = [];
478
732
  const topLevelWarnings = [];
479
733
  const chat = options.chat ?? chatCompletion;
@@ -497,39 +751,95 @@ export async function akmExtract(options) {
497
751
  stateDb = undefined;
498
752
  }
499
753
  }
754
+ // WS-3b Step-0b: schema-similarity intake gate.
755
+ // Load derived-layer (lesson/knowledge) embeddings once per run, but ONLY
756
+ // when the gate is enabled in config. When disabled (the default) this block
757
+ // is fully skipped and schemaSimilarityCtx stays null → byte-identical to
758
+ // prior behaviour.
759
+ const schemaSimilarityCfg = extractProcess?.schemaSimilarity;
760
+ let schemaSimilarityCtx = null;
761
+ if (schemaSimilarityCfg?.enabled === true) {
762
+ const derivedEmbeddings = options.schemaSimilarityEmbeddings ?? loadDerivedLayerEmbeddings();
763
+ schemaSimilarityCtx = {
764
+ config: schemaSimilarityCfg,
765
+ derivedEmbeddings,
766
+ embeddingConfig: config.embedding,
767
+ embedFn: options.schemaSimilarityEmbedFn,
768
+ };
769
+ }
770
+ // Stash authoring standards (convention/meta fact bodies) for non-wiki
771
+ // extract output. Resolved ONCE per run and threaded into each session's
772
+ // prompt so facts are not re-read per session.
773
+ const extractStandardsContext = resolveStashStandards(stashDir);
500
774
  for (const summary of candidates) {
501
- // Skip-tracking: if this session was already processed AND no new events
502
- // have arrived since (live endedAt <= recorded endedAt), don't burn an LLM
503
- // call. --force or single-session mode (explicit sessionId) bypasses.
775
+ // #602 the already-extracted skip moved INTO processSession (the content
776
+ // hash needs the session body, only available after readSession). The prior
777
+ // row + bypass flags are threaded through; an unchanged session returns
778
+ // skipReason 'already_extracted' WITHOUT any LLM call.
504
779
  const prior = seenMap.get(summary.sessionId);
505
- if (!options.force && !options.sessionId && shouldSkipAlreadyExtractedSession(prior, summary.endedAt)) {
506
- sessions.push({
507
- sessionId: summary.sessionId,
508
- harness: harness.name,
509
- candidateCount: 0,
510
- proposalIds: [],
511
- preFilter: { inputCount: 0, outputCount: 0, truncatedCount: 0 },
512
- warnings: [
513
- `already extracted at ${prior?.processed_at}; pass --force to re-process or wait until the session has new content`,
514
- ],
515
- skipped: true,
516
- skipReason: "already_extracted",
517
- });
518
- skippedCount += 1;
519
- continue;
780
+ // Per-run cap on LLM-processed sessions (skip-tracked seen sessions above
781
+ // don't count). Single-session / --force modes bypass the cap (explicit
782
+ // intent). Overflow sessions are left unseen for the next run.
783
+ if (!options.sessionId && !options.force && maxSessionsPerRun > 0 && processedCount >= maxSessionsPerRun) {
784
+ topLevelWarnings.push(`Reached maxSessionsPerRun=${maxSessionsPerRun}; ${candidates.length - processedCount - skippedCount} session(s) deferred to a later run.`);
785
+ break;
786
+ }
787
+ // Q5 — per-session lock so two concurrent extracts (e.g. a session-end hook
788
+ // firing `--session-id` while the hourly improve discovery pass runs) can't
789
+ // both LLM-process the SAME session. The holder records the outcome; a
790
+ // second run skips without any LLM call. Engaged only for real cross-process
791
+ // runs (those that open their own state.db): dry-run is read-only, an
792
+ // injected `stateDb` handle is an in-process/test scenario with no cross-
793
+ // process race, and skip-tracking-off opts out entirely.
794
+ let sessionLockPath;
795
+ if (trackingEnabled && !dryRun && !options.stateDb) {
796
+ sessionLockPath = getExtractSessionLockPath(harness.name, summary.sessionId, options.stateDbPath ?? getStateDbPath());
797
+ if (!acquireExtractSessionLock(sessionLockPath)) {
798
+ sessions.push({
799
+ sessionId: summary.sessionId,
800
+ harness: harness.name,
801
+ candidateCount: 0,
802
+ proposalIds: [],
803
+ preFilter: { inputCount: 0, outputCount: 0, truncatedCount: 0 },
804
+ warnings: ["concurrent extract holds this session's lock — skipped (handled by the other run)"],
805
+ skipped: true,
806
+ skipReason: "locked_concurrent",
807
+ });
808
+ skippedCount += 1;
809
+ continue;
810
+ }
520
811
  }
521
812
  try {
522
- const result = await processSession(harness, summary, stashDir, config, llmConfig, chat, options.ctx, sourceRun, dryRun, timeoutMs, maxTotalChars, minContentChars, sessionIndexing);
813
+ const result = await processSession(harness, summary, stashDir, config, llmConfig, chat, options.ctx, sourceRun, dryRun, timeoutMs, maxTotalChars, minContentChars, triage, sessionIndexing, schemaSimilarityCtx, prior, options.force === true, extractStandardsContext);
523
814
  sessions.push(result);
815
+ // #626 — triage aggregation. A session reached the triage gate only when it
816
+ // was NOT already preempted by an earlier skip (read_failed / too_short /
817
+ // already_extracted handled above the processSession call). When triage is
818
+ // enabled, processSession either triages-out (skipReason 'triaged_out') or
819
+ // proceeds past the gate — both count as "evaluated".
820
+ if (triage.enabled) {
821
+ const preemptedBeforeTriage = result.skipReason === "read_failed" ||
822
+ result.skipReason === "too_short" ||
823
+ result.skipReason === "already_extracted";
824
+ if (!preemptedBeforeTriage) {
825
+ triageEvaluated += 1;
826
+ if (result.skipReason === "triaged_out")
827
+ triagedOut += 1;
828
+ else
829
+ triagePassed += 1;
830
+ }
831
+ }
524
832
  if (result.skipped)
525
833
  skippedCount += 1;
526
834
  else
527
835
  processedCount += 1;
528
836
  allProposalIds.push(...result.proposalIds);
529
- // Persist outcome so the next run skips this session unless new events
530
- // arrive. We only track non-dry-run paths — dry-run is for inspection
531
- // and should never poison the seen-table.
532
- if (trackingEnabled && stateDb && !dryRun) {
837
+ // Persist outcome so the next run skips this session unless its content
838
+ // changes. We only track non-dry-run paths — dry-run is for inspection
839
+ // and should never poison the seen-table. #602: an `already_extracted`
840
+ // skip is a no-op (the row already carries the matching hash), so don't
841
+ // re-write it — that keeps `processed_at` stable across unchanged runs.
842
+ if (trackingEnabled && stateDb && !dryRun && result.skipReason !== "already_extracted") {
533
843
  try {
534
844
  const outcome = result.skipped
535
845
  ? result.skipReason === "read_failed" || result.skipReason === "exception"
@@ -548,6 +858,10 @@ export async function akmExtract(options) {
548
858
  proposalCount: result.proposalIds.length,
549
859
  rationale: result.rationaleIfEmpty ?? null,
550
860
  sourceRun,
861
+ // #602 — persist the freshly computed content hash so the NEXT run
862
+ // can compare byte-for-byte. read_failed (before hash) → null, which
863
+ // keeps the row eligible for retry (matches failed-row semantics).
864
+ contentHash: result.contentHash ?? null,
551
865
  metadata: {
552
866
  preFilterInputCount: result.preFilter.inputCount,
553
867
  preFilterOutputCount: result.preFilter.outputCount,
@@ -584,6 +898,10 @@ export async function akmExtract(options) {
584
898
  });
585
899
  skippedCount += 1;
586
900
  }
901
+ finally {
902
+ if (sessionLockPath)
903
+ releaseLock(sessionLockPath);
904
+ }
587
905
  }
588
906
  // Close the state.db connection we opened. Callers that injected stateDb
589
907
  // via the test seam own its lifecycle.
@@ -595,6 +913,20 @@ export async function akmExtract(options) {
595
913
  // best-effort close
596
914
  }
597
915
  }
916
+ // #626 — counts-only triage telemetry (AC4). Exactly ONE aggregated event per
917
+ // run, emitted only when the gate was enabled and actually evaluated at least
918
+ // one session. No per-session events (avoids the log-spam the issue warns of).
919
+ if (triage.enabled && triageEvaluated > 0) {
920
+ appendEvent({
921
+ eventType: "extract_triaged",
922
+ metadata: {
923
+ evaluated: triageEvaluated,
924
+ passed: triagePassed,
925
+ triagedOut,
926
+ sourceRun,
927
+ },
928
+ }, options.ctx);
929
+ }
598
930
  return {
599
931
  schemaVersion: 1,
600
932
  ok: true,
@@ -616,20 +948,34 @@ export async function akmExtract(options) {
616
948
  * logic in {@link akmExtract} so the `#554 minNewSessions` gate in `improve`
617
949
  * can decide whether the extract pass is worth running before any work begins.
618
950
  *
619
- * A session is a "new candidate" when it is in the `since` window AND it would
620
- * not be skipped by {@link shouldSkipAlreadyExtractedSession} (i.e. it has never
621
- * been extracted, or new events have arrived since it was last extracted).
951
+ * #602 this gate is intentionally CHEAP: it does NOT read session bodies, so
952
+ * it cannot compute the content hash that {@link shouldSkipAlreadyExtractedSession}
953
+ * now uses. It therefore uses a CONSERVATIVE row-presence approximation: a
954
+ * session counts as "new" when there is NO prior row OR the prior row's
955
+ * `content_hash` is null (never-seen or backfill-eligible). A prior row WITH a
956
+ * non-null content_hash counts as NOT new — it MIGHT have changed, but the
957
+ * precise per-session hash check happens downstream in processSession, so an
958
+ * over-/under-count here only affects whether the pass RUNS, never whether a
959
+ * changed session is actually re-processed.
622
960
  */
623
961
  export function countNewExtractCandidates(config, options = {}) {
624
962
  const extractProcess = config.profiles?.improve?.default?.processes?.extract;
625
963
  const effectiveSince = options.since ?? extractProcess?.defaultSince;
626
- const sinceMs = parseSinceArg(effectiveSince);
964
+ // Mirror akmExtract: when no explicit window is set, default per-harness to
965
+ // "since the last run" (floored at 48h) instead of a fixed 24h. Keeps this
966
+ // gate's discovery window identical to what akmExtract will actually scan.
967
+ const explicitSinceMs = effectiveSince ? parseSinceArg(effectiveSince) : undefined;
627
968
  const harnesses = (options.harnesses ?? getAvailableHarnesses()).filter((h) => h.isAvailable());
628
969
  let stateDb = options.stateDb;
629
970
  let openedStateDb = false;
630
971
  let total = 0;
631
972
  try {
632
973
  for (const harness of harnesses) {
974
+ const sinceMs = explicitSinceMs ??
975
+ resolveDefaultSinceMs(harness.name, Date.now(), {
976
+ ...(options.stateDb ? { stateDb: options.stateDb } : {}),
977
+ ...(options.stateDbPath ? { stateDbPath: options.stateDbPath } : {}),
978
+ });
633
979
  const candidates = harness.listSessions({ sinceMs });
634
980
  if (candidates.length === 0)
635
981
  continue;
@@ -652,7 +998,10 @@ export function countNewExtractCandidates(config, options = {}) {
652
998
  }
653
999
  for (const summary of candidates) {
654
1000
  const prior = seenMap.get(summary.sessionId);
655
- if (shouldSkipAlreadyExtractedSession(prior, summary.endedAt))
1001
+ // #602 row-presence approximation (see fn doc): a prior row WITH a
1002
+ // non-null content_hash is treated as not-new here; everything else
1003
+ // (never-seen, or null-hash backfill-eligible) counts as new.
1004
+ if (prior && prior.content_hash != null)
656
1005
  continue;
657
1006
  total += 1;
658
1007
  }