akm-cli 0.9.26 → 0.9.27-alpha.2

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 (39) hide show
  1. package/CHANGELOG.md +237 -0
  2. package/LICENSE +3 -4
  3. package/dist/assets/prompts/consolidate-system.md +2 -2
  4. package/dist/assets/prompts/distill-lesson-system.md +29 -7
  5. package/dist/assets/prompts/extract-session.md +2 -2
  6. package/dist/commands/improve/consolidate/coverage.js +71 -17
  7. package/dist/commands/improve/consolidate/pair-pass.js +12 -9
  8. package/dist/commands/improve/consolidate.js +77 -5
  9. package/dist/commands/improve/distill-guards.js +9 -10
  10. package/dist/commands/improve/distill.js +70 -22
  11. package/dist/commands/improve/extract-prompt.js +61 -39
  12. package/dist/commands/improve/extract.js +2 -1
  13. package/dist/commands/improve/preparation.js +14 -1
  14. package/dist/commands/improve/reflect.js +36 -4
  15. package/dist/commands/improve/retrieval-gate.js +1 -1
  16. package/dist/commands/improve/session-asset.js +3 -2
  17. package/dist/commands/improve/stage.js +35 -53
  18. package/dist/commands/proposal/drain.js +85 -21
  19. package/dist/commands/proposal/proposal-types.js +1 -1
  20. package/dist/core/config/schema/improve-processes.js +3 -3
  21. package/dist/core/paths.js +0 -9
  22. package/dist/indexer/indexer.js +35 -19
  23. package/dist/integrations/harnesses/codex/agent-builder.js +23 -15
  24. package/dist/llm/client.js +44 -14
  25. package/dist/llm/memory-infer.js +1 -1
  26. package/dist/scripts/akm-migrate-node.js +25 -20
  27. package/dist/scripts/akm-migrate.js +25 -20
  28. package/dist/storage/repositories/improve-ledger-repository.js +4 -1
  29. package/dist/storage/repositories/index-entry-schema.js +20 -4
  30. package/dist/storage/repositories/index-fts-repository.js +44 -3
  31. package/dist/storage/repositories/index-schema.js +14 -8
  32. package/docs/README.md +1 -2
  33. package/docs/integration/bundling-akm.md +1 -1
  34. package/docs/migration/v0.8-to-v0.9.md +3 -1
  35. package/docs/reference/README.md +1 -1
  36. package/docs/reference/cli.md +8 -4
  37. package/docs/reference/configuration.md +5 -2
  38. package/docs/reference/data-and-telemetry.md +1 -1
  39. package/package.json +1 -1
@@ -93,7 +93,9 @@ export function isHotCapturedMemory(filePath) {
93
93
  /**
94
94
  * Structured-output schema for a plan. Promote-only: merge/delete/contradict
95
95
  * were removed in 0.9.17-alpha.1 (`e82eec811`) after running in production —
96
- * they cost thousands of completion tokens.
96
+ * they cost thousands of completion tokens. Every property is required, as a
97
+ * strict structured-output provider needs: an empty `description` keeps the
98
+ * memory's own, a null `confidence` is none.
97
99
  */
98
100
  export const CONSOLIDATE_PLAN_JSON_SCHEMA = {
99
101
  type: "object",
@@ -105,15 +107,23 @@ export const CONSOLIDATE_PLAN_JSON_SCHEMA = {
105
107
  description: "Ordered list of promote operations the planner proposes.",
106
108
  items: {
107
109
  type: "object",
108
- required: ["op", "ref", "knowledgeRef", "reason"],
110
+ required: ["op", "ref", "knowledgeRef", "reason", "description", "confidence"],
109
111
  additionalProperties: false,
110
112
  properties: {
111
113
  op: { type: "string", enum: ["promote"] },
112
114
  ref: { type: "string", minLength: 1 },
113
115
  knowledgeRef: { type: "string", minLength: 1 },
114
116
  reason: { type: "string", minLength: 1, maxLength: 200 },
115
- description: { type: "string" },
116
- confidence: { type: "number", minimum: 0, maximum: 1 },
117
+ description: {
118
+ type: "string",
119
+ description: "One sentence describing the new knowledge asset; an empty string keeps the memory's own.",
120
+ },
121
+ confidence: {
122
+ type: ["number", "null"],
123
+ minimum: 0,
124
+ maximum: 1,
125
+ description: "Certainty in [0, 1] that the operation is correct and safe; null when unsure.",
126
+ },
117
127
  },
118
128
  },
119
129
  },
@@ -250,6 +260,39 @@ function loadPendingConsolidateProposalHashes(stashDir, proposalsCtx) {
250
260
  }
251
261
  return hashes;
252
262
  }
263
+ /**
264
+ * Rejections decided before this are not a verdict on the memory's text: the
265
+ * 2026-08-02 and 2026-08-18 bulk audits rejected hundreds of promotions
266
+ * wholesale, and holding their bodies would skip good memories for good.
267
+ */
268
+ const REJECTED_BODY_HOLD_FROM = "2026-09-29";
269
+ /**
270
+ * Body hashes of the memories whose promotion was rejected on review. A
271
+ * proposal minted with `promotionSourceHash` names the memory's raw body; an
272
+ * older one is hashed from its own body, which is the memory's unless
273
+ * sanitization changed it.
274
+ */
275
+ function loadRejectedPromotionBodyHashes(stashDir, proposalsCtx) {
276
+ const hashes = new Set();
277
+ try {
278
+ for (const p of listProposalsReadOnly(stashDir, { status: "rejected", includeArchive: true }, proposalsCtx)) {
279
+ if (p.source !== "consolidate")
280
+ continue;
281
+ if ((p.review?.decidedAt ?? p.updatedAt) < REJECTED_BODY_HOLD_FROM)
282
+ continue;
283
+ try {
284
+ hashes.add(p.promotionSourceHash ?? contentHash(proposalContent(p), "body"));
285
+ }
286
+ catch {
287
+ // A malformed payload cannot hold a memory.
288
+ }
289
+ }
290
+ }
291
+ catch {
292
+ // Best-effort: a failed read never blocks judging.
293
+ }
294
+ return hashes;
295
+ }
253
296
  /**
254
297
  * Body hashes of the live knowledge assets, read from disk (the index may lag
255
298
  * a just-written asset), so an accepted promotion is not proposed again.
@@ -459,6 +502,18 @@ export function inspectConsolidationPool(opts, stashDir, warnings, existingKnowl
459
502
  return !isLedgerBlocked(row, nowIso, changedAt);
460
503
  });
461
504
  }
505
+ // A memory whose text a reviewer already rejected as a promotion waits for an edit, whatever it is called.
506
+ const rejectedBodies = loadRejectedPromotionBodyHashes(stashDir, opts.proposalsCtx);
507
+ if (rejectedBodies.size > 0) {
508
+ memories = memories.filter((memory) => {
509
+ try {
510
+ return !rejectedBodies.has(contentHash(fs.readFileSync(memory.filePath, "utf8"), "body"));
511
+ }
512
+ catch {
513
+ return true;
514
+ }
515
+ });
516
+ }
462
517
  const judgedUnchanged = poolSize - memories.length;
463
518
  // Only what retrieval returned or new material improve never processed (#986).
464
519
  const retrievalScope = loadRetrievalScope({ proposalsCtx: opts.proposalsCtx, readOnly }, stashDir);
@@ -758,7 +813,13 @@ async function consolidate(opts, config, stashDir, startMs, stateDb) {
758
813
  recordLedgerAttempt({ proposalsCtx: opts.proposalsCtx }, [...acc.judgedRefs]
759
814
  .filter((ref) => !ctx.promotedSourceRefs.has(ref) &&
760
815
  !acc.skipReasonByRef.get(ref)?.skips.some((skip) => skip.reason === "promote_create_failed"))
761
- .map((ref) => ({ stashDir, ref, source: "consolidate", outcome: "judged_no_action" })));
816
+ .map((ref) => ({
817
+ stashDir,
818
+ ref,
819
+ source: "consolidate",
820
+ outcome: "judged_no_action",
821
+ ...bodyHashOf(ctx.memoryByRef.get(ref)),
822
+ })));
762
823
  return makeConsolidateResult({
763
824
  ...summary(),
764
825
  promoted: ctx.promoted,
@@ -773,6 +834,17 @@ async function consolidate(opts, config, stashDir, startMs, stateDb) {
773
834
  },
774
835
  });
775
836
  }
837
+ /** The memory's current body hash as a ledger input field; empty when it cannot be read (the row then keeps its 7-day window). */
838
+ function bodyHashOf(memory) {
839
+ if (!memory)
840
+ return {};
841
+ try {
842
+ return { contentHash: contentHash(fs.readFileSync(memory.filePath, "utf8"), "body") };
843
+ }
844
+ catch {
845
+ return {};
846
+ }
847
+ }
776
848
  /** The conceptId a ref maps to, or undefined for an invalid ref. */
777
849
  function conceptIdForRef(ref) {
778
850
  try {
@@ -2,25 +2,24 @@
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  /**
5
- * Distill guards: related lessons/knowledge shown to the model so it does not
6
- * overwrite prior generalizations (CLS context), and a cheap check that a
7
- * proposal does not contradict the memories it came from.
5
+ * Distill guards: the related lessons, knowledge notes and skills shown to the
6
+ * writer so it does not repeat or overwrite them (CLS context), and a cheap check
7
+ * that a proposal does not contradict the memories it came from.
8
8
  */
9
9
  export const DEFAULT_CLS_ADJACENT_COUNT = 3;
10
- /** The CLS prompt section (each entry capped at 400 chars); empty when disabled or nothing is related. */
10
+ /** The CLS prompt section (each entry capped at 600 chars); empty when disabled (on unless `enabled: false`) or nothing is related. */
11
11
  export function buildClsContext(adjacentItems, config) {
12
- if (!config.enabled || adjacentItems.length === 0)
12
+ if (config.enabled === false || adjacentItems.length === 0)
13
13
  return "";
14
14
  const lines = [
15
15
  "",
16
- "## Existing adjacent lessons / knowledge (CLS context)",
17
- "The following are semantically related entries already in the stash.",
18
- "Your proposal MUST NOT contradict or silently overwrite these — if you",
19
- "disagree with one, flag it as contradicted (do not ignore it).",
16
+ "## Related assets already in the library",
17
+ "The library already holds these lessons, knowledge notes and skills near this memory. They may be about another subject.",
18
+ "If one of them already states the rule the memory would give, answer NONE. Do not contradict or overwrite them.",
20
19
  "",
21
20
  ];
22
21
  for (const item of adjacentItems)
23
- lines.push(`### ${item.ref}`, item.content.trim().slice(0, 400), "");
22
+ lines.push(`### ${item.ref}`, item.content.trim().slice(0, 600), "");
24
23
  return lines.join("\n");
25
24
  }
26
25
  /**
@@ -72,36 +72,46 @@ export function deriveLessonRef(inputRef) {
72
72
  return `lessons/${safeScope ? `${safeScope}/` : ""}${clean(`${parsed.type}-${parts.join("-")}`)}-lesson`;
73
73
  }
74
74
  // ── Output contract ──────────────────────────────────────────────────────────
75
+ //
76
+ // The client sends a response schema `strict: true`, and a strict provider
77
+ // (OpenAI's) rejects an object whose `required` leaves out any of its
78
+ // properties, so every property is required and "none" is an empty array (#1046).
75
79
  export const DISTILL_LESSON_JSON_SCHEMA = {
76
80
  type: "object",
77
- required: ["description", "when_to_use", "body"],
81
+ required: ["reason", "decision", "description", "when_to_use", "body", "tags"],
78
82
  additionalProperties: false,
79
83
  properties: {
84
+ reason: {
85
+ type: "string",
86
+ description: "One sentence, written first: the cause and the fix the memory states, or why it states none.",
87
+ },
88
+ decision: {
89
+ type: "string",
90
+ enum: ["lesson", "none"],
91
+ description: "`none` when the memory holds no lesson (it records what was done, a design, or steps already written elsewhere): leave the other fields empty. Otherwise `lesson`.",
92
+ },
80
93
  description: {
81
94
  type: "string",
82
- minLength: 10,
83
- description: "Single complete sentence (80-200 chars) summarising what the lesson teaches. No markdown, no leading 'When'/'If'.",
95
+ description: "Single complete sentence summarising what the lesson teaches. No markdown, no leading 'When'/'If'. Empty for `none`.",
84
96
  },
85
97
  when_to_use: {
86
98
  type: "string",
87
- minLength: 10,
88
- description: "Single complete sentence describing the concrete trigger condition for the lesson.",
99
+ description: "Single complete sentence describing the concrete trigger condition for the lesson. Empty for `none`.",
89
100
  },
90
101
  body: {
91
102
  type: "string",
92
- minLength: 1,
93
- description: "Lesson body — plain markdown, 1-3 short paragraphs of practical guidance.",
103
+ description: "Lesson body: plain markdown, shorter than the memory, stating only what it and its feedback say. Empty for `none`.",
94
104
  },
95
105
  tags: {
96
106
  type: "array",
97
107
  items: { type: "string" },
98
- description: "Optional tag list. Empty array is allowed; the post-processor drops it if empty.",
108
+ description: "Tag list. Use an empty array for none; the post-processor drops it if empty.",
99
109
  },
100
110
  },
101
111
  };
102
112
  export const DISTILL_KNOWLEDGE_JSON_SCHEMA = {
103
113
  type: "object",
104
- required: ["description", "body"],
114
+ required: ["description", "body", "tags", "sources"],
105
115
  additionalProperties: false,
106
116
  properties: {
107
117
  description: { type: "string", minLength: 1, description: "One-line summary of the knowledge asset." },
@@ -113,12 +123,12 @@ export const DISTILL_KNOWLEDGE_JSON_SCHEMA = {
113
123
  tags: {
114
124
  type: "array",
115
125
  items: { type: "string" },
116
- description: "Optional tag list. Empty array is allowed; the post-processor drops it if empty.",
126
+ description: "Tag list. Use an empty array for none; the post-processor drops it if empty.",
117
127
  },
118
128
  sources: {
119
129
  type: "array",
120
130
  items: { type: "string" },
121
- description: "Optional list of source refs the knowledge was distilled from.",
131
+ description: "Source refs the knowledge was distilled from. Use an empty array for none.",
122
132
  },
123
133
  },
124
134
  };
@@ -150,6 +160,19 @@ export function assembleStructuredDistillMarkdown(payload, kind) {
150
160
  fm.xrefs = sources;
151
161
  return assembleAssetFromString(serializeFrontmatterQuoted(fm), body);
152
162
  }
163
+ /**
164
+ * The writer's answer when it found no lesson: the word NONE, or `decision: "none"` in a reply bound to the schema,
165
+ * with the reason it gave (`""` for the bare word). `null` for any other reply.
166
+ */
167
+ function answeredNone(raw) {
168
+ if (/^[\s"'`*_]*none[\s.!"'`*_]*$/i.test(stripMarkdownFences(raw)))
169
+ return { reason: "" };
170
+ const payload = parseEmbeddedJsonResponse(raw);
171
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload) || payload.decision !== "none") {
172
+ return null;
173
+ }
174
+ return { reason: typeof payload.reason === "string" ? payload.reason.trim() : "" };
175
+ }
153
176
  function validateKnowledgeContent(content, inputRef) {
154
177
  const findings = [];
155
178
  const parsed = parseFrontmatter(content);
@@ -247,7 +270,7 @@ export function buildDistillPrompt(input) {
247
270
  }
248
271
  lines.push(input.proposalKind === "knowledge"
249
272
  ? "Produce the knowledge markdown file now. Start your response with `---` on the first line, followed by a `description:` field whose value is a 1-sentence summary (20–400 chars). Never use placeholder values like `---`, `tbd`, `n/a`, or a single dash. If the source has nothing meaningful to summarize, do NOT produce a proposal — return an empty response instead. The frontmatter block ends with a second `---` line; do not emit any additional `---` fences in the body."
250
- : "Produce the lesson markdown file now. Start your response with `---` on the first line, followed by `description:` and `when_to_use:` fields. Both must be real one-sentence summaries (20–400 chars) — never placeholder values like `---`, `tbd`, or `n/a`. The frontmatter block ends with a second `---` line; do not emit any additional `---` fences in the body.");
273
+ : "Produce the lesson markdown file now. Start your response with `---` on the first line, followed by `description:` and `when_to_use:` fields. Both must be real one-sentence summaries (20–400 chars) — never placeholder values like `---`, `tbd`, or `n/a`. The frontmatter block ends with a second `---` line; do not emit any additional `---` fences in the body. If the memory holds no lesson, answer NONE instead.");
251
274
  return lines.join("\n");
252
275
  }
253
276
  // ── Invocation ───────────────────────────────────────────────────────────────
@@ -339,7 +362,7 @@ export async function akmDistill(options) {
339
362
  asset,
340
363
  vocabulary: loadRefVocabulary(),
341
364
  outcomeWeightEnabled: config.improve?.salience?.outcomeWeightEnabled !== false,
342
- similar: options.fetchSimilarLessonsFn ?? fetchTopSimilarLessons,
365
+ related: options.fetchRelatedFn ?? fetchRelatedAssets,
343
366
  lookup,
344
367
  };
345
368
  const feedbackEvents = readDistillFeedback(run);
@@ -377,6 +400,8 @@ async function distill(run, targetKind, kind, outputRef, feedbackEvents) {
377
400
  system,
378
401
  prompt,
379
402
  gate: { config: run.config, enabled: true },
403
+ // NONE is an answer: a parser that rejected it would ask for a lesson again.
404
+ parse: (raw) => (answeredNone(raw) ? raw : parseEmbeddedJsonResponse(raw)),
380
405
  // The injected test transport never sees the schema.
381
406
  request: {
382
407
  ...(run.options.chat === undefined
@@ -409,6 +434,10 @@ async function distill(run, targetKind, kind, outputRef, feedbackEvents) {
409
434
  ...exclusionMeta(run, true),
410
435
  };
411
436
  }
437
+ const none = answeredNone(call.raw);
438
+ if (none) {
439
+ return skipDistill(run, outputRef, kind, "nothing_reusable", `The writer found no lesson in ${run.inputRef}${none.reason ? `: ${none.reason}` : "."}`);
440
+ }
412
441
  const assembled = assembleDistilledContent(run, call.raw, kind, outputRef);
413
442
  if ("rejection" in assembled)
414
443
  return assembled.rejection;
@@ -418,8 +447,20 @@ async function distill(run, targetKind, kind, outputRef, feedbackEvents) {
418
447
  content: assembled.content,
419
448
  source: run.asset.content,
420
449
  descriptionSwapped: assembled.descriptionSwapped,
450
+ feedback: feedbackLines(feedback),
421
451
  });
422
452
  }
453
+ /** The feedback that says something, one line each, for the judge. A bare signal says nothing the writer could use. */
454
+ function feedbackLines(feedback) {
455
+ const lines = [];
456
+ for (const event of feedback) {
457
+ const meta = event.metadata ?? {};
458
+ const detail = (typeof meta.reason === "string" ? meta.reason : "") || (typeof meta.note === "string" ? meta.note : "");
459
+ if (detail.trim())
460
+ lines.push(`- [${typeof meta.signal === "string" ? meta.signal : event.eventType}] ${detail.trim()}`);
461
+ }
462
+ return lines;
463
+ }
423
464
  /** Whether a file already holds the lesson `ref` in the stash the proposal would be filed in. */
424
465
  function lessonExists(run, ref) {
425
466
  const { type, name } = parseRefInput(ref);
@@ -485,11 +526,12 @@ async function judgeAndQueue(run, out) {
485
526
  let confidence;
486
527
  let judged;
487
528
  if (qualityGateEnabled(run)) {
488
- const similarLessons = await run.similar(content.slice(0, 500), 3);
529
+ const related = await run.related(content.slice(0, 500), RELATED_COUNT);
489
530
  // The judge reads what the generator read: the source body, without its frontmatter (buildDistillPrompt).
490
531
  const source = out.source ? parseFrontmatter(out.source).content.trim() : "";
491
532
  const verdict = await runLessonQualityJudge(run.config, content, source, run.options.chat, {
492
- ...(similarLessons.length > 0 ? { similarLessons } : {}),
533
+ ...(related.length > 0 ? { related } : {}),
534
+ ...(out.feedback && out.feedback.length > 0 ? { feedback: out.feedback } : {}),
493
535
  ...((run.judgeRunner ?? run.runner) ? { llmRunner: run.judgeRunner ?? run.runner } : {}),
494
536
  ...(run.options.signal ? { signal: run.options.signal } : {}),
495
537
  onNotices: run.notices.add,
@@ -869,13 +911,13 @@ function readDistillFeedback(run) {
869
911
  /** System + user prompt: rejected-proposal context, optional CLS neighbours, stash standards. */
870
912
  async function buildDistillMessages(run, feedback, kind, outputRef) {
871
913
  const rejectedProposals = rejectedProposalContext(run.stash, run.inputRef, run.options.ctx, run.options.eventsCtx);
872
- // CLS interleaving (default off): show related lessons so the model does not overwrite them.
914
+ // CLS interleaving (default on): show the related lessons, knowledge notes and skills, so the writer neither repeats nor overwrites them.
873
915
  const cls = getImproveProcessConfig("distill", run.profile)?.cls ?? {};
874
916
  let clsContext = "";
875
- if (cls.enabled) {
917
+ if (cls.enabled !== false) {
876
918
  try {
877
919
  const query = run.asset.content ? run.asset.content.slice(0, 500) : run.inputRef;
878
- clsContext = buildClsContext(await run.similar(query, cls.adjacentCount ?? DEFAULT_CLS_ADJACENT_COUNT), cls);
920
+ clsContext = buildClsContext(await run.related(query, cls.adjacentCount ?? DEFAULT_CLS_ADJACENT_COUNT), cls);
879
921
  }
880
922
  catch {
881
923
  // CLS context is supplemental.
@@ -904,12 +946,18 @@ async function defaultLookup(ref, stashDir) {
904
946
  honorOrigin: false,
905
947
  });
906
948
  }
907
- /** Top-N existing lessons similar to `query` (empty when search is unavailable). */
908
- async function fetchTopSimilarLessons(query, n) {
949
+ /** What the library already holds on a subject: lessons and knowledge notes say it, a skill is how to do it. */
950
+ const RELATED_TYPES = ["lesson", "knowledge", "skill"];
951
+ const RELATED_COUNT = 3;
952
+ /** The top-N lessons, knowledge notes and skills related to `query`, best first (empty when search is unavailable). */
953
+ async function fetchRelatedAssets(query, n) {
909
954
  try {
910
- const result = await akmSearch({ query, type: "lesson", limit: n, skipLogging: true, eventSource: "improve" });
911
- return (result?.hits ?? [])
955
+ // One search per type: memories outnumber the rest and would fill an untyped list.
956
+ const results = await Promise.all(RELATED_TYPES.map((type) => akmSearch({ query, type, limit: n, skipLogging: true, eventSource: "improve" })));
957
+ return results
958
+ .flatMap((result) => result?.hits ?? [])
912
959
  .filter((h) => "path" in h && typeof h.path === "string")
960
+ .sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
913
961
  .slice(0, n)
914
962
  .map((h) => {
915
963
  let content = "";
@@ -24,16 +24,21 @@ const EXTRACT_CANDIDATE_NAME_RE = new RegExp(EXTRACT_CANDIDATE_NAME_PATTERN);
24
24
  *
25
25
  * Shape:
26
26
  * {
27
- * "candidates": [{type, name, description, when_to_use?, body, confidence, evidence}, ...],
28
- * "rationale_if_empty"?: string
27
+ * "candidates": [{type, name, description, when_to_use, body, confidence, evidence}, ...],
28
+ * "rationale_if_empty": string
29
29
  * }
30
30
  *
31
31
  * `additionalProperties: false` at each level so any hallucinated keys are
32
- * dropped before parsing.
32
+ * dropped before parsing. Every property is required, as a strict structured-
33
+ * output provider needs (#1046), and an empty string stands for "none": a
34
+ * `when_to_use` only a lesson needs, a `rationale_if_empty` only an empty
35
+ * answer needs. Before, `when_to_use` was optional, so a model that followed
36
+ * the schema could leave it out of a lesson and the parser dropped the lesson
37
+ * (#1047).
33
38
  */
34
39
  export const EXTRACT_JSON_SCHEMA = {
35
40
  type: "object",
36
- required: ["candidates"],
41
+ required: ["candidates", "rationale_if_empty"],
37
42
  additionalProperties: false,
38
43
  properties: {
39
44
  candidates: {
@@ -41,7 +46,7 @@ export const EXTRACT_JSON_SCHEMA = {
41
46
  description: "Zero or more durable-insight candidates extracted from the session.",
42
47
  items: {
43
48
  type: "object",
44
- required: ["type", "name", "description", "body", "confidence", "evidence"],
49
+ required: ["type", "name", "description", "when_to_use", "body", "confidence", "evidence"],
45
50
  additionalProperties: false,
46
51
  properties: {
47
52
  type: {
@@ -62,9 +67,8 @@ export const EXTRACT_JSON_SCHEMA = {
62
67
  },
63
68
  when_to_use: {
64
69
  type: "string",
65
- minLength: 15,
66
70
  maxLength: 400,
67
- description: "Trigger sentence for the candidate; REQUIRED when type=lesson.",
71
+ description: "Trigger sentence of at least 15 characters; REQUIRED when type=lesson (a lesson without one is dropped). An empty string for a memory or knowledge candidate.",
68
72
  },
69
73
  body: {
70
74
  type: "string",
@@ -87,8 +91,7 @@ export const EXTRACT_JSON_SCHEMA = {
87
91
  },
88
92
  rationale_if_empty: {
89
93
  type: "string",
90
- minLength: 10,
91
- description: "Required when `candidates` is empty — explains why nothing rose to durable-insight level.",
94
+ description: "When `candidates` is empty, one sentence on why nothing rose to durable-insight level; an empty string otherwise.",
92
95
  },
93
96
  },
94
97
  };
@@ -214,10 +217,47 @@ function parseFirstJsonObject(stdout) {
214
217
  }
215
218
  return { objectFound: true };
216
219
  }
220
+ /** The candidate the model wrote when the contract keeps it, else the first rule it breaks. */
221
+ function readCandidate(c) {
222
+ const { type, name, description, body, confidence, evidence } = c;
223
+ if (type !== "memory" && type !== "lesson" && type !== "knowledge") {
224
+ return { problem: "type is not memory, lesson or knowledge" };
225
+ }
226
+ if (typeof name !== "string" || !EXTRACT_CANDIDATE_NAME_RE.test(name)) {
227
+ return { problem: "name is not a kebab-case slug" };
228
+ }
229
+ if (typeof description !== "string" || description.trim().length < 20) {
230
+ return { problem: "description is shorter than 20 characters" };
231
+ }
232
+ if (typeof body !== "string" || body.trim().length < 50)
233
+ return { problem: "body is shorter than 50 characters" };
234
+ if (typeof confidence !== "number" || !Number.isFinite(confidence))
235
+ return { problem: "confidence is not a number" };
236
+ if (typeof evidence !== "string" || evidence.trim().length < 5) {
237
+ return { problem: "evidence is shorter than 5 characters" };
238
+ }
239
+ // An empty string is how a strict-schema reply says "none".
240
+ const whenToUse = typeof c.when_to_use === "string" ? c.when_to_use.trim() : "";
241
+ if (type === "lesson" && whenToUse.length < 15) {
242
+ return { problem: "a lesson needs a when_to_use of at least 15 characters" };
243
+ }
244
+ return {
245
+ candidate: {
246
+ type,
247
+ name,
248
+ description: description.trim(),
249
+ ...(whenToUse ? { when_to_use: whenToUse } : {}),
250
+ body,
251
+ confidence: Math.max(0, Math.min(1, confidence)),
252
+ evidence: evidence.trim(),
253
+ },
254
+ };
255
+ }
217
256
  /**
218
257
  * Parse the LLM's JSON response into a structured {@link ExtractPayload}.
219
258
  * Defensive — drops candidates that violate the shape rather than failing
220
- * the whole call. Returns the empty-candidates payload when nothing parses.
259
+ * the whole call, and names each one in `dropped` so the run can say so.
260
+ * Returns the empty-candidates payload when nothing parses.
221
261
  */
222
262
  export function parseExtractPayload(stdout) {
223
263
  if (!stdout || stdout.trim().length === 0) {
@@ -238,42 +278,24 @@ export function parseExtractPayload(stdout) {
238
278
  }
239
279
  const rawCandidates = obj.candidates;
240
280
  const candidates = [];
281
+ const dropped = [];
241
282
  for (const raw of rawCandidates) {
242
- if (!raw || typeof raw !== "object")
283
+ if (!raw || typeof raw !== "object") {
284
+ dropped.push("candidate dropped: not an object");
243
285
  continue;
286
+ }
244
287
  const c = raw;
245
- const type = c.type;
246
- if (type !== "memory" && type !== "lesson" && type !== "knowledge")
247
- continue;
248
- if (typeof c.name !== "string" || !EXTRACT_CANDIDATE_NAME_RE.test(c.name))
249
- continue;
250
- if (typeof c.description !== "string" || c.description.trim().length < 20)
251
- continue;
252
- if (typeof c.body !== "string" || c.body.trim().length < 50)
288
+ const read = readCandidate(c);
289
+ if ("problem" in read) {
290
+ dropped.push(`${typeof c.type === "string" ? c.type : "candidate"}:${typeof c.name === "string" ? c.name : "(unnamed)"} dropped: ${read.problem}`);
253
291
  continue;
254
- if (typeof c.confidence !== "number" || !Number.isFinite(c.confidence))
255
- continue;
256
- if (typeof c.evidence !== "string" || c.evidence.trim().length < 5)
257
- continue;
258
- if (type === "lesson") {
259
- if (typeof c.when_to_use !== "string" || c.when_to_use.trim().length < 15)
260
- continue;
261
292
  }
262
- const confidence = Math.max(0, Math.min(1, c.confidence));
263
- const candidate = {
264
- type,
265
- name: c.name,
266
- description: c.description.trim(),
267
- body: c.body,
268
- confidence,
269
- evidence: c.evidence.trim(),
270
- };
271
- if (typeof c.when_to_use === "string")
272
- candidate.when_to_use = c.when_to_use.trim();
273
- candidates.push(candidate);
293
+ candidates.push(read.candidate);
274
294
  }
275
295
  const result = { candidates };
276
- if (typeof obj.rationale_if_empty === "string") {
296
+ if (dropped.length > 0)
297
+ result.dropped = dropped;
298
+ if (typeof obj.rationale_if_empty === "string" && obj.rationale_if_empty.trim()) {
277
299
  result.rationale_if_empty = obj.rationale_if_empty.trim();
278
300
  }
279
301
  return result;
@@ -468,7 +468,8 @@ async function processSession(run, sessionRef, gate) {
468
468
  });
469
469
  }
470
470
  const { payload } = extraction;
471
- const warnings = [];
471
+ // A candidate the contract refused is reported, not silently lost (#1047).
472
+ const warnings = [...(payload.dropped ?? [])];
472
473
  // Provenance xrefs are added only after the cited session asset exists.
473
474
  const { warning, ...sessionAsset } = await maybeWriteSessionAsset(run, data);
474
475
  if (warning)
@@ -601,6 +601,16 @@ export function buildSnapshotManifest(args) {
601
601
  const latestNegativeTs = new Map();
602
602
  const feedback = new Map(candidates.map((r) => [r.ref, { hasSignal: false, positive: 0, negative: 0 }]));
603
603
  if (candidates.length > 0) {
604
+ // When each ref's accepted feedback proposals were created: a fix event is acted on once one exists at or after it.
605
+ const fixedAt = new Map();
606
+ if (stashDir) {
607
+ withRunState(eventsCtx, args.readOnly !== true, (db) => {
608
+ for (const p of listStateProposals(db, { stashDir, status: "accepted" })) {
609
+ if (p.source === "feedback")
610
+ fixedAt.set(p.ref, [...(fixedAt.get(p.ref) ?? []), p.createdAt]);
611
+ }
612
+ });
613
+ }
604
614
  for (const e of readEvents({ type: "feedback" }, eventsCtx).events) {
605
615
  const ref = e.ref ? refByKey.get(e.ref) : undefined;
606
616
  const entry = ref ? feedback.get(ref) : undefined;
@@ -612,8 +622,11 @@ export function buildSnapshotManifest(args) {
612
622
  entry.hasSignal = true;
613
623
  if (ts > (latestFeedbackTs.get(ref) ?? ""))
614
624
  latestFeedbackTs.set(ref, ts);
615
- if (signal === "negative" && ts > (latestNegativeTs.get(ref) ?? ""))
625
+ const fixApplied = e.metadata?.fix !== undefined &&
626
+ (fixedAt.get(e.ref ?? "") ?? []).some((createdAt) => createdAt >= ts);
627
+ if (signal === "negative" && !fixApplied && ts > (latestNegativeTs.get(ref) ?? "")) {
616
628
  latestNegativeTs.set(ref, ts);
629
+ }
617
630
  }
618
631
  if (signal === "positive")
619
632
  entry.positive++;
@@ -13,11 +13,13 @@
13
13
  * pre-dispatch refusals still emit both).
14
14
  */
15
15
  import fs from "node:fs";
16
+ import path from "node:path";
17
+ import { assetPathForName, stashDirFor } from "../../core/asset/asset-placement.js";
16
18
  import { serializeFrontmatter } from "../../core/asset/asset-serialize.js";
17
19
  import { parseFrontmatter } from "../../core/asset/frontmatter.js";
18
20
  import { parseRefInput } from "../../core/asset/resolve-ref.js";
19
21
  import { DESCRIPTION_MAX_CHARS, requiresDescription } from "../../core/authoring-rules.js";
20
- import { resolveStashDir } from "../../core/common.js";
22
+ import { isWithin, resolveStashDir, safeRealpath } from "../../core/common.js";
21
23
  import { loadConfig } from "../../core/config/config.js";
22
24
  import { generatedContentRejection } from "../../core/content-safety.js";
23
25
  import { ConfigError, UsageError } from "../../core/errors.js";
@@ -248,7 +250,7 @@ export const REFLECT_JSON_SCHEMA = {
248
250
  frontmatterPatch: REFLECT_FRONTMATTER_PATCH_JSON_SCHEMA,
249
251
  },
250
252
  };
251
- const REFLECT_UNSCOPED_JSON_SCHEMA = {
253
+ export const REFLECT_UNSCOPED_JSON_SCHEMA = {
252
254
  type: "object",
253
255
  required: ["ref", "confidence", "frontmatterPatch"],
254
256
  additionalProperties: false,
@@ -539,6 +541,24 @@ function unsupportedTypeFailure(ref, type, detail, emitFailed) {
539
541
  },
540
542
  };
541
543
  }
544
+ /**
545
+ * A proposal writes the file derived from the ref's type and name under the bundle's root. An asset indexed
546
+ * anywhere else (a skill's `references/a.md` is `knowledge/skills/<name>/references/a`) has nothing there, so the
547
+ * proposal would be a `create` and accepting it would add a second file for the ref (#1052).
548
+ */
549
+ function fileOutsideLayoutFailure(ref, file, writes, emitFailed) {
550
+ emitFailed("unsupported_type", "file_outside_layout", ref);
551
+ return {
552
+ failure: {
553
+ schemaVersion: 2,
554
+ ok: false,
555
+ reason: "unsupported_type",
556
+ error: `Reflect refused: the file for ${ref} is ${file}, but a proposal would write ${writes}. Edit the file directly.`,
557
+ ref,
558
+ exitCode: null,
559
+ },
560
+ };
561
+ }
542
562
  /** The target's parsed ref and current content, or a refusal for a type reflect cannot patch. */
543
563
  async function resolveReflectSource(options, stash, emitFailed) {
544
564
  if (!options.ref)
@@ -549,18 +569,21 @@ async function resolveReflectSource(options, stash, emitFailed) {
549
569
  return unsupportedTypeFailure(options.ref, parsedRef.type, "secret material is never read or sent to an LLM", emitFailed);
550
570
  }
551
571
  let assetContent = options.assetContent;
572
+ let assetFile;
552
573
  if (assetContent === undefined) {
553
574
  try {
554
575
  const qualifiedRef = options.itemRef ?? options.ref;
555
576
  const localFilePath = await findAssetFilePath(qualifiedRef, stash);
556
577
  if (localFilePath && fs.existsSync(localFilePath)) {
557
- assetContent = fs.readFileSync(localFilePath, "utf8");
578
+ assetFile = localFilePath;
558
579
  }
559
580
  else {
560
581
  const entry = await lookup(parseRefInput(qualifiedRef));
561
582
  if (entry?.filePath && fs.existsSync(entry.filePath))
562
- assetContent = fs.readFileSync(entry.filePath, "utf8");
583
+ assetFile = entry.filePath;
563
584
  }
585
+ if (assetFile !== undefined)
586
+ assetContent = fs.readFileSync(assetFile, "utf8");
564
587
  }
565
588
  catch {
566
589
  // An index miss is not fatal: reflect then has no content to patch.
@@ -570,6 +593,15 @@ async function resolveReflectSource(options, stash, emitFailed) {
570
593
  (assetContent === undefined || parseFrontmatter(assetContent).frontmatter === null)) {
571
594
  return unsupportedTypeFailure(options.ref, parsedRef.type, "its content is not frontmatter + markdown", emitFailed);
572
595
  }
596
+ // A file in another bundle is `createProposal`'s to refuse (#1000); this one is in the proposal's own bundle.
597
+ const root = path.resolve(options.target?.root ?? stash);
598
+ const typeDir = stashDirFor(parsedRef.type);
599
+ if (assetFile !== undefined && typeDir !== undefined && isWithin(assetFile, root)) {
600
+ const writes = assetPathForName(parsedRef.type, path.join(root, typeDir), parsedRef.name);
601
+ if (safeRealpath(writes) !== safeRealpath(assetFile)) {
602
+ return fileOutsideLayoutFailure(options.ref, assetFile, writes, emitFailed);
603
+ }
604
+ }
573
605
  return { assetContent, parsedRef };
574
606
  }
575
607
  /**