akm-cli 0.9.25-alpha.2 → 0.9.25-alpha.3

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.
@@ -2,23 +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
- * `akm reflect [ref]` — ask an engine for a revised asset and queue it as a
6
- * proposal (`source: "reflect"`). Reflect never writes an asset: the proposal
7
- * queue is the only path, `akm proposal accept` the bridge.
5
+ * `akm reflect [ref]` — ask an engine whether an asset's `description`,
6
+ * `when_to_use` and title need a fix, and queue the asset with that fix as a
7
+ * proposal (`source: "reflect"`). The engine never writes the body: akm keeps
8
+ * it byte for byte. Reflect never writes an asset: the proposal queue is the
9
+ * only path, `akm proposal accept` the bridge.
8
10
  *
9
11
  * Every invocation closes with one `reflect_completed` event; `reflect_invoked`
10
12
  * is emitted once the dispatch has validated its credentials (deterministic
11
13
  * pre-dispatch refusals still emit both).
12
14
  */
13
15
  import fs from "node:fs";
14
- import path from "node:path";
15
- import { assembleAssetFromString, serializeFrontmatter } from "../../core/asset/asset-serialize.js";
16
+ import { serializeFrontmatter } from "../../core/asset/asset-serialize.js";
16
17
  import { parseFrontmatter } from "../../core/asset/frontmatter.js";
17
- import { conceptIdFromTypeName, parseRefInput } from "../../core/asset/resolve-ref.js";
18
+ import { parseRefInput } from "../../core/asset/resolve-ref.js";
18
19
  import { DESCRIPTION_MAX_CHARS, requiresDescription } from "../../core/authoring-rules.js";
19
20
  import { resolveStashDir } from "../../core/common.js";
20
21
  import { loadConfig } from "../../core/config/config.js";
21
- import { generatedContentRejection, stripReflectPromptScaffolding } from "../../core/content-safety.js";
22
+ import { generatedContentRejection } from "../../core/content-safety.js";
22
23
  import { ConfigError, UsageError } from "../../core/errors.js";
23
24
  import { appendEvent, readEvents } from "../../core/events.js";
24
25
  import { lintLessonContent } from "../../core/lesson-lint.js";
@@ -30,14 +31,13 @@ import { lookup } from "../../indexer/indexer.js";
30
31
  import { DEFAULT_LLM_TIMEOUT_MS } from "../../integrations/agent/config.js";
31
32
  import { fallbackAnnouncement, NO_ENGINE_MESSAGE_SUFFIX, NO_ENGINE_REMEDY, withEngineFallback, } from "../../integrations/agent/engine-fallback.js";
32
33
  import { buildExecution, resolveExecution } from "../../integrations/agent/execution.js";
33
- import { buildReflectOutputRepairPrompt, buildReflectPrompt, parseAgentProposalPayload, REFLECT_CONTENT_CAP, REFLECT_TRUNCATION_MARKER, } from "../../integrations/agent/prompts.js";
34
+ import { buildReflectOutputRepairPrompt, buildReflectPrompt, REFLECT_CONTENT_CAP, } from "../../integrations/agent/prompts.js";
34
35
  import { runnerIsLlm } from "../../integrations/agent/runner.js";
35
36
  import { assertRunnerCredentials, collectDispatchSensitiveValues, } from "../../integrations/agent/runner-dispatch.js";
36
37
  import { isJsonSchemaKnownUnsupported, LlmCallError } from "../../llm/client.js";
37
38
  import { baseFailureFields, enoentHintMessage, isEnoentFailure } from "../agent/agent-support.js";
38
- import { checkReflectSize, isValidDescription } from "../proposal/validators/proposal-quality-validators.js";
39
+ import { isValidDescription } from "../proposal/validators/proposal-quality-validators.js";
39
40
  import { CHARS_PER_TOKEN, DEFAULT_CONTEXT_LENGTH_TOKENS } from "./consolidate/chunking.js";
40
- import { deriveLessonRef } from "./distill.js";
41
41
  import { findAssetFilePath } from "./eligibility.js";
42
42
  import { resolveImproveExecution } from "./execution.js";
43
43
  import { recordLedgerAttempt } from "./ledger.js";
@@ -66,7 +66,7 @@ function readRecentFeedback(ref, eventsCtx) {
66
66
  }
67
67
  }
68
68
  /**
69
- * Types reflect may rewrite: its output is frontmatter + markdown, which would
69
+ * Types reflect may patch: its output is frontmatter + markdown, which would
70
70
  * break a script or env file. Another type is allowed only when its current
71
71
  * content already has that shape; secrets are never read.
72
72
  */
@@ -80,99 +80,12 @@ export const REFLECT_ALLOWED_TYPES = new Set([
80
80
  "workflow",
81
81
  ]);
82
82
  const REFLECT_REFUSED_TYPES = new Set(["secret"]);
83
- /** Identity fields the model may never change (a renamed `name` breaks ref resolution). */
84
- const PROTECTED_FRONTMATTER_FIELDS = new Set(["name", "ref", "id", "slug", "type"]);
85
83
  /** Lesson lint findings for the prompt: a concrete starting point for the revision. */
86
84
  function buildSchemaHints(type, content) {
87
85
  if (!content || type !== "lesson")
88
86
  return [];
89
87
  return lintLessonContent(content, "reflect").findings.map((f) => `[${f.kind}] ${f.message}`);
90
88
  }
91
- /**
92
- * Lessons related to a skill: its derived lesson, lessons distilled from it,
93
- * and lessons citing it in `sources`. Without independent feedback on the skill,
94
- * lessons reflect itself produced are dropped so its own output is not fed
95
- * back as evidence.
96
- */
97
- async function readRelatedLessons(stash, ref, parsedRef, itemRef, eventsCtx) {
98
- if (parsedRef.type !== "skill")
99
- return [];
100
- const cache = new Map();
101
- const read = (filePath) => {
102
- const key = path.resolve(filePath);
103
- const cached = cache.get(key) ?? fs.readFileSync(filePath, "utf8");
104
- cache.set(key, cached);
105
- return cached;
106
- };
107
- const related = new Map();
108
- const derivedLessonRef = deriveLessonRef(ref);
109
- const candidateRefs = new Set([derivedLessonRef]);
110
- const derivedLessonPath = path.join(stash, "lessons", `${parseRefInput(derivedLessonRef).name}.md`);
111
- if (fs.existsSync(derivedLessonPath)) {
112
- related.set(derivedLessonRef, { ref: derivedLessonRef, content: read(derivedLessonPath) });
113
- }
114
- try {
115
- const keys = new Set([itemRef ?? ref]);
116
- for (const event of readEvents({ type: "distill_invoked" }, readOnlyEventsContext(eventsCtx)).events) {
117
- if (event.ref === undefined || !keys.has(event.ref))
118
- continue;
119
- const proposalRef = typeof event.metadata?.proposalRef === "string" ? event.metadata.proposalRef : undefined;
120
- if (proposalRef && lenientRefType(proposalRef) === "lesson")
121
- candidateRefs.add(proposalRef);
122
- }
123
- }
124
- catch {
125
- // best-effort
126
- }
127
- for (const candidateRef of candidateRefs) {
128
- try {
129
- const filePath = await findAssetFilePath(candidateRef, stash);
130
- if (filePath && fs.existsSync(filePath))
131
- related.set(candidateRef, { ref: candidateRef, content: read(filePath) });
132
- }
133
- catch {
134
- // An index miss is not fatal.
135
- }
136
- }
137
- try {
138
- const lessonsDir = path.join(stash, "lessons");
139
- if (fs.existsSync(lessonsDir)) {
140
- for (const fileName of fs.readdirSync(lessonsDir)) {
141
- if (!fileName.endsWith(".md"))
142
- continue;
143
- const content = read(path.join(lessonsDir, fileName));
144
- const sources = parseFrontmatter(content).data.sources;
145
- if (!Array.isArray(sources) || !sources.some((s) => typeof s === "string" && s.trim() === ref))
146
- continue;
147
- const lessonRef = conceptIdFromTypeName("lesson", fileName.slice(0, -3));
148
- if (!related.has(lessonRef))
149
- related.set(lessonRef, { ref: lessonRef, content });
150
- }
151
- }
152
- }
153
- catch {
154
- // best-effort
155
- }
156
- let hasIndependentFeedback = true;
157
- try {
158
- hasIndependentFeedback = readEvents({ type: "feedback", ref }, readOnlyEventsContext(eventsCtx)).events.length > 0;
159
- }
160
- catch {
161
- // Unknown: keep every lesson.
162
- }
163
- if (!hasIndependentFeedback) {
164
- for (const [lessonRef, lesson] of related) {
165
- try {
166
- if (parseFrontmatter(lesson.content).data.derived_from_reflect === true)
167
- related.delete(lessonRef);
168
- }
169
- catch {
170
- // Unparseable frontmatter: keep it.
171
- }
172
- }
173
- }
174
- return [...related.values()];
175
- }
176
89
  /** The asset type of a maybe-ref, or `""` when it does not parse. */
177
90
  function lenientRefType(ref) {
178
91
  if (!ref)
@@ -184,35 +97,21 @@ function lenientRefType(ref) {
184
97
  return "";
185
98
  }
186
99
  }
187
- /**
188
- * Cut a duplicate frontmatter block the model appended after its rewrite.
189
- * Requires a balanced fence AND `key:` lines so thematic breaks survive.
190
- */
191
- function stripAppendedFrontmatter(body) {
192
- const match = body.match(/\n---\r?\n([\s\S]*?)\n---\r?\n/);
193
- if (!match || !/^\w[\w-]*:/m.test(match[1]))
194
- return body;
195
- return body.slice(0, body.indexOf(match[0])).replace(/\s+$/, "");
196
- }
197
100
  /**
198
101
  * A description derived from existing metadata (title, first heading, first
199
102
  * prose sentence) that passes `isValidDescription`, or `undefined`. Never
200
103
  * free-form invention.
201
104
  */
202
- function deriveDescriptionFromAsset(title, proposedBody, sourceBody, targetRef) {
105
+ function deriveDescriptionFromAsset(title, body, targetRef) {
203
106
  const candidates = [];
204
107
  if (typeof title === "string" && title.trim())
205
108
  candidates.push({ text: title.trim(), kind: "fragment" });
206
- for (const body of [proposedBody, sourceBody]) {
207
- const heading = body.match(/^#{1,6}\s+(.+?)\s*$/m)?.[1];
208
- if (heading)
209
- candidates.push({ text: heading.trim(), kind: "fragment" });
210
- }
211
- for (const body of [proposedBody, sourceBody]) {
212
- const sentence = firstProseSentence(body);
213
- if (sentence)
214
- candidates.push({ text: sentence, kind: "prose" });
215
- }
109
+ const heading = body.match(/^#{1,6}\s+(.+?)\s*$/m)?.[1];
110
+ if (heading)
111
+ candidates.push({ text: heading.trim(), kind: "fragment" });
112
+ const sentence = firstProseSentence(body);
113
+ if (sentence)
114
+ candidates.push({ text: sentence, kind: "prose" });
216
115
  for (const { text, kind } of candidates) {
217
116
  const normalized = text
218
117
  .replace(/`/g, "")
@@ -241,81 +140,71 @@ function firstProseSentence(body) {
241
140
  return "";
242
141
  }
243
142
  /**
244
- * Reflect's content rails: the source frontmatter is restored and the model's
245
- * frontmatter merged on top except identity fields; a stray or appended
246
- * frontmatter block and echoed run-only guidance are stripped; a missing
247
- * required description is derived deterministically; a body outside the size
248
- * ratios or echoing the truncation notice is flagged for review.
143
+ * The asset with the patch applied, or `undefined` when the patch changes
144
+ * nothing (or there is no asset to patch). The body is the source's own, byte
145
+ * for byte; the one thing akm adds to it is a `# title` heading it lacks. A
146
+ * required description that neither the source nor the patch has is derived
147
+ * from the asset's own text (#636). Only the changed keys' frontmatter lines are
148
+ * rewritten: every other line is kept as it is, so a source whose YAML the
149
+ * parser reads only in part (a broken description beside a list) loses nothing.
249
150
  */
250
- export function sanitizeReflectPayload(payload, sourceContent, targetRef) {
251
- const warnings = [];
252
- const { fmText: sourceFmText, body: sourceBody } = sourceContent
253
- ? splitFrontmatter(sourceContent)
254
- : { fmText: null, body: "" };
255
- const sourceFm = sourceFmText !== null ? parseFrontmatter(sourceContent ?? "").data : {};
256
- const { fmText: llmFmText, body: rawLlmBody } = splitFrontmatter(payload.content);
257
- let llmFm = {};
258
- if (llmFmText !== null) {
259
- warnings.push("LLM emitted frontmatter in content; stripped and merged through identity guard.");
260
- try {
261
- llmFm = parseFrontmatter(payload.content).data;
262
- }
263
- catch {
264
- llmFm = {};
265
- }
266
- }
267
- if (payload.frontmatter && typeof payload.frontmatter === "object")
268
- llmFm = { ...llmFm, ...payload.frontmatter };
269
- for (const field of PROTECTED_FRONTMATTER_FIELDS) {
270
- if (field in llmFm && llmFm[field] !== sourceFm[field]) {
271
- warnings.push(`LLM attempted to change protected frontmatter field "${field}"; restored from source.`);
272
- delete llmFm[field];
273
- }
274
- }
275
- const mergedFm = { ...sourceFm, ...llmFm };
276
- for (const field of PROTECTED_FRONTMATTER_FIELDS)
277
- if (field in sourceFm)
278
- mergedFm[field] = sourceFm[field];
279
- const scaffolding = stripReflectPromptScaffolding(stripAppendedFrontmatter(rawLlmBody.replace(/^\s+/, "")));
280
- const cleanedBody = scaffolding.content;
281
- if (scaffolding.stripped) {
282
- warnings.push('Removed echoed run-only "Avoid These Patterns" guidance from the proposed asset body (#963).');
283
- }
151
+ export function applyReflectPatch(patch, sourceContent, targetRef) {
152
+ if (!sourceContent.trim())
153
+ return undefined;
154
+ const { fmText, body: sourceBody } = splitFrontmatter(sourceContent);
155
+ // A fence that is opened and never closed (`---` fused onto the last value)
156
+ // would leave the old block in the body under a new one: a person fixes it.
157
+ if (fmText === null && /^---\r?\n/.test(sourceContent))
158
+ return undefined;
159
+ const sourceFm = fmText !== null ? parseFrontmatter(sourceContent).data : {};
160
+ const { title, ...changes } = patch;
161
+ const addTitle = title !== undefined && !/^#[ \t]+\S/m.test(sourceBody);
162
+ const body = addTitle ? `# ${title}\n\n${sourceBody.replace(/^(\r?\n)+/, "")}` : sourceBody;
284
163
  // Only a source that already has frontmatter but no description gets one:
285
164
  // injecting a whole block, or overwriting an authored one, is out of scope.
286
165
  const refType = lenientRefType(targetRef);
287
- const desc = mergedFm.description;
288
- const sourceHadFrontmatter = sourceFmText !== null && Object.keys(sourceFm).length > 0;
166
+ const desc = changes.description ?? sourceFm.description;
289
167
  if (refType &&
290
168
  requiresDescription(refType) &&
291
169
  (typeof desc !== "string" || desc.trim().length === 0) &&
292
- sourceHadFrontmatter) {
293
- const derived = deriveDescriptionFromAsset(mergedFm.title, cleanedBody, sourceBody, targetRef);
294
- if (derived) {
295
- mergedFm.description = derived;
296
- warnings.push("Synthesized a deterministic `description` from title/heading (#636) — source and proposal lacked one.");
170
+ Object.keys(sourceFm).length > 0) {
171
+ const derived = deriveDescriptionFromAsset(sourceFm.title, body, targetRef);
172
+ if (derived)
173
+ changes.description = derived;
174
+ }
175
+ for (const key of Object.keys(changes))
176
+ if (changes[key] === sourceFm[key])
177
+ delete changes[key];
178
+ if (!addTitle && Object.keys(changes).length === 0)
179
+ return undefined;
180
+ // No frontmatter at all stays body-only unless the patch adds a field. The
181
+ // blank line after a closing fence is part of the source body, so only a
182
+ // block or heading akm adds brings its own.
183
+ if (fmText === null) {
184
+ if (Object.keys(changes).length === 0)
185
+ return { content: body };
186
+ const content = `---\n${serializeFrontmatter(changes)}\n---\n\n${body}`;
187
+ return { content, frontmatter: parseFrontmatter(content).data };
188
+ }
189
+ const content = `---\n${patchFrontmatterLines(fmText, changes)}\n---\n${addTitle ? "\n" : ""}${body}`;
190
+ return { content, frontmatter: parseFrontmatter(content).data };
191
+ }
192
+ /** The frontmatter text with each changed key's lines (the key line and its indented continuation) replaced, or appended. */
193
+ function patchFrontmatterLines(fmText, changes) {
194
+ const lines = fmText.split(/\r?\n/);
195
+ for (const [key, value] of Object.entries(changes)) {
196
+ const replacement = serializeFrontmatter({ [key]: value }).split("\n");
197
+ const start = lines.findIndex((line) => line.startsWith(`${key}:`));
198
+ if (start === -1) {
199
+ lines.push(...replacement);
200
+ continue;
297
201
  }
202
+ let end = start + 1;
203
+ while (end < lines.length && /^[ \t]/.test(lines[end] ?? ""))
204
+ end++;
205
+ lines.splice(start, end - start, ...replacement);
298
206
  }
299
- const size = checkReflectSize(sourceBody, cleanedBody);
300
- let sizeGuardRatio;
301
- if (!size.ok) {
302
- const shrink = size.code === "EXCESSIVE_SHRINKAGE";
303
- warnings.push(`${size.code} — proposed body is ${(size.ratio * 100).toFixed(0)}% of source (${shrink ? "minimum 50%" : "maximum 250%"}) for ref ${targetRef}. ${shrink ? "Concrete content was likely deleted." : "Speculative material was likely added."} Flagged for review.`);
304
- sizeGuardRatio = { code: size.code, ratio: size.ratio };
305
- }
306
- const truncationMarkerLeaked = cleanedBody.includes(REFLECT_TRUNCATION_MARKER);
307
- if (truncationMarkerLeaked) {
308
- warnings.push(`Proposed body for ref ${targetRef} contains the truncation-notice text the model was shown for a capped source asset ("${REFLECT_TRUNCATION_MARKER}"). The model likely echoed the notice instead of writing real content. Flagged for review.`);
309
- }
310
- // No frontmatter at all stays body-only, never gaining a stray `---`.
311
- const hasFrontmatter = Object.keys(mergedFm).length > 0;
312
- return {
313
- content: hasFrontmatter ? assembleAssetFromString(serializeFrontmatter(mergedFm), cleanedBody) : cleanedBody,
314
- ...(hasFrontmatter ? { frontmatter: mergedFm } : {}),
315
- warnings,
316
- ...(sizeGuardRatio ? { sizeGuardRatio } : {}),
317
- ...(truncationMarkerLeaked ? { truncationMarkerLeaked } : {}),
318
- };
207
+ return lines.join("\n");
319
208
  }
320
209
  // ── Output contract ──────────────────────────────────────────────────────────
321
210
  //
@@ -325,11 +214,12 @@ export function sanitizeReflectPayload(payload, sourceContent, targetRef) {
325
214
  // repaired once, whatever engine produced it.
326
215
  const REFLECT_FRONTMATTER_PATCH_JSON_SCHEMA = {
327
216
  type: "object",
328
- required: ["description", "when_to_use"],
217
+ required: ["description", "when_to_use", "title"],
329
218
  additionalProperties: false,
330
219
  properties: {
331
220
  description: { type: ["string", "null"] },
332
221
  when_to_use: { type: ["string", "null"] },
222
+ title: { type: ["string", "null"] },
333
223
  },
334
224
  };
335
225
  const REFLECT_CONFIDENCE_SCHEMA = {
@@ -340,21 +230,19 @@ const REFLECT_CONFIDENCE_SCHEMA = {
340
230
  };
341
231
  export const REFLECT_JSON_SCHEMA = {
342
232
  type: "object",
343
- required: ["content", "confidence", "frontmatterPatch"],
233
+ required: ["confidence", "frontmatterPatch"],
344
234
  additionalProperties: false,
345
235
  properties: {
346
- content: { type: "string", description: "Complete improved markdown body without YAML frontmatter." },
347
236
  confidence: REFLECT_CONFIDENCE_SCHEMA,
348
237
  frontmatterPatch: REFLECT_FRONTMATTER_PATCH_JSON_SCHEMA,
349
238
  },
350
239
  };
351
240
  const REFLECT_UNSCOPED_JSON_SCHEMA = {
352
241
  type: "object",
353
- required: ["ref", "content", "confidence", "frontmatterPatch"],
242
+ required: ["ref", "confidence", "frontmatterPatch"],
354
243
  additionalProperties: false,
355
244
  properties: {
356
245
  ref: { type: "string", description: "Selected asset ref as a subdir-qualified conceptId." },
357
- content: { type: "string", description: "Complete improved markdown body without YAML frontmatter." },
358
246
  confidence: { type: "number", minimum: 0, maximum: 1, description: "Self-reported quality confidence in [0, 1]." },
359
247
  frontmatterPatch: REFLECT_FRONTMATTER_PATCH_JSON_SCHEMA,
360
248
  },
@@ -387,67 +275,48 @@ function parseReflectConfidence(value) {
387
275
  }
388
276
  return value;
389
277
  }
278
+ const REFLECT_PATCH_FIELDS = ["description", "when_to_use", "title"];
390
279
  function parseReflectFrontmatterPatch(value) {
391
280
  if (!value || typeof value !== "object" || Array.isArray(value)) {
392
281
  throw new Error('direct reflect response missing required object field "frontmatterPatch"');
393
282
  }
394
- const patch = value;
395
- const keys = Object.keys(patch).sort();
396
- if (keys.length !== 2 || keys[0] !== "description" || keys[1] !== "when_to_use") {
397
- throw new Error("direct reflect frontmatterPatch fields must be exactly: description, when_to_use");
283
+ const fields = value;
284
+ if (Object.keys(fields).length !== REFLECT_PATCH_FIELDS.length || REFLECT_PATCH_FIELDS.some((f) => !(f in fields))) {
285
+ throw new Error(`direct reflect frontmatterPatch fields must be exactly: ${REFLECT_PATCH_FIELDS.join(", ")}`);
398
286
  }
399
- const frontmatter = {};
400
- for (const field of ["description", "when_to_use"]) {
401
- const fieldValue = patch[field];
287
+ const patch = {};
288
+ for (const field of REFLECT_PATCH_FIELDS) {
289
+ const fieldValue = fields[field];
402
290
  if (fieldValue === null)
403
291
  continue;
404
292
  if (typeof fieldValue !== "string" || !fieldValue.trim() || /[\r\n]/.test(fieldValue)) {
405
293
  throw new Error(`direct reflect frontmatterPatch.${field} must be a non-empty single-line string or null`);
406
294
  }
407
- frontmatter[field] = fieldValue.trim();
295
+ patch[field] = fieldValue.trim();
408
296
  }
409
- return Object.keys(frontmatter).length > 0 ? frontmatter : undefined;
297
+ return patch;
410
298
  }
411
299
  function parseSchemaReflectOutput(raw, targetRef) {
412
300
  const parsed = parseEmbeddedJsonResponse(raw);
413
301
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
414
302
  throw new Error("direct reflect response was not valid JSON");
415
303
  }
416
- const expectedKeys = targetRef
417
- ? ["confidence", "content", "frontmatterPatch"]
418
- : ["confidence", "content", "frontmatterPatch", "ref"];
304
+ const expectedKeys = targetRef ? ["confidence", "frontmatterPatch"] : ["confidence", "frontmatterPatch", "ref"];
419
305
  const actualKeys = Object.keys(parsed).sort();
420
306
  if (actualKeys.length !== expectedKeys.length || actualKeys.some((key, index) => key !== expectedKeys[index])) {
421
307
  throw new Error(`direct reflect response fields must be exactly: ${expectedKeys.join(", ")}`);
422
308
  }
423
- if (typeof parsed.content !== "string" || !parsed.content.trim()) {
424
- throw new Error('direct reflect response missing required string field "content"');
425
- }
426
309
  const ref = targetRef ?? (typeof parsed.ref === "string" ? parsed.ref.trim() : "");
427
310
  if (!ref)
428
311
  throw new Error('direct reflect response missing required string field "ref"');
429
- const frontmatter = parseReflectFrontmatterPatch(parsed.frontmatterPatch);
430
312
  return {
431
313
  ref,
432
- content: parsed.content,
433
314
  confidence: parseReflectConfidence(parsed.confidence),
434
- ...(frontmatter ? { frontmatter } : {}),
315
+ patch: parseReflectFrontmatterPatch(parsed.frontmatterPatch),
435
316
  };
436
317
  }
437
318
  function parseFramedReflectOutput(raw, targetRef) {
438
- const normalized = raw.replaceAll("\r\n", "\n").trim();
439
- const beginMarker = "AKM_REFLECT_CONTENT_BEGIN\n";
440
- const endMarker = "\nAKM_REFLECT_CONTENT_END";
441
- const beginIndex = normalized.indexOf(beginMarker);
442
- if (beginIndex < 0 || (beginIndex > 0 && normalized[beginIndex - 1] !== "\n")) {
443
- throw new Error("direct reflect response missing AKM_REFLECT_CONTENT_BEGIN marker");
444
- }
445
- const contentStart = beginIndex + beginMarker.length;
446
- const endIndex = normalized.lastIndexOf(endMarker);
447
- if (endIndex < contentStart || normalized.slice(endIndex + endMarker.length).trim()) {
448
- throw new Error("direct reflect response missing terminal AKM_REFLECT_CONTENT_END marker");
449
- }
450
- const headerLines = normalized.slice(0, beginIndex).trim().split("\n").filter(Boolean);
319
+ const headerLines = raw.replaceAll("\r\n", "\n").trim().split("\n").filter(Boolean);
451
320
  const header = (prefix) => headerLines.find((line) => line.startsWith(prefix));
452
321
  const confidenceLine = header("AKM_REFLECT_CONFIDENCE:");
453
322
  const refLine = header("AKM_REFLECT_REF:");
@@ -463,9 +332,6 @@ function parseFramedReflectOutput(raw, targetRef) {
463
332
  const ref = targetRef ?? refLine?.slice("AKM_REFLECT_REF:".length).trim() ?? "";
464
333
  if (!ref)
465
334
  throw new Error("direct reflect response contained an empty AKM_REFLECT_REF value");
466
- const content = normalized.slice(contentStart, endIndex);
467
- if (!content.trim())
468
- throw new Error("direct reflect response contained empty framed content");
469
335
  let parsedPatch;
470
336
  try {
471
337
  parsedPatch = JSON.parse(patchLine.slice("AKM_REFLECT_FRONTMATTER_PATCH:".length).trim());
@@ -473,9 +339,11 @@ function parseFramedReflectOutput(raw, targetRef) {
473
339
  catch {
474
340
  throw new Error("direct reflect response contained invalid frontmatter patch JSON");
475
341
  }
476
- const frontmatter = parseReflectFrontmatterPatch(parsedPatch);
477
- const confidence = parseReflectConfidence(Number(confidenceText));
478
- return { ref, content, confidence, ...(frontmatter ? { frontmatter } : {}) };
342
+ return {
343
+ ref,
344
+ confidence: parseReflectConfidence(Number(confidenceText)),
345
+ patch: parseReflectFrontmatterPatch(parsedPatch),
346
+ };
479
347
  }
480
348
  /**
481
349
  * One reflect iteration on any engine, as an agent-shaped result (errors
@@ -660,7 +528,7 @@ function unsupportedTypeFailure(ref, type, detail, emitFailed) {
660
528
  },
661
529
  };
662
530
  }
663
- /** The target's parsed ref and current content, or a refusal for a type reflect cannot rewrite. */
531
+ /** The target's parsed ref and current content, or a refusal for a type reflect cannot patch. */
664
532
  async function resolveReflectSource(options, stash, emitFailed) {
665
533
  if (!options.ref)
666
534
  return { assetContent: undefined, parsedRef: undefined };
@@ -684,7 +552,7 @@ async function resolveReflectSource(options, stash, emitFailed) {
684
552
  }
685
553
  }
686
554
  catch {
687
- // An index miss is not fatal: the agent can still propose a fresh asset.
555
+ // An index miss is not fatal: reflect then has no content to patch.
688
556
  }
689
557
  }
690
558
  if (!REFLECT_ALLOWED_TYPES.has(parsedRef.type) &&
@@ -756,7 +624,7 @@ function preflightReflectDispatch(runnerSpec, onNotices) {
756
624
  /**
757
625
  * The flat 12k content cap exists for CLI argv; the HTTP runner can spend half
758
626
  * its context window (after the rest of the prompt) on the asset, reserving
759
- * the other half for the rewrite. Never below the flat floor.
627
+ * the other half for the reply. Never below the flat floor.
760
628
  */
761
629
  function computeReflectContentBudgetChars(promptInput, runnerSpec) {
762
630
  if (!runnerIsLlm(runnerSpec) || !promptInput.assetContent?.trim())
@@ -766,13 +634,10 @@ function computeReflectContentBudgetChars(promptInput, runnerSpec) {
766
634
  return Math.max(REFLECT_CONTENT_CAP, Math.floor((window - overhead) / 2));
767
635
  }
768
636
  /** Every read-only prompt input, shared by dispatch and `--show-prompt`. */
769
- async function gatherReflectPromptSources(options, stash, parsedRef, assetContent) {
637
+ function gatherReflectPromptSources(options, stash, parsedRef, assetContent) {
770
638
  return {
771
639
  feedback: readRecentFeedback(options.ref ? (options.itemRef ?? options.ref) : undefined, options.eventsCtx),
772
640
  schemaHints: buildSchemaHints(parsedRef?.type ?? "", assetContent),
773
- relatedLessons: options.ref && parsedRef
774
- ? await readRelatedLessons(stash, options.ref, parsedRef, options.itemRef, options.eventsCtx)
775
- : [],
776
641
  rejectedProposals: rejectedProposalContext(stash, options.ref, options.ctx, options.eventsCtx),
777
642
  standardsContext: resolveStandardsContext(options.ref, stash),
778
643
  };
@@ -780,7 +645,7 @@ async function gatherReflectPromptSources(options, stash, parsedRef, assetConten
780
645
  /** The exact prompt reflect sends, shared by dispatch and `--show-prompt`. */
781
646
  function buildReflectPromptText(args) {
782
647
  const { options, parsedRef, assetContent, sources, runnerSpec, priorDraft } = args;
783
- const { feedback, schemaHints, relatedLessons, rejectedProposals, standardsContext } = sources;
648
+ const { feedback, schemaHints, rejectedProposals, standardsContext } = sources;
784
649
  // An LLM engine that rejects JSON Schema gets the framed contract; every other engine gets the JSON object.
785
650
  const outputMode = runnerIsLlm(runnerSpec) && !wantsJsonSchemaOutput(runnerSpec.connection) ? "framed_markdown" : "json_schema";
786
651
  const input = {
@@ -790,7 +655,6 @@ function buildReflectPromptText(args) {
790
655
  ...(assetContent !== undefined ? { assetContent } : {}),
791
656
  ...(feedback.length > 0 ? { feedback } : {}),
792
657
  ...(schemaHints.length > 0 ? { schemaHints } : {}),
793
- ...(relatedLessons.length > 0 ? { relatedLessons } : {}),
794
658
  ...(options.task ? { task: options.task } : {}),
795
659
  ...(standardsContext.trim() ? { standardsContext } : {}),
796
660
  ...(options.avoidPatterns && options.avoidPatterns.length > 0 ? { avoidPatterns: options.avoidPatterns } : {}),
@@ -872,61 +736,36 @@ async function runReflectRefineIterations(args) {
872
736
  }
873
737
  return result;
874
738
  }
875
- /** The proposal payload from a successful run: the JSON payload on stdout. */
876
- function resolveReflectPayload(run, result) {
877
- const { options } = run;
878
- try {
879
- return { payload: parseAgentProposalPayload(result.stdout ?? "") };
880
- }
881
- catch (err) {
882
- run.emitFailed("parse_error", "parse_error", options.ref, {
883
- ...exitCodeMeta(result),
884
- ...(reflectTelemetry(result) ?? {}),
885
- });
886
- return {
887
- failure: reflectFailure(run, result, "parse_error", err instanceof Error ? err.message : String(err), true),
888
- };
889
- }
890
- }
891
739
  const NOISE_SUBREASONS = {
892
740
  noop: "reflect_skipped_noop",
893
741
  cosmetic: "reflect_skipped_cosmetic",
894
742
  "low-value": "reflect_skipped_low_value",
895
743
  };
896
744
  /**
897
- * Sanitize, drop a no-op/cosmetic (and optionally low-value) change, judge the
898
- * exact content that would be persisted, then mint. A judge pass is staged only
899
- * when the body is unchanged: a body edit the judge passes, or one made with the
900
- * gate off, waits for review. Size-flagged or truncation-leaking content skips
901
- * the judge and waits for review. A revision with a deterministic defect is
902
- * refused before the judge runs, whether or not the gate is on.
745
+ * Apply the patch, drop a no-op/cosmetic (and optionally low-value) change,
746
+ * judge the exact content that would be persisted, then mint. A revision with a
747
+ * deterministic defect is refused before the judge runs, whether or not the
748
+ * gate is on.
903
749
  */
904
750
  async function finalizeReflectProposal(args) {
905
- const { run, assetContent, result, judge, feedback } = args;
751
+ const { run, payload, assetContent, result, judge, feedback } = args;
906
752
  const { options } = run;
907
753
  const telemetry = reflectTelemetry(result) ?? {};
908
- const sanitized = sanitizeReflectPayload({ content: args.payload.content, ...(args.payload.frontmatter ? { frontmatter: args.payload.frontmatter } : {}) }, assetContent, args.payload.ref);
909
- const payload = {
910
- ...args.payload,
911
- content: sanitized.content,
912
- ...(sanitized.frontmatter ? { frontmatter: sanitized.frontmatter } : {}),
913
- };
914
- if (assetContent !== undefined) {
915
- const changeKind = classifyReflectChange(assetContent, payload.content);
916
- if (changeKind === "noop" ||
917
- changeKind === "cosmetic" ||
918
- (changeKind === "low-value" && options.lowValueFilter === true)) {
919
- run.emitFailed("no_change", NOISE_SUBREASONS[changeKind], options.ref, { changeKind, ...telemetry });
920
- const what = changeKind === "noop"
921
- ? "identical to the current asset (empty diff)"
922
- : changeKind === "low-value"
923
- ? "a low-value prose micro-rewrite (few changed tokens, no structural changes)"
924
- : "a cosmetic-only reformat of the current asset (whitespace/fence/YAML-folding changes)";
925
- return reflectFailure(run, result, "no_change", `Reflect skipped: proposed content for ${payload.ref} is ${what}; no proposal created.`, false);
926
- }
754
+ const patched = applyReflectPatch(payload.patch, assetContent, payload.ref);
755
+ // A patch that changes nothing leaves the asset as it is: an empty diff.
756
+ const content = patched?.content ?? assetContent;
757
+ const changeKind = classifyReflectChange(assetContent, content);
758
+ if (changeKind === "noop" ||
759
+ changeKind === "cosmetic" ||
760
+ (changeKind === "low-value" && options.lowValueFilter === true)) {
761
+ run.emitFailed("no_change", NOISE_SUBREASONS[changeKind], options.ref, { changeKind, ...telemetry });
762
+ const what = changeKind === "noop"
763
+ ? "identical to the current asset (empty diff)"
764
+ : changeKind === "low-value"
765
+ ? "a low-value prose micro-rewrite (few changed tokens, no structural changes)"
766
+ : "a cosmetic-only reformat of the current asset (whitespace/fence/YAML-folding changes)";
767
+ return reflectFailure(run, result, "no_change", `Reflect skipped: proposed content for ${payload.ref} is ${what}; no proposal created.`, false);
927
768
  }
928
- const flagged = Boolean(sanitized.sizeGuardRatio || sanitized.truncationMarkerLeaked);
929
- const judged = judge.enabled && !flagged;
930
769
  /** A judge refused the revision: record it for the ledger's rejection window and stop. */
931
770
  const refuse = (detail, metadata, message) => {
932
771
  if (options.ref) {
@@ -946,13 +785,13 @@ async function finalizeReflectProposal(args) {
946
785
  return reflectFailure(run, result, "quality_rejected", message, false);
947
786
  };
948
787
  // A defect no judge needs to weigh is refused before any judge call, whether or not the gate is on.
949
- const defect = assetContent === undefined ? undefined : findReflectDefect(assetContent, payload.content, options.defectFilter);
788
+ const defect = findReflectDefect(assetContent, content, options.defectFilter);
950
789
  if (defect)
951
790
  return refuse(defect, { reflectDefect: defect }, `Reflect proposal refused before the judge: ${defect}`);
952
791
  let verdict;
953
792
  let judgeFailed = false;
954
- if (judged) {
955
- verdict = await runReflectQualityJudge(run.config, payload.content, assetContent ?? "", feedback, options.chat, {
793
+ if (judge.enabled) {
794
+ verdict = await runReflectQualityJudge(run.config, content, assetContent, feedback, options.chat, {
956
795
  runnerSelectionFrozen: true,
957
796
  ref: payload.ref,
958
797
  ...(judge.runner ? { llmRunner: judge.runner } : {}),
@@ -970,12 +809,12 @@ async function finalizeReflectProposal(args) {
970
809
  }, `Reflect proposal quality gate rejected: score=${verdict.score}, reason="${verdict.reason}"`);
971
810
  }
972
811
  }
973
- // #722: a rewrite of an existing asset must not grade lower on its own retrieval queries.
974
- if (verdict?.pass && judge.runner && assetContent !== undefined) {
812
+ // #722: a revision of an existing asset must not grade lower on its own retrieval queries.
813
+ if (verdict?.pass && judge.runner) {
975
814
  const retrieval = await runRetrievalRegressionGate({
976
815
  ref: payload.ref,
977
816
  before: assetContent,
978
- after: payload.content,
817
+ after: content,
979
818
  queries: loadRetrievalQueries({ proposalsCtx: options.ctx, eventsCtx: options.eventsCtx }, payload.ref),
980
819
  runner: judge.runner,
981
820
  ...(options.chat ? { chat: options.chat } : {}),
@@ -992,33 +831,17 @@ async function finalizeReflectProposal(args) {
992
831
  }, `Reflect proposal refused: ${retrieval.reason}`);
993
832
  }
994
833
  }
995
- // A lesson reflect wrote is marked so a later reflect on the same skill does
996
- // not read it back as independent evidence.
997
- const frontmatter = {
998
- ...(payload.frontmatter ?? {}),
999
- ...(lenientRefType(payload.ref) === "lesson" ? { derived_from_reflect: true } : {}),
1000
- };
1001
834
  const reviewReasons = [
1002
835
  ...(judge.skippedNoJudge ? ["no-judge-configured"] : []),
1003
836
  ...(judgeFailed ? ["judge-error"] : []),
1004
- ...(sanitized.sizeGuardRatio ? ["reflect-size-ratio"] : []),
1005
- ...(sanitized.truncationMarkerLeaked ? ["reflect-truncation-leak"] : []),
1006
837
  ];
1007
- // A revision that changes the body is never auto-accepted: on labelled edits, the judge's
1008
- // passes on body edits were good 12 times in 37, and on frontmatter-only edits 13 in 13.
1009
- // One that nothing above holds for review (the judge passed it, or the gate is off) waits
1010
- // for a person, as does a revision with no source to compare.
1011
- const bodyOf = (content) => splitFrontmatter(content).body.replace(/\s+/g, " ").trim();
1012
- const bodyEdit = reviewReasons.length === 0 && (assetContent === undefined || bodyOf(assetContent) !== bodyOf(payload.content));
1013
- if (bodyEdit)
1014
- reviewReasons.push("body-edit");
1015
838
  const proposal = mintProposal(run.stash, options.ctx, {
1016
839
  ref: payload.ref,
1017
840
  ...(options.target ? { target: options.target } : {}),
1018
841
  source: "reflect",
1019
842
  sourceRun: `reflect-${Date.now()}`,
1020
- payload: { content: payload.content, ...(Object.keys(frontmatter).length > 0 ? { frontmatter } : {}) },
1021
- ...(typeof payload.confidence === "number" ? { confidence: payload.confidence } : {}),
843
+ payload: { content, ...(patched?.frontmatter ? { frontmatter: patched.frontmatter } : {}) },
844
+ confidence: payload.confidence,
1022
845
  ...(options.eligibilitySource ? { eligibilitySource: options.eligibilitySource } : {}),
1023
846
  ...(options.itemRef ? { itemRef: options.itemRef, attemptedRefs: [options.itemRef] } : {}),
1024
847
  }, reviewReasons.length > 0
@@ -1027,10 +850,6 @@ async function finalizeReflectProposal(args) {
1027
850
  reason: reviewReasons.join("+"),
1028
851
  // The quality gate's hand-off to a person, as distill's: the triage drain leaves it alone.
1029
852
  gate: judgeFailed ? "quality-gate" : "reflect",
1030
- ...(sanitized.sizeGuardRatio ? { measured: Math.round(sanitized.sizeGuardRatio.ratio * 100) } : {}),
1031
- // The reviewer sees why the judge passed it (with the gate off, nothing judged it).
1032
- ...(bodyEdit && verdict?.criteria ? { scores: verdict.criteria } : {}),
1033
- ...(bodyEdit && verdict ? { judgeReason: verdict.reason } : {}),
1034
853
  },
1035
854
  }
1036
855
  : { judged: verdict });
@@ -1043,10 +862,6 @@ async function finalizeReflectProposal(args) {
1043
862
  engine: run.engineName,
1044
863
  ...(judge.skippedNoJudge ? { qualityGateSkippedNoJudge: true } : {}),
1045
864
  ...(judgeFailed ? { qualityReason: verdict?.reason } : {}),
1046
- ...(sanitized.sizeGuardRatio
1047
- ? { sizeGuardRatio: sanitized.sizeGuardRatio.code, sizeGuardRatioValue: sanitized.sizeGuardRatio.ratio }
1048
- : {}),
1049
- ...(sanitized.truncationMarkerLeaked ? { truncationMarkerLeaked: true } : {}),
1050
865
  ...telemetry,
1051
866
  },
1052
867
  }, options.eventsCtx);
@@ -1076,7 +891,7 @@ export async function renderReflectPromptPreview(options) {
1076
891
  throw new UsageError((!failure.ok && failure.error) || `Reflect cannot preview ref "${ref}".`, "INVALID_FLAG_VALUE");
1077
892
  }
1078
893
  const { runnerSpec, engineName } = resolveReflectRunner(options);
1079
- const sources = await gatherReflectPromptSources(options, stash, source.parsedRef, source.assetContent);
894
+ const sources = gatherReflectPromptSources(options, stash, source.parsedRef, source.assetContent);
1080
895
  const { prompt } = buildReflectPromptText({
1081
896
  options,
1082
897
  parsedRef: source.parsedRef,
@@ -1123,7 +938,7 @@ export async function akmReflect(options = {}) {
1123
938
  preflightReflectDispatch(runnerSpec, notices.add);
1124
939
  if (judgeRunner && judgeRunner !== runnerSpec)
1125
940
  preflightReflectDispatch(judgeRunner, notices.add);
1126
- const sources = await gatherReflectPromptSources(options, stash, parsedRef, assetContent);
941
+ const sources = gatherReflectPromptSources(options, stash, parsedRef, assetContent);
1127
942
  const agentEnv = options.eventSource === "improve" ? { AKM_EVENT_SOURCE: "improve" } : {};
1128
943
  const sensitiveValues = collectDispatchSensitiveValues(runnerSpec, {
1129
944
  ...(Object.keys(agentEnv).length > 0 ? { env: agentEnv } : {}),
@@ -1160,25 +975,30 @@ export async function akmReflect(options = {}) {
1160
975
  });
1161
976
  return { ...envelope, ...notices.fields() };
1162
977
  }
1163
- const resolved = resolveReflectPayload(run, result);
1164
- if ("failure" in resolved)
1165
- return resolved.failure;
1166
- payload = resolved.payload;
978
+ // The iteration parsed the reply and put its payload on stdout.
979
+ payload = JSON.parse(result.stdout);
1167
980
  }
1168
981
  catch (error) {
1169
982
  if (!(error instanceof ConfigError))
1170
983
  emitInvoked();
1171
984
  throw error;
1172
985
  }
1173
- const unsafeContent = generatedContentRejection(payload.content, redactSensitiveText(payload.content, sensitiveValues));
986
+ const generated = Object.values(payload.patch).join("\n");
987
+ const unsafeContent = generatedContentRejection(generated, redactSensitiveText(generated, sensitiveValues));
1174
988
  if (unsafeContent) {
1175
989
  emitFailed("parse_error", "parse_error", options.ref, exitCodeMeta(result));
1176
990
  return reflectFailure(run, result, "parse_error", unsafeContent, false);
1177
991
  }
992
+ // An unscoped reply names its asset only now: read it as a targeted run read its own before dispatch.
993
+ const target = options.ref
994
+ ? { assetContent }
995
+ : await resolveReflectSource({ ...options, ref: payload.ref }, stash, emitFailed);
996
+ if ("failure" in target)
997
+ return target.failure;
1178
998
  return finalizeReflectProposal({
1179
999
  run,
1180
1000
  payload,
1181
- assetContent,
1001
+ assetContent: target.assetContent ?? "",
1182
1002
  result,
1183
1003
  judge: { enabled: judgeWanted && !skippedNoJudge, skippedNoJudge, runner: judgeRunner },
1184
1004
  feedback: sources.feedback,