akm-cli 0.10.261009-beta → 0.10.261010-beta

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 (50) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/STABILITY.md +15 -4
  3. package/dist/assets/prompts/memory-infer-system.md +1 -1
  4. package/dist/assets/prompts/memory-infer-user.md +3 -2
  5. package/dist/assets/ui/app.js +59 -2
  6. package/dist/assets/ui/asset.js +71 -6
  7. package/dist/assets/ui/bundles.js +80 -3
  8. package/dist/assets/ui/diff-view.js +1 -1
  9. package/dist/assets/ui/editor.js +103 -0
  10. package/dist/assets/ui/index.html +2 -0
  11. package/dist/assets/ui/lib.js +51 -1
  12. package/dist/assets/ui/markdown.js +86 -19
  13. package/dist/assets/ui/search.js +25 -5
  14. package/dist/assets/ui/styles.css +70 -0
  15. package/dist/commands/health/improve-metrics.js +11 -1
  16. package/dist/commands/health/md-report.js +3 -3
  17. package/dist/commands/improve/loop-stages.js +2 -2
  18. package/dist/commands/read/curate.js +1 -1
  19. package/dist/commands/read/knowledge.js +109 -77
  20. package/dist/commands/sources/bundle-cli.js +4 -6
  21. package/dist/commands/sources/installed-stashes.js +10 -19
  22. package/dist/commands/ui/api.js +95 -5
  23. package/dist/commands/ui/document-source.js +51 -0
  24. package/dist/commands/ui/git-read.js +18 -2
  25. package/dist/commands/ui/proposal-actions.js +5 -3
  26. package/dist/commands/ui/server.js +25 -13
  27. package/dist/commands/ui/ui-cli.js +3 -3
  28. package/dist/commands/ui/workspace-actions.js +264 -0
  29. package/dist/commands/workflow-cli.js +3 -1
  30. package/dist/core/asset/akm-markdown.js +19 -3
  31. package/dist/core/asset/markdown.js +14 -6
  32. package/dist/core/config/config.js +6 -4
  33. package/dist/core/non-task-input.js +67 -3
  34. package/dist/core/write-source.js +2 -2
  35. package/dist/indexer/index-writer-lock.js +5 -1
  36. package/dist/indexer/passes/memory-inference.js +28 -6
  37. package/dist/llm/memory-infer.js +23 -14
  38. package/dist/scripts/akm-migrate-node.js +18 -10
  39. package/dist/scripts/akm-migrate.js +18 -10
  40. package/dist/workflows/exec/environment.js +4 -8
  41. package/dist/workflows/parser.js +13 -4
  42. package/docs/architecture/0.10-process-rules.md +5 -3
  43. package/docs/architecture/internals/storage-locations.md +1 -1
  44. package/docs/guides/author-workflows.md +78 -28
  45. package/docs/guides/bundles.md +16 -2
  46. package/docs/reference/cli.md +39 -16
  47. package/docs/reference/env-and-secrets.md +11 -0
  48. package/docs/reference/ui-api.md +84 -8
  49. package/docs/reference/workflow-schema.md +63 -32
  50. package/package.json +1 -1
@@ -138,7 +138,11 @@ export async function withAssetMutationLease(purpose, run) {
138
138
  return run();
139
139
  const context = inherited ?? new Set();
140
140
  const execute = async () => {
141
- const lease = await acquireAssetMutationLease({ purpose });
141
+ const lease = await acquireAssetMutationLease({
142
+ purpose,
143
+ // #956: say who is in the way instead of waiting silently, as the sync path does.
144
+ onWait: ({ waitedMs }) => warn(`waiting for ${describeAssetMutationHolder(lockPath)} — ${Math.round(waitedMs / 1000)}s`),
145
+ });
142
146
  if (!lease)
143
147
  throw new Error(`asset mutation lease unavailable for ${purpose}`);
144
148
  context.add(lockPath);
@@ -64,6 +64,12 @@ const FM_INFERRED = "inferred";
64
64
  const FM_INFERENCE_PROCESSED = "inferenceProcessed";
65
65
  const FM_SOURCE = "source";
66
66
  const FM_CAPTURE_MODE = "captureMode";
67
+ /**
68
+ * Cache namespace for this pass. Bump it when the cached response semantics
69
+ * change (v3: a cached row may be an explicit no-change decision); rows under an
70
+ * older namespace are never read again, which is a disposable cache miss.
71
+ */
72
+ const MEMORY_INFERENCE_CACHE_VARIANT = "memory-inference-v3";
67
73
  function validateDerivedMemoryDraft(raw) {
68
74
  if (!raw || typeof raw !== "object")
69
75
  return undefined;
@@ -79,6 +85,10 @@ function validateDerivedMemoryDraft(raw) {
79
85
  ? { title, description, tags, searchHints, content }
80
86
  : undefined;
81
87
  }
88
+ /** Cache validator: a recognised no-change decision, a complete draft, or `undefined` (unusable → recompute). */
89
+ function validateMemoryInferDecision(raw) {
90
+ return memoryInfer.isMemoryInferNoChange(raw) ? memoryInfer.MEMORY_INFER_NO_CHANGE : validateDerivedMemoryDraft(raw);
91
+ }
82
92
  function planPendingMemoryRecord(record, ctx) {
83
93
  if (ctx.signal?.aborted)
84
94
  return { kind: "aborted", record };
@@ -86,11 +96,11 @@ function planPendingMemoryRecord(record, ctx) {
86
96
  return { kind: "existing-child", record };
87
97
  if (ctx.db && !ctx.reEnrich) {
88
98
  const cached = bestEffort(() => {
89
- const entry = getLlmCacheEntry(ctx.db, record.filePath, computeBodyHash(record.body), "memory-inference-v2");
90
- return entry ? validateDerivedMemoryDraft(JSON.parse(entry.resultJson)) : undefined;
99
+ const entry = getLlmCacheEntry(ctx.db, record.filePath, computeBodyHash(record.body), MEMORY_INFERENCE_CACHE_VARIANT);
100
+ return entry ? validateMemoryInferDecision(JSON.parse(entry.resultJson)) : undefined;
91
101
  }, "memory inference cache read corrupt — fall through to recompute");
92
102
  if (cached)
93
- return { kind: "cache-hit", record, derived: cached };
103
+ return { kind: "cache-hit", record, decision: cached };
94
104
  }
95
105
  return { kind: "model", record };
96
106
  }
@@ -132,11 +142,11 @@ async function inferPendingMemoryRecord(plan, ctx) {
132
142
  retryAttempts += 1;
133
143
  };
134
144
  const infer = () => compressMemoryToDerivedMemory(llmRunner, record.body, signal, featureConfig, (event) => warn(`[akm] LLM fallback for ${event.feature}: ${event.reason}`), inferTelemetry, onRetryAttempt, onNotices);
135
- let derived = plan.kind === "cache-hit" ? plan.derived : undefined;
145
+ let derived = plan.kind === "cache-hit" ? plan.decision : undefined;
136
146
  try {
137
147
  if (plan.kind === "model") {
138
148
  derived = db
139
- ? await withLlmCache(db, record.filePath, record.body, reEnrich ?? false, infer, validateDerivedMemoryDraft, undefined, "memory-inference-v2", {
149
+ ? await withLlmCache(db, record.filePath, record.body, reEnrich ?? false, infer, validateMemoryInferDecision, undefined, MEMORY_INFERENCE_CACHE_VARIANT, {
140
150
  onCacheHit: () => {
141
151
  fromCache = true;
142
152
  },
@@ -153,6 +163,12 @@ async function inferPendingMemoryRecord(plan, ctx) {
153
163
  }
154
164
  if (!derived)
155
165
  return { skipped: true, fromCache, retryAttempts };
166
+ // An explicit decision, not a failure. The parent is left exactly as it is: stamping it
167
+ // `inferenceProcessed` would hide every later edit, and the cached decision already
168
+ // stops the retry for as long as the effective input is unchanged.
169
+ if (memoryInfer.isMemoryInferNoChange(derived)) {
170
+ return { skipped: false, splitParent: false, written: 0, noChange: true, fromCache, retryAttempts };
171
+ }
156
172
  const writeOutcome = await writeDerivedMemory(record, derived);
157
173
  if (writeOutcome.written > 0) {
158
174
  markParentProcessed(record);
@@ -209,6 +225,7 @@ async function runMemoryInferencePassBody(ctx, provenance) {
209
225
  retryAttempts: 0,
210
226
  splitParents: 0,
211
227
  writtenFacts: 0,
228
+ noChange: 0,
212
229
  skippedNoFacts: 0,
213
230
  skippedChildExists: 0,
214
231
  skippedAborted: 0,
@@ -273,7 +290,7 @@ async function runMemoryInferencePassBody(ctx, provenance) {
273
290
  assertRunnerCredentials(llmRunner);
274
291
  let processed = 0;
275
292
  const total = pending.length;
276
- onProgress?.({ processed, total, writtenFacts: 0, skippedNoFacts: 0 });
293
+ onProgress?.({ processed, total, writtenFacts: 0, noChange: 0, skippedNoFacts: 0 });
277
294
  let configFailure;
278
295
  const perRecordResults = await concurrentMap(plans, (plan) => inferPendingMemoryRecord(plan, {
279
296
  config,
@@ -305,6 +322,7 @@ async function runMemoryInferencePassBody(ctx, provenance) {
305
322
  processed,
306
323
  total,
307
324
  writtenFacts: result.writtenFacts,
325
+ noChange: result.noChange,
308
326
  skippedNoFacts: result.skippedNoFacts,
309
327
  currentRef: pending[i]?.ref,
310
328
  });
@@ -321,6 +339,9 @@ async function runMemoryInferencePassBody(ctx, provenance) {
321
339
  // Intentionally NOT marked processed — a transient LLM failure should
322
340
  // be retried on the next index run.
323
341
  }
342
+ else if ("noChange" in res && res.noChange) {
343
+ result.noChange += 1;
344
+ }
324
345
  else if (res.splitParent) {
325
346
  result.splitParents += 1;
326
347
  result.writtenFacts += res.written;
@@ -349,6 +370,7 @@ async function runMemoryInferencePassBody(ctx, provenance) {
349
370
  processed,
350
371
  total,
351
372
  writtenFacts: result.writtenFacts,
373
+ noChange: result.noChange,
352
374
  skippedNoFacts: result.skippedNoFacts,
353
375
  currentRef: pending[i]?.ref,
354
376
  });
@@ -34,35 +34,40 @@ const PROMPT_PLACEHOLDERS = new Set([
34
34
  "search phrase 2",
35
35
  "2-3 sentence compressed body preserving key facts verbatim",
36
36
  ]);
37
+ export const MEMORY_INFER_NO_CHANGE = Object.freeze({ noChange: true });
38
+ export function isMemoryInferNoChange(value) {
39
+ return typeof value === "object" && value !== null && value.noChange === true;
40
+ }
37
41
  /**
38
42
  * Strict JSON Schema for the derived-memory payload. Sent as `response_format`
39
43
  * unless the engine sets `supportsJsonSchema: false`; the client drops it once
40
44
  * for a provider that rejects it.
41
45
  *
42
- * Extends the responseSchema lift (PR 1, asset-writers-investigation §5) to
43
- * the memory-inference path. Mirrors the validation gate below
44
- * (title/description/content + non-empty tags/searchHints) so a
45
- * schema-compliant response is guaranteed to pass the downstream check
46
- * — no more "incomplete derived memory payload from LLM; skipping memory"
47
- * for shape-only failures.
46
+ * Every property is required (a strict provider refuses otherwise), so the
47
+ * explicit no-change decision is `noChange: true` with the other fields empty.
48
+ * That also means the schema cannot demand non-empty fields: the parser below
49
+ * is the gate that requires a complete payload whenever `noChange` is false.
48
50
  */
49
51
  export const DERIVED_MEMORY_JSON_SCHEMA = {
50
52
  type: "object",
51
53
  properties: {
52
- title: { type: "string", minLength: 1 },
53
- description: { type: "string", minLength: 1 },
54
- content: { type: "string", minLength: 1 },
55
- tags: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 8 },
56
- searchHints: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 6 },
54
+ noChange: { type: "boolean" },
55
+ title: { type: "string" },
56
+ description: { type: "string" },
57
+ content: { type: "string" },
58
+ tags: { type: "array", items: { type: "string" }, maxItems: 8 },
59
+ searchHints: { type: "array", items: { type: "string" }, maxItems: 6 },
57
60
  },
58
- required: ["title", "description", "content", "tags", "searchHints"],
61
+ required: ["noChange", "title", "description", "content", "tags", "searchHints"],
59
62
  additionalProperties: false,
60
63
  };
61
64
  /**
62
65
  * Compress a single memory body into one derived memory via the configured LLM.
63
66
  *
64
- * Returns `undefined` on any failure (timeout, invalid JSON, empty response).
65
- * Errors are logged via `warn()` but never thrown — a failed split for one memory
67
+ * Returns the draft, or {@link MEMORY_INFER_NO_CHANGE} when the model explicitly
68
+ * decides the memory needs no derived memory. Returns `undefined` on any failure
69
+ * (timeout, invalid JSON, empty or incomplete response, HTML, abort, disabled
70
+ * pass), which is never the same thing as no-change. Errors are logged via `warn()` but never thrown — a failed split for one memory
66
71
  * must not abort the rest of the index pass.
67
72
  *
68
73
  * Routes through `callStructured({ feature: "memory_inference", ... })` so the
@@ -105,6 +110,10 @@ export async function compressMemoryToDerivedMemory(llmRunner, body, signal, akm
105
110
  warn("memory inference: invalid JSON response from LLM; skipping memory.");
106
111
  return undefined;
107
112
  }
113
+ // Only a literal boolean `true` is a decision; anything else falls through
114
+ // to the complete-payload checks, so a malformed reply is never restraint.
115
+ if (parsed.noChange === true)
116
+ return MEMORY_INFER_NO_CHANGE;
108
117
  const title = typeof parsed.title === "string" ? parsed.title.trim() : "";
109
118
  const description = typeof parsed.description === "string" ? parsed.description.trim() : "";
110
119
  const content = typeof parsed.content === "string" ? parsed.content.trim() : "";
@@ -28296,7 +28296,7 @@ function renderRunsDetailMd(runs) {
28296
28296
  "refl_ok/fail/cd/skip",
28297
28297
  "distill_q/llm-fail/judge/validator/cfg/skip",
28298
28298
  "cons_proc/promo/merge/del",
28299
- "mem_cons/written/skip",
28299
+ "mem_cons/written/nochange/skip",
28300
28300
  "orphans",
28301
28301
  "lint_f/fl",
28302
28302
  "result_status"
@@ -28311,7 +28311,7 @@ function renderRunsDetailMd(runs) {
28311
28311
  `${r.actions.reflect.ok}/${r.actions.reflect.failed}/${r.actions.reflect.cooldown}/${r.actions.reflect.skipped}`,
28312
28312
  `${r.actions.distill.queued}/${r.actions.distill.llmFailed}/${r.actions.distill.judgeRejected}/${r.actions.distill.validatorRejected}/${r.actions.distill.configDisabled}/${r.actions.distill.skipped}`,
28313
28313
  `${r.consolidation.processed}/${r.consolidation.promoted}/${r.consolidation.merged}/${r.consolidation.deleted}`,
28314
- `${r.memoryInference.considered}/${r.memoryInference.written}/${r.memoryInference.skippedNoFacts}`,
28314
+ `${r.memoryInference.considered}/${r.memoryInference.written}/${r.memoryInference.noChange}/${r.memoryInference.skippedNoFacts}`,
28315
28315
  String(r.orphansPurged),
28316
28316
  `${r.lintFixed}/${r.lintFlagged}`,
28317
28317
  r.resultStatus ?? "valid"
@@ -44342,14 +44342,18 @@ function parseMarkdownToc(content) {
44342
44342
  const headings = [];
44343
44343
  const parsed = parseFrontmatter(content);
44344
44344
  const start = parsed.frontmatter ? parsed.bodyStartLine - 1 : 0;
44345
- let inFence = false;
44345
+ let fence;
44346
44346
  for (let i = start;i < lines.length; i++) {
44347
- if (/^\s*(`{3,}|~{3,})/.test(lines[i])) {
44348
- inFence = !inFence;
44347
+ const [, run = "", rest = ""] = lines[i].match(/^ {0,3}(`{3,}|~{3,})(.*)$/) ?? [];
44348
+ if (fence) {
44349
+ if (run[0] === fence.char && run.length >= fence.length && !rest.trim())
44350
+ fence = undefined;
44349
44351
  continue;
44350
44352
  }
44351
- if (inFence)
44353
+ if (run && !(run[0] === "`" && rest.includes("`"))) {
44354
+ fence = { char: run[0], length: run.length };
44352
44355
  continue;
44356
+ }
44353
44357
  const match = lines[i].match(/^(#{1,6})\s+(.+)$/);
44354
44358
  if (match) {
44355
44359
  headings.push({
@@ -44975,7 +44979,9 @@ function parseSteps(ctx, raw) {
44975
44979
  return;
44976
44980
  }
44977
44981
  const label = typeof rawStep.id === "string" && rawStep.id !== "" ? `Step "${rawStep.id}"` : `Step ${index + 1}`;
44978
- checkUnknownKeys(ctx, rawStep, path27, STEP_KEYS, label);
44982
+ const isMapStep = rawStep.map !== undefined;
44983
+ const unitKeyHome = isMapStep ? "map.unit:" : "unit:";
44984
+ checkUnknownKeys(ctx, rawStep, path27, STEP_KEYS, label, (key) => UNIT_KEYS.includes(key) ? ` (this belongs under "${unitKeyHome}")` : "");
44979
44985
  let id = "";
44980
44986
  if (typeof rawStep.id !== "string" || rawStep.id === "") {
44981
44987
  ctx.err([...path27, "id"], `${label} requires a non-empty string "id".`);
@@ -44990,7 +44996,6 @@ function parseSteps(ctx, raw) {
44990
44996
  seenIds.set(id, index);
44991
44997
  }
44992
44998
  }
44993
- const isMapStep = rawStep.map !== undefined;
44994
44999
  if (isMapStep && rawStep.unit !== undefined) {
44995
45000
  ctx.err(path27, `${label} is a map step; the per-item dispatch-override bag belongs at "map.unit", not top-level "unit".`);
44996
45001
  }
@@ -45002,6 +45007,9 @@ function parseSteps(ctx, raw) {
45002
45007
  const step = { id, source: ctx.refAt(path27) };
45003
45008
  if (unit)
45004
45009
  step.unit = unit;
45010
+ if (output !== undefined && !isMapStep && unit?.output === undefined && unit?.workflow === undefined) {
45011
+ step.unit = { ...unit ?? { source: step.source }, output };
45012
+ }
45005
45013
  if (map)
45006
45014
  step.map = map;
45007
45015
  if (inputs)
@@ -45448,10 +45456,10 @@ function checkReferenceSyntax(ctx, text, path27, label) {
45448
45456
  if (!result.ok)
45449
45457
  ctx.err(path27, `${label}: ${result.message}`);
45450
45458
  }
45451
- function checkUnknownKeys(ctx, obj, path27, allowed, label) {
45459
+ function checkUnknownKeys(ctx, obj, path27, allowed, label, hint = () => "") {
45452
45460
  for (const key of Object.keys(obj)) {
45453
45461
  if (!allowed.includes(key)) {
45454
- ctx.err([...path27, key], `Unknown ${label} key "${key}". Allowed keys: ${allowed.join(", ")}.`);
45462
+ ctx.err([...path27, key], `Unknown ${label} key "${key}"${hint(key)}. Allowed keys: ${allowed.join(", ")}.`);
45455
45463
  }
45456
45464
  }
45457
45465
  }
@@ -27624,7 +27624,7 @@ function renderRunsDetailMd(runs) {
27624
27624
  "refl_ok/fail/cd/skip",
27625
27625
  "distill_q/llm-fail/judge/validator/cfg/skip",
27626
27626
  "cons_proc/promo/merge/del",
27627
- "mem_cons/written/skip",
27627
+ "mem_cons/written/nochange/skip",
27628
27628
  "orphans",
27629
27629
  "lint_f/fl",
27630
27630
  "result_status"
@@ -27639,7 +27639,7 @@ function renderRunsDetailMd(runs) {
27639
27639
  `${r.actions.reflect.ok}/${r.actions.reflect.failed}/${r.actions.reflect.cooldown}/${r.actions.reflect.skipped}`,
27640
27640
  `${r.actions.distill.queued}/${r.actions.distill.llmFailed}/${r.actions.distill.judgeRejected}/${r.actions.distill.validatorRejected}/${r.actions.distill.configDisabled}/${r.actions.distill.skipped}`,
27641
27641
  `${r.consolidation.processed}/${r.consolidation.promoted}/${r.consolidation.merged}/${r.consolidation.deleted}`,
27642
- `${r.memoryInference.considered}/${r.memoryInference.written}/${r.memoryInference.skippedNoFacts}`,
27642
+ `${r.memoryInference.considered}/${r.memoryInference.written}/${r.memoryInference.noChange}/${r.memoryInference.skippedNoFacts}`,
27643
27643
  String(r.orphansPurged),
27644
27644
  `${r.lintFixed}/${r.lintFlagged}`,
27645
27645
  r.resultStatus ?? "valid"
@@ -43670,14 +43670,18 @@ function parseMarkdownToc(content) {
43670
43670
  const headings = [];
43671
43671
  const parsed = parseFrontmatter(content);
43672
43672
  const start = parsed.frontmatter ? parsed.bodyStartLine - 1 : 0;
43673
- let inFence = false;
43673
+ let fence;
43674
43674
  for (let i = start;i < lines.length; i++) {
43675
- if (/^\s*(`{3,}|~{3,})/.test(lines[i])) {
43676
- inFence = !inFence;
43675
+ const [, run = "", rest = ""] = lines[i].match(/^ {0,3}(`{3,}|~{3,})(.*)$/) ?? [];
43676
+ if (fence) {
43677
+ if (run[0] === fence.char && run.length >= fence.length && !rest.trim())
43678
+ fence = undefined;
43677
43679
  continue;
43678
43680
  }
43679
- if (inFence)
43681
+ if (run && !(run[0] === "`" && rest.includes("`"))) {
43682
+ fence = { char: run[0], length: run.length };
43680
43683
  continue;
43684
+ }
43681
43685
  const match = lines[i].match(/^(#{1,6})\s+(.+)$/);
43682
43686
  if (match) {
43683
43687
  headings.push({
@@ -44303,7 +44307,9 @@ function parseSteps(ctx, raw) {
44303
44307
  return;
44304
44308
  }
44305
44309
  const label = typeof rawStep.id === "string" && rawStep.id !== "" ? `Step "${rawStep.id}"` : `Step ${index + 1}`;
44306
- checkUnknownKeys(ctx, rawStep, path27, STEP_KEYS, label);
44310
+ const isMapStep = rawStep.map !== undefined;
44311
+ const unitKeyHome = isMapStep ? "map.unit:" : "unit:";
44312
+ checkUnknownKeys(ctx, rawStep, path27, STEP_KEYS, label, (key) => UNIT_KEYS.includes(key) ? ` (this belongs under "${unitKeyHome}")` : "");
44307
44313
  let id = "";
44308
44314
  if (typeof rawStep.id !== "string" || rawStep.id === "") {
44309
44315
  ctx.err([...path27, "id"], `${label} requires a non-empty string "id".`);
@@ -44318,7 +44324,6 @@ function parseSteps(ctx, raw) {
44318
44324
  seenIds.set(id, index);
44319
44325
  }
44320
44326
  }
44321
- const isMapStep = rawStep.map !== undefined;
44322
44327
  if (isMapStep && rawStep.unit !== undefined) {
44323
44328
  ctx.err(path27, `${label} is a map step; the per-item dispatch-override bag belongs at "map.unit", not top-level "unit".`);
44324
44329
  }
@@ -44330,6 +44335,9 @@ function parseSteps(ctx, raw) {
44330
44335
  const step = { id, source: ctx.refAt(path27) };
44331
44336
  if (unit)
44332
44337
  step.unit = unit;
44338
+ if (output !== undefined && !isMapStep && unit?.output === undefined && unit?.workflow === undefined) {
44339
+ step.unit = { ...unit ?? { source: step.source }, output };
44340
+ }
44333
44341
  if (map)
44334
44342
  step.map = map;
44335
44343
  if (inputs)
@@ -44776,10 +44784,10 @@ function checkReferenceSyntax(ctx, text, path27, label) {
44776
44784
  if (!result.ok)
44777
44785
  ctx.err(path27, `${label}: ${result.message}`);
44778
44786
  }
44779
- function checkUnknownKeys(ctx, obj, path27, allowed, label) {
44787
+ function checkUnknownKeys(ctx, obj, path27, allowed, label, hint = () => "") {
44780
44788
  for (const key of Object.keys(obj)) {
44781
44789
  if (!allowed.includes(key)) {
44782
- ctx.err([...path27, key], `Unknown ${label} key "${key}". Allowed keys: ${allowed.join(", ")}.`);
44790
+ ctx.err([...path27, key], `Unknown ${label} key "${key}"${hint(key)}. Allowed keys: ${allowed.join(", ")}.`);
44783
44791
  }
44784
44792
  }
44785
44793
  }
@@ -26,15 +26,11 @@ export function materializeFrozenWorkflowEnvironment(descriptors, options = {})
26
26
  sensitive.add(descriptor.value);
27
27
  continue;
28
28
  }
29
- if (descriptor.kind === "pass-through") {
30
- const value = options.readPassThrough ? options.readPassThrough(descriptor.name) : process.env[descriptor.name];
31
- if (value !== undefined) {
32
- values[descriptor.name] = value;
33
- if (value)
34
- sensitive.add(value);
35
- }
29
+ // `pass_env` names reach the exec child through its allowlist
30
+ // (`execAllowlist`, exec-unit.ts), not as bindings. Their values are
31
+ // deliberately not redacted, so they are neither read nor marked sensitive here.
32
+ if (descriptor.kind === "pass-through")
36
33
  continue;
37
- }
38
34
  const source = options.readEnvFile ? options.readEnvFile(descriptor) : readEnvFile(descriptor.owner);
39
35
  const parsed = dotenv.parse(typeof source === "string" ? source : Buffer.from(source));
40
36
  const keys = Object.keys(parsed).sort(compareCodePoints);
@@ -588,7 +588,9 @@ function parseSteps(ctx, raw) {
588
588
  return;
589
589
  }
590
590
  const label = typeof rawStep.id === "string" && rawStep.id !== "" ? `Step "${rawStep.id}"` : `Step ${index + 1}`;
591
- checkUnknownKeys(ctx, rawStep, path, STEP_KEYS, label);
591
+ const isMapStep = rawStep.map !== undefined;
592
+ const unitKeyHome = isMapStep ? "map.unit:" : "unit:";
593
+ checkUnknownKeys(ctx, rawStep, path, STEP_KEYS, label, (key) => UNIT_KEYS.includes(key) ? ` (this belongs under "${unitKeyHome}")` : "");
592
594
  let id = "";
593
595
  if (typeof rawStep.id !== "string" || rawStep.id === "") {
594
596
  ctx.err([...path, "id"], `${label} requires a non-empty string "id".`);
@@ -608,7 +610,6 @@ function parseSteps(ctx, raw) {
608
610
  seenIds.set(id, index);
609
611
  }
610
612
  }
611
- const isMapStep = rawStep.map !== undefined;
612
613
  if (isMapStep && rawStep.unit !== undefined) {
613
614
  ctx.err(path, `${label} is a map step; the per-item dispatch-override bag belongs at "map.unit", not top-level "unit".`);
614
615
  }
@@ -620,6 +621,14 @@ function parseSteps(ctx, raw) {
620
621
  const step = { id, source: ctx.refAt(path) };
621
622
  if (unit)
622
623
  step.unit = unit;
624
+ // A solo step's `output` is also its unit's contract. Only `unit.output`
625
+ // makes the dispatch ask for, parse and re-request JSON, so without this a
626
+ // step-level schema met a raw text reply and could never pass. A map
627
+ // step's `output` describes the collected/reduced value, and a child
628
+ // workflow unit's result is the child's own, so both keep the step-only meaning.
629
+ if (output !== undefined && !isMapStep && unit?.output === undefined && unit?.workflow === undefined) {
630
+ step.unit = { ...(unit ?? { source: step.source }), output };
631
+ }
623
632
  if (map)
624
633
  step.map = map;
625
634
  if (inputs)
@@ -1149,10 +1158,10 @@ function checkReferenceSyntax(ctx, text, path, label) {
1149
1158
  if (!result.ok)
1150
1159
  ctx.err(path, `${label}: ${result.message}`);
1151
1160
  }
1152
- function checkUnknownKeys(ctx, obj, path, allowed, label) {
1161
+ function checkUnknownKeys(ctx, obj, path, allowed, label, hint = () => "") {
1153
1162
  for (const key of Object.keys(obj)) {
1154
1163
  if (!allowed.includes(key)) {
1155
- ctx.err([...path, key], `Unknown ${label} key "${key}". Allowed keys: ${allowed.join(", ")}.`);
1164
+ ctx.err([...path, key], `Unknown ${label} key "${key}"${hint(key)}. Allowed keys: ${allowed.join(", ")}.`);
1156
1165
  }
1157
1166
  }
1158
1167
  }
@@ -539,9 +539,9 @@ All paths are relative to the `rules-010` worktree. Citation form is `path:line`
539
539
 
540
540
  - **Only the default akm bundle is written** — Read-only caches (git, npm, website) are untouched. The pass takes the source whose `isDefault` flag is true, never `sources[0]` (fixed in this PR, #8), in line with `AGENTS.md` ("Never infer the default/trusted bundle from array position"). With no default bundle it warns `memory inference: skipped — no default bundle among the sources…`, and with a default bundle that is not an `akm` adapter bundle it warns and names the path; either way it writes nothing. The write target is still hardcoded `name: "stash"`. *Why:* inferred children written to caches "would be clobbered by the next sync()"; a skipped pass should say so (the gate's "never silent" rule). (`src/indexer/passes/memory-inference.ts:440-455,660-675`, `autonomy-gate.ts:26`)
541
541
 
542
- - **What is written: one derived memory per parent** — The LLM compresses the memory body into one derived memory: `title`, a one-sentence `description`, `content` (2–3 sentences), `tags` (3–8 in the prompt; schema min 1, max 8) and `searchHints` (3–6; schema min 1, max 6). It keeps names, versions, paths and config keys verbatim. The whole body is sent, because the 4000-char `MAX_BODY_CHARS` truncation was removed (otherwise the derived memory "looked complete" while the model saw a fraction). The temperature is the engine's or process's configured one, else 0.1. *Why:* compress a memory into a higher-signal memory for later retrieval. (`src/llm/memory-infer.ts:77-88,123-139`; `src/assets/prompts/memory-infer-user.md`; CHANGELOG 0.9.2x ("Memory inference uses the configured temperature") and "Content and memory bodies are no longer silently truncated")
542
+ - **What is written: one derived memory per parent** — The LLM compresses the memory body into one derived memory: `title`, a one-sentence `description`, `content` (2–3 sentences), `tags` (3–8 in the prompt; schema max 8) and `searchHints` (3–6; schema max 6; the parser requires at least one of each unless the reply is a no-change). It keeps names, versions, paths and config keys verbatim. The whole body is sent, because the 4000-char `MAX_BODY_CHARS` truncation was removed (otherwise the derived memory "looked complete" while the model saw a fraction). The temperature is the engine's or process's configured one, else 0.1. *Why:* compress a memory into a higher-signal memory for later retrieval. (`src/llm/memory-infer.ts:77-88,123-139`; `src/assets/prompts/memory-infer-user.md`; CHANGELOG 0.9.2x ("Memory inference uses the configured temperature") and "Content and memory bodies are no longer silently truncated")
543
543
 
544
- - **Reply validation** — The reply is skipped (returns nothing and warns) if it is invalid JSON, if any of title, description, content, tags or searchHints is missing or empty, or if it is a prompt placeholder echoed back ("short title string", "tag1", etc.). Tags are trimmed and capped at 8, hints at 6. *Why:* "never return field descriptions or placeholder values". (`src/llm/memory-infer.ts:36-44,140-177`)
544
+ - **Reply validation** — A reply with `noChange: true` is the no-change result (see "No change is a successful result"). Otherwise the reply is skipped (returns nothing and warns) if it is invalid JSON, if any of title, description, content, tags or searchHints is missing or empty, or if it is a prompt placeholder echoed back ("short title string", "tag1", etc.). Tags are trimmed and capped at 8, hints at 6. *Why:* "never return field descriptions or placeholder values". (`src/llm/memory-infer.ts:36-44,140-177`)
545
545
 
546
546
  - **Derived file shape and naming** — The child is `memories/<parent>.derived.md` with frontmatter `inferred: true`, `captureMode: background`, `source: memories/<parent>`, `derivedFrom`, `description`, `tags`, `searchHints`, `title` and `updated`. The body is `# Title` plus the content. It is written through `writeAssetToSource`. The child carries no `createdAt`, so memory cleanup anchors relative dates on its file mtime. *Why:* `.derived` is a provenance marker on the same identity. (`src/indexer/passes/memory-inference.ts:659-717`; `.derived` inputs are skipped by `eligibility.ts:283-285`, `distill-promotion-policy.ts:129-131` and `memory/derived-ref.ts:46-47`; #882 in CHANGELOG)
547
547
 
@@ -551,10 +551,12 @@ All paths are relative to the `rules-010` worktree. Citation form is `path:line`
551
551
 
552
552
  - **Child-exists pre-check (#588, #550)** — Before any LLM or cache call, if `<parent>.derived.md` already exists on disk, the parent is marked processed and no attempt is used (`skippedChildExists`). A child that appears mid-flight, or a write throw, also counts there. A genuine write failure leaves the parent unmarked for retry. *Why:* #588: about 55% of the inference budget was spent rediscovering children that already existed. #550: parents were re-queued every run (about 37 wasted LLM calls per run on one stash). (`src/indexer/passes/memory-inference.ts:204-210,265-285,343-358,663-700`; CHANGELOG #588/#550)
553
553
 
554
- - **Caching** — Results are cached in the index DB keyed by the parent's file path, a body hash and variant `memory-inference-v2`. An unchanged body gets a validated cache hit with no LLM call (`cacheHits`). `reEnrich` bypasses it (the improve step passes `false`). A corrupt cache entry falls through to recompute. *Why:* skip re-processing unchanged content; cache hits are tracked separately so the yield rate does not drift as the cache warms. (`src/indexer/passes/memory-inference.ts:210-221,304-332`; `src/indexer/db/llm-cache.ts:53-86`)
554
+ - **Caching** — Results are cached in the index DB keyed by the parent's file path, a body hash and variant `memory-inference-v3` (bumped from v2 when a cached row could become an explicit no-change decision; older rows are never read, a disposable miss). An unchanged body gets a validated cache hit with no LLM call (`cacheHits`). The cached value is a derived memory or `{ "noChange": true }`; an unrecognised value falls through to recompute. `reEnrich` bypasses it (the improve step passes `false`). A corrupt cache entry falls through to recompute. *Why:* skip re-processing unchanged content; cache hits are tracked separately so the yield rate does not drift as the cache warms. (`src/indexer/passes/memory-inference.ts:210-221,304-332`; `src/indexer/db/llm-cache.ts:53-86`)
555
555
 
556
556
  - **Retries** — The LLM client retries once on a retryable transient failure, after a jittered backoff of 200–800 ms. The retry is skipped if 90% of the timeout was already used. The retry counts as `retryAttempts`, not a failure. *Why:* "a single bounded retry for transient failures". (`src/llm/client.ts:144-182,300-330`; `src/indexer/passes/memory-inference.ts:289-291`)
557
557
 
558
+ - **No change is a successful result (#1208)** — The model may reply `noChange: true` (the other fields empty) when a derived memory would add nothing: a tool-count or checkpoint note, or a note already short and specific. Only a literal boolean `true` counts. The pass counts it in `noChange` (fresh or cached, also in `cacheHits` when cached), writes no child, and leaves the parent untouched: it is NOT stamped `inferenceProcessed`, because that flag is checked before the body hash and would hide a later edit. The decision is cached like any result, so an unchanged body makes no further call and does not mutate the parent; an edited body (the cache key is the body hash, the whole input the model sees) or `reEnrich` asks the model again. `noChange` never feeds `skippedNoFacts`, `htmlErrorCount` or `unaccounted`. Anything that is not a clean reply (invalid or incomplete JSON, HTML, timeout, abort, transport error, disabled pass) stays `undefined`, is never cached, and is retried. Without a database the decision is reported but not remembered. The count is `improve` result `memoryInference.noChange` and health `memoryInference.noChange`; health `yieldRate` is unchanged (`written / freshAttempts`) and measures child yield, which restraint lowers by design. *Why:* the prompt demanded a child for every note, so an unusable reply was a failure that cost a model call on every run. (`src/llm/memory-infer.ts`; `src/indexer/passes/memory-inference.ts`)
559
+
558
560
  - **Skips leave the parent pending** — If the LLM returns nothing usable (timeout, invalid, incomplete or placeholder reply), `skippedNoFacts` counts it and the parent is NOT marked processed, so it is retried on the next run. An HTML reply is counted in `htmlErrorCount`. The per-call failure never throws. An abort signal skips unstarted parents (`skippedAborted`). Credentials are checked up front only if at least one parent will dispatch to the model, so cache-only batches work offline. *Why:* "a transient LLM failure should be retried on the next index run". (`src/indexer/passes/memory-inference.ts:341,516-519,464-465`; `src/llm/memory-infer.ts:178-188`)
559
561
 
560
562
  - **Concurrency and enablement layers** — The pass runs with the engine connection's `concurrency` or 1. The live gates are autonomy, then `processes.memoryInference.enabled`, then `minPendingCount`, then a resolvable runner; any off makes it a no-op. `index.memory.enabled` has no effect in 0.10: the only caller passes a runner, which forces it on, so there is no standalone `akm index` path. Existing derived children are never deleted. *Why:* "A pass runs iff both layers allow it." (`src/indexer/passes/memory-inference.ts:22-30,425-435,472-492`; `src/llm/feature-gate.ts:54`)
@@ -275,7 +275,7 @@ Incremental indexing cache. Directory skipped if hash + mtime unchanged.
275
275
  | Column | Type | Notes |
276
276
  |---|---|---|
277
277
  | `asset_ref` | TEXT NOT NULL | Absolute file path |
278
- | `cache_variant` | TEXT NOT NULL | Extractor/cache fingerprint. Memory inference uses a version-tagged variant (`memory-inference-v2`). |
278
+ | `cache_variant` | TEXT NOT NULL | Extractor/cache fingerprint. Memory inference uses a version-tagged variant (`memory-inference-v3`). |
279
279
  | `body_hash` | TEXT NOT NULL | SHA-256 hex digest of file body |
280
280
  | `result_json` | TEXT NOT NULL | Serialized LLM enrichment result |
281
281
  | `updated_at` | INTEGER NOT NULL | Unix ms timestamp |