akm-cli 0.9.16 → 0.9.17-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 (50) hide show
  1. package/CHANGELOG.md +504 -0
  2. package/dist/assets/prompts/consolidate-system.md +4 -11
  3. package/dist/assets/prompts/graph-extract-user-prompt.md +5 -5
  4. package/dist/commands/health/accept-rate.js +6 -0
  5. package/dist/commands/health/checks.js +54 -0
  6. package/dist/commands/health/improve-metrics.js +1 -5
  7. package/dist/commands/health/report-view-model.js +0 -1
  8. package/dist/commands/health.js +10 -0
  9. package/dist/commands/improve/consolidate/chunking.js +19 -35
  10. package/dist/commands/improve/consolidate/merge.js +6 -9
  11. package/dist/commands/improve/consolidate.js +104 -91
  12. package/dist/commands/improve/distill/promote-memory.js +40 -2
  13. package/dist/commands/improve/distill/quality-gate.js +186 -23
  14. package/dist/commands/improve/distill.js +42 -8
  15. package/dist/commands/improve/eligibility.js +13 -3
  16. package/dist/commands/improve/improve-cli.js +32 -9
  17. package/dist/commands/improve/improve-strategies.js +23 -1
  18. package/dist/commands/improve/improve.js +121 -84
  19. package/dist/commands/improve/loop-stages.js +241 -108
  20. package/dist/commands/improve/preparation.js +50 -17
  21. package/dist/commands/improve/reflect.js +16 -5
  22. package/dist/commands/improve/shared.js +0 -10
  23. package/dist/commands/proposal/drain.js +79 -10
  24. package/dist/commands/proposal/proposal-types.js +21 -0
  25. package/dist/commands/proposal/repository.js +108 -29
  26. package/dist/commands/tasks/tasks.js +19 -2
  27. package/dist/core/asset/frontmatter.js +106 -1
  28. package/dist/core/config/config.js +5 -2
  29. package/dist/core/config/retired-experimental-keys-shim.js +62 -0
  30. package/dist/core/config/schema/improve-processes.js +29 -2
  31. package/dist/core/improve-result.js +9 -0
  32. package/dist/core/paths.js +7 -0
  33. package/dist/core/write-source.js +10 -2
  34. package/dist/indexer/ensure-index.js +52 -7
  35. package/dist/indexer/graph/graph-extraction.js +82 -8
  36. package/dist/indexer/passes/memory-inference.js +16 -1
  37. package/dist/llm/client.js +16 -2
  38. package/dist/llm/graph-extract.js +162 -18
  39. package/dist/scripts/akm-migrate-node.js +97 -36
  40. package/dist/scripts/akm-migrate.js +97 -36
  41. package/dist/storage/repositories/index-entries-repository.js +43 -0
  42. package/dist/storage/repositories/proposals-repository.js +4 -1
  43. package/dist/storage/state-db-integrity.js +123 -0
  44. package/dist/workflows/program/schema.js +1 -0
  45. package/docs/reference/cli.md +17 -7
  46. package/docs/reference/data-and-telemetry.md +1 -0
  47. package/package.json +1 -1
  48. package/schemas/akm-config.json +44 -0
  49. package/schemas/akm-workflow.json +1 -0
  50. package/dist/commands/improve/eval-cases.js +0 -52
@@ -43,7 +43,7 @@ import { ensureAkmMarkdownType } from "../../core/asset/akm-markdown.js";
43
43
  import { assetPathForName, placementTypes, stashDirFor } from "../../core/asset/asset-placement.js";
44
44
  import { isBundleSlug, parseBundleRef } from "../../core/asset/asset-ref.js";
45
45
  import { assembleAsset, serializeFrontmatter } from "../../core/asset/asset-serialize.js";
46
- import { parseFrontmatter } from "../../core/asset/frontmatter.js";
46
+ import { carryForwardBookkeepingFrontmatter, computeNormalizedContentHash, parseFrontmatter, } from "../../core/asset/frontmatter.js";
47
47
  import { conceptIdFromTypeName, parseRefInput } from "../../core/asset/resolve-ref.js";
48
48
  import { isWithin } from "../../core/common.js";
49
49
  import { loadConfig } from "../../core/config/config.js";
@@ -66,7 +66,7 @@ import { openSqliteReadSnapshot } from "../../storage/sqlite-read-snapshot.js";
66
66
  import { pkgVersion } from "../../version.js";
67
67
  import { runBaseChecks } from "../lint/base-linter.js";
68
68
  import { formatNewAssetDiff, formatUnifiedDiff } from "./diff-format.js";
69
- import { isAutomatedProposalSource, isValidProposalSource, PROPOSAL_SOURCES, } from "./proposal-types.js";
69
+ import { isAutomatedProposalSource, isStaleTargetRejection, isValidProposalSource, PROPOSAL_SOURCES, } from "./proposal-types.js";
70
70
  import { canonicalOnlyProposalValidators, hasCanonicalProposalValidator, runProposalValidators, } from "./validators/proposal-validators.js";
71
71
  import { repairProposalContent, validateProposal } from "./validators/proposals.js";
72
72
  const PROMOTION_LINT_ISSUE_TYPES = new Set(["unquoted-colon", "missing-ref", "stale-path"]);
@@ -241,6 +241,40 @@ export function resolveProposalQueueTarget(stashDir, config = loadConfig()) {
241
241
  }
242
242
  return { source: bundleId, root };
243
243
  }
244
+ /**
245
+ * Resolve the durable target ref, target root/rel-path, and current
246
+ * before-hash for a parsed proposal ref (WI-6.2). This is the exact
247
+ * computation `createProposal` uses to derive its mint-time `beforeHash`
248
+ * fingerprint term; {@link checkProposalGuard} (R9) calls it too, so a
249
+ * pre-generation guard check and `createProposal`'s post-generation check
250
+ * always agree on what "these inputs" means for the same ref/target.
251
+ */
252
+ function resolveProposalTargetInfo(stashDir, parsedRef, explicitTarget) {
253
+ const proposalTarget = resolveCreateProposalTarget(stashDir, explicitTarget, parsedRef.origin);
254
+ const normalizedRef = proposalDurableRef(parsedRef, proposalTarget);
255
+ const targetRoot = path.resolve(proposalTarget.root);
256
+ let targetRelPath;
257
+ let mintBeforeContent;
258
+ try {
259
+ const typeRoot = path.join(targetRoot, stashDirFor(parsedRef.type));
260
+ const targetAbs = assetPathForName(parsedRef.type, typeRoot, parsedRef.name);
261
+ targetRelPath = path.relative(targetRoot, targetAbs);
262
+ if (fs.existsSync(targetAbs))
263
+ mintBeforeContent = fs.readFileSync(targetAbs, "utf8");
264
+ }
265
+ catch {
266
+ // Resolution failure degrades to a best-effort create — never blocks the mint.
267
+ targetRelPath = path.join(stashDirFor(parsedRef.type), parsedRef.name);
268
+ }
269
+ return {
270
+ proposalTarget,
271
+ normalizedRef,
272
+ targetRoot,
273
+ targetRelPath,
274
+ beforeHash: mintBeforeContent !== undefined ? contentHash(mintBeforeContent) : undefined,
275
+ beforeHashNormalized: mintBeforeContent !== undefined ? computeNormalizedContentHash(mintBeforeContent) : undefined,
276
+ };
277
+ }
244
278
  // ── Public API ──────────────────────────────────────────────────────────────
245
279
  /**
246
280
  * Create a new pending proposal. The id is a stable random UUID, so two
@@ -310,27 +344,12 @@ export function createProposal(stashDir, input, ctx) {
310
344
  return rejectProposal("missing_description", `Proposal for "${input.ref}" (source=consolidate) has empty or missing frontmatter description.`);
311
345
  }
312
346
  }
313
- const proposalTarget = resolveCreateProposalTarget(stashDir, input.target, parsedRef.origin);
314
- const normalizedRef = proposalDurableRef(parsedRef, proposalTarget);
315
- const targetRoot = path.resolve(proposalTarget.root);
316
347
  // WI-6.2: derive the FileChange[] envelope + mint-time beforeHash. The
317
348
  // target is resolved against the proposal's OWN stash (a local snapshot —
318
349
  // accept re-resolves the write target from config at apply time), and only
319
350
  // the before-state's HASH is kept: the change's `before` body is a
320
351
  // transaction-time capture that does not exist at mint time.
321
- let targetRelPath;
322
- let mintBeforeContent;
323
- try {
324
- const typeRoot = path.join(targetRoot, stashDirFor(parsedRef.type));
325
- const targetAbs = assetPathForName(parsedRef.type, typeRoot, parsedRef.name);
326
- targetRelPath = path.relative(targetRoot, targetAbs);
327
- if (fs.existsSync(targetAbs))
328
- mintBeforeContent = fs.readFileSync(targetAbs, "utf8");
329
- }
330
- catch {
331
- // Resolution failure degrades to a best-effort create — never blocks the mint.
332
- targetRelPath = path.join(stashDirFor(parsedRef.type), parsedRef.name);
333
- }
352
+ const { proposalTarget, normalizedRef, targetRoot, targetRelPath, beforeHash: mintedBeforeHash, beforeHashNormalized: mintedBeforeHashNormalized, } = resolveProposalTargetInfo(stashDir, parsedRef, input.target);
334
353
  const proposalContent = targetRelPath.toLowerCase().endsWith(".md")
335
354
  ? ensureAkmMarkdownType(input.payload.content, parsedRef.type)
336
355
  : input.payload.content;
@@ -338,10 +357,9 @@ export function createProposal(stashDir, input, ctx) {
338
357
  {
339
358
  path: targetRelPath,
340
359
  after: proposalContent,
341
- op: mintBeforeContent !== undefined ? "update" : "create",
360
+ op: mintedBeforeHash !== undefined ? "update" : "create",
342
361
  },
343
362
  ];
344
- const mintedBeforeHash = mintBeforeContent !== undefined ? contentHash(mintBeforeContent) : undefined;
345
363
  if (hasCanonicalProposalValidator(parsedRef.type)) {
346
364
  // Mint-time gate: structural shape only (generic + canonical-per-type),
347
365
  // NOT the full quality-validator list — see canonicalOnlyProposalValidators'
@@ -374,7 +392,7 @@ export function createProposal(stashDir, input, ctx) {
374
392
  return withProposalsDb(stashDir, ctx, (db) => {
375
393
  return withImmediateTransaction(db, () => {
376
394
  if (!input.force) {
377
- const skip = checkFingerprintAndBackoff(db, stashDir, normalizedRef, input, fingerprint, ctx);
395
+ const skip = checkFingerprintAndBackoff(db, stashDir, normalizedRef, input.source, fingerprint, ctx);
378
396
  if (skip)
379
397
  return skip;
380
398
  }
@@ -403,6 +421,7 @@ export function createProposal(stashDir, input, ctx) {
403
421
  changes: mintedChanges,
404
422
  proposedTarget: { source: proposalTarget.source, root: targetRoot },
405
423
  ...(mintedBeforeHash !== undefined ? { beforeHash: mintedBeforeHash } : {}),
424
+ ...(mintedBeforeHashNormalized !== undefined ? { beforeHashNormalized: mintedBeforeHashNormalized } : {}),
406
425
  ...(sanitizedConfidence !== undefined ? { confidence: sanitizedConfidence } : {}),
407
426
  // Attribution tagging: persist the eligibility lane so it survives to
408
427
  // accept/reject/revert time. See EligibilitySource.
@@ -455,9 +474,9 @@ function recordProposalFingerprint(db, stashDir, fingerprint, ref, input, propos
455
474
  * Evaluate the fingerprint + rejection-backoff guards. Returns the skip
456
475
  * record when a guard fires, or undefined when the create may proceed.
457
476
  */
458
- function checkFingerprintAndBackoff(db, stashDir, normalizedRef, input, fingerprint, ctx) {
477
+ function checkFingerprintAndBackoff(db, stashDir, normalizedRef, source, fingerprint, ctx) {
459
478
  const nowMs = (ctx?.now ?? Date.now)();
460
- const backoffMs = cooldownMsForSource(input.source);
479
+ const backoffMs = cooldownMsForSource(source);
461
480
  // §23.6: an already-processed fingerprint skips another model call's output
462
481
  // unless explicitly forced. The row survives the proposal's lifecycle —
463
482
  // identical inputs stay deduplicated until the target (before-hash), the
@@ -474,9 +493,13 @@ function checkFingerprintAndBackoff(db, stashDir, normalizedRef, input, fingerpr
474
493
  };
475
494
  }
476
495
  // Rejection backoff (RETAINED cooldown semantics): a recent rejection for
477
- // this ref+source suppresses new proposals until the window expires.
496
+ // this ref+source suppresses new proposals until the window expires. A
497
+ // stale-target auto-reject (STALE, R20) is excluded — it is not a
498
+ // judgement on the content, and counting it here would suppress a
499
+ // legitimate re-propose against the ref's now-current content.
478
500
  const rejected = listStateProposals(db, { stashDir, ref: normalizedRef, status: "rejected" })
479
- .filter((p) => p.source === input.source)
501
+ .filter((p) => p.source === source)
502
+ .filter((p) => !isStaleTargetRejection(p))
480
503
  .sort((a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime());
481
504
  const mostRecent = rejected[0];
482
505
  if (mostRecent !== undefined) {
@@ -487,7 +510,7 @@ function checkFingerprintAndBackoff(db, stashDir, normalizedRef, input, fingerpr
487
510
  return {
488
511
  skipped: true,
489
512
  reason: "rejection_backoff",
490
- message: `Proposal for ${normalizedRef} from source "${input.source}" is in rejection backoff ` +
513
+ message: `Proposal for ${normalizedRef} from source "${source}" is in rejection backoff ` +
491
514
  `(${backoffDays}d window, ~${remainingDays}d remaining). Pass force:true to bypass.`,
492
515
  existingProposalId: mostRecent.id,
493
516
  };
@@ -495,6 +518,36 @@ function checkFingerprintAndBackoff(db, stashDir, normalizedRef, input, fingerpr
495
518
  }
496
519
  return undefined;
497
520
  }
521
+ /**
522
+ * R9 (tier2-0917): pure pre-generation check of the fingerprint-match /
523
+ * rejection-backoff guard — the same computation `createProposal` runs AFTER
524
+ * generation (`checkFingerprintAndBackoff`), exposed so a caller can skip an
525
+ * expensive LLM call BEFORE making it. Shares {@link resolveProposalTargetInfo}
526
+ * and {@link checkFingerprintAndBackoff} verbatim with `createProposal`, so
527
+ * the two can never disagree about what "these inputs" means for the same
528
+ * ref/source/target/model. `createProposal`'s post-generation check remains
529
+ * the authoritative gate: this is a best-effort optimisation that fails open
530
+ * (returns `undefined`, i.e. "not skipped") on any resolution error — an
531
+ * unresolvable target here must never block the real dispatch.
532
+ */
533
+ export function checkProposalGuard(input, ctx) {
534
+ try {
535
+ const parsedRef = parseRefInput(input.ref);
536
+ if (!stashDirFor(parsedRef.type))
537
+ return undefined;
538
+ const { normalizedRef, beforeHash } = resolveProposalTargetInfo(input.stash, parsedRef, input.target);
539
+ const fingerprint = computeProposalFingerprint({
540
+ ref: normalizedRef,
541
+ source: input.source,
542
+ ...(beforeHash !== undefined ? { beforeHash } : {}),
543
+ ...(input.modelId !== undefined ? { modelId: input.modelId } : {}),
544
+ });
545
+ return withProposalsDb(input.stash, ctx, (db) => checkFingerprintAndBackoff(db, input.stash, normalizedRef, input.source, fingerprint, ctx));
546
+ }
547
+ catch {
548
+ return undefined;
549
+ }
550
+ }
498
551
  /**
499
552
  * List proposals for one stash. By default returns only the live (pending)
500
553
  * queue; pass `{ includeArchive: true }` to include accepted / rejected /
@@ -1571,9 +1624,24 @@ export function preflightProposalPromotion(config, proposal, options = {}, ctx)
1571
1624
  if (!assetPath)
1572
1625
  throw new UsageError(`Cannot resolve proposal target ${preparedProposal.ref}.`, "INVALID_PROPOSAL");
1573
1626
  assertAkmAssetWrite(target.source);
1574
- const stampedContent = assetPath.toLowerCase().endsWith(".md")
1627
+ let stampedContent = assetPath.toLowerCase().endsWith(".md")
1575
1628
  ? stampProposalProvenance(repairedContent, preparedProposal, options.gateDecision, ctx, nowIso(ctx))
1576
1629
  : repairedContent;
1630
+ if (assetPath.toLowerCase().endsWith(".md") && fs.existsSync(assetPath)) {
1631
+ // STALE (R20): carry the live target's akm bookkeeping frontmatter
1632
+ // forward when the proposal's own frontmatter doesn't set it, so
1633
+ // promoting never drops e.g. `inferenceProcessed` and makes memory
1634
+ // inference reprocess the memory. Best-effort — an unreadable live file
1635
+ // here doesn't block promotion; the freshness guard below is the real
1636
+ // gate on staleness.
1637
+ try {
1638
+ const liveRaw = fs.readFileSync(assetPath, "utf8");
1639
+ stampedContent = carryForwardBookkeepingFrontmatter(stampedContent, liveRaw);
1640
+ }
1641
+ catch {
1642
+ // Best-effort — see comment above.
1643
+ }
1644
+ }
1577
1645
  const lintBlockers = promotionLintBlockers(stampedContent, assetPath, target.source.path, ref.type, config);
1578
1646
  if (lintBlockers.length > 0) {
1579
1647
  const summary = lintBlockers.map((finding) => `[${finding.issue}] ${finding.detail}`).join("; ");
@@ -1707,8 +1775,19 @@ async function promoteProposalWithLease(stashDir, config, id, options, ctx) {
1707
1775
  throw new Error(`Proposal backup read failed for ${assetPath}: ${error instanceof Error ? error.message : String(error)}`);
1708
1776
  }
1709
1777
  }
1710
- if (proposal.beforeHash !== undefined && (!backup || proposalHash(backup) !== proposal.beforeHash)) {
1711
- throw new UsageError(`Proposal target changed after proposal ${id} was created; refusing to overwrite newer content.`, "INVALID_FLAG_VALUE");
1778
+ if (proposal.beforeHash !== undefined) {
1779
+ // STALE (R20): a proposal minted with a normalized before-hash is fresh
1780
+ // when the CURRENT target's bookkeeping-stripped content still matches —
1781
+ // insensitive to a same-run bookkeeping rewrite (salience scoring,
1782
+ // inference dedup marking) of the target after mint. A legacy proposal
1783
+ // without one keeps the exact raw-hash check it always had.
1784
+ const fresh = proposal.beforeHashNormalized !== undefined
1785
+ ? backup !== undefined &&
1786
+ computeNormalizedContentHash(backup.toString("utf8")) === proposal.beforeHashNormalized
1787
+ : backup !== undefined && proposalHash(backup) === proposal.beforeHash;
1788
+ if (!fresh) {
1789
+ throw new UsageError(`Proposal target changed after proposal ${id} was created; refusing to overwrite newer content.`, "INVALID_FLAG_VALUE");
1790
+ }
1712
1791
  }
1713
1792
  if (proposal.beforeHash === undefined &&
1714
1793
  backup !== undefined &&
@@ -23,7 +23,7 @@ import { IMPROVE_AUTONOMY_CONFIG_KEY, isImproveAutonomyEnabled } from "../../cor
23
23
  import { ConfigError, NotFoundError, UsageError } from "../../core/errors.js";
24
24
  import { getTaskHistoryDir, getTaskLogDir } from "../../core/paths.js";
25
25
  import { warn } from "../../core/warn.js";
26
- import { commitWriteTargetBoundary, deleteAssetFromSource, prepareWriteTargetForMutation, resolveWorkingStashTarget, resolveWriteTarget, writeAssetToSource, } from "../../core/write-source.js";
26
+ import { commitWriteTargetBoundary, deleteAssetFromSource, isWriteCapableSourceKind, prepareWriteTargetForMutation, resolveWorkingStashTarget, resolveWriteTarget, writeAssetToSource, } from "../../core/write-source.js";
27
27
  import { withEngineFallback } from "../../integrations/agent/engine-fallback.js";
28
28
  import { resolveAssetPath } from "../../sources/resolve.js";
29
29
  import { activeSchedulerActivations, isSchedulerRefEnabled, setSchedulerRefEnabled, } from "../../tasks/activation-config.js";
@@ -411,7 +411,24 @@ async function buildSchedulerSyncPlan(deps, bundleTarget, options) {
411
411
  const nativeArtifacts = inspection.artifacts;
412
412
  const configuredSources = resolveConfiguredSources(config);
413
413
  const activeSources = resolveActiveConfiguredSources(config);
414
- const sourceNames = bundleTarget ? [bundleTarget] : activeSources.map((source) => source.name);
414
+ if (bundleTarget) {
415
+ // adaptConfiguredSource (src/core/write-source.ts) rejects any kind
416
+ // other than filesystem/git outright, so a website/npm bundle can never
417
+ // carry scheduler state (akm task enable already fails the same way).
418
+ // Surface that as a clear usage error here instead of letting the
419
+ // write-target resolution below raise a generic ConfigError.
420
+ const targetSource = activeSources.find((source) => source.name === bundleTarget);
421
+ if (targetSource && !isWriteCapableSourceKind(targetSource.type)) {
422
+ throw new UsageError(`Bundle "${bundleTarget}" has kind "${targetSource.type}"; task scheduling is only supported for filesystem and git bundles.`, "INVALID_FLAG_VALUE");
423
+ }
424
+ }
425
+ // Unscoped sync only installs/removes bindings for bundles that can carry
426
+ // them (filesystem/git). A website/npm bundle contributes no installs and
427
+ // must not crash the loop; inactiveOperations below still sees it via
428
+ // configuredSources for removal/revocation.
429
+ const sourceNames = bundleTarget
430
+ ? [bundleTarget]
431
+ : activeSources.filter((source) => isWriteCapableSourceKind(source.type)).map((source) => source.name);
415
432
  const inactiveOperations = bundleTarget
416
433
  ? []
417
434
  : inactiveBundleRemovalOperations(config, configuredSources, allEntries, nativeArtifacts);
@@ -8,11 +8,12 @@
8
8
  * (block scalars, multi-line strings, nested objects, flow sequences, escape
9
9
  * sequences) is handled correctly without a brittle hand-rolled state machine.
10
10
  */
11
+ import { createHash } from "node:crypto";
11
12
  import fs from "node:fs";
12
13
  import { parse as yamlParse, stringify as yamlStringify } from "yaml";
13
14
  import { existingFileMode, writeFileAtomic } from "../common.js";
14
15
  import { recordWrittenPath } from "../write-provenance.js";
15
- import { assembleAsset, serializeFrontmatter } from "./asset-serialize.js";
16
+ import { assembleAsset, assembleAssetFromString, serializeFrontmatter } from "./asset-serialize.js";
16
17
  /**
17
18
  * Parse YAML frontmatter from a Markdown (or similar) string.
18
19
  *
@@ -451,6 +452,110 @@ export function parseYamlScalar(value) {
451
452
  }
452
453
  return value;
453
454
  }
455
+ // ── Bookkeeping-insensitive freshness (STALE, R20) ────────────────────────────
456
+ /**
457
+ * Frontmatter keys akm's own improve/index passes write into an EXISTING
458
+ * asset as pipeline bookkeeping — never a change to the asset's authored
459
+ * meaning. `salience`/`salienceInputs` come from {@link writeSalienceToFrontmatter}
460
+ * (distill); `inferenceProcessed` comes from memory inference
461
+ * (`indexer/passes/memory-inference.ts`). A pending proposal's mint-time
462
+ * before-hash is sensitive to these rewrites even though nothing a reviewer
463
+ * or the proposal's own diff cares about actually changed, which stales out
464
+ * proposals that a same-run bookkeeping pass touches after mint. See
465
+ * {@link computeNormalizedContentHash} and {@link carryForwardBookkeepingFrontmatter}.
466
+ *
467
+ * Deliberately excludes `beliefState`/`contradictedBy`/`supersededBy`: those
468
+ * are editorial state (contradiction/supersession demotions), not derived
469
+ * audit metadata — a promote should still refuse when one of those changed
470
+ * underneath a pending proposal.
471
+ */
472
+ export const BOOKKEEPING_FRONTMATTER_KEYS = ["salience", "salienceInputs", "inferenceProcessed"];
473
+ function sha256Hex(content) {
474
+ return createHash("sha256").update(content, "utf8").digest("hex");
475
+ }
476
+ /**
477
+ * Hash of `raw` with {@link BOOKKEEPING_FRONTMATTER_KEYS} removed from its
478
+ * frontmatter and the remaining frontmatter re-serialized with sorted keys,
479
+ * so pipeline-only bookkeeping rewrites (and incidental key-order churn)
480
+ * cannot change the result. The body's boundary is normalized the same way
481
+ * {@link assembleAssetFromString} normalizes it (leading newlines stripped,
482
+ * exactly one trailing newline) before hashing, so the body-boundary shift
483
+ * that `writeSalienceToFrontmatter` and the `assembleAsset` bookkeeping
484
+ * rewrite both introduce cannot change the result either; the body's
485
+ * interior is still hashed verbatim.
486
+ *
487
+ * An empty frontmatter block (`---\n---\n…`) is treated as `{}` rather than
488
+ * falling back to the raw hash, so adding bookkeeping keys to a memory with
489
+ * no prior frontmatter fields is still insensitive.
490
+ *
491
+ * Falls back to hashing `raw` verbatim when there is no frontmatter block at
492
+ * all or it fails to parse — normalizing unparsable frontmatter isn't safe,
493
+ * and the caller's existing raw-hash check already covers that case.
494
+ */
495
+ export function computeNormalizedContentHash(raw) {
496
+ const block = parseFrontmatterBlock(raw);
497
+ if (!block)
498
+ return sha256Hex(raw);
499
+ let data;
500
+ try {
501
+ data = block.frontmatter.trim() ? yamlParse(block.frontmatter) : {};
502
+ }
503
+ catch {
504
+ return sha256Hex(raw);
505
+ }
506
+ if (data === null || typeof data !== "object" || Array.isArray(data))
507
+ return sha256Hex(raw);
508
+ const normalized = { ...data };
509
+ for (const key of BOOKKEEPING_FRONTMATTER_KEYS)
510
+ delete normalized[key];
511
+ const canonicalFrontmatter = yamlStringify(normalized, { sortMapEntries: true }).trimEnd();
512
+ return sha256Hex(assembleAssetFromString(canonicalFrontmatter, block.content));
513
+ }
514
+ /**
515
+ * Fold the live target's {@link BOOKKEEPING_FRONTMATTER_KEYS} into
516
+ * `proposedRaw` wherever the proposal's own frontmatter does not already set
517
+ * them, so promoting a proposal minted before a bookkeeping rewrite never
518
+ * drops that bookkeeping (STALE, R20) — e.g. dropping `inferenceProcessed`
519
+ * would make memory inference reprocess the memory.
520
+ *
521
+ * Returns `proposedRaw` unchanged when either side has no parseable
522
+ * frontmatter block or nothing needs to carry forward.
523
+ */
524
+ export function carryForwardBookkeepingFrontmatter(proposedRaw, liveRaw) {
525
+ const proposedBlock = parseFrontmatterBlock(proposedRaw);
526
+ const liveBlock = parseFrontmatterBlock(liveRaw);
527
+ if (!proposedBlock || !liveBlock)
528
+ return proposedRaw;
529
+ let proposedData;
530
+ let liveData;
531
+ try {
532
+ const parsedProposed = proposedBlock.frontmatter.trim() ? yamlParse(proposedBlock.frontmatter) : {};
533
+ const parsedLive = liveBlock.frontmatter.trim() ? yamlParse(liveBlock.frontmatter) : {};
534
+ if (parsedProposed === null || typeof parsedProposed !== "object" || Array.isArray(parsedProposed)) {
535
+ return proposedRaw;
536
+ }
537
+ if (parsedLive === null || typeof parsedLive !== "object" || Array.isArray(parsedLive))
538
+ return proposedRaw;
539
+ proposedData = parsedProposed;
540
+ liveData = parsedLive;
541
+ }
542
+ catch {
543
+ return proposedRaw;
544
+ }
545
+ const merged = { ...proposedData };
546
+ let changed = false;
547
+ for (const key of BOOKKEEPING_FRONTMATTER_KEYS) {
548
+ if (!(key in merged) && key in liveData) {
549
+ merged[key] = liveData[key];
550
+ changed = true;
551
+ }
552
+ }
553
+ if (!changed)
554
+ return proposedRaw;
555
+ const newFrontmatter = yamlStringify(merged).trimEnd();
556
+ const separator = proposedBlock.content.startsWith("\n") ? "" : "\n";
557
+ return `---\n${newFrontmatter}\n---\n${separator}${proposedBlock.content}`;
558
+ }
454
559
  // ── Minimum score delta to trigger a frontmatter salience rewrite ─────────────
455
560
  const SALIENCE_WRITE_DELTA_THRESHOLD = 0.05;
456
561
  /**
@@ -16,6 +16,7 @@ import { bundleComponentConfig, bundleContentRoot, bundleContentRoots, bundlesTo
16
16
  import { upgradeConfigVersion } from "./config-version-shim.js";
17
17
  import { deepMergeConfig, isPlainObject } from "./deep-merge.js";
18
18
  import { migrateLegacySourceShape } from "./legacy-source-shape-shim.js";
19
+ import { stripRetiredExperimentalKeys } from "./retired-experimental-keys-shim.js";
19
20
  import { isApiKeyReference, SECRET_STORE_REFERENCE_PATTERN } from "./schema/primitives.js";
20
21
  export { stripJsonComments } from "./config-io.js";
21
22
  import { getConfigPath } from "../paths.js";
@@ -147,7 +148,8 @@ export function acquireConfigReadFence() {
147
148
  * before it is either validated (the local/top-level file) or merged in as
148
149
  * an `extends` base: JSONC parse already done by the caller, then version
149
150
  * shim, then legacy `stashDir`/`sources[]`/`installed[]` shim, then the
150
- * legacy `extraParams` lift (#852). Shared by {@link parseAndValidateConfigText}
151
+ * legacy `extraParams` lift (#852), then the retired `experimental.*` key
152
+ * shim. Shared by {@link parseAndValidateConfigText}
151
153
  * (the local file) and {@link resolveExtendsChain} (each base in the chain) so
152
154
  * a fleet-shared base config can carry its own old `configVersion` / legacy
153
155
  * shape independently of the file that extends it.
@@ -155,7 +157,8 @@ export function acquireConfigReadFence() {
155
157
  function runConfigFilePipeline(text, sourcePath) {
156
158
  const versioned = upgradeConfigVersion(parseConfigText(text, sourcePath), sourcePath);
157
159
  const parsedRaw = migrateLegacySourceShape(versioned, sourcePath);
158
- return liftExtraParamsOrThrow(parsedRaw, sourcePath);
160
+ const liftedRaw = liftExtraParamsOrThrow(parsedRaw, sourcePath);
161
+ return stripRetiredExperimentalKeys(liftedRaw, sourcePath);
159
162
  }
160
163
  /**
161
164
  * #852 (following #815): a config still using legacy `extraParams` keys —
@@ -0,0 +1,62 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * `experimental.*` retired-key shim.
6
+ *
7
+ * `ExperimentalConfigSchema` (`./schema/experimental.ts`) moved from
8
+ * `.passthrough()` to `.strict()` in 0.9.16 (`cc6152e02`) so a typo in an
9
+ * authority flag (e.g. `improveAutonomyy`) fails loudly instead of silently
10
+ * doing nothing. `workflowEngine` was removed from that block earlier, in
11
+ * `e0655d13c`, but 0.9.15's passthrough still accepted it — so a real config
12
+ * written by 0.9.15 can carry `experimental.workflowEngine` and now fails
13
+ * every command with `Invalid config: experimental: Unrecognized key(s)`.
14
+ *
15
+ * Per AGENTS.md "Reading persisted data": a reader tolerates what older
16
+ * releases wrote, converts in memory, warns once, and leaves the on-disk
17
+ * rewrite to `akm migrate apply`. This mirrors `./legacy-source-shape-shim.ts`
18
+ * exactly — strip the retired key(s) before schema validation, warn once
19
+ * naming the key and the migrate command — rather than reintroducing
20
+ * `.passthrough()`, which would also let a live key typo through silently.
21
+ */
22
+ import { isRecord } from "../common.js";
23
+ import { warnOnce } from "../warn.js";
24
+ /**
25
+ * Every `experimental.*` key `ExperimentalConfigSchema` has ever retired.
26
+ * `workflowEngine` (removed in `e0655d13c`) is the only one so far — kept as
27
+ * a list because the shim and `akm migrate apply`
28
+ * (`scripts/akm-migrate/migrate/config-retired-experimental-keys.ts`) share
29
+ * it.
30
+ */
31
+ export const RETIRED_EXPERIMENTAL_KEYS = ["workflowEngine"];
32
+ /**
33
+ * Which `RETIRED_EXPERIMENTAL_KEYS` are present in a raw config's
34
+ * `experimental` section. Returns `[]` when `raw.experimental` is missing or
35
+ * not a record. Shared by `stripRetiredExperimentalKeys` below and by
36
+ * `akm migrate apply`'s on-disk counterpart
37
+ * (`scripts/akm-migrate/migrate/config-retired-experimental-keys.ts`).
38
+ */
39
+ export function retiredExperimentalKeysIn(raw) {
40
+ const experimental = raw.experimental;
41
+ if (!isRecord(experimental))
42
+ return [];
43
+ return RETIRED_EXPERIMENTAL_KEYS.filter((key) => key in experimental);
44
+ }
45
+ /**
46
+ * Drop retired `experimental.*` keys from a raw parsed config object before
47
+ * schema validation, warning once per source when any were present. Live
48
+ * keys (including an unrecognized one, e.g. a typo) are left untouched for
49
+ * `ExperimentalConfigSchema.strict()` to reject as before.
50
+ */
51
+ export function stripRetiredExperimentalKeys(raw, sourcePath) {
52
+ const present = retiredExperimentalKeysIn(raw);
53
+ if (present.length === 0)
54
+ return raw;
55
+ const original = raw.experimental;
56
+ const experimental = { ...original };
57
+ for (const key of present)
58
+ delete experimental[key];
59
+ const where = sourcePath ? ` at ${sourcePath}` : "";
60
+ warnOnce(`config:retired-experimental-key${sourcePath ? `:${sourcePath}` : ""}`, `Config${where} uses the retired experimental key(s) ${present.join(", ")} — ignored in memory. Run \`akm migrate apply\` to remove ${present.length === 1 ? "it" : "them"} from the config file and silence this warning.`);
61
+ return { ...raw, experimental };
62
+ }
@@ -42,6 +42,15 @@ const IMPROVE_PROCESS_BASE_FIELDS = {
42
42
  };
43
43
  /** Reflect/distill/consolidate: process-level asset-type filter (see improve-strategies.ts shouldSkipRef/DEFAULT_ALLOWED_TYPES). */
44
44
  const allowedTypesField = z.array(z.string().min(1)).optional();
45
+ /**
46
+ * Reflect only (R12): conceptId prefixes to exclude, matched after stripping
47
+ * an optional `bundle//` from both the ref and each prefix (see
48
+ * improve-strategies.ts shouldSkipRef). `allowedTypes` is type-only and can't
49
+ * exclude a subset of one type, e.g. raw wiki-ingest snapshots indexed as
50
+ * `knowledge/wikis/articles/raw/*`. distill/consolidate are memory-only and
51
+ * never read this field.
52
+ */
53
+ const excludeRefPrefixesField = z.array(z.string().min(1)).optional();
45
54
  /**
46
55
  * Consolidate process: hard cap on memories processed per pass.
47
56
  * Reflect/distill: max refs processed (same as profile-level `limit`).
@@ -160,6 +169,7 @@ const antiCollapseField = z
160
169
  .optional();
161
170
  const REFLECT_PROCESS_FIELDS = {
162
171
  allowedTypes: allowedTypesField,
172
+ excludeRefPrefixes: excludeRefPrefixesField,
163
173
  limit: processLimitField,
164
174
  qualityGate: qualityGateField,
165
175
  lowValueFilter: lowValueFilterField,
@@ -215,6 +225,11 @@ const GRAPH_EXTRACTION_PROCESS_FIELDS = {
215
225
  // instead of only files touched by actionable refs in the current run.
216
226
  // Used by the `graph-refresh` built-in profile / a scheduled weekly task.
217
227
  fullScan: z.boolean().optional(),
228
+ // R12b + R20: cap on chunks processed per asset. A body chunked beyond this
229
+ // is truncated to the first N chunks instead of paying for unbounded
230
+ // per-asset LLM calls; the coverage loss is recorded as truncatedChunks.
231
+ // Absent = default 8 (src/llm/graph-extract.ts DEFAULT_MAX_CHUNKS_PER_ASSET).
232
+ maxChunksPerAsset: positiveInt.optional(),
218
233
  };
219
234
  const EXTRACT_PROCESS_FIELDS = {
220
235
  defaultSince: z.string().min(1).optional(),
@@ -293,6 +308,16 @@ function checkRetiredProcessKeys(value, ctx) {
293
308
  }
294
309
  }
295
310
  }
311
+ /** distill/consolidate are memory-only and never read `excludeRefPrefixes` (reflect only, R12). */
312
+ function rejectExcludeRefPrefixesOutsideReflect(value, ctx) {
313
+ if ("excludeRefPrefixes" in value) {
314
+ ctx.addIssue({
315
+ code: z.ZodIssueCode.custom,
316
+ path: ["excludeRefPrefixes"],
317
+ message: "excludeRefPrefixes is only valid on the reflect process",
318
+ });
319
+ }
320
+ }
296
321
  export const ImproveProcessConfigSchema = z
297
322
  .object({
298
323
  ...IMPROVE_PROCESS_BASE_FIELDS,
@@ -316,12 +341,14 @@ export const ReflectProcessConfigSchema = z
316
341
  export const DistillProcessConfigSchema = z
317
342
  .object({ ...IMPROVE_PROCESS_BASE_FIELDS, ...DISTILL_PROCESS_FIELDS })
318
343
  .passthrough()
319
- .superRefine(checkRetiredProcessKeys);
344
+ .superRefine(checkRetiredProcessKeys)
345
+ .superRefine(rejectExcludeRefPrefixesOutsideReflect);
320
346
  /** `processes.consolidate` — narrow per-process schema (WI-9.6). */
321
347
  export const ConsolidateProcessConfigSchema = z
322
348
  .object({ ...IMPROVE_PROCESS_BASE_FIELDS, ...CONSOLIDATE_PROCESS_FIELDS })
323
349
  .passthrough()
324
- .superRefine(checkRetiredProcessKeys);
350
+ .superRefine(checkRetiredProcessKeys)
351
+ .superRefine(rejectExcludeRefPrefixesOutsideReflect);
325
352
  /** `processes.memoryInference` — narrow per-process schema (WI-9.6). */
326
353
  export const MemoryInferenceProcessConfigSchema = z
327
354
  .object({ ...IMPROVE_PROCESS_BASE_FIELDS, ...MEMORY_INFERENCE_PROCESS_FIELDS })
@@ -19,6 +19,7 @@ const COMMON_FIELDS = [
19
19
  "plan",
20
20
  "actions",
21
21
  "skippedProcesses",
22
+ "engineProbe",
22
23
  "distillSkipped",
23
24
  "validationFailures",
24
25
  "schemaRepairs",
@@ -27,6 +28,10 @@ const COMMON_FIELDS = [
27
28
  "lintSummary",
28
29
  "memoryIndexHealth",
29
30
  "coverageGaps",
31
+ // R10: no longer written (the write-only eval-cases path was removed),
32
+ // but kept in the allow-list so `decodeImproveResult` still reads
33
+ // improve-result envelopes a prior release wrote with this field —
34
+ // AGENTS.md "Reading persisted data".
30
35
  "evalCasesWritten",
31
36
  "deadUrls",
32
37
  "deadUrlCoverage",
@@ -35,6 +40,7 @@ const COMMON_FIELDS = [
35
40
  "graphExtraction",
36
41
  "memoryInferenceDurationMs",
37
42
  "graphExtractionDurationMs",
43
+ "ensureIndexDurationMs",
38
44
  "orphansPurged",
39
45
  "proposalsExpired",
40
46
  "reflectCooldownActions",
@@ -442,6 +448,7 @@ function validateCommon(value) {
442
448
  for (const field of [
443
449
  "actions",
444
450
  "skippedProcesses",
451
+ "engineProbe",
445
452
  "validationFailures",
446
453
  "schemaRepairs",
447
454
  "extract",
@@ -457,10 +464,12 @@ function validateCommon(value) {
457
464
  }
458
465
  for (const field of [
459
466
  "cyclesRun",
467
+ // R10: retired write, kept readable — see the COMMON_FIELDS comment above.
460
468
  "evalCasesWritten",
461
469
  "reflectsWithErrorContext",
462
470
  "memoryInferenceDurationMs",
463
471
  "graphExtractionDurationMs",
472
+ "ensureIndexDurationMs",
464
473
  "orphansPurged",
465
474
  "proposalsExpired",
466
475
  "reflectCooldownActions",
@@ -352,6 +352,13 @@ export function getDistillRejectedDir(stashDir) {
352
352
  * `$STATE/improve/eval-cases/<stash>/` — regression eval cases captured from
353
353
  * rejected distill/proposal output. Moved out of `$STASH/.akm/eval-cases/`
354
354
  * (itlackey/akm#890).
355
+ *
356
+ * R10: the writer (`src/commands/improve/eval-cases.ts`) was removed —
357
+ * nothing ever read the files it wrote, and a rejected proposal row now
358
+ * carries the same information (see `quality-gate.ts`'s
359
+ * `writeQualityRejection`). This resolver stays: `scripts/akm-migrate/migrate/
360
+ * writer-relocation.ts` still uses it to relocate pre-existing eval-case
361
+ * files an older release wrote to the legacy `$STASH/.akm/eval-cases/` path.
355
362
  */
356
363
  export function getEvalCasesDir(stashDir) {
357
364
  return stashScopedDir(path.join(getStateDir(), "improve", "eval-cases"), stashDir);
@@ -1028,6 +1028,14 @@ export function assertAkmAssetWrite(source, allowedAdapters = ["akm"]) {
1028
1028
  return;
1029
1029
  throw new UsageError(`Bundle "${source.name}" uses adapter "${source.adapterId}", which does not support AKM asset writes.`, "INVALID_FLAG_VALUE");
1030
1030
  }
1031
+ /**
1032
+ * The write-capable source kinds. Writes (and therefore scheduler state,
1033
+ * which only ever binds to a writable source) are defined only for these
1034
+ * two kinds; anything else throws `ConfigError`.
1035
+ */
1036
+ export function isWriteCapableSourceKind(kind) {
1037
+ return kind === "filesystem" || kind === "git";
1038
+ }
1031
1039
  /**
1032
1040
  * Reject any kind reaching the write/delete helpers other than the two
1033
1041
  * supported writable kinds. The config loader is the first line of defence
@@ -1035,7 +1043,7 @@ export function assertAkmAssetWrite(source, allowedAdapters = ["akm"]) {
1035
1043
  * bypass the loader still get a clear error.
1036
1044
  */
1037
1045
  function assertSupportedKind(source) {
1038
- if (source.kind === "filesystem" || source.kind === "git")
1046
+ if (isWriteCapableSourceKind(source.kind))
1039
1047
  return;
1040
1048
  throw new ConfigError(`write-source: unsupported kind "${source.kind}" for source "${source.name}". ` +
1041
1049
  "Writes are only defined for `filesystem` and `git` sources.", "INVALID_CONFIG_FILE", 'Set `kind: "filesystem"` (or `kind: "git"`) on the source, or add a parallel filesystem entry.');
@@ -1073,7 +1081,7 @@ function adaptConfiguredSource(runtime) {
1073
1081
  // reaching this point is a config-loader bug (assertWritableAllowedForKind
1074
1082
  // should have rejected it). Throw a ConfigError rather than silently
1075
1083
  // forwarding an unsupported kind.
1076
- if (runtime.type !== "filesystem" && runtime.type !== "git") {
1084
+ if (!isWriteCapableSourceKind(runtime.type)) {
1077
1085
  throw new ConfigError(`write-source: source "${runtime.name}" has unsupported kind "${runtime.type}" for writes. ` +
1078
1086
  "Writes are only defined for `filesystem` and `git` sources.", "INVALID_CONFIG_FILE", 'Use `kind: "filesystem"` or `kind: "git"` for writable sources.');
1079
1087
  }