akm-cli 0.9.0-beta.34 → 0.9.0-beta.36

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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,69 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.0-beta.36] — 2026-06-22
10
+
11
+ ### Added
12
+
13
+ - **Stash standards + wiki schemas are surfaced to authoring agents at write
14
+ time.** When an agent edits a page under `wikis/<name>/`, that wiki's
15
+ `schema.md` body is injected into the prompt; when it creates/edits a non-wiki
16
+ asset, the bodies of `category: convention`/`meta` `fact` assets are injected.
17
+ Two mutually-exclusive features selected by target type, sharing one
18
+ `standardsContext` prompt seam. Wired into reflect, propose, and every
19
+ improve authoring pass (distill, consolidate, recombine, procedural, extract,
20
+ schema-repair). (#642)
21
+ - **Unified, validator-sourced authoring-rules seam.** A new
22
+ `authoringRulesForType(type)` injects the hard authoring rules (no
23
+ pseudo-frontmatter in body, exactly two `---` fences, description/`when_to_use`
24
+ length + shape) into every authoring prompt. The numeric bounds live in one
25
+ module that the validators import, so the prompt can no longer drift from what
26
+ the gate enforces. (#645)
27
+
28
+ ### Fixed
29
+
30
+ - **High-salience reflect lane now reflects each asset at most once.** The
31
+ `#608` admission gate lacked the cooldown its sibling high-retrieval gate has,
32
+ so zero-feedback assets were re-selected on every run (auto-accept emits
33
+ `promoted`, not `feedback`), burning LLM calls and churning assets. (#643)
34
+ - **Stuck validation-failing proposals no longer dead-end.** The triage drain no
35
+ longer overwrites an `auto-rejected` gate stamp with a misleading
36
+ `auto-accepted` (the failure stays truthful and visible). A bounded,
37
+ content-preserving auto-repair (strip pseudo-frontmatter / stray `---`, repair
38
+ truncated descriptions) runs at the promote boundary and re-validates — fixable
39
+ proposals promote; genuinely unrepairable ones stay `pending` for manual
40
+ review, with nothing fabricated and validation never bypassed. (#645)
41
+ - Corrected a prompt/validator drift where the distill system prompt asked for an
42
+ 80–200 char description while the gate enforced 20–400. (#645)
43
+
44
+ ## [0.9.0-beta.35] — 2026-06-21
45
+
46
+ ### Fixed
47
+
48
+ - **Default extract discovery window is now "since the last run" (floored at 48h),
49
+ not a fixed 24h.** An intermittently-online host that was off for longer than
50
+ the old 24h window could permanently miss sessions that ended during the gap.
51
+ Discovery now looks back to the last recorded extract run for the harness, never
52
+ less than 48h. Widening is free of redundant LLM cost — the content-hash ledger
53
+ skips unchanged sessions with zero LLM calls. An explicit `--since`/`defaultSince`
54
+ still wins.
55
+ - **Per-session lock prevents concurrent double-extraction.** A session-end hook
56
+ firing `extract --session-id` while the periodic `akm improve` extract pass runs
57
+ discovery could both LLM-process the SAME session (duplicate spend + near-dup
58
+ proposals). A per-(harness, session) advisory lock (co-located with state.db,
59
+ PID + age staleness recovery) now makes the second run skip without any LLM call.
60
+ - **`minNewSessions` is read from the ACTIVE improve profile, not always `default`.**
61
+ A non-default profile (e.g. `frequent`) setting `minNewSessions` was silently
62
+ ignored because the gate (and its candidate-count discovery window) read
63
+ `profiles.improve.default`. They now read the resolved active profile, matching
64
+ how `extract.enabled` already resolves.
65
+
66
+ ### Docs
67
+
68
+ - Documented that `processes.extract.indexSessions` (default on) makes a second
69
+ LLM call per processed session (the session summary); set it to `false` to halve
70
+ per-session extract cost. Unchanged/skipped sessions still cost zero.
71
+
9
72
  ## [0.9.0-beta.34] — 2026-06-21
10
73
 
11
74
  ### Fixed
@@ -15,7 +15,7 @@ when_to_use: <one complete sentence describing the concrete trigger condition>
15
15
  <lesson body — plain markdown, 1–3 short paragraphs of practical guidance>
16
16
 
17
17
  ## description field (MANDATORY)
18
- - A single complete sentence in present tense, 80-200 chars, NO markdown.
18
+ - A single complete sentence in present tense, 20–400 chars, NO markdown.
19
19
  - Self-contained: a reviewer must understand the lesson from this field alone.
20
20
  - DO NOT start with "When ", "If ", or a connector word — that belongs in when_to_use.
21
21
  - DO NOT copy a section heading ("Key takeaways", "For example", "Key pitfalls").
@@ -28,7 +28,7 @@ Things NOT to extract:
28
28
  - Started: {{STARTED_AT}}
29
29
  - Ended: {{ENDED_AT}}
30
30
  - Project hint: {{PROJECT_HINT}}
31
-
31
+ {{STANDARDS}}
32
32
  ## Filtered session transcript
33
33
 
34
34
  The transcript below has already had read-only `akm` meta-ops and platform boilerplate stripped. Only content that might carry signal remains.
@@ -15,6 +15,7 @@ import { getDefaultLlmConfig, loadConfig } from "../../core/config/config.js";
15
15
  import { ConfigError } from "../../core/errors.js";
16
16
  // Note: appendEvent import removed (WS-3a: archive TTL machinery retired)
17
17
  import { parseEmbeddedJsonResponse } from "../../core/parse.js";
18
+ import { resolveStashStandards } from "../../core/standards/resolve-stash-standards.js";
18
19
  import { detectTruncatedDescription } from "../../core/text-truncation.js";
19
20
  import { hasHotCaptureMode, hasSupersededStatus, MERGE_ABSOLUTE_FLOOR_CHARS, MERGE_SHRINK_RATIO_MIN, validateProposalFrontmatter, } from "../proposal/validators/proposal-quality-validators.js";
20
21
  import { createProposal, isProposalSkipped, listProposals } from "../proposal/validators/proposals.js";
@@ -377,7 +378,7 @@ async function clusterMemoriesBySimilarity(memories, config, stateDb) {
377
378
  * is precomputed once per run by `loadPendingConsolidateProposalHashes`
378
379
  * so the cost stays O(memories) inside the chunk loop.
379
380
  */
380
- export function buildChunkPrompt(sourceName, memories, chunkIndex, totalChunks, bodyTruncation, pendingProposalBodyHashes = new Set()) {
381
+ export function buildChunkPrompt(sourceName, memories, chunkIndex, totalChunks, bodyTruncation, pendingProposalBodyHashes = new Set(), standardsContext = "") {
381
382
  const start = memories[0] ? `memory:${memories[0].name}` : "";
382
383
  const end = memories[memories.length - 1] ? `memory:${memories[memories.length - 1].name}` : "";
383
384
  const annotationsByIndex = [];
@@ -405,6 +406,11 @@ export function buildChunkPrompt(sourceName, memories, chunkIndex, totalChunks,
405
406
  `Chunk ${chunkIndex + 1} of ${totalChunks}, memories ${start}–${end}:`,
406
407
  "",
407
408
  ];
409
+ if (standardsContext.trim()) {
410
+ lines.push("Standards to follow (the rulebook for this target):");
411
+ lines.push(standardsContext.trim());
412
+ lines.push("");
413
+ }
408
414
  // Top-of-prompt protection block for hot refs. Neutral phrasing — avoid
409
415
  // op-words like "promote", "merge", "contradict" so the model doesn't
410
416
  // accidentally treat the warning as a hint to use that op elsewhere
@@ -1246,6 +1252,10 @@ async function akmConsolidateInner(opts, config, stashDir, startMs, sourceRun, w
1246
1252
  }
1247
1253
  warn(`[consolidate] ${memories.length} memories / ${chunks.length} chunk(s) / chunk_size=${chunkSize}` +
1248
1254
  ` / pending-proposal hashes: ${pendingProposalBodyHashes.size}`);
1255
+ // Consolidate output merges memories (non-wiki) → stash authoring standards.
1256
+ // Resolved ONCE per run and passed to each chunk prompt (facts not re-read
1257
+ // per chunk).
1258
+ const standardsContext = resolveStashStandards(stashDir);
1249
1259
  const chunkOpsArrays = [];
1250
1260
  // Structured skip-reason histogram (2026-05-26): every deterministic
1251
1261
  // post-LLM op rejection site below also calls `pushSkipReason` so the
@@ -1363,7 +1373,7 @@ async function akmConsolidateInner(opts, config, stashDir, startMs, sourceRun, w
1363
1373
  continue;
1364
1374
  }
1365
1375
  warn(`[consolidate] chunk ${chunkIdx + 1}/${chunks.length} (${chunk.length} memories) …`);
1366
- const userPrompt = buildChunkPrompt(sourceName, chunk, chunkIdx, chunks.length, bodyTruncation, pendingProposalBodyHashes);
1376
+ const userPrompt = buildChunkPrompt(sourceName, chunk, chunkIdx, chunks.length, bodyTruncation, pendingProposalBodyHashes, standardsContext);
1367
1377
  let raw = await tryLlmFeature("memory_consolidation", config, async () => {
1368
1378
  if (!llmConfig)
1369
1379
  return { ok: false, error: "No LLM configured for consolidation" };
@@ -58,12 +58,14 @@ import { parseAssetRef } from "../../core/asset/asset-ref.js";
58
58
  import { assembleAssetFromString } from "../../core/asset/asset-serialize.js";
59
59
  import { parseFrontmatter, writeSalienceToFrontmatter } from "../../core/asset/frontmatter.js";
60
60
  import { stripMarkdownFences } from "../../core/asset/markdown.js";
61
+ import { authoringRulesForType } from "../../core/authoring-rules.js";
61
62
  import { resolveStashDir, timestampForFilename } from "../../core/common.js";
62
63
  import { getDefaultLlmConfig, loadConfig } from "../../core/config/config.js";
63
64
  import { ConfigError, UsageError } from "../../core/errors.js";
64
65
  import { appendEvent, readEvents } from "../../core/events.js";
65
66
  import { lintLessonContent } from "../../core/lesson-lint.js";
66
67
  import { getDbPath } from "../../core/paths.js";
68
+ import { resolveStashStandards } from "../../core/standards/resolve-stash-standards.js";
67
69
  import { openStateDatabase } from "../../core/state-db.js";
68
70
  import { warnVerbose } from "../../core/warn.js";
69
71
  import { closeDatabase, getAllEntries, openDatabase } from "../../indexer/db/db.js";
@@ -307,6 +309,18 @@ export function buildDistillPrompt(input) {
307
309
  const lines = [];
308
310
  lines.push(`Asset ref: ${input.inputRef}`);
309
311
  lines.push("");
312
+ if (input.standardsContext?.trim()) {
313
+ lines.push("Standards to follow (the rulebook for this target):");
314
+ lines.push(input.standardsContext.trim());
315
+ lines.push("");
316
+ }
317
+ {
318
+ const authoringRules = authoringRulesForType(input.proposalKind ?? "lesson");
319
+ if (authoringRules) {
320
+ lines.push(authoringRules);
321
+ lines.push("");
322
+ }
323
+ }
310
324
  lines.push("Asset content:");
311
325
  if (input.assetContent) {
312
326
  const body = input.assetContent.trim().slice(0, 3000);
@@ -939,12 +953,16 @@ export async function akmDistill(options) {
939
953
  // Fail open — CLS is supplemental, never required.
940
954
  }
941
955
  }
956
+ // Distill output is a lesson/knowledge (non-wiki) → stash authoring
957
+ // standards. Resolved once for this single call.
958
+ const standardsContext = resolveStashStandards(stash);
942
959
  const baseUserPrompt = buildDistillPrompt({
943
960
  inputRef,
944
961
  assetContent,
945
962
  feedback,
946
963
  proposalKind: effectiveProposalKind,
947
964
  ...(rejectedForRef.length > 0 ? { rejectedProposals: rejectedForRef } : {}),
965
+ ...(standardsContext.trim() ? { standardsContext } : {}),
948
966
  });
949
967
  const userPrompt = clsContext ? `${baseUserPrompt}${clsContext}` : baseUserPrompt;
950
968
  const messages = [
@@ -149,6 +149,11 @@ export function buildExtractPrompt(input) {
149
149
  const ref = input.data.ref;
150
150
  const startedAt = ref.startedAt ? new Date(ref.startedAt).toISOString() : "unknown";
151
151
  const endedAt = ref.endedAt ? new Date(ref.endedAt).toISOString() : "unknown";
152
+ // Optional standards block — rendered to the lead-in + body when present,
153
+ // or an empty string (no section) when absent. Gated on non-empty.
154
+ const standards = input.standardsContext?.trim()
155
+ ? `\n## Standards to follow (the rulebook for this target)\n\n${input.standardsContext.trim()}\n`
156
+ : "";
152
157
  return promptTemplate
153
158
  .replace("{{HARNESS}}", ref.harness)
154
159
  .replace("{{TITLE}}", ref.title ?? "(no title)")
@@ -156,6 +161,7 @@ export function buildExtractPrompt(input) {
156
161
  .replace("{{ENDED_AT}}", endedAt)
157
162
  .replace("{{PROJECT_HINT}}", ref.projectHint ?? "(no project hint)")
158
163
  .replace("{{ALREADY_PRESERVED}}", formatAlreadyPreserved(input.inlineRefs))
164
+ .replace("{{STANDARDS}}", standards)
159
165
  .replace("{{TRANSCRIPT}}", formatTranscript(input.events));
160
166
  }
161
167
  /**
@@ -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, } 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";
@@ -63,6 +67,88 @@ const DEFAULT_MIN_CONTENT_CHARS = 10;
63
67
  * processed by subsequent runs, so coverage is preserved — just spread out.
64
68
  */
65
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
+ let db = opts.stateDb;
98
+ let opened = false;
99
+ try {
100
+ if (!db) {
101
+ db = openStateDatabase(opts.stateDbPath);
102
+ opened = true;
103
+ }
104
+ const lastRun = getLastExtractRunAt(db, harnessName);
105
+ return lastRun != null ? Math.min(lastRun, floor) : floor;
106
+ }
107
+ catch {
108
+ return floor;
109
+ }
110
+ finally {
111
+ if (opened && db) {
112
+ try {
113
+ db.close();
114
+ }
115
+ catch {
116
+ // best-effort close
117
+ }
118
+ }
119
+ }
120
+ }
121
+ /** Filesystem-safe per-session lock path, co-located with the state.db. */
122
+ function getExtractSessionLockPath(harness, sessionId, stateDbPath) {
123
+ const safe = `${harness}-${sessionId}`.replace(/[^A-Za-z0-9._-]/g, "_");
124
+ return path.join(path.dirname(stateDbPath), "extract-locks", `extract-${safe}.lock`);
125
+ }
126
+ /**
127
+ * Try to claim the per-session extract lock so a concurrent extract (e.g. a
128
+ * session-end hook firing `--session-id` while the hourly improve pass runs
129
+ * discovery) cannot double-process the SAME session — duplicate LLM spend and
130
+ * near-duplicate proposals. Reclaims a stale lock (dead holder PID or age past
131
+ * {@link EXTRACT_SESSION_LOCK_STALE_MS}). Returns false when another LIVE run
132
+ * holds it — the caller then skips the session without any LLM call. Best-effort:
133
+ * any filesystem error resolves to `true` (proceed) so locking never blocks
134
+ * extraction outright.
135
+ */
136
+ function acquireExtractSessionLock(lockPath) {
137
+ try {
138
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
139
+ if (tryAcquireLockSync(lockPath, String(process.pid)))
140
+ return true;
141
+ const probe = probeLock(lockPath, { staleAfterMs: EXTRACT_SESSION_LOCK_STALE_MS });
142
+ if (probe.state === "held")
143
+ return false;
144
+ // absent (released between attempt + probe) or stale → reclaim and retry once.
145
+ releaseLock(lockPath);
146
+ return tryAcquireLockSync(lockPath, String(process.pid));
147
+ }
148
+ catch {
149
+ return true;
150
+ }
151
+ }
66
152
  // ── Helpers ──────────────────────────────────────────────────────────────────
67
153
  /**
68
154
  * Parse a since-string into an absolute ms-epoch cutoff. Accepts:
@@ -180,7 +266,11 @@ triage, sessionIndexing, schemaSimilarityCtx,
180
266
  // prior row + bypass flags are threaded in from the caller. Skipping here still
181
267
  // costs ZERO LLM calls (the expensive resource #602 protects); only the cheap
182
268
  // file read is incurred.
183
- prior, force) {
269
+ prior, force,
270
+ // Stash authoring standards (convention/meta fact bodies) for non-wiki
271
+ // output. Resolved ONCE per run by the caller and threaded in so facts are
272
+ // not re-read per session. Empty string when none exist.
273
+ standardsContext) {
184
274
  const warnings = [];
185
275
  let data;
186
276
  try {
@@ -275,7 +365,12 @@ prior, force) {
275
365
  };
276
366
  }
277
367
  }
278
- const prompt = buildExtractPrompt({ data, events: filtered.events, inlineRefs: data.inlineRefs });
368
+ const prompt = buildExtractPrompt({
369
+ data,
370
+ events: filtered.events,
371
+ inlineRefs: data.inlineRefs,
372
+ ...(standardsContext.trim() ? { standardsContext } : {}),
373
+ });
279
374
  // #561 — ADDITIVE session indexing. Generate + write the session asset
280
375
  // (`sessions/<harness>/<id>.md`). FAIL-OPEN: any failure only records a
281
376
  // warning; it NEVER changes the proposal/skip outcome of extract. Returns the
@@ -625,7 +720,16 @@ export async function akmExtract(options) {
625
720
  candidates = [target];
626
721
  }
627
722
  else {
628
- const sinceMs = parseSinceArg(effectiveSince);
723
+ // No explicit `--since`/`defaultSince` → default to "since the last run"
724
+ // (floored at 48h) so an intermittently-online host doesn't lose sessions
725
+ // that ended while it was off. See {@link resolveDefaultSinceMs}.
726
+ const sinceMs = effectiveSince
727
+ ? parseSinceArg(effectiveSince)
728
+ : resolveDefaultSinceMs(harness.name, startMs, {
729
+ ...(options.stateDb ? { stateDb: options.stateDb } : {}),
730
+ ...(options.stateDbPath ? { stateDbPath: options.stateDbPath } : {}),
731
+ ...(options.skipTracking ? { skipTracking: options.skipTracking } : {}),
732
+ });
629
733
  candidates = harness.listSessions({
630
734
  sinceMs,
631
735
  ...(options.location ? { location: options.location } : {}),
@@ -677,6 +781,10 @@ export async function akmExtract(options) {
677
781
  embedFn: options.schemaSimilarityEmbedFn,
678
782
  };
679
783
  }
784
+ // Stash authoring standards (convention/meta fact bodies) for non-wiki
785
+ // extract output. Resolved ONCE per run and threaded into each session's
786
+ // prompt so facts are not re-read per session.
787
+ const extractStandardsContext = resolveStashStandards(stashDir);
680
788
  for (const summary of candidates) {
681
789
  // #602 — the already-extracted skip moved INTO processSession (the content
682
790
  // hash needs the session body, only available after readSession). The prior
@@ -690,8 +798,33 @@ export async function akmExtract(options) {
690
798
  topLevelWarnings.push(`Reached maxSessionsPerRun=${maxSessionsPerRun}; ${candidates.length - processedCount - skippedCount} session(s) deferred to a later run.`);
691
799
  break;
692
800
  }
801
+ // Q5 — per-session lock so two concurrent extracts (e.g. a session-end hook
802
+ // firing `--session-id` while the hourly improve discovery pass runs) can't
803
+ // both LLM-process the SAME session. The holder records the outcome; a
804
+ // second run skips without any LLM call. Engaged only for real cross-process
805
+ // runs (those that open their own state.db): dry-run is read-only, an
806
+ // injected `stateDb` handle is an in-process/test scenario with no cross-
807
+ // process race, and skip-tracking-off opts out entirely.
808
+ let sessionLockPath;
809
+ if (trackingEnabled && !dryRun && !options.stateDb) {
810
+ sessionLockPath = getExtractSessionLockPath(harness.name, summary.sessionId, options.stateDbPath ?? getStateDbPath());
811
+ if (!acquireExtractSessionLock(sessionLockPath)) {
812
+ sessions.push({
813
+ sessionId: summary.sessionId,
814
+ harness: harness.name,
815
+ candidateCount: 0,
816
+ proposalIds: [],
817
+ preFilter: { inputCount: 0, outputCount: 0, truncatedCount: 0 },
818
+ warnings: ["concurrent extract holds this session's lock — skipped (handled by the other run)"],
819
+ skipped: true,
820
+ skipReason: "locked_concurrent",
821
+ });
822
+ skippedCount += 1;
823
+ continue;
824
+ }
825
+ }
693
826
  try {
694
- const result = await processSession(harness, summary, stashDir, config, llmConfig, chat, options.ctx, sourceRun, dryRun, timeoutMs, maxTotalChars, minContentChars, triage, sessionIndexing, schemaSimilarityCtx, prior, options.force === true);
827
+ 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);
695
828
  sessions.push(result);
696
829
  // #626 — triage aggregation. A session reached the triage gate only when it
697
830
  // was NOT already preempted by an earlier skip (read_failed / too_short /
@@ -779,6 +912,10 @@ export async function akmExtract(options) {
779
912
  });
780
913
  skippedCount += 1;
781
914
  }
915
+ finally {
916
+ if (sessionLockPath)
917
+ releaseLock(sessionLockPath);
918
+ }
782
919
  }
783
920
  // Close the state.db connection we opened. Callers that injected stateDb
784
921
  // via the test seam own its lifecycle.
@@ -838,13 +975,21 @@ export async function akmExtract(options) {
838
975
  export function countNewExtractCandidates(config, options = {}) {
839
976
  const extractProcess = config.profiles?.improve?.default?.processes?.extract;
840
977
  const effectiveSince = options.since ?? extractProcess?.defaultSince;
841
- const sinceMs = parseSinceArg(effectiveSince);
978
+ // Mirror akmExtract: when no explicit window is set, default per-harness to
979
+ // "since the last run" (floored at 48h) instead of a fixed 24h. Keeps this
980
+ // gate's discovery window identical to what akmExtract will actually scan.
981
+ const explicitSinceMs = effectiveSince ? parseSinceArg(effectiveSince) : undefined;
842
982
  const harnesses = (options.harnesses ?? getAvailableHarnesses()).filter((h) => h.isAvailable());
843
983
  let stateDb = options.stateDb;
844
984
  let openedStateDb = false;
845
985
  let total = 0;
846
986
  try {
847
987
  for (const harness of harnesses) {
988
+ const sinceMs = explicitSinceMs ??
989
+ resolveDefaultSinceMs(harness.name, Date.now(), {
990
+ ...(options.stateDb ? { stateDb: options.stateDb } : {}),
991
+ ...(options.stateDbPath ? { stateDbPath: options.stateDbPath } : {}),
992
+ });
848
993
  const candidates = harness.listSessions({ sinceMs });
849
994
  if (candidates.length === 0)
850
995
  continue;
@@ -1887,7 +1887,10 @@ async function runImprovePreparationStage(args) {
1887
1887
  // memory mtimes, so a skipped extract never flags work for the NEXT run's
1888
1888
  // consolidation mtime-gate (the downstream trigger #554 asks us to suppress).
1889
1889
  const EXTRACT_DEFAULT_MIN_NEW_SESSIONS = 0;
1890
- const configuredMinNewSessions = extractConfig.profiles?.improve?.default?.processes?.extract?.minNewSessions;
1890
+ // Read from the ACTIVE resolved profile (not always `default`), matching how
1891
+ // `extract.enabled` resolves — otherwise a non-default profile (e.g.
1892
+ // `frequent`) setting `minNewSessions` was silently ignored.
1893
+ const configuredMinNewSessions = improveProfile.processes?.extract?.minNewSessions;
1891
1894
  const minNewSessions = typeof configuredMinNewSessions === "number" ? configuredMinNewSessions : EXTRACT_DEFAULT_MIN_NEW_SESSIONS;
1892
1895
  // #593/#594: the ACTIVE resolved improve profile is the single source of
1893
1896
  // truth for whether extract runs. (Previously this also ANDed in the legacy
@@ -1904,6 +1907,11 @@ async function runImprovePreparationStage(args) {
1904
1907
  const countFn = options.extractCandidateCountFn ?? countNewExtractCandidates;
1905
1908
  const newCandidateCount = countFn(extractConfig, {
1906
1909
  ...(options.extractHarnesses ? { harnesses: options.extractHarnesses } : {}),
1910
+ // Use the ACTIVE profile's discovery window so the gate counts over the
1911
+ // same window akmExtract will scan (not always `default`).
1912
+ ...(improveProfile.processes?.extract?.defaultSince
1913
+ ? { since: improveProfile.processes.extract.defaultSince }
1914
+ : {}),
1907
1915
  // C2: pin the candidate-count state.db open to the boundary-resolved path.
1908
1916
  ...(eventsCtx?.dbPath ? { stateDbPath: eventsCtx.dbPath } : {}),
1909
1917
  });
@@ -2455,6 +2463,14 @@ async function runImprovePreparationStage(args) {
2455
2463
  // Cap: at most 10% of the effective run limit so the lane cannot crowd out
2456
2464
  // reactive feedback. Requires state.db to have an asset_salience row — refs
2457
2465
  // without a row (pre-#608 assets still on the type-weight stub) are skipped.
2466
+ //
2467
+ // Cooldown: a ref qualifies at most once — when no prior reflect proposal
2468
+ // exists for it (`!lastReflectProposalTs.has`). Without this guard the lane
2469
+ // re-selects the same high-salience refs on EVERY run (auto-accept emits a
2470
+ // `promoted` event, not `feedback`, so the ref never leaves
2471
+ // noFeedbackCandidates), burning LLM calls and churning the asset. This
2472
+ // mirrors the P0-A high-retrieval gate's `!lastReflectProposalTs.has(r.ref)`
2473
+ // guard above so all "rescue" lanes share the same once-per-asset semantics.
2458
2474
  const highSalienceRefs = [];
2459
2475
  const salienceCfg = (options.config ?? loadConfig()).improve?.salience;
2460
2476
  const salienceThreshold = salienceCfg?.salienceThreshold ?? 0.75;
@@ -2470,7 +2486,7 @@ async function runImprovePreparationStage(args) {
2470
2486
  if (highSalienceRefs.length >= highSalienceCap)
2471
2487
  break;
2472
2488
  const row = getAssetSalience(dbForHighSalience, r.ref);
2473
- if (row && row.encoding_salience >= salienceThreshold) {
2489
+ if (row && row.encoding_salience >= salienceThreshold && !lastReflectProposalTs.has(r.ref)) {
2474
2490
  highSalienceRefs.push(r);
2475
2491
  }
2476
2492
  }
@@ -27,6 +27,7 @@ import { resolveStashDir } from "../../core/common.js";
27
27
  import { getDefaultLlmConfig, loadConfig } from "../../core/config/config.js";
28
28
  import { appendEvent } from "../../core/events.js";
29
29
  import { parseEmbeddedJsonResponse } from "../../core/parse.js";
30
+ import { resolveStashStandards } from "../../core/standards/resolve-stash-standards.js";
30
31
  import { warn } from "../../core/warn.js";
31
32
  import { closeDatabase, getAllEntries, openExistingDatabase } from "../../indexer/db/db.js";
32
33
  import { resolveImproveProcessRunnerFromProfile, runnerIsLlm } from "../../integrations/agent/runner.js";
@@ -144,12 +145,17 @@ export function deriveProceduralWorkflowRef(cluster) {
144
145
  }
145
146
  // ── Prompt + parse ──────────────────────────────────────────────────────────────
146
147
  /** Assemble the per-sequence user prompt fed to the procedural LLM. */
147
- export function buildProceduralPrompt(cluster) {
148
+ export function buildProceduralPrompt(cluster, standardsContext = "") {
148
149
  const lines = [
149
150
  `A recurring successful action sequence observed across ${cluster.members.length} sessions.`,
150
151
  "",
151
- "Ordered actions (turn EACH into exactly one step, in this order):",
152
152
  ];
153
+ if (standardsContext.trim()) {
154
+ lines.push("Standards to follow (the rulebook for this target):");
155
+ lines.push(standardsContext.trim());
156
+ lines.push("");
157
+ }
158
+ lines.push("Ordered actions (turn EACH into exactly one step, in this order):");
153
159
  cluster.normalized.forEach((step, i) => {
154
160
  lines.push(`${i + 1}. ${step}`);
155
161
  });
@@ -312,6 +318,9 @@ export async function akmProcedural(opts) {
312
318
  warnings.push("procedural: no LLM configured — skipping");
313
319
  return finish({ sequencesScanned, clustersFormed: 0 });
314
320
  }
321
+ // Procedural output is a workflow (non-wiki) → stash authoring standards.
322
+ // Resolved ONCE per run and passed to each sequence prompt.
323
+ const standardsContext = resolveStashStandards(stashDir);
315
324
  for (const cluster of clusters) {
316
325
  if (opts.signal?.aborted) {
317
326
  warnings.push("aborted-mid-run");
@@ -319,7 +328,7 @@ export async function akmProcedural(opts) {
319
328
  }
320
329
  clustersFormed += 1;
321
330
  const workflowRef = deriveProceduralWorkflowRef(cluster);
322
- const prompt = buildProceduralPrompt(cluster);
331
+ const prompt = buildProceduralPrompt(cluster, standardsContext);
323
332
  const raw = await llmFn(prompt);
324
333
  const doc = parseProceduralWorkflow(raw, cluster.normalized.length);
325
334
  if (!doc) {
@@ -45,6 +45,7 @@ import { resolveStashDir } from "../../core/common.js";
45
45
  import { getDefaultLlmConfig, loadConfig } from "../../core/config/config.js";
46
46
  import { appendEvent } from "../../core/events.js";
47
47
  import { parseEmbeddedJsonResponse } from "../../core/parse.js";
48
+ import { resolveStashStandards } from "../../core/standards/resolve-stash-standards.js";
48
49
  import { decayUnseenRecombineHypotheses, findMatchingRecombineHypothesis, getRecombineHypothesis, getStateDbPath, markRecombineHypothesisPromoted, openStateDatabase, recordRecombineInduction, } from "../../core/state-db.js";
49
50
  import { warn } from "../../core/warn.js";
50
51
  import { closeDatabase, getAllEntries, getEntitiesByEntryIds, openExistingDatabase, } from "../../indexer/db/db.js";
@@ -228,12 +229,17 @@ function readBody(entry) {
228
229
  }
229
230
  }
230
231
  /** Assemble the per-cluster user prompt fed to the recombine LLM. */
231
- export function buildClusterPrompt(cluster) {
232
+ export function buildClusterPrompt(cluster, standardsContext = "") {
232
233
  const lines = [
233
234
  `Shared signal: ${cluster.signature}`,
234
235
  `Cluster of ${cluster.members.length} related memories:`,
235
236
  "",
236
237
  ];
238
+ if (standardsContext.trim()) {
239
+ lines.push("Standards to follow (the rulebook for this target):");
240
+ lines.push(standardsContext.trim());
241
+ lines.push("");
242
+ }
237
243
  for (const m of cluster.members) {
238
244
  lines.push(`[memory:${m.entry.name}]`);
239
245
  if (m.entry.description)
@@ -399,6 +405,9 @@ export async function akmRecombine(opts) {
399
405
  // Refs re-induced (defensible generalization passed the quality gate) THIS
400
406
  // run — everything else is decayed after the loop.
401
407
  const seenThisRun = new Set();
408
+ // Recombine output is knowledge/lesson (non-wiki) → stash authoring
409
+ // standards. Resolved ONCE per run and passed to each cluster prompt.
410
+ const standardsContext = resolveStashStandards(stashDir);
402
411
  try {
403
412
  for (const cluster of clusters) {
404
413
  if (opts.signal?.aborted) {
@@ -406,7 +415,7 @@ export async function akmRecombine(opts) {
406
415
  break;
407
416
  }
408
417
  clustersFormed += 1;
409
- const prompt = buildClusterPrompt(cluster);
418
+ const prompt = buildClusterPrompt(cluster, standardsContext);
410
419
  const raw = await llmFn(prompt);
411
420
  const generalization = parseGeneralization(raw);
412
421
  if (!generalization) {
@@ -34,6 +34,7 @@ import { loadConfig } from "../../core/config/config.js";
34
34
  import { ConfigError, UsageError } from "../../core/errors.js";
35
35
  import { appendEvent, readEvents } from "../../core/events.js";
36
36
  import { lintLessonContent } from "../../core/lesson-lint.js";
37
+ import { resolveStandardsContext } from "../../core/standards/resolve-standards-context.js";
37
38
  import { lookup } from "../../indexer/indexer.js";
38
39
  import { runAgent, } from "../../integrations/agent/index.js";
39
40
  import { resolveProcessAgentProfile } from "../../integrations/agent/config.js";
@@ -745,6 +746,9 @@ export async function akmReflect(options = {}) {
745
746
  // Reflexion-style verbal-RL: inject rejected proposals so the agent avoids
746
747
  // reproducing proposals that have already been reviewed and refused.
747
748
  const rejectedProposals = readRejectedProposals(stash, options.ref);
749
+ // Standards "rulebook" for this target — wiki schema (wiki page) or stash
750
+ // convention/meta facts (non-wiki asset); empty when neither fires.
751
+ const standardsContext = resolveStandardsContext(options.ref, stash);
748
752
  // 5. Spawn the agent — with optional Self-Refine loop (R-1 / #372).
749
753
  //
750
754
  // maxRefineIters controls how many agent invocations are made:
@@ -813,6 +817,7 @@ export async function akmReflect(options = {}) {
813
817
  ...(schemaHints.length > 0 ? { schemaHints } : {}),
814
818
  ...(relatedLessons.length > 0 ? { relatedLessons } : {}),
815
819
  ...(options.task ? { task: options.task } : {}),
820
+ ...(standardsContext.trim() ? { standardsContext } : {}),
816
821
  ...(options.avoidPatterns && options.avoidPatterns.length > 0 ? { avoidPatterns: options.avoidPatterns } : {}),
817
822
  ...(rejectedProposals.length > 0 ? { rejectedProposals } : {}),
818
823
  // R-1: inject prior draft as self-critique target on iterations > 0
@@ -370,6 +370,13 @@ export async function drainProposals(opts, promoteFn = akmProposalAccept, reject
370
370
  // — these are re-stamped `no-judge-configured` when no runner resolves them.
371
371
  const needsJudge = new Set();
372
372
  for (const proposal of pending) {
373
+ // Do NOT reclassify a proposal that was already conclusively stamped
374
+ // `auto-rejected` by a prior gate run (e.g. the improve confidence gate).
375
+ // Overwriting an authoritative rejection with `auto-accepted` would corrupt
376
+ // the audit trail and silently promote content the gate explicitly rejected.
377
+ // Such proposals remain pending for manual review (or TTL expiry).
378
+ if (proposal.gateDecision?.outcome === "auto-rejected")
379
+ continue;
373
380
  const decision = classifyProposal(proposal, opts.policy, opts.maxDiffLines);
374
381
  if (decision === null)
375
382
  continue;
@@ -18,6 +18,7 @@ import { TYPE_DIRS } from "../../core/asset/asset-spec.js";
18
18
  import { resolveStashDir } from "../../core/common.js";
19
19
  import { ConfigError, UsageError } from "../../core/errors.js";
20
20
  import { appendEvent } from "../../core/events.js";
21
+ import { resolveStandardsContext } from "../../core/standards/resolve-standards-context.js";
21
22
  import { runAgent, } from "../../integrations/agent/index.js";
22
23
  import { resolveProcessAgentProfile } from "../../integrations/agent/config.js";
23
24
  import { buildProposePrompt, parseAgentProposalPayload } from "../../integrations/agent/prompts.js";
@@ -93,10 +94,14 @@ export async function akmPropose(options) {
93
94
  // directly using its file tools rather than returning JSON via stdout.
94
95
  const draftFilePath = import("node:os").then((os) => import("node:path").then((path) => path.join(os.tmpdir(), `akm-propose-${options.type}-${options.name.replace(/[^a-z0-9_-]/gi, "_")}-${Date.now()}.md`)));
95
96
  const resolvedDraftPath = await draftFilePath;
97
+ // Standards "rulebook" for this target — wiki schema (wiki page) or stash
98
+ // convention/meta facts (non-wiki asset); empty when neither fires.
99
+ const standardsContext = resolveStandardsContext(`${options.type}:${options.name}`, stash);
96
100
  const prompt = buildProposePrompt({
97
101
  type: options.type,
98
102
  name: options.name,
99
103
  task: options.task,
104
+ ...(standardsContext.trim() ? { standardsContext } : {}),
100
105
  draftFilePath: resolvedDraftPath,
101
106
  });
102
107
  // 4. Spawn the agent.