akm-cli 0.9.26-alpha.2 → 0.9.27-alpha.1

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 +294 -0
  2. package/dist/assets/hints/cli-hints-full.md +16 -10
  3. package/dist/assets/hints/cli-hints-short.md +5 -5
  4. package/dist/assets/prompts/consolidate-system.md +2 -2
  5. package/dist/assets/prompts/extract-session.md +2 -2
  6. package/dist/assets/stash-skeleton/README.md +4 -3
  7. package/dist/commands/feedback-cli.js +244 -43
  8. package/dist/commands/improve/consolidate/pair-pass.js +1 -1
  9. package/dist/commands/improve/consolidate.js +14 -4
  10. package/dist/commands/improve/distill.js +70 -7
  11. package/dist/commands/improve/extract-prompt.js +61 -39
  12. package/dist/commands/improve/extract.js +2 -1
  13. package/dist/commands/improve/loop-stages.js +16 -1
  14. package/dist/commands/improve/memory/memory-belief.js +1 -1
  15. package/dist/commands/improve/preparation.js +46 -0
  16. package/dist/commands/improve/reflect.js +51 -8
  17. package/dist/commands/improve/retrieval-gate.js +1 -1
  18. package/dist/commands/improve/session-asset.js +3 -2
  19. package/dist/commands/improve/stage.js +1 -1
  20. package/dist/commands/proposal/repository.js +22 -6
  21. package/dist/core/asset/akm-markdown.js +40 -16
  22. package/dist/core/asset/frontmatter.js +67 -7
  23. package/dist/core/config/config-schema.js +1 -1
  24. package/dist/core/config/config.js +0 -4
  25. package/dist/core/config/schema/feedback.js +2 -19
  26. package/dist/indexer/indexer.js +35 -19
  27. package/dist/integrations/harnesses/codex/agent-builder.js +23 -15
  28. package/dist/llm/client.js +44 -14
  29. package/dist/llm/memory-infer.js +1 -1
  30. package/dist/scripts/akm-migrate-node.js +26 -22
  31. package/dist/scripts/akm-migrate.js +26 -22
  32. package/dist/storage/repositories/index-entry-schema.js +20 -4
  33. package/dist/storage/repositories/index-fts-repository.js +44 -3
  34. package/dist/storage/repositories/index-schema.js +14 -8
  35. package/docs/reference/cli.md +36 -17
  36. package/docs/reference/configuration.md +12 -6
  37. package/docs/reference/data-and-telemetry.md +3 -3
  38. package/package.json +1 -1
  39. package/schemas/akm-config.json +0 -14
@@ -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)
@@ -28,7 +28,7 @@ import { checkDeadUrls } from "../url-checker.js";
28
28
  import { isDistillCandidateRef } from "./eligibility.js";
29
29
  import { shouldSkipRef } from "./improve-strategies.js";
30
30
  import { recordLedgerAttempt, stateKey, stripBundle } from "./ledger.js";
31
- import { pushRecentError } from "./preparation.js";
31
+ import { hasOnlyBarePositiveFeedback, isFlaggedSinceLastEdit, pushRecentError } from "./preparation.js";
32
32
  import { recordNoOp, resetConsecutiveNoOps } from "./salience.js";
33
33
  import { attributeStage, errMessage } from "./stage.js";
34
34
  export function prepareImproveLoopEnv(args) {
@@ -196,6 +196,8 @@ async function runLoopReflectPass(planned, env, tally) {
196
196
  }, env.eventsCtx);
197
197
  recordPlasticity(env, planned, reason === "no_change" ? "noop" : result.ok ? "changed" : undefined);
198
198
  }
199
+ const FLAGGED_WRONG_REASON = "flagged wrong since its last edit";
200
+ const BARE_POSITIVE_REASON = "only positive feedback, without a reason";
199
201
  async function runLoopDistillPass(planned, refType, isDistillOnly, env, tally) {
200
202
  const { options, primaryStashDir, improveProfile, resolvedPlan } = env;
201
203
  const distillSkip = shouldSkipRef(planned.ref, "distill", improveProfile);
@@ -217,6 +219,19 @@ async function runLoopDistillPass(planned, refType, isDistillOnly, env, tally) {
217
219
  return;
218
220
  if (env.distillCooledRefs.has(planned.ref) && !explicitRefScope)
219
221
  return;
222
+ // A memory flagged wrong and not edited since is no source for a lesson. The attempt goes in the
223
+ // ledger so the ref waits for newer feedback; an explicit `--scope` ref still runs.
224
+ if (!explicitRefScope && isFlaggedSinceLastEdit(planned, env.eventsCtx)) {
225
+ recordLoopAttempt(planned, env, "distill", "unchanged", FLAGGED_WRONG_REASON);
226
+ return recordSkip(tally, planned.ref, FLAGGED_WRONG_REASON, { env, reason: "distill_flagged_wrong" });
227
+ }
228
+ // A memory whose only recent feedback is a positive with no reason or note gives the writer nothing to distil:
229
+ // 10 of the 11 lessons made from one were rejected. The ledger holds it until newer feedback; an explicit `--scope`
230
+ // ref still runs.
231
+ if (!explicitRefScope && hasOnlyBarePositiveFeedback(planned, env.eventsCtx)) {
232
+ recordLoopAttempt(planned, env, "distill", "unchanged", BARE_POSITIVE_REASON);
233
+ return recordSkip(tally, planned.ref, BARE_POSITIVE_REASON, { env, reason: "distill_positive_without_reason" });
234
+ }
220
235
  const result = await attributeStage(resolvedPlan, "distill", () => env.distillFn({
221
236
  ref: planned.ref,
222
237
  ...(planned.itemRef ? { itemRef: planned.itemRef } : {}),
@@ -17,7 +17,7 @@ import { mutateFrontmatter } from "../../../core/asset/frontmatter.js";
17
17
  * An edge value as a list. A scalar string is live data (the indexer accepts
18
18
  * it), so it is promoted rather than dropped on the next write.
19
19
  */
20
- function readEdgeList(value) {
20
+ export function readEdgeList(value) {
21
21
  if (Array.isArray(value))
22
22
  return value.filter((v) => typeof v === "string" && v.trim().length > 0);
23
23
  if (typeof value === "string" && value.trim())
@@ -34,6 +34,7 @@ import { runSchemaRepairPass } from "../sources/schema-repair.js";
34
34
  import { isAutonomyLaneAllowed } from "./autonomy-gate.js";
35
35
  import { akmConsolidate, inspectConsolidationPool, loadExistingKnowledgeBodyHashes, makeConsolidateResult, } from "./consolidate.js";
36
36
  import { computeSafeChunkSize, DEFAULT_CONTEXT_LENGTH_TOKENS } from "./consolidate/chunking.js";
37
+ import { contentHash } from "./content-hash.js";
37
38
  import { assetTypeOf, buildUtilityMap, dedupeRefs, findAssetFilePath, isDistillCandidateRef, isLessonCandidate, resolveImproveScope, withIndexDb, } from "./eligibility.js";
38
39
  import { akmExtract, countNewExtractCandidates } from "./extract.js";
39
40
  import { computeValenceScore } from "./feedback-valence.js";
@@ -545,6 +546,51 @@ function isSignalEvent(metadata) {
545
546
  const meta = metadata;
546
547
  return meta !== undefined && (typeof meta.signal === "string" || typeof meta.note === "string");
547
548
  }
549
+ /**
550
+ * Whether `candidate` was flagged wrong and not edited since. A negative
551
+ * feedback inside the signal window that recorded the hash of the body it
552
+ * judged flags it exactly when the body still has that hash, so a later write
553
+ * that leaves the text alone (an inference stamp, a frontmatter repair) does not
554
+ * lift the flag. One without a hash flags it when it is newer than the file's
555
+ * last write: its mtime, the one the retrieval scope reads for new material. A
556
+ * candidate with no readable file is not flagged.
557
+ */
558
+ export function isFlaggedSinceLastEdit(candidate, eventsCtx) {
559
+ if (!candidate.filePath)
560
+ return false;
561
+ let editedAtMs;
562
+ let bodyHash;
563
+ try {
564
+ editedAtMs = fs.statSync(candidate.filePath).mtimeMs;
565
+ bodyHash = contentHash(fs.readFileSync(candidate.filePath, "utf8"), "body");
566
+ }
567
+ catch {
568
+ return false;
569
+ }
570
+ const since = new Date(Date.now() - daysToMs(FEEDBACK_SIGNAL_WINDOW_DAYS)).toISOString();
571
+ return readEvents({ type: "feedback", ref: keyOf(candidate), since }, eventsCtx).events.some((e) => {
572
+ const meta = e.metadata;
573
+ if (meta?.signal !== "negative")
574
+ return false;
575
+ return typeof meta.contentHash === "string" ? meta.contentHash === bodyHash : Date.parse(e.ts) > editedAtMs;
576
+ });
577
+ }
578
+ /**
579
+ * Whether the only feedback `candidate` has inside the signal window is positive and says nothing: no reason, no
580
+ * note. A bare `--positive` only records that a note helped, which gives the writer nothing to distil, so it restates
581
+ * the memory: 10 of the 11 lessons distilled from a memory with nothing more were rejected (the 2026-10-05 review).
582
+ * A candidate with no feedback in the window is not matched.
583
+ */
584
+ export function hasOnlyBarePositiveFeedback(candidate, eventsCtx) {
585
+ const since = new Date(Date.now() - daysToMs(FEEDBACK_SIGNAL_WINDOW_DAYS)).toISOString();
586
+ const signals = readEvents({ type: "feedback", ref: keyOf(candidate), since }, eventsCtx).events.filter((e) => isSignalEvent(e.metadata));
587
+ const hasText = (value) => typeof value === "string" && value.trim() !== "";
588
+ return (signals.length > 0 &&
589
+ signals.every((e) => {
590
+ const meta = e.metadata;
591
+ return meta.signal === "positive" && !hasText(meta.reason) && !hasText(meta.note);
592
+ }));
593
+ }
548
594
  /** One read of the feedback events and the ledger's reflect/distill rows. */
549
595
  export function buildSnapshotManifest(args) {
550
596
  const { eventsCtx, stashDir } = args;
@@ -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";
@@ -38,6 +40,7 @@ import { isJsonSchemaKnownUnsupported, LlmCallError } from "../../llm/client.js"
38
40
  import { baseFailureFields, enoentHintMessage, isEnoentFailure } from "../agent/agent-support.js";
39
41
  import { isValidDescription } from "../proposal/validators/proposal-quality-validators.js";
40
42
  import { CHARS_PER_TOKEN, DEFAULT_CONTEXT_LENGTH_TOKENS } from "./consolidate/chunking.js";
43
+ import { contentHash } from "./content-hash.js";
41
44
  import { findAssetFilePath } from "./eligibility.js";
42
45
  import { resolveImproveExecution } from "./execution.js";
43
46
  import { recordLedgerAttempt } from "./ledger.js";
@@ -46,19 +49,29 @@ import { loadRetrievalQueries, runRetrievalRegressionGate } from "./retrieval-ga
46
49
  import { callStageOnce, errMessage, mintProposal, noticeSet, rejectedProposalContext, resolveQualityGateJudge, runReflectQualityJudge, } from "./stage.js";
47
50
  const MAX_FEEDBACK_LINES = 10;
48
51
  const MAX_GLOBAL_FEEDBACK_LINES = 20;
52
+ /** Ends a feedback line whose event judged text the asset no longer has. */
53
+ const EARLIER_TEXT_MARK = " (given on an earlier version of the text)";
49
54
  function readOnlyEventsContext(ctx) {
50
55
  return ctx?.db ? ctx : { ...(ctx ?? {}), readOnly: true };
51
56
  }
52
- /** Recent `feedback` lines for `ref` (or across all assets without one). Best-effort. */
53
- function readRecentFeedback(ref, eventsCtx) {
57
+ /**
58
+ * Recent `feedback` lines for `ref` (or across all assets without one). Given the
59
+ * asset's current content, a line whose event recorded the hash of a different
60
+ * body is marked. Best-effort.
61
+ */
62
+ function readRecentFeedback(ref, eventsCtx, assetContent) {
54
63
  try {
55
64
  const events = readEvents({ type: "feedback", ...(ref ? { ref } : {}) }, readOnlyEventsContext(eventsCtx)).events;
65
+ const bodyHash = assetContent === undefined ? undefined : contentHash(assetContent, "body");
56
66
  return events.slice(-(ref ? MAX_FEEDBACK_LINES : MAX_GLOBAL_FEEDBACK_LINES)).map((event) => {
57
67
  const md = event.metadata ?? {};
58
68
  const signal = typeof md.signal === "string" ? md.signal : "?";
59
69
  const note = typeof md.reason === "string" ? md.reason : typeof md.note === "string" ? md.note : "";
60
70
  const details = note ? `[${signal}] ${note}` : `[${signal}]`;
61
- return !ref && event.ref ? `${event.ref} ${details}` : details;
71
+ const line = !ref && event.ref ? `${event.ref} ${details}` : details;
72
+ return bodyHash !== undefined && typeof md.contentHash === "string" && md.contentHash !== bodyHash
73
+ ? `${line}${EARLIER_TEXT_MARK}`
74
+ : line;
62
75
  });
63
76
  }
64
77
  catch {
@@ -237,7 +250,7 @@ export const REFLECT_JSON_SCHEMA = {
237
250
  frontmatterPatch: REFLECT_FRONTMATTER_PATCH_JSON_SCHEMA,
238
251
  },
239
252
  };
240
- const REFLECT_UNSCOPED_JSON_SCHEMA = {
253
+ export const REFLECT_UNSCOPED_JSON_SCHEMA = {
241
254
  type: "object",
242
255
  required: ["ref", "confidence", "frontmatterPatch"],
243
256
  additionalProperties: false,
@@ -528,6 +541,24 @@ function unsupportedTypeFailure(ref, type, detail, emitFailed) {
528
541
  },
529
542
  };
530
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
+ }
531
562
  /** The target's parsed ref and current content, or a refusal for a type reflect cannot patch. */
532
563
  async function resolveReflectSource(options, stash, emitFailed) {
533
564
  if (!options.ref)
@@ -538,18 +569,21 @@ async function resolveReflectSource(options, stash, emitFailed) {
538
569
  return unsupportedTypeFailure(options.ref, parsedRef.type, "secret material is never read or sent to an LLM", emitFailed);
539
570
  }
540
571
  let assetContent = options.assetContent;
572
+ let assetFile;
541
573
  if (assetContent === undefined) {
542
574
  try {
543
575
  const qualifiedRef = options.itemRef ?? options.ref;
544
576
  const localFilePath = await findAssetFilePath(qualifiedRef, stash);
545
577
  if (localFilePath && fs.existsSync(localFilePath)) {
546
- assetContent = fs.readFileSync(localFilePath, "utf8");
578
+ assetFile = localFilePath;
547
579
  }
548
580
  else {
549
581
  const entry = await lookup(parseRefInput(qualifiedRef));
550
582
  if (entry?.filePath && fs.existsSync(entry.filePath))
551
- assetContent = fs.readFileSync(entry.filePath, "utf8");
583
+ assetFile = entry.filePath;
552
584
  }
585
+ if (assetFile !== undefined)
586
+ assetContent = fs.readFileSync(assetFile, "utf8");
553
587
  }
554
588
  catch {
555
589
  // An index miss is not fatal: reflect then has no content to patch.
@@ -559,6 +593,15 @@ async function resolveReflectSource(options, stash, emitFailed) {
559
593
  (assetContent === undefined || parseFrontmatter(assetContent).frontmatter === null)) {
560
594
  return unsupportedTypeFailure(options.ref, parsedRef.type, "its content is not frontmatter + markdown", emitFailed);
561
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
+ }
562
605
  return { assetContent, parsedRef };
563
606
  }
564
607
  /**
@@ -636,7 +679,7 @@ function computeReflectContentBudgetChars(promptInput, runnerSpec) {
636
679
  /** Every read-only prompt input, shared by dispatch and `--show-prompt`. */
637
680
  function gatherReflectPromptSources(options, stash, parsedRef, assetContent) {
638
681
  return {
639
- feedback: readRecentFeedback(options.ref ? (options.itemRef ?? options.ref) : undefined, options.eventsCtx),
682
+ feedback: readRecentFeedback(options.ref ? (options.itemRef ?? options.ref) : undefined, options.eventsCtx, assetContent),
640
683
  schemaHints: buildSchemaHints(parsedRef?.type ?? "", assetContent),
641
684
  rejectedProposals: rejectedProposalContext(stash, options.ref, options.ctx, options.eventsCtx),
642
685
  standardsContext: resolveStandardsContext(options.ref, stash),
@@ -26,7 +26,7 @@ const MAX_QUERIES = 5;
26
26
  const MAX_QUERY_CHARS = 2000;
27
27
  /** The judge sees this much of the body, as in the retrieval eval. */
28
28
  const MAX_DOC_CHARS = 1500;
29
- const GRADE_SCHEMA = {
29
+ export const GRADE_SCHEMA = {
30
30
  type: "object",
31
31
  required: ["grade", "reason"],
32
32
  additionalProperties: false,
@@ -14,9 +14,10 @@ import { assembleAsset } from "../../core/asset/asset-serialize.js";
14
14
  import { conceptIdFromTypeName } from "../../core/asset/resolve-ref.js";
15
15
  import { parseEmbeddedJsonResponse } from "../../core/parse.js";
16
16
  import { recordWrittenPath } from "../../core/write-provenance.js";
17
+ // Every property is required, as a strict structured-output provider needs; an empty `tags` array is none.
17
18
  export const SESSION_SUMMARY_JSON_SCHEMA = {
18
19
  type: "object",
19
- required: ["summary", "key_topics"],
20
+ required: ["summary", "key_topics", "tags"],
20
21
  additionalProperties: false,
21
22
  properties: {
22
23
  summary: { type: "string" },
@@ -61,7 +62,7 @@ export function buildSessionSummaryPrompt(data) {
61
62
  "Transcript:",
62
63
  renderTranscriptForSummary(data.events),
63
64
  "",
64
- 'Respond as JSON: {"summary": string, "key_topics": string[], "tags"?: string[]}.',
65
+ 'Respond as JSON: {"summary": string, "key_topics": string[], "tags": string[]} (an empty array for no tags).',
65
66
  ].join("\n");
66
67
  }
67
68
  /** The summary JSON, tolerating prose around it; `undefined` when nothing usable parses. */
@@ -370,7 +370,7 @@ function parseJudgeResponse(raw, keys) {
370
370
  }
371
371
  return inRange(parsed.score) ? { score: parsed.score, lowest: parsed.score, reason } : undefined;
372
372
  }
373
- function judgeResponseSchema(keys) {
373
+ export function judgeResponseSchema(keys) {
374
374
  return {
375
375
  type: "object",
376
376
  required: ["scores", "reason"],
@@ -13,12 +13,13 @@ import { randomUUID } from "node:crypto";
13
13
  import fs from "node:fs";
14
14
  import os from "node:os";
15
15
  import path from "node:path";
16
+ import { isDeepStrictEqual } from "node:util";
16
17
  import { parse as parseYaml } from "yaml";
17
18
  import { ensureAkmMarkdownType } from "../../core/asset/akm-markdown.js";
18
19
  import { assetPathForName, placementTypes, stashDirFor } from "../../core/asset/asset-placement.js";
19
20
  import { isBundleSlug, parseBundleRef } from "../../core/asset/asset-ref.js";
20
21
  import { assembleAsset, serializeFrontmatter } from "../../core/asset/asset-serialize.js";
21
- import { carryForwardBookkeepingFrontmatter, parseFrontmatter } from "../../core/asset/frontmatter.js";
22
+ import { carryForwardBookkeepingFrontmatter, parseFrontmatter, replaceFrontmatterBlocks, } from "../../core/asset/frontmatter.js";
22
23
  import { conceptIdFromTypeName, parseRefInput } from "../../core/asset/resolve-ref.js";
23
24
  import { loadConfig } from "../../core/config/config.js";
24
25
  import { ConfigError, NotFoundError, rethrowIfTestIsolationError, UsageError } from "../../core/errors.js";
@@ -883,8 +884,17 @@ function isPlainRecord(value) {
883
884
  * Stamp provenance onto a promoted asset's frontmatter: bare top-level
884
885
  * `generated` and `verified` (as OKF v0.2 spells them; `verified` accumulates),
885
886
  * and `sources` under `provenance:`, since a bare `sources:` is the wiki
886
- * citation-string convention. An existing frontmatter block keeps its raw body
887
- * bytes.
887
+ * citation-string convention.
888
+ *
889
+ * Source preservation: an existing frontmatter block is edited as text and not
890
+ * written out again through the YAML serializer, which rewraps long values,
891
+ * requotes and drops comments in lines the stamp never touched. The blocks of
892
+ * the keys the stamp sets (`generated`, `verified`, `provenance`) are removed
893
+ * wherever they were and written again, serialized, as the last lines of the
894
+ * frontmatter; every other byte, the body included, stays as written. The
895
+ * edited text is parsed back to confirm it holds exactly the intended mapping;
896
+ * when it does not (an indented `---` inside a block scalar), the frontmatter is
897
+ * written out again instead, as it always used to be.
888
898
  */
889
899
  function stampProposalProvenance(content, proposal, gateDecision, ctx, nowIsoStr) {
890
900
  const parsed = parseFrontmatter(content);
@@ -917,9 +927,15 @@ function stampProposalProvenance(content, proposal, gateDecision, ctx, nowIsoStr
917
927
  fm.provenance = provenance;
918
928
  else
919
929
  delete fm.provenance;
920
- return parsed.frontmatter !== null
921
- ? `---\n${serializeFrontmatter(fm)}\n---\n${parsed.content}`
922
- : assembleAsset(fm, parsed.content);
930
+ if (parsed.frontmatter === null)
931
+ return assembleAsset(fm, parsed.content);
932
+ const stamped = { generated: fm.generated, verified: fm.verified };
933
+ if (fm.provenance !== undefined)
934
+ stamped.provenance = fm.provenance;
935
+ const edited = replaceFrontmatterBlocks(content, ["generated", "verified", "provenance"], serializeFrontmatter(stamped).split("\n"));
936
+ if (edited !== null && isDeepStrictEqual(parseFrontmatter(edited).data, fm))
937
+ return edited;
938
+ return `---\n${serializeFrontmatter(fm)}\n---\n${parsed.content}`;
923
939
  }
924
940
  /**
925
941
  * Validate, stamp and write an accepted proposal into its bound target, then
@@ -1,11 +1,12 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
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
+ import { isDeepStrictEqual } from "node:util";
4
5
  import { parse as parseYaml } from "yaml";
5
6
  import { localDateStamp } from "../common.js";
6
7
  import { UsageError } from "../errors.js";
7
8
  import { serializeFrontmatter } from "./asset-serialize.js";
8
- import { parseFrontmatterBlock, spliceFrontmatterLine } from "./frontmatter.js";
9
+ import { parseFrontmatterBlock, replaceFrontmatterLine, spliceFrontmatterLine } from "./frontmatter.js";
9
10
  /**
10
11
  * Ensure an AKM-authored Markdown concept is also a conformant OKF concept.
11
12
  *
@@ -20,12 +21,18 @@ import { parseFrontmatterBlock, spliceFrontmatterLine } from "./frontmatter.js";
20
21
  * re-stamp on every write (which would churn timestamps and manufacture
21
22
  * needless diffs in git-backed bundles).
22
23
  *
23
- * Source preservation: when the type already matches and the ONLY change is
24
- * adding `updated`, the line is spliced into the original block textually —
25
- * round-tripping through the YAML serializer would drop user-authored
26
- * comments and normalize formatting just to contribute one field. Only a
27
- * document whose `type` must actually be corrected takes the re-serialize
28
- * path (as it always has).
24
+ * Source preservation: whenever the frontmatter block parses, it is edited as
25
+ * text and never round-tripped through the YAML serializer, which rewraps long
26
+ * values, reorders keys and drops comments — changes nobody made, shown to the
27
+ * reviewer of what may be a one-line correction. A missing `type` and a
28
+ * missing `updated` are each added as one line before the closing `---`, in
29
+ * that order; a wrong `type` is replaced on its own line. Every other byte —
30
+ * comments, wrapping, quoting, key order, line endings, the body — is kept as
31
+ * written. The edited text is parsed back to confirm it holds exactly the
32
+ * intended mapping; when it does not (a `type` value that spans several lines,
33
+ * a flow-style `{…}` block), the document is re-serialized instead, as it
34
+ * always used to be. A document with no frontmatter block gets a new one;
35
+ * malformed YAML throws.
29
36
  */
30
37
  export function ensureAkmMarkdownType(content, type, now = new Date()) {
31
38
  const block = parseFrontmatterBlock(content);
@@ -45,19 +52,36 @@ export function ensureAkmMarkdownType(content, type, now = new Date()) {
45
52
  throw new UsageError("AKM Markdown frontmatter must be a YAML mapping.", "INVALID_FLAG_VALUE");
46
53
  }
47
54
  const data = parsed;
55
+ const updated = localDateStamp(now);
48
56
  const needsUpdated = !("updated" in data);
49
- if (data.type === type) {
50
- if (!needsUpdated)
51
- return content;
52
- const spliced = spliceFrontmatterLine(content, `updated: ${localDateStamp(now)}`);
53
- if (spliced !== null)
54
- return spliced;
55
- // Unreachable in practice (parseFrontmatterBlock succeeded above), but a
56
- // re-serialized document beats a non-conformant one.
57
+ if (data.type === type && !needsUpdated)
58
+ return content;
59
+ let edited = content;
60
+ if (data.type !== type) {
61
+ edited =
62
+ "type" in data
63
+ ? replaceFrontmatterLine(edited, "type", `type: ${type}`)
64
+ : spliceFrontmatterLine(edited, `type: ${type}`);
57
65
  }
66
+ if (edited !== null && needsUpdated)
67
+ edited = spliceFrontmatterLine(edited, `updated: ${updated}`);
68
+ const intended = needsUpdated ? { ...data, type, updated } : { ...data, type };
69
+ if (edited !== null && parsesTo(edited, intended))
70
+ return edited;
71
+ // The text edit could not be done safely, but a re-serialized document
72
+ // beats a non-conformant one.
58
73
  const { type: _priorType, ...rest } = data;
59
74
  const next = { type, ...rest };
60
75
  if (needsUpdated)
61
- next.updated = localDateStamp(now);
76
+ next.updated = updated;
62
77
  return `---\n${serializeFrontmatter(next)}\n---\n${block.content}`;
63
78
  }
79
+ /** True when the frontmatter of `text` parses to exactly `intended`. */
80
+ function parsesTo(text, intended) {
81
+ try {
82
+ return isDeepStrictEqual(parseYaml(parseFrontmatterBlock(text)?.frontmatter ?? ""), intended);
83
+ }
84
+ catch {
85
+ return false;
86
+ }
87
+ }