akm-cli 0.9.10 → 0.9.12

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 (145) hide show
  1. package/CHANGELOG.md +226 -0
  2. package/STABILITY.md +28 -15
  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/cli/shared.js +16 -4
  8. package/dist/cli.js +15 -13
  9. package/dist/commands/agent/agent-dispatch.js +8 -0
  10. package/dist/commands/command/execution-source-loader.js +25 -22
  11. package/dist/commands/command/portable-template.js +4 -26
  12. package/dist/commands/config-cli.js +10 -4
  13. package/dist/commands/env/env-binding.js +10 -3
  14. package/dist/commands/env/env-cli.js +7 -0
  15. package/dist/commands/env/secret-cli.js +15 -4
  16. package/dist/commands/health/checks.js +209 -78
  17. package/dist/commands/health/improve-metrics.js +12 -0
  18. package/dist/commands/health.js +16 -4
  19. package/dist/commands/improve/distill/quality-gate.js +15 -7
  20. package/dist/commands/improve/distill.js +28 -12
  21. package/dist/commands/improve/eval-cases.js +9 -2
  22. package/dist/commands/improve/execution.js +1 -2
  23. package/dist/commands/improve/extract.js +82 -56
  24. package/dist/commands/improve/improve-strategies.js +26 -8
  25. package/dist/commands/improve/improve.js +32 -4
  26. package/dist/commands/improve/loop-stages.js +13 -3
  27. package/dist/commands/improve/preparation.js +9 -6
  28. package/dist/commands/improve/reflect.js +61 -77
  29. package/dist/commands/lint/index.js +3 -1
  30. package/dist/commands/migrate-cli.js +6 -4
  31. package/dist/commands/proposal/drain-policies.js +22 -2
  32. package/dist/commands/proposal/repository.js +4 -4
  33. package/dist/commands/proposal/validators/proposal-quality-validators.js +23 -2
  34. package/dist/commands/proposal/validators/proposals.js +10 -19
  35. package/dist/commands/read/show.js +42 -31
  36. package/dist/commands/registry-cli.js +4 -2
  37. package/dist/commands/sources/init.js +4 -8
  38. package/dist/commands/sources/self-update.js +2 -2
  39. package/dist/commands/sources/source-clone.js +5 -7
  40. package/dist/commands/sources/sources-cli.js +3 -5
  41. package/dist/commands/tasks/tasks-cli.js +36 -12
  42. package/dist/commands/tasks/tasks.js +38 -35
  43. package/dist/commands/tasks/validate.js +186 -0
  44. package/dist/commands/url-checker.js +75 -16
  45. package/dist/commands/workflow-cli.js +17 -15
  46. package/dist/core/activation-policy.js +31 -3
  47. package/dist/core/adapter/execution-source.js +39 -11
  48. package/dist/core/asset/stash-meta.js +7 -41
  49. package/dist/core/bundle-id.js +7 -1
  50. package/dist/core/common.js +8 -17
  51. package/dist/core/config/config-schema.js +3 -23
  52. package/dist/core/config/config-walker.js +50 -4
  53. package/dist/core/config/config.js +21 -5
  54. package/dist/core/config/legacy-source-shape-shim.js +79 -0
  55. package/dist/core/config/schema/embedding.js +2 -2
  56. package/dist/core/config/schema/engines.js +19 -2
  57. package/dist/core/config/schema/index-config.js +19 -21
  58. package/dist/core/config/schema/primitives.js +21 -10
  59. package/dist/core/config/schema/sources-bundles.js +1 -6
  60. package/dist/core/errors.js +2 -3
  61. package/dist/core/improve-result.js +8 -0
  62. package/dist/core/improve-types.js +17 -0
  63. package/dist/core/json-schema.js +1 -11
  64. package/dist/core/maintenance-barrier.js +17 -2
  65. package/dist/core/paths.js +124 -15
  66. package/dist/core/state/migrations.js +28 -0
  67. package/dist/core/state-db.js +28 -1
  68. package/dist/core/write-source.js +6 -6
  69. package/dist/indexer/bundle-identity-guard.js +3 -0
  70. package/dist/indexer/ensure-index.js +5 -0
  71. package/dist/indexer/indexer.js +11 -3
  72. package/dist/indexer/lookup/adapter-concept-owner.js +14 -3
  73. package/dist/indexer/passes/metadata.js +16 -5
  74. package/dist/indexer/search/search-fields.js +1 -30
  75. package/dist/indexer/search/search-source.js +3 -2
  76. package/dist/integrations/agent/engine-resolution.js +107 -4
  77. package/dist/integrations/agent/execution-lowering.js +15 -2
  78. package/dist/integrations/agent/model-map.js +16 -10
  79. package/dist/integrations/agent/prompts.js +13 -6
  80. package/dist/integrations/agent/runner-dispatch.js +16 -3
  81. package/dist/integrations/agent/runner.js +2 -0
  82. package/dist/integrations/lockfile.js +22 -7
  83. package/dist/llm/client.js +16 -0
  84. package/dist/llm/index-passes.js +3 -2
  85. package/dist/output/shapes/passthrough.js +10 -3
  86. package/dist/output/shapes.js +5 -3
  87. package/dist/output/text/workflow-format.js +8 -1
  88. package/dist/scripts/akm-migrate-node.js +2028 -1613
  89. package/dist/scripts/akm-migrate.js +2027 -1612
  90. package/dist/setup/setup.js +14 -21
  91. package/dist/sources/include.js +150 -20
  92. package/dist/sources/providers/git-install.js +14 -12
  93. package/dist/sources/providers/git-provider.js +3 -3
  94. package/dist/sources/snapshot-fetchers/website-ingest.js +54 -16
  95. package/dist/sources/website-url.js +12 -4
  96. package/dist/storage/engines/sqlite-migrations.js +40 -10
  97. package/dist/storage/like-pattern.js +7 -0
  98. package/dist/storage/repositories/extract-sessions-repository.js +23 -0
  99. package/dist/storage/repositories/index-connection.js +27 -10
  100. package/dist/storage/repositories/index-entry-schema.js +19 -2
  101. package/dist/storage/repositories/index-schema.js +30 -9
  102. package/dist/storage/repositories/proposals-repository.js +2 -1
  103. package/dist/storage/repositories/task-history-repository.js +14 -7
  104. package/dist/storage/repositories/workflow-runs-repository.js +15 -1
  105. package/dist/storage/sqlite-read-snapshot.js +11 -9
  106. package/dist/tasks/backends/cron.js +34 -5
  107. package/dist/tasks/backends/launchd.js +23 -26
  108. package/dist/tasks/backends/schtasks.js +50 -3
  109. package/dist/tasks/frozen-script.js +2 -0
  110. package/dist/tasks/prepare/prepare.js +2 -7
  111. package/dist/tasks/prepare/script-capture.js +38 -6
  112. package/dist/tasks/schedule.js +154 -13
  113. package/dist/tasks/scheduler-sync.js +51 -25
  114. package/dist/tasks/source/task-source-v3-frozen.js +0 -1
  115. package/dist/tasks/source/task-source-v4.js +0 -1
  116. package/dist/workflows/exec/child-workflow.js +2 -3
  117. package/dist/workflows/exec/dispatch-redaction.js +21 -7
  118. package/dist/workflows/exec/exec-unit.js +3 -4
  119. package/dist/workflows/exec/run-workflow.js +19 -10
  120. package/dist/workflows/exec/step-work.js +35 -56
  121. package/dist/workflows/freeze/resolve-steps.js +19 -11
  122. package/dist/workflows/freeze/source-freeze.js +7 -0
  123. package/dist/workflows/freeze/targets/child-workflow.js +12 -18
  124. package/dist/workflows/freeze/targets/command.js +14 -2
  125. package/dist/workflows/ir/environment-v4.js +4 -2
  126. package/dist/workflows/ir/freeze-v4.js +2 -5
  127. package/dist/workflows/ir/plan-hash.js +0 -3
  128. package/dist/workflows/ir/schema-v4.js +14 -9
  129. package/dist/workflows/ir/schema.js +1 -3
  130. package/dist/workflows/resource-limits.js +35 -48
  131. package/dist/workflows/runtime/plan-classifier.js +89 -41
  132. package/dist/workflows/runtime/run-outputs.js +1 -21
  133. package/dist/workflows/runtime/runs.js +76 -150
  134. package/dist/workflows/source-files.js +28 -54
  135. package/dist/workflows/source-ir/program.js +2 -2
  136. package/dist/workflows/source-ir/semantics.js +5 -23
  137. package/docs/integration/bundling-akm.md +1 -1
  138. package/docs/migration/v0.8-to-v0.9.md +32 -0
  139. package/docs/migration/v0.9.1-to-v0.9.2.md +20 -0
  140. package/docs/reference/cli.md +105 -22
  141. package/docs/reference/configuration.md +12 -2
  142. package/docs/reference/data-and-telemetry.md +1 -1
  143. package/docs/reference/tasks.md +8 -0
  144. package/package.json +1 -1
  145. package/schemas/akm-config.json +11 -8
@@ -11,7 +11,7 @@ import { ConfigError, rethrowIfTestIsolationError, UsageError } from "../../core
11
11
  import { appendEvent, readEvents } from "../../core/events.js";
12
12
  import { classifyImproveAction, foldDistillSkipped } from "../../core/improve-types.js";
13
13
  import { resolveMutationTarget } from "../../core/mutation-target.js";
14
- import { getDbPath, getStateDbPathInDataDir } from "../../core/paths.js";
14
+ import { getDbPath, getStashLocksDir, getStateDbPathInDataDir } from "../../core/paths.js";
15
15
  import { redactSensitiveText } from "../../core/redaction.js";
16
16
  import { openStateDatabase } from "../../core/state-db.js";
17
17
  import { info, warn, warnVerbose } from "../../core/warn.js";
@@ -481,8 +481,11 @@ function resolveImproveRunSetup(options) {
481
481
  // calling test's own at this point; we capture it before yielding the loop.
482
482
  const resolvedStateDbPath = getStateDbPathInDataDir();
483
483
  // One conservative run lock protects the complete mutation window, including
484
- // triage, indexing, proposal work, maintenance, and final stash sync.
485
- const lockBaseDir = primaryStashDir ? path.join(primaryStashDir, ".akm") : path.join(options.stashDir ?? ".", ".akm");
484
+ // triage, indexing, proposal work, maintenance, and final stash sync. Moved
485
+ // out of `$STASH/.akm` to `$STATE/locks/<stash>/` (itlackey/akm#890): a lock
486
+ // file is machine-local coordination state, not content that must travel
487
+ // with the bundle.
488
+ const lockBaseDir = getStashLocksDir(primaryStashDir ?? options.stashDir ?? ".");
486
489
  const resolvedLockPath = improveLockPath(lockBaseDir);
487
490
  const effectiveSync = { ...improveProfile.sync, ...options.sync };
488
491
  return {
@@ -1129,6 +1132,19 @@ async function runImproveStageSequence(args) {
1129
1132
  },
1130
1133
  }, eventsCtx);
1131
1134
  }
1135
+ for (const item of resolvedPlan.engineUnavailable) {
1136
+ warn(`[improve] ${item.process} skipped — it ${item.reason}.`);
1137
+ appendEvent({
1138
+ eventType: "improve_skipped",
1139
+ ref: undefined,
1140
+ metadata: {
1141
+ strategy: selectedStrategy.name,
1142
+ reason: "engine_unavailable",
1143
+ process: item.process,
1144
+ configKey: item.configKey,
1145
+ },
1146
+ }, eventsCtx);
1147
+ }
1132
1148
  // Single prep->loop->post-loop pass, run under the invocation's lock.
1133
1149
  // Accumulators are direct assignments from the single pass's results.
1134
1150
  let preparation;
@@ -1146,6 +1162,7 @@ async function runImproveStageSequence(args) {
1146
1162
  // Concatenated arrays.
1147
1163
  const allWarnings = [];
1148
1164
  let deadUrls;
1165
+ let deadUrlCoverage;
1149
1166
  const finalActions = [];
1150
1167
  {
1151
1168
  const runPreparation = () => runImprovePreparationStageImpl({
@@ -1228,6 +1245,9 @@ async function runImproveStageSequence(args) {
1228
1245
  if (postLoopResult.deadUrls !== undefined) {
1229
1246
  deadUrls = [...(deadUrls ?? []), ...postLoopResult.deadUrls];
1230
1247
  }
1248
+ if (postLoopResult.deadUrlCoverage !== undefined) {
1249
+ deadUrlCoverage = postLoopResult.deadUrlCoverage;
1250
+ }
1231
1251
  const maintenanceActions = postLoopResult.maintenanceActions;
1232
1252
  if (maintenanceActions && maintenanceActions.length > 0) {
1233
1253
  finalActions.push(...preparation.actions, ...maintenanceActions);
@@ -1250,6 +1270,7 @@ async function runImproveStageSequence(args) {
1250
1270
  proposalsExpired,
1251
1271
  allWarnings,
1252
1272
  deadUrls,
1273
+ deadUrlCoverage,
1253
1274
  finalActions,
1254
1275
  };
1255
1276
  }
@@ -1261,7 +1282,7 @@ async function runImproveStageSequence(args) {
1261
1282
  function finalizeImproveResult(args) {
1262
1283
  const { guidance, memorySummary, memoryCleanupPlan, strategyFilteredRefs, rawPlannedRefs, indexSnapshot, triageDrain, eventsCtx, } = args;
1263
1284
  const { selectedStrategy, scope, options, primaryStashDir, startMs } = args.run;
1264
- const { preparation, consolidation, memoryInference, graphExtraction, cycleMetrics, reflectsWithErrorContext, memoryInferenceDurationMs, graphExtractionDurationMs, orphansPurged, proposalsExpired, allWarnings, deadUrls, finalActions, } = args.seq;
1285
+ const { preparation, consolidation, memoryInference, graphExtraction, cycleMetrics, reflectsWithErrorContext, memoryInferenceDurationMs, graphExtractionDurationMs, orphansPurged, proposalsExpired, allWarnings, deadUrls, deadUrlCoverage, finalActions, } = args.seq;
1265
1286
  // C1 (13-bus-factor): fold the per-ref `distill-skipped` rows (~13k/run,
1266
1287
  // ~91% of result_json bytes) into a bounded aggregate BEFORE persistence.
1267
1288
  // The metric total + per-reason breakdown are preserved on `distillSkipped`;
@@ -1326,6 +1347,10 @@ function finalizeImproveResult(args) {
1326
1347
  ...(preparation.extract && preparation.extract.length > 0 ? { extract: preparation.extract } : {}),
1327
1348
  ...(primaryStashDir !== undefined ? { evalCasesWritten: countEvalCases(primaryStashDir) } : {}),
1328
1349
  ...(deadUrls !== undefined && deadUrls.length > 0 ? { deadUrls } : {}),
1350
+ // Present whenever the check ran, unlike `deadUrls` above — a clean run
1351
+ // (zero dead links) still needs to tell the health report how much of
1352
+ // the bundle it actually covered (#892).
1353
+ ...(deadUrlCoverage !== undefined ? { deadUrlCoverage } : {}),
1329
1354
  ...(reflectsWithErrorContext > 0 ? { reflectsWithErrorContext } : {}),
1330
1355
  ...(memoryInference ? { memoryInference } : {}),
1331
1356
  ...(graphExtraction ? { graphExtraction } : {}),
@@ -1501,6 +1526,9 @@ function emitImproveCompletedEvent(result, durations, eventsCtx) {
1501
1526
  coverageGapCount: result.coverageGaps?.length ?? 0,
1502
1527
  evalCasesWritten: result.evalCasesWritten ?? 0,
1503
1528
  deadUrlCount: result.deadUrls?.length ?? 0,
1529
+ deadUrlsChecked: result.deadUrlCoverage?.checked ?? 0,
1530
+ deadUrlsTotal: result.deadUrlCoverage?.total ?? 0,
1531
+ deadUrlsSkipped: result.deadUrlCoverage?.skipped ?? 0,
1504
1532
  memoryEligible: result.memorySummary.eligible,
1505
1533
  memoryDerived: result.memorySummary.derived,
1506
1534
  memoryCleanupPruneCandidates: result.memoryCleanup?.pruneCandidates.length ?? 0,
@@ -569,8 +569,16 @@ export async function runImprovePostLoopStage(args) {
569
569
  resolvedPlan,
570
570
  });
571
571
  let deadUrls;
572
+ let deadUrlCoverage;
572
573
  if (scope.mode === "all" && primaryStashDir && actionableRefs.length > 0) {
573
574
  try {
575
+ // Every actionable knowledge ref is scanned for URLs — there used to be
576
+ // a `.slice(0, 10)` here, capping the scan to the first ten refs while
577
+ // `deadUrlCoverage.total` counted only those, so a real bundle reported
578
+ // checked === total while most refs were never looked at (#892). URL
579
+ // extraction (a regex over already-loaded text) is cheap; it is the
580
+ // network requests that are expensive, and those are bounded by
581
+ // `checkDeadUrls`'s concurrency limit, not by trimming what gets scanned.
574
582
  const knowledgeEntries = actionableRefs
575
583
  .filter((r) => {
576
584
  try {
@@ -580,7 +588,6 @@ export async function runImprovePostLoopStage(args) {
580
588
  return false;
581
589
  }
582
590
  })
583
- .slice(0, 10)
584
591
  .map((r) => {
585
592
  // The URL scan needs the document body; filePath is pre-resolved on
586
593
  // eligible refs at planning time (#591). Best-effort — an unreadable
@@ -598,8 +605,10 @@ export async function runImprovePostLoopStage(args) {
598
605
  });
599
606
  if (knowledgeEntries.length > 0) {
600
607
  info(`[improve] checking URLs in ${knowledgeEntries.length} knowledge refs`);
601
- deadUrls = await checkDeadUrls(primaryStashDir, knowledgeEntries);
602
- info(`[improve] URL check complete (${deadUrls.length} dead/timeout URLs)`);
608
+ const urlCheck = await checkDeadUrls(primaryStashDir, knowledgeEntries);
609
+ deadUrls = urlCheck.deadUrls;
610
+ deadUrlCoverage = urlCheck.coverage;
611
+ info(`[improve] URL check complete (${deadUrls.length} dead/timeout URLs; checked ${urlCheck.coverage.checked} of ${urlCheck.coverage.total})`);
603
612
  }
604
613
  }
605
614
  catch {
@@ -625,6 +634,7 @@ export async function runImprovePostLoopStage(args) {
625
634
  return {
626
635
  allWarnings,
627
636
  deadUrls,
637
+ ...(deadUrlCoverage ? { deadUrlCoverage } : {}),
628
638
  ...(cycleMetrics ? { cycleMetrics } : {}),
629
639
  ...(maintenanceResult.memoryInference ? { memoryInference: maintenanceResult.memoryInference } : {}),
630
640
  ...(maintenanceResult.graphExtraction ? { graphExtraction: maintenanceResult.graphExtraction } : {}),
@@ -25,7 +25,7 @@ import { isAutonomyLaneAllowed } from "./autonomy-gate.js";
25
25
  import { akmConsolidate, inspectConsolidationPool } from "./consolidate.js";
26
26
  import { computeSafeChunkSize, DEFAULT_CONTEXT_LENGTH_TOKENS } from "./consolidate/chunking.js";
27
27
  // Eligibility / candidate-selection predicates live in ./eligibility.
28
- import { buildLatestFeedbackTsMap, buildLatestProposalTsMap, buildUtilityMap, dedupeRefs, findAssetFilePath, isDistillCandidateRef, isLessonCandidate, isSignalDeltaEligible, } from "./eligibility.js";
28
+ import { buildLatestFeedbackTsMap, buildLatestProposalTsMap, buildUtilityMap, dedupeRefs, findAssetFilePath, isDistillCandidateRef, isLessonCandidate, isSignalDeltaEligible, resolveImproveScope, } from "./eligibility.js";
29
29
  import { akmExtract, countNewExtractCandidates } from "./extract.js";
30
30
  import { computeValenceScore, FEEDBACK_WEIGHT, UTILITY_WEIGHT } from "./feedback-valence.js";
31
31
  import { applyMemoryCleanup } from "./memory/memory-improve.js";
@@ -178,17 +178,20 @@ function evaluateConsolidationEligibility(args) {
178
178
  // #553 minPoolSize guard: skip consolidation when the eligible memory pool is
179
179
  // below a minimum size, rather than spending an LLM pass on a handful of
180
180
  // memories. This is an INDEPENDENT skip condition from #551's mtime pool-delta
181
- // gate — either can skip. Default 500; `minPoolSize: 0` disables the guard.
182
- // Evaluated against the eligible-pool count BEFORE entering the LLM loop so a
183
- // skip costs ZERO LLM calls.
184
- const CONSOLIDATE_DEFAULT_MIN_POOL_SIZE = 500;
181
+ // gate — either can skip. Default 0 (disabled) every built-in strategy
182
+ // used to ship 500, which meant `akm improve --strategy consolidate`, typed
183
+ // by a human, silently did nothing on almost every real install. Evaluated
184
+ // against the eligible-pool count BEFORE entering the LLM loop so a skip
185
+ // costs ZERO LLM calls when an operator opts back into a floor.
186
+ const CONSOLIDATE_DEFAULT_MIN_POOL_SIZE = 0;
185
187
  const configuredMinPoolSize = improveProfile?.processes?.consolidate?.minPoolSize;
186
188
  const minPoolSize = typeof configuredMinPoolSize === "number" ? configuredMinPoolSize : CONSOLIDATE_DEFAULT_MIN_POOL_SIZE;
187
189
  const eligiblePoolSize = typeof memorySummary.eligible === "number" ? memorySummary.eligible : 0;
190
+ const userNamedStrategyOrScope = options.strategy !== undefined || resolveImproveScope(options.scope).mode === "ref";
188
191
  // volumeTriggered means the pool already exceeds the volume threshold (100),
189
192
  // so a force-triggered run never trips the pool-size guard. The guard only
190
193
  // engages when minPoolSize > 0 and the eligible pool is strictly below it.
191
- const poolBelowMinSize = !volumeTriggered && minPoolSize > 0 && eligiblePoolSize < minPoolSize;
194
+ const poolBelowMinSize = !volumeTriggered && !userNamedStrategyOrScope && minPoolSize > 0 && eligiblePoolSize < minPoolSize;
192
195
  return {
193
196
  volumeTriggered,
194
197
  consolidationOnCooldown,
@@ -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,
@@ -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
  }
@@ -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);