akm-cli 0.9.11 → 0.9.13

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 (134) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/STABILITY.md +6 -1
  3. package/dist/assets/hints/cli-hints-full.md +1 -1
  4. package/dist/assets/improve-strategies/consolidate.json +1 -1
  5. package/dist/assets/improve-strategies/default.json +1 -1
  6. package/dist/assets/improve-strategies/thorough.json +1 -2
  7. package/dist/assets/workflows/workflow-template.md +4 -0
  8. package/dist/cli/shared.js +16 -4
  9. package/dist/cli.js +15 -13
  10. package/dist/commands/agent/agent-dispatch.js +8 -0
  11. package/dist/commands/command/execution-source-loader.js +25 -22
  12. package/dist/commands/command/portable-template.js +4 -26
  13. package/dist/commands/config-cli.js +10 -4
  14. package/dist/commands/env/env-binding.js +10 -3
  15. package/dist/commands/env/env-cli.js +7 -0
  16. package/dist/commands/env/secret-cli.js +15 -4
  17. package/dist/commands/health/checks.js +186 -71
  18. package/dist/commands/health.js +16 -4
  19. package/dist/commands/improve/distill/quality-gate.js +2 -2
  20. package/dist/commands/improve/distill.js +28 -12
  21. package/dist/commands/improve/execution.js +1 -2
  22. package/dist/commands/improve/extract.js +82 -56
  23. package/dist/commands/improve/improve-strategies.js +26 -8
  24. package/dist/commands/improve/improve.js +14 -0
  25. package/dist/commands/improve/preparation.js +9 -6
  26. package/dist/commands/improve/reflect.js +61 -77
  27. package/dist/commands/lint/base-linter.js +10 -0
  28. package/dist/commands/lint/index.js +3 -1
  29. package/dist/commands/migrate-cli.js +6 -4
  30. package/dist/commands/proposal/drain-policies.js +22 -2
  31. package/dist/commands/proposal/drain.js +48 -6
  32. package/dist/commands/proposal/proposal-cli.js +1 -0
  33. package/dist/commands/proposal/repository.js +4 -4
  34. package/dist/commands/proposal/validators/proposal-quality-validators.js +23 -2
  35. package/dist/commands/proposal/validators/proposals.js +10 -19
  36. package/dist/commands/read/show.js +42 -31
  37. package/dist/commands/registry-cli.js +4 -2
  38. package/dist/commands/sources/init.js +4 -8
  39. package/dist/commands/sources/self-update.js +2 -2
  40. package/dist/commands/sources/source-clone.js +5 -7
  41. package/dist/commands/sources/sources-cli.js +3 -5
  42. package/dist/commands/tasks/tasks-cli.js +4 -12
  43. package/dist/commands/tasks/tasks.js +38 -35
  44. package/dist/commands/workflow-cli.js +17 -15
  45. package/dist/core/activation-policy.js +31 -3
  46. package/dist/core/adapter/execution-source.js +39 -11
  47. package/dist/core/asset/stash-meta.js +7 -41
  48. package/dist/core/common.js +8 -17
  49. package/dist/core/config/config-schema.js +3 -23
  50. package/dist/core/config/config-walker.js +56 -6
  51. package/dist/core/config/config.js +42 -17
  52. package/dist/core/config/legacy-source-shape-shim.js +79 -0
  53. package/dist/core/config/schema/embedding.js +2 -2
  54. package/dist/core/config/schema/engines.js +2 -2
  55. package/dist/core/config/schema/index-config.js +19 -21
  56. package/dist/core/config/schema/primitives.js +27 -10
  57. package/dist/core/config/schema/sources-bundles.js +1 -6
  58. package/dist/core/errors.js +4 -3
  59. package/dist/core/improve-types.js +17 -0
  60. package/dist/core/json-schema.js +1 -11
  61. package/dist/core/maintenance-barrier.js +17 -2
  62. package/dist/core/paths.js +12 -15
  63. package/dist/core/state/migrations.js +28 -0
  64. package/dist/core/state-db.js +28 -1
  65. package/dist/core/write-source.js +6 -6
  66. package/dist/indexer/bundle-identity-guard.js +3 -0
  67. package/dist/indexer/ensure-index.js +5 -0
  68. package/dist/indexer/indexer.js +11 -3
  69. package/dist/indexer/lookup/adapter-concept-owner.js +14 -3
  70. package/dist/indexer/passes/metadata.js +16 -5
  71. package/dist/indexer/search/search-fields.js +1 -30
  72. package/dist/integrations/agent/engine-resolution.js +15 -1
  73. package/dist/integrations/agent/model-map.js +16 -10
  74. package/dist/integrations/agent/prompts.js +13 -6
  75. package/dist/integrations/lockfile.js +22 -7
  76. package/dist/llm/client.js +28 -8
  77. package/dist/llm/embedders/remote.js +3 -2
  78. package/dist/llm/index-passes.js +3 -2
  79. package/dist/output/shapes/passthrough.js +9 -3
  80. package/dist/output/shapes.js +50 -3
  81. package/dist/output/text/proposal-format.js +5 -0
  82. package/dist/output/text/workflow-format.js +8 -1
  83. package/dist/scripts/akm-migrate-node.js +1737 -1392
  84. package/dist/scripts/akm-migrate.js +1736 -1391
  85. package/dist/setup/setup.js +14 -21
  86. package/dist/sources/include.js +150 -20
  87. package/dist/sources/providers/git-install.js +14 -12
  88. package/dist/sources/providers/git-provider.js +3 -3
  89. package/dist/sources/snapshot-fetchers/website-ingest.js +54 -16
  90. package/dist/sources/website-url.js +12 -4
  91. package/dist/storage/engines/sqlite-migrations.js +40 -10
  92. package/dist/storage/like-pattern.js +7 -0
  93. package/dist/storage/repositories/extract-sessions-repository.js +23 -0
  94. package/dist/storage/repositories/index-connection.js +27 -10
  95. package/dist/storage/repositories/index-entry-schema.js +19 -2
  96. package/dist/storage/repositories/index-schema.js +30 -9
  97. package/dist/storage/repositories/proposals-repository.js +2 -1
  98. package/dist/storage/repositories/task-history-repository.js +14 -7
  99. package/dist/storage/repositories/workflow-runs-repository.js +133 -11
  100. package/dist/storage/sqlite-read-snapshot.js +11 -9
  101. package/dist/tasks/backends/cron.js +34 -5
  102. package/dist/tasks/backends/launchd.js +23 -26
  103. package/dist/tasks/backends/schtasks.js +50 -3
  104. package/dist/tasks/frozen-script.js +2 -0
  105. package/dist/tasks/prepare/prepare.js +2 -7
  106. package/dist/tasks/prepare/script-capture.js +38 -6
  107. package/dist/tasks/schedule.js +154 -13
  108. package/dist/tasks/source/task-source-v3-frozen.js +0 -1
  109. package/dist/tasks/source/task-source-v4.js +0 -1
  110. package/dist/workflows/exec/child-workflow.js +2 -3
  111. package/dist/workflows/exec/exec-unit.js +3 -4
  112. package/dist/workflows/exec/run-workflow.js +20 -11
  113. package/dist/workflows/exec/step-work.js +76 -56
  114. package/dist/workflows/freeze/resolve-steps.js +19 -11
  115. package/dist/workflows/freeze/source-freeze.js +7 -0
  116. package/dist/workflows/freeze/targets/child-workflow.js +12 -18
  117. package/dist/workflows/freeze/targets/command.js +14 -2
  118. package/dist/workflows/ir/environment-v4.js +4 -2
  119. package/dist/workflows/ir/freeze-v4.js +2 -5
  120. package/dist/workflows/ir/plan-hash.js +0 -3
  121. package/dist/workflows/ir/schema-v4.js +14 -9
  122. package/dist/workflows/ir/schema.js +1 -3
  123. package/dist/workflows/parser.js +1 -1
  124. package/dist/workflows/resource-limits.js +35 -48
  125. package/dist/workflows/runtime/plan-classifier.js +89 -41
  126. package/dist/workflows/runtime/run-outputs.js +1 -21
  127. package/dist/workflows/runtime/runs.js +104 -154
  128. package/dist/workflows/source-files.js +28 -54
  129. package/dist/workflows/source-ir/program.js +2 -2
  130. package/dist/workflows/source-ir/semantics.js +5 -23
  131. package/docs/migration/v0.9.1-to-v0.9.2.md +20 -0
  132. package/docs/reference/cli.md +92 -17
  133. package/package.json +1 -1
  134. package/schemas/akm-config.json +5 -10
@@ -35,7 +35,7 @@ import { lintLessonContent } from "../../core/lesson-lint.js";
35
35
  import { parseEmbeddedJsonResponse } from "../../core/parse.js";
36
36
  import { redactSensitiveText } from "../../core/redaction.js";
37
37
  import { resolveStandardsContext } from "../../core/standards/resolve-standards-context.js";
38
- import { warn } from "../../core/warn.js";
38
+ import { warn, warnOnce } from "../../core/warn.js";
39
39
  import { lookup } from "../../indexer/indexer.js";
40
40
  import { DEFAULT_LLM_TIMEOUT_MS } from "../../integrations/agent/config.js";
41
41
  import { fallbackAnnouncement, NO_ENGINE_MESSAGE_SUFFIX, NO_ENGINE_REMEDY, withEngineFallback, } from "../../integrations/agent/engine-fallback.js";
@@ -47,7 +47,7 @@ import { collectDispatchSensitiveValues } from "../../integrations/agent/runner-
47
47
  import { isJsonSchemaKnownUnsupported, LlmCallError } from "../../llm/client.js";
48
48
  import { callStructured } from "../../llm/structured-call.js";
49
49
  import { baseFailureFields, enoentHintMessage, isEnoentFailure } from "../agent/agent-support.js";
50
- import { isProposalSkipped, listProposalsReadOnly, proposalContent, } from "../proposal/repository.js";
50
+ import { isProposalSkipped, listProposalsReadOnly, proposalContent, recordGateDecision, } from "../proposal/repository.js";
51
51
  import { checkReflectSize, isValidDescription } from "../proposal/validators/proposal-quality-validators.js";
52
52
  import { deriveLessonRef } from "./distill.js";
53
53
  import { runReflectQualityJudge } from "./distill/quality-gate.js";
@@ -119,6 +119,10 @@ export const REFLECT_ALLOWED_TYPES = new Set([
119
119
  "command",
120
120
  "workflow",
121
121
  ]);
122
+ const REFLECT_REFUSED_TYPES = new Set(["secret"]);
123
+ function isReflectableSourceShape(content) {
124
+ return parseFrontmatter(content).frontmatter !== null;
125
+ }
122
126
  /**
123
127
  * Identity / structural frontmatter fields the LLM is NEVER allowed to change.
124
128
  *
@@ -539,24 +543,15 @@ export function sanitizeReflectPayload(payload, sourceContent, targetRef) {
539
543
  // predicate lives in `core/proposal-quality-validators` so the same check
540
544
  // also runs inside `runProposalValidators` on `proposal accept`.
541
545
  const sizeOutcome = checkReflectSize(sourceBody, cleanedBody);
546
+ let sizeGuardRatio;
542
547
  if (!sizeOutcome.ok) {
543
548
  const pct = (sizeOutcome.ratio * 100).toFixed(0);
544
549
  const limit = sizeOutcome.code === "EXCESSIVE_SHRINKAGE" ? "minimum 50%" : "maximum 250%";
545
550
  const cause = sizeOutcome.code === "EXCESSIVE_SHRINKAGE"
546
551
  ? "Concrete content was likely deleted."
547
552
  : "Speculative material was likely added.";
548
- return {
549
- content: payload.content,
550
- warnings,
551
- reject: {
552
- // Content-policy guard hit (EXCESSIVE_SHRINKAGE / EXCESSIVE_EXPANSION).
553
- // This is the guard working as designed — the LLM responded fine, we
554
- // blocked the output. Routed through `content_policy_reject` so the
555
- // health aggregator can split guard hits out of true LLM faults.
556
- reason: "content_policy_reject",
557
- error: `Reflect rejected: ${sizeOutcome.code} — proposed body is ${pct}% of source (${limit}) for ref ${targetRef}. ${cause}`,
558
- },
559
- };
553
+ warnings.push(`${sizeOutcome.code} — proposed body is ${pct}% of source (${limit}) for ref ${targetRef}. ${cause} Flagged for review.`);
554
+ sizeGuardRatio = { code: sizeOutcome.code, ratio: sizeOutcome.ratio };
560
555
  }
561
556
  // Reassemble final content: merged frontmatter + cleaned body.
562
557
  // When there is no frontmatter at all (no source fm and no LLM fm), emit body
@@ -570,6 +565,7 @@ export function sanitizeReflectPayload(payload, sourceContent, targetRef) {
570
565
  content: reassembled,
571
566
  ...(hasFrontmatter ? { frontmatter: mergedFm } : {}),
572
567
  warnings,
568
+ ...(sizeGuardRatio ? { sizeGuardRatio } : {}),
573
569
  };
574
570
  }
575
571
  /**
@@ -636,29 +632,6 @@ function wantsJsonSchemaOutput(connection) {
636
632
  }
637
633
  /** Critique prompt injected between prior draft and refinement request (Self-Refine loop). */
638
634
  const REFLECT_CRITIQUE_PROMPT = "Your previous proposal is shown above. Review it critically and provide an improved version that is more specific, actionable, and avoids any issues with the previous attempt. Return only the improved response using the output contract from the original prompt.";
639
- /**
640
- * OpenAI-compatible thinking models charge hidden reasoning against
641
- * `max_tokens` before they emit the visible response. Reflect asks for a
642
- * machine-readable payload and requests `enableThinking: false`, but local
643
- * servers do not uniformly honour that flag. Keep visible-content sizing
644
- * separate from the allowance that lets an uncooperative thinking model reach
645
- * its JSON/frame envelope.
646
- *
647
- * The 2,048-token allowance exceeds the observed 1,798-token peak that
648
- * previously cut direct reflect responses off mid-envelope. It applies to all
649
- * bounded direct-LLM calls because a server's thinking behavior is not a
650
- * reliable capability signal; the post-processor still enforces the original
651
- * content-size policy.
652
- */
653
- const REFLECT_REASONING_TOKEN_HEADROOM = 2_048;
654
- const REFLECT_RESPONSE_ENVELOPE_CHARS = 500;
655
- function reflectMaxTokensForOutput(maxOutputChars) {
656
- if (maxOutputChars === undefined)
657
- return undefined;
658
- // Divide by 3 chars/token (conservative — most models are 3.5–4), retain
659
- // space for the JSON/frame wrapper, then reserve independent reasoning room.
660
- return Math.ceil((maxOutputChars + REFLECT_RESPONSE_ENVELOPE_CHARS) / 3) + REFLECT_REASONING_TOKEN_HEADROOM;
661
- }
662
635
  function reflectLlmTelemetry(result) {
663
636
  if (!result.parsed || typeof result.parsed !== "object" || Array.isArray(result.parsed))
664
637
  return undefined;
@@ -909,7 +882,7 @@ function failureEnvelope(result, ref, engine, fallbackReason = "non_zero_exit")
909
882
  * byte-identical.
910
883
  */
911
884
  async function finalizeReflectProposal(args) {
912
- const { assetContent, result, options, engineName, config, qualityGateEnabled, qualityJudgeRunner, qualityJudgeLease, feedback, stash, emitReflectFailed, onNotices, } = args;
885
+ const { assetContent, result, options, engineName, config, qualityGateEnabled, qualityGateSkippedNoJudge, qualityJudgeRunner, qualityJudgeLease, feedback, stash, emitReflectFailed, onNotices, } = args;
913
886
  let payload = args.payload;
914
887
  const outputTelemetry = reflectLlmTelemetry(result);
915
888
  // 7. Reflect content-preservation rails:
@@ -989,7 +962,7 @@ async function finalizeReflectProposal(args) {
989
962
  }
990
963
  // 7c. Judge the exact sanitized content that can be persisted. Fail closed
991
964
  // on cancellation, transport failure, malformed output, or an invalid score.
992
- if (qualityGateEnabled) {
965
+ if (qualityGateEnabled && !sanitizeOutcome.sizeGuardRatio) {
993
966
  const judgeResult = await runReflectQualityJudge(config, payload.content, assetContent ?? "", feedback, options.chat, {
994
967
  runnerSelectionFrozen: true,
995
968
  ...(qualityJudgeRunner ? { llmRunner: qualityJudgeRunner } : {}),
@@ -1029,6 +1002,8 @@ async function finalizeReflectProposal(args) {
1029
1002
  durationMs: result.durationMs,
1030
1003
  emitReflectFailed,
1031
1004
  outputTelemetry,
1005
+ qualityGateSkippedNoJudge,
1006
+ sizeGuardRatio: sanitizeOutcome.sizeGuardRatio,
1032
1007
  });
1033
1008
  }
1034
1009
  /**
@@ -1038,7 +1013,7 @@ async function finalizeReflectProposal(args) {
1038
1013
  * `akmReflect`'s finalize tail.
1039
1014
  */
1040
1015
  function createReflectProposal(args) {
1041
- const { payload, options, stash, engineName, durationMs, emitReflectFailed, outputTelemetry } = args;
1016
+ const { payload, options, stash, engineName, durationMs, emitReflectFailed, outputTelemetry, qualityGateSkippedNoJudge, sizeGuardRatio, } = args;
1042
1017
  // 8. Create the proposal. The proposal queue is the ONLY thing reflect
1043
1018
  // writes — promotion to a real asset is gated by `akm proposal accept`.
1044
1019
  //
@@ -1101,7 +1076,21 @@ function createReflectProposal(args) {
1101
1076
  exitCode: null,
1102
1077
  };
1103
1078
  }
1104
- const proposal = proposalResult;
1079
+ let proposal = proposalResult;
1080
+ const reviewReasons = [];
1081
+ if (qualityGateSkippedNoJudge)
1082
+ reviewReasons.push("no-judge-configured");
1083
+ if (sizeGuardRatio)
1084
+ reviewReasons.push("reflect-size-ratio");
1085
+ if (reviewReasons.length > 0) {
1086
+ proposal =
1087
+ recordGateDecision(stash, proposal.id, {
1088
+ outcome: "deferred",
1089
+ reason: reviewReasons.join("+"),
1090
+ gate: "reflect",
1091
+ ...(sizeGuardRatio ? { measured: Math.round(sizeGuardRatio.ratio * 100) } : {}),
1092
+ }, options.ctx) ?? proposal;
1093
+ }
1105
1094
  appendEvent({
1106
1095
  eventType: "reflect_completed",
1107
1096
  ref: proposal.ref,
@@ -1109,6 +1098,8 @@ function createReflectProposal(args) {
1109
1098
  proposalId: proposal.id,
1110
1099
  source: "reflect",
1111
1100
  engine: engineName,
1101
+ ...(qualityGateSkippedNoJudge ? { qualityGateSkippedNoJudge: true } : {}),
1102
+ ...(sizeGuardRatio ? { sizeGuardRatio: sizeGuardRatio.code, sizeGuardRatioValue: sizeGuardRatio.ratio } : {}),
1112
1103
  ...(outputTelemetry ?? {}),
1113
1104
  },
1114
1105
  }, options.eventsCtx);
@@ -1299,6 +1290,19 @@ function resolveReflectRunner(options) {
1299
1290
  }
1300
1291
  return { config, activeStrategy, runnerSpec, engineName, notices };
1301
1292
  }
1293
+ function unsupportedTypeFailure(ref, type, detail, emitReflectFailed) {
1294
+ emitReflectFailed("unsupported_type", "unsupported_type", ref, { type });
1295
+ return {
1296
+ failure: {
1297
+ schemaVersion: 2,
1298
+ ok: false,
1299
+ reason: "unsupported_type",
1300
+ error: `Reflect refused: asset type "${type}" is not supported by reflect (${detail}). Use \`akm proposal new\` or edit the file directly.`,
1301
+ ref,
1302
+ exitCode: null,
1303
+ },
1304
+ };
1305
+ }
1302
1306
  /**
1303
1307
  * Resolve the reflect target's parsed ref + current on-disk content: enforce the
1304
1308
  * REFLECT_ALLOWED_TYPES markdown-canonical type guard (returning a terminal
@@ -1311,27 +1315,10 @@ async function resolveReflectSource(options, stash, emitReflectFailed) {
1311
1315
  let parsedRef;
1312
1316
  if (options.ref) {
1313
1317
  parsedRef = parseRefInput(options.ref);
1314
- // 2a. Type guard reflect only operates on asset types whose canonical
1315
- // shape is `frontmatter + markdown body`. Refuse non-markdown types
1316
- // (script / env / task) up-front so reflect never prepends YAML to a
1317
- // `.ts` file or rewrites a `.env` blob as prose. See REFLECT_ALLOWED_TYPES.
1318
- if (!REFLECT_ALLOWED_TYPES.has(parsedRef.type)) {
1319
- // Deterministic type-guard rejection — the LLM is never invoked. Emit
1320
- // with reason `unsupported_type` so the improve loop can route this to
1321
- // the `reflect-skipped` action bucket instead of `reflect-failed`. See
1322
- // `/tmp/akm-health-investigations/metrics-taxonomy-review.md` §1a
1323
- // ("Reflect refused asset type" — ~9% of reflect-failed events).
1324
- emitReflectFailed("unsupported_type", "unsupported_type", options.ref, { type: parsedRef.type });
1325
- return {
1326
- failure: {
1327
- schemaVersion: 2,
1328
- ok: false,
1329
- reason: "unsupported_type",
1330
- error: `Reflect refused: asset type "${parsedRef.type}" is not supported by reflect (only markdown-canonical types are allowed: ${[...REFLECT_ALLOWED_TYPES].sort().join(", ")}). Use \`akm proposal new\` or edit the file directly.`,
1331
- ref: options.ref,
1332
- exitCode: null,
1333
- },
1334
- };
1318
+ // 2a. Refuse `secret` before any content is read a secret's content is
1319
+ // never touched by reflect, regardless of what it happens to look like.
1320
+ if (REFLECT_REFUSED_TYPES.has(parsedRef.type)) {
1321
+ return unsupportedTypeFailure(options.ref, parsedRef.type, "secret material is never read or sent to an LLM", emitReflectFailed);
1335
1322
  }
1336
1323
  if (options.assetContent !== undefined) {
1337
1324
  // Test seam — caller pre-loaded the source content.
@@ -1357,6 +1344,11 @@ async function resolveReflectSource(options, stash, emitReflectFailed) {
1357
1344
  // Index miss is non-fatal — the agent can still propose a fresh asset.
1358
1345
  }
1359
1346
  }
1347
+ if (!REFLECT_ALLOWED_TYPES.has(parsedRef.type)) {
1348
+ if (assetContent === undefined || !isReflectableSourceShape(assetContent)) {
1349
+ return unsupportedTypeFailure(options.ref, parsedRef.type, "its content is not frontmatter + markdown", emitReflectFailed);
1350
+ }
1351
+ }
1360
1352
  }
1361
1353
  return { assetContent, parsedRef };
1362
1354
  }
@@ -1393,7 +1385,7 @@ async function runReflectRefineIterations(args) {
1393
1385
  draftPathsToCleanup.push(iterDraftPath);
1394
1386
  lastDraftPath = iterDraftPath;
1395
1387
  }
1396
- const { prompt, maxOutputChars } = buildReflectPrompt({
1388
+ const { prompt } = buildReflectPrompt({
1397
1389
  ...(options.ref ? { ref: options.ref } : {}),
1398
1390
  ...(parsedRef?.type ? { type: parsedRef.type } : {}),
1399
1391
  ...(parsedRef?.name ? { name: parsedRef.name } : {}),
@@ -1414,7 +1406,6 @@ async function runReflectRefineIterations(args) {
1414
1406
  ...(iterDraftPath ? { draftFilePath: iterDraftPath } : {}),
1415
1407
  ...(outputMode ? { outputMode } : {}),
1416
1408
  });
1417
- const maxTokensForLlm = reflectMaxTokensForOutput(maxOutputChars);
1418
1409
  let iterResult;
1419
1410
  if (runnerIsLlm(runnerSpec)) {
1420
1411
  // LLM HTTP runners cannot honor the file-write contract, so they return
@@ -1435,7 +1426,6 @@ async function runReflectRefineIterations(args) {
1435
1426
  ...(options.ref ? { targetRef: options.ref } : {}),
1436
1427
  allowRepair: repairAttempts === 0,
1437
1428
  ...(options.chat ? { chat: options.chat } : {}),
1438
- ...(maxTokensForLlm !== undefined ? { maxTokens: maxTokensForLlm } : {}),
1439
1429
  onNotices,
1440
1430
  });
1441
1431
  }
@@ -1638,18 +1628,11 @@ export async function akmReflect(options = {}) {
1638
1628
  const executionNotices = new Map();
1639
1629
  collectLoweringNotices(executionNotices, resolutionNotices);
1640
1630
  const collectExecutionNotices = (notices) => collectLoweringNotices(executionNotices, notices);
1641
- const qualityJudgeSelection = resolveReflectQualityJudgeRunner(config, runnerSpec, isReflectQualityGateEnabled(activeStrategy), collectExecutionNotices);
1642
- if (qualityJudgeSelection.enabled && !qualityJudgeSelection.runner) {
1643
- return {
1644
- schemaVersion: 2,
1645
- ok: false,
1646
- reason: "parse_error",
1647
- error: 'Reflect proposal quality gate rejected: score=-1, reason="no LLM configured — cannot judge, failing closed"',
1648
- ...(options.ref ? { ref: options.ref } : {}),
1649
- engine: engineName,
1650
- exitCode: null,
1651
- ...reflectNoticeFields(executionNotices),
1652
- };
1631
+ let qualityJudgeSelection = resolveReflectQualityJudgeRunner(config, runnerSpec, isReflectQualityGateEnabled(activeStrategy), collectExecutionNotices);
1632
+ const qualityGateSkippedNoJudge = qualityJudgeSelection.enabled && !qualityJudgeSelection.runner;
1633
+ if (qualityGateSkippedNoJudge) {
1634
+ warnOnce("reflect-quality-gate-no-judge", "Reflect proposal quality gate has no LLM configured to judge proposals (set defaults.llmEngine, or improve.strategies.<name>.processes.reflect.qualityGate.engine). Skipping the gate for this run; the proposal is queued for human review instead.");
1635
+ qualityJudgeSelection = Object.freeze({ enabled: false, runner: undefined });
1653
1636
  }
1654
1637
  const qualityJudgeRunner = qualityJudgeSelection.runner;
1655
1638
  let generationLease;
@@ -1782,6 +1765,7 @@ export async function akmReflect(options = {}) {
1782
1765
  engineName,
1783
1766
  config,
1784
1767
  qualityGateEnabled: qualityJudgeSelection.enabled,
1768
+ qualityGateSkippedNoJudge,
1785
1769
  qualityJudgeRunner,
1786
1770
  qualityJudgeLease,
1787
1771
  feedback,
@@ -132,6 +132,14 @@ function fixMissingUpdated(raw, mtime) {
132
132
  return spliceFrontmatterLine(raw, `updated: ${localDateStamp(mtime)}`) ?? raw;
133
133
  }
134
134
  // ── stale-path helpers ────────────────────────────────────────────────────────
135
+ /**
136
+ * A path segment shaped like a run-time filename template rather than a
137
+ * literal reference: `<timestamp>`-style angle brackets, `{stamp}`/`${VAR}`
138
+ * braces, a `YYYYMMDD`/`HHMMSS`-style run of date-format letters, or a glob
139
+ * character. Such a path never exists under its literal spelling, so
140
+ * `stale-path` skips it instead of flagging it.
141
+ */
142
+ const PATH_PLACEHOLDER_PATTERN = /[<{]|[*?]|[YMDHS]{4,}/;
135
143
  function checkStalePath(body) {
136
144
  const pathRe = /(?:\/home\/|\/tmp\/|\/var\/|\/root\/|\/opt\/)[^\s"'`)\]>,\n]+/g;
137
145
  let match;
@@ -139,6 +147,8 @@ function checkStalePath(body) {
139
147
  // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex loop
140
148
  while ((match = pathRe.exec(body)) !== null) {
141
149
  const candidate = match[0];
150
+ if (PATH_PLACEHOLDER_PATTERN.test(candidate))
151
+ continue;
142
152
  if (!fs.existsSync(candidate)) {
143
153
  stale.push(candidate);
144
154
  }
@@ -753,7 +753,9 @@ export async function akmLint(options = {}) {
753
753
  // classic singular/plural typo ("workflow" for "workflows"). Non-akm
754
754
  // adapters keep their own type vocabularies (see lintViaAdapter).
755
755
  if (options.typeFilter && !STASH_SUBDIRS.includes(options.typeFilter)) {
756
- throw new UsageError(`lint: unknown --type "${options.typeFilter}". Valid types: ${STASH_SUBDIRS.join(", ")}.`, "INVALID_FLAG_VALUE");
756
+ warn(`Warning: lint --type "${options.typeFilter}" is not a recognized akm stash subdirectory — ` +
757
+ `valid types: ${STASH_SUBDIRS.join(", ")}. The whole bundle was validated.`);
758
+ options = { ...options, typeFilter: undefined };
757
759
  }
758
760
  return lintAkmSweep(stashRoot, extraStashRoots, cfg, sources, options);
759
761
  }
@@ -1,7 +1,7 @@
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 { defineGroupCommand, defineJsonCommand, EXIT_CODES, output } from "../cli/shared.js";
4
+ import { defineGroupCommand, defineJsonCommand, EXIT_CODES, outputWithExitCode } from "../cli/shared.js";
5
5
  import { runMigrationTool } from "./migration-tool.js";
6
6
  /**
7
7
  * `akm migrate` is a thin wrapper over the standalone `akm-migrate`
@@ -23,9 +23,11 @@ export async function runMigrateSubcommand(command, args, runTool = runMigration
23
23
  catch {
24
24
  plan = undefined;
25
25
  }
26
- if (plan)
27
- output(command, plan);
28
- else if (line)
26
+ if (plan) {
27
+ outputWithExitCode(command, plan, result.status);
28
+ return;
29
+ }
30
+ if (line)
29
31
  console.log(line);
30
32
  if (result.status !== EXIT_CODES.SUCCESS)
31
33
  process.exitCode = result.status;
@@ -17,6 +17,7 @@
17
17
  import fs from "node:fs";
18
18
  import { z } from "zod";
19
19
  import { UsageError } from "../../core/errors.js";
20
+ import { warnOnce } from "../../core/warn.js";
20
21
  import { PROPOSAL_SOURCES } from "./repository.js";
21
22
  // Valid `generator` values for a drain rule are exactly the canonical proposal
22
23
  // `source` values (see {@link PROPOSAL_SOURCES} in src/commands/proposal/repository.ts). The
@@ -87,7 +88,7 @@ const DrainAcceptRuleSchema = z
87
88
  minContentLines: z.number().int().nonnegative().optional(),
88
89
  requireType: z.string().optional(),
89
90
  })
90
- .strict();
91
+ .passthrough();
91
92
  const DrainPolicySchema = z
92
93
  .object({
93
94
  name: z.string().min(1),
@@ -95,7 +96,17 @@ const DrainPolicySchema = z
95
96
  rejectEmpty: z.boolean(),
96
97
  defer: z.array(GeneratorSchema),
97
98
  })
98
- .strict();
99
+ .passthrough();
100
+ function warnIgnoredPolicyKeys(filePath, label, raw, knownKeys) {
101
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
102
+ return;
103
+ const extra = Object.keys(raw).filter((key) => !knownKeys.includes(key));
104
+ if (extra.length === 0)
105
+ return;
106
+ warnOnce(`drain-policy-ignored-keys:${filePath}:${label}:${extra.join(",")}`, `[proposal] Policy file "${filePath}" has ${label} field(s) akm does not recognize and ignores: ${extra.join(", ")}. Check for a typo, or the file may be written for a newer akm version.`);
107
+ }
108
+ const DRAIN_POLICY_KNOWN_KEYS = Object.keys(DrainPolicySchema.shape);
109
+ const DRAIN_ACCEPT_RULE_KNOWN_KEYS = Object.keys(DrainAcceptRuleSchema.shape);
99
110
  /**
100
111
  * Resolve a `--policy <preset|path>` argument into a {@link DrainPolicy}.
101
112
  *
@@ -127,5 +138,14 @@ export function resolveDrainPolicy(arg) {
127
138
  if (!validated.success) {
128
139
  throw new UsageError(`Invalid policy file "${value}": ${validated.error.issues.map((i) => `${i.path.join(".") || "<root>"}: ${i.message}`).join("; ")}`, "INVALID_FLAG_VALUE");
129
140
  }
141
+ warnIgnoredPolicyKeys(value, "top-level", parsed, DRAIN_POLICY_KNOWN_KEYS);
142
+ if (parsed &&
143
+ typeof parsed === "object" &&
144
+ !Array.isArray(parsed) &&
145
+ Array.isArray(parsed.accept)) {
146
+ parsed.accept.forEach((rule, index) => {
147
+ warnIgnoredPolicyKeys(value, `accept[${index}]`, rule, DRAIN_ACCEPT_RULE_KNOWN_KEYS);
148
+ });
149
+ }
130
150
  return validated.data;
131
151
  }
@@ -123,6 +123,42 @@ export function classifyProposal(proposal, policy, maxDiffLines) {
123
123
  function deferReasonForSource(source) {
124
124
  return source === "distill" ? "possible-dup" : "mid-band";
125
125
  }
126
+ /**
127
+ * Map a thrown error's message to one of `DrainResult.failed`'s stable reason
128
+ * codes, falling back to `fallback` for anything not specifically recognized.
129
+ * Recognizes the write-time guards a proposal can trip during promotion
130
+ * (see repository.ts's `promoteProposalWithLease` / `preflightProposalPromotion`).
131
+ */
132
+ function categorizeDrainFailure(message, fallback) {
133
+ if (/target (?:changed after|was created after) proposal/.test(message))
134
+ return "stale-target";
135
+ if (/failed validation:/.test(message))
136
+ return "validation";
137
+ return fallback;
138
+ }
139
+ function pushDrainFailure(result, id, err, fallbackReason) {
140
+ const message = err instanceof Error ? err.message : String(err);
141
+ result.failed.push({ id, reason: categorizeDrainFailure(message, fallbackReason), detail: message });
142
+ return message;
143
+ }
144
+ /**
145
+ * Mirror repository.ts's `promoteProposalWithLease` stale-target guard so a
146
+ * dry-run preflight predicts the same refusal a real promote would hit,
147
+ * without writing anything. `assetPath` is the path `preflightProposalPromotion`
148
+ * already resolved for this proposal.
149
+ */
150
+ function assertProposalTargetFresh(proposal, assetPath) {
151
+ const backup = fs.existsSync(assetPath) ? fs.readFileSync(assetPath) : undefined;
152
+ const currentHash = backup ? createHash("sha256").update(backup).digest("hex") : undefined;
153
+ if (proposal.beforeHash !== undefined && (!backup || currentHash !== proposal.beforeHash)) {
154
+ throw new Error(`Proposal target changed after proposal ${proposal.id} was created; refusing to overwrite newer content.`);
155
+ }
156
+ if (proposal.beforeHash === undefined &&
157
+ backup !== undefined &&
158
+ proposal.changes.some((change) => change.op === "create")) {
159
+ throw new Error(`Proposal target was created after proposal ${proposal.id} was created; refusing to overwrite newer content.`);
160
+ }
161
+ }
126
162
  // ---------------------------------------------------------------------------
127
163
  // Judgment tier (Phase 3)
128
164
  // ---------------------------------------------------------------------------
@@ -493,6 +529,7 @@ export async function drainProposals(opts, promoteFn = akmProposalAccept, reject
493
529
  deferred: classification.deferred,
494
530
  skippedByCap: [],
495
531
  staged: [],
532
+ failed: [],
496
533
  };
497
534
  // A configured judgment runner makes every deferred item dispatch-eligible.
498
535
  // Validate its symbolic credentials before applying any deterministic gate,
@@ -518,7 +555,8 @@ export async function drainProposals(opts, promoteFn = akmProposalAccept, reject
518
555
  result.rejected.push(target.id);
519
556
  }
520
557
  catch (err) {
521
- warn(`[triage] reject failed for ${target.id}: ${err instanceof Error ? err.message : String(err)}`);
558
+ const message = pushDrainFailure(result, target.id, err, "reject-error");
559
+ warn(`[triage] reject failed for ${target.id}: ${message}`);
522
560
  }
523
561
  }
524
562
  // --- Accept ceiling: enforced BEFORE the promote loop ---
@@ -550,13 +588,15 @@ export async function drainProposals(opts, promoteFn = akmProposalAccept, reject
550
588
  deterministicPromoted += 1;
551
589
  }
552
590
  catch (err) {
553
- warn(`[triage] promote failed for ${id}: ${err instanceof Error ? err.message : String(err)}`);
591
+ const message = pushDrainFailure(result, id, err, "promote-error");
592
+ warn(`[triage] promote failed for ${id}: ${message}`);
554
593
  }
555
594
  }
556
595
  }
557
596
  else if (opts.applyMode === "promote" && opts.dryRun) {
558
- // Exercise the same stamped candidate and lint boundary as real promotion.
559
- // Tests that omit config retain the classification-only seam.
597
+ // Exercise the same stamped candidate, lint, and stale-target boundary as
598
+ // real promotion so a dry-run's predicted promotions match what a real
599
+ // run would do. Tests that omit config retain the classification-only seam.
560
600
  const byId = new Map(pending.map((proposal) => [proposal.id, proposal]));
561
601
  for (const id of withinCap) {
562
602
  try {
@@ -564,7 +604,7 @@ export async function drainProposals(opts, promoteFn = akmProposalAccept, reject
564
604
  const proposal = byId.get(id);
565
605
  if (!proposal)
566
606
  throw new Error(`Proposal ${id} disappeared during drain preflight.`);
567
- preflightProposalPromotion(opts.config, proposal, {
607
+ const preflight = preflightProposalPromotion(opts.config, proposal, {
568
608
  ...(opts.target ? { target: opts.target } : {}),
569
609
  gateDecision: {
570
610
  outcome: "auto-accepted",
@@ -572,12 +612,14 @@ export async function drainProposals(opts, promoteFn = akmProposalAccept, reject
572
612
  gate: gateLabel,
573
613
  },
574
614
  });
615
+ assertProposalTargetFresh(proposal, preflight.assetPath);
575
616
  }
576
617
  result.promoted.push(id);
577
618
  deterministicPromoted += 1;
578
619
  }
579
620
  catch (err) {
580
- warn(`[triage] preflight failed for ${id}: ${err instanceof Error ? err.message : String(err)}`);
621
+ const message = pushDrainFailure(result, id, err, "promote-error");
622
+ warn(`[triage] preflight failed for ${id}: ${message}`);
581
623
  }
582
624
  }
583
625
  }
@@ -473,6 +473,7 @@ const proposalDrainCommand = defineJsonCommand({
473
473
  deferred: result.deferred,
474
474
  skippedByCap: result.skippedByCap,
475
475
  staged: result.staged,
476
+ failed: result.failed,
476
477
  });
477
478
  },
478
479
  });
@@ -69,7 +69,7 @@ import { formatNewAssetDiff, formatUnifiedDiff } from "./diff-format.js";
69
69
  import { isAutomatedProposalSource, isValidProposalSource, PROPOSAL_SOURCES, } from "./proposal-types.js";
70
70
  import { hasCanonicalProposalValidator } from "./validators/proposal-validators.js";
71
71
  import { repairProposalContent, validateProposal } from "./validators/proposals.js";
72
- const PROMOTION_LINT_BLOCKERS = new Set(["unquoted-colon", "missing-ref", "stale-path"]);
72
+ const PROMOTION_LINT_ISSUE_TYPES = new Set(["unquoted-colon", "missing-ref", "stale-path"]);
73
73
  // ── Proposal domain types (moved to ./proposal-types.ts, WI-9.8 KILL 1) ─────
74
74
  //
75
75
  // Proposal / ProposalStatus / ProposalPayload / ProposalReview /
@@ -1546,7 +1546,7 @@ function promotionLintBlockers(raw, assetPath, targetRoot, refType, config) {
1546
1546
  fix: false,
1547
1547
  stashRoot: targetRoot,
1548
1548
  extraStashRoots,
1549
- }).filter((finding) => PROMOTION_LINT_BLOCKERS.has(finding.issue));
1549
+ }).filter((finding) => PROMOTION_LINT_ISSUE_TYPES.has(finding.issue));
1550
1550
  }
1551
1551
  /** Build and validate the exact stamped bytes promotion would publish, without writing. */
1552
1552
  export function preflightProposalPromotion(config, proposal, options = {}, ctx) {
@@ -1571,8 +1571,8 @@ export function preflightProposalPromotion(config, proposal, options = {}, ctx)
1571
1571
  : repairedContent;
1572
1572
  const lintBlockers = promotionLintBlockers(stampedContent, assetPath, target.source.path, ref.type, config);
1573
1573
  if (lintBlockers.length > 0) {
1574
- const message = lintBlockers.map((finding) => `[${finding.issue}] ${finding.detail}`).join("\n");
1575
- throw new UsageError(`Proposal ${proposal.id} failed lint:\n${message}`, "INVALID_PROPOSAL", "Fix or explicitly suppress the reported lint findings, then retry.");
1574
+ const summary = lintBlockers.map((finding) => `[${finding.issue}] ${finding.detail}`).join("; ");
1575
+ warn(`[proposal] promotion lint for ${proposal.id} found (non-blocking): ${summary}`);
1576
1576
  }
1577
1577
  return { proposal: preparedProposal, repairedContent, ref, target, assetPath, stampedContent };
1578
1578
  }
@@ -370,14 +370,35 @@ const reflectSizeGuardValidator = {
370
370
  ];
371
371
  },
372
372
  };
373
+ /**
374
+ * Report a validator's findings as advisory.
375
+ *
376
+ * These validators judge prose quality — a description that reads like a
377
+ * heading, an odd backtick count, a body that grew more than the reflect
378
+ * ratio allows. They used to BLOCK `proposal accept`, which a human types
379
+ * after reading the diff, and the error told that human to "fix the proposal
380
+ * payload and try again" — but there is no `akm proposal edit` and `accept`
381
+ * takes no `--force`, so the only way out was hand-editing the proposals
382
+ * database. A blocking check whose remedy does not exist is not a check.
383
+ *
384
+ * Structural findings stay blocking: an empty body, an unparseable ref,
385
+ * malformed frontmatter and a broken workflow shape genuinely cannot be
386
+ * written, and they live in {@link defaultProposalValidators}.
387
+ */
388
+ function advisory(validator) {
389
+ return {
390
+ ...validator,
391
+ validate: (proposal, ctx) => validator.validate(proposal, ctx).map((finding) => ({ ...finding, severity: "warn" })),
392
+ };
393
+ }
373
394
  /**
374
395
  * Full set of quality validators in registration order. Appended onto
375
396
  * {@link defaultProposalValidators} so they run inside `validateProposal` on
376
- * `proposal accept` automatically.
397
+ * `proposal accept` automatically, and report without blocking.
377
398
  */
378
399
  export const defaultProposalQualityValidators = [
379
400
  descriptionQualityValidator,
380
401
  lessonContentQualityValidator,
381
402
  sourceNotSupersededValidator,
382
403
  reflectSizeGuardValidator,
383
- ];
404
+ ].map(advisory);
@@ -26,14 +26,18 @@ export function validateProposal(proposal) {
26
26
  * structure and applies {@link repairTruncatedDescription} to a truncated
27
27
  * description when one is detected.
28
28
  *
29
- * Repairs performed (in order):
30
- * 1. Strip body lines that restate frontmatter fields as pseudo-frontmatter
31
- * (e.g. `**description**: …` or `when_to_use: …` in the body).
32
- * 2. Remove stray body `---` horizontal-rule lines (leaving exactly the two
33
- * frontmatter fences when the content has a valid frontmatter block).
34
- * 3. Apply {@link repairTruncatedDescription} to a truncated/hanging
29
+ * Repairs performed:
30
+ * 1. Apply {@link repairTruncatedDescription} to a truncated/hanging
35
31
  * `description` field in the frontmatter.
36
32
  *
33
+ * It deliberately does NOT delete body lines. Two earlier repairs dropped
34
+ * every body line that restated a frontmatter key and every `---` in a body
35
+ * with frontmatter. Both fired inside fenced code blocks, so any asset
36
+ * documenting frontmatter — a note about akm, Claude Code skills, Jekyll,
37
+ * Hugo — was silently gutted on `proposal accept`, and the repaired bytes
38
+ * were written back over the original in the proposals database. A repair
39
+ * that can destroy content is not a repair.
40
+ *
37
41
  * Returns the repaired content string. When no repairs apply the input is
38
42
  * returned byte-identical so callers can use strict equality to detect
39
43
  * whether a repair actually happened.
@@ -81,19 +85,6 @@ export function repairProposalContent(content) {
81
85
  repairedLines.push(line);
82
86
  continue;
83
87
  }
84
- // Repair 1: Strip pseudo-frontmatter restatements in the body.
85
- // Matches lines like `**description**: …` or `when_to_use: …`.
86
- if (/^\s*(\*\*|__)?\s*(description|when_to_use)\s*(\*\*|__)?\s*:/i.test(line)) {
87
- // Drop the line — it is a structural defect, not user content.
88
- continue;
89
- }
90
- // Repair 2: Remove stray `---` horizontal-rule lines in the body.
91
- // We keep these only when the content has NO frontmatter (in that case
92
- // `---` is a legitimate thematic break in plain-body content).
93
- if (isFence && hasFrontmatter) {
94
- // Drop: these are extra `---` fences beyond the two frontmatter delimiters.
95
- continue;
96
- }
97
88
  repairedLines.push(line);
98
89
  }
99
90
  let repaired = repairedLines.join("\n");