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
@@ -3,7 +3,7 @@
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
- import { resolveStashDir } from "../core/common.js";
6
+ import { daysToMs, resolveStashDir } from "../core/common.js";
7
7
  import { loadConfig } from "../core/config/config.js";
8
8
  import { ConfigError, UsageError } from "../core/errors.js";
9
9
  import { readEvents } from "../core/events.js";
@@ -12,12 +12,14 @@ import { classifyPathAccess, describeInaccessiblePath } from "../core/path-acces
12
12
  import { getConfigPath, getDataDir, getDbPath, getStateDbPathInDataDir } from "../core/paths.js";
13
13
  import { listExistingTableNames, listPendingStateMigrations, openStateDatabase } from "../core/state-db.js";
14
14
  import { DURATION_UNITS, parseDuration, parseSinceToIso } from "../core/time.js";
15
+ import { probeLlmEndpoint } from "../llm/client.js";
16
+ import { getExtractOutcomeCountsSince } from "../storage/repositories/extract-sessions-repository.js";
15
17
  import { closeDatabase, openReadonlyExistingDatabase } from "../storage/repositories/index-connection.js";
16
18
  import { getAllEntries } from "../storage/repositories/index-entries-repository.js";
17
19
  import { queryTaskHistory } from "../storage/repositories/task-history-repository.js";
18
20
  import { pkgVersion } from "../version.js";
19
21
  import { collectImproveAdvisories } from "./health/advisories.js";
20
- import { HEALTH_CHECKS, runHealthEngineProbes, runPendingStateMigrationsCheck, } from "./health/checks.js";
22
+ import { HEALTH_CHECKS, runHealthEngineProbes, runPendingStateMigrationsCheck, SESSION_EXTRACTION_LEDGER_WINDOW_DAYS, } from "./health/checks.js";
21
23
  import { collectDataDirUsageAdvisory } from "./health/data-dir-usage.js";
22
24
  import { buildImproveSkipSummary, computeWallTimeStats, isAgentTaskHistoryRow, roundRate, summarizeImproveCompleted, summarizeImproveRuns, taskFailureDetail, } from "./health/improve-metrics.js";
23
25
  import { emptyLlmUsageAggregate, readLlmUsageAggregate } from "./health/llm-usage.js";
@@ -166,6 +168,11 @@ function gatherEgressConfigPhase() {
166
168
  }
167
169
  return { egressConfigView };
168
170
  }
171
+ /** Extract-ledger outcome counts for the `session-extraction` check's window, independent of `--since`. */
172
+ function gatherSessionExtractionLedgerPhase(db, now) {
173
+ const since = new Date(now() - daysToMs(SESSION_EXTRACTION_LEDGER_WINDOW_DAYS)).toISOString();
174
+ return { since, rows: getExtractOutcomeCountsSince(db, since) };
175
+ }
169
176
  /**
170
177
  * Assemble the window's improve-pipeline summary: invoked/completed/skipped
171
178
  * counts from events, the per-run result_json aggregate, wall-time stats, and
@@ -466,7 +473,7 @@ function unreadableStateDbCheck(detail) {
466
473
  evidence: { detail },
467
474
  };
468
475
  }
469
- export function akmHealth(options = {}) {
476
+ export async function akmHealth(options = {}) {
470
477
  validateAkmHealthOptions(options);
471
478
  const now = options.now ?? (() => Date.now());
472
479
  const since = parseHealthSince(options.since);
@@ -519,12 +526,16 @@ export function akmHealth(options = {}) {
519
526
  logsDb = undefined;
520
527
  }
521
528
  try {
529
+ // Network probes overlap the local database phases below; awaited where consumed.
530
+ const engineProbesPromise = runHealthEngineProbes({ probeReachable: options.probe ? probeLlmEndpoint : undefined });
531
+ engineProbesPromise.catch(() => undefined);
522
532
  const taskHistory = gatherTaskHistoryPhase(db, logsDb, since, stateDbPath, now);
523
533
  const { tableNames, missingTables, probe } = taskHistory;
524
534
  const { egressConfigView } = gatherEgressConfigPhase();
525
535
  const { improveSummary } = gatherImproveSummaryPhase(db, stateDbPath, since, now);
526
536
  advisories.push(...gatherAncillaryAdvisories(db, stateDbPath, since, improveSummary, options, egressConfigView));
527
- const engineProbes = runHealthEngineProbes();
537
+ const sessionExtractionLedger = gatherSessionExtractionLedgerPhase(db, now);
538
+ const engineProbes = await engineProbesPromise;
528
539
  // Run the ordered health-check registry. Each check projects the shared
529
540
  // context computed above into one HealthCheckResult; `channel` routes it to
530
541
  // hardChecks or advisories. Declaration order in HEALTH_CHECKS is the
@@ -544,6 +555,7 @@ export function akmHealth(options = {}) {
544
555
  stuckActiveTasks: taskHistory.stuckActiveTasks,
545
556
  worstTaskFailRate: taskHistory.worstTaskFailRate,
546
557
  sessionExtraction: improveSummary.sessionExtraction,
558
+ sessionExtractionLedger,
547
559
  autoAccept: improveSummary.autoAccept,
548
560
  engineProbes,
549
561
  };
@@ -15,6 +15,7 @@ import { timestampForFilename } from "../../../core/common.js";
15
15
  import { ConfigError } from "../../../core/errors.js";
16
16
  import { appendEvent } from "../../../core/events.js";
17
17
  import { parseEmbeddedJsonResponse } from "../../../core/parse.js";
18
+ import { getDistillRejectedDir } from "../../../core/paths.js";
18
19
  import { withStateDb } from "../../../core/state-db.js";
19
20
  import { recordWrittenPath } from "../../../core/write-provenance.js";
20
21
  import { callStructured } from "../../../llm/structured-call.js";
@@ -199,7 +200,7 @@ async function runQualityJudge(feature, config, prompt, chat, options = {}) {
199
200
  parsed.score < 1 ||
200
201
  parsed.score > 5 ||
201
202
  typeof parsed.reason !== "string") {
202
- return { pass: false, score: -1, reason: "judge parse failed — cannot judge, failing closed" };
203
+ return { pass: false, score: -1, reason: "judge parse failed — routed to review", reviewNeeded: true };
203
204
  }
204
205
  // D-5 / #388: Three-band system (MT-Bench arXiv:2306.05685 — ~±0.5 judge variance).
205
206
  // >= 3.5: auto-queue as pending (pass: true)
@@ -218,7 +219,7 @@ async function runQualityJudge(feature, config, prompt, chat, options = {}) {
218
219
  // content verdict. Provider/runtime failures retain the fail-closed result.
219
220
  if (error instanceof ConfigError)
220
221
  throw error;
221
- return { pass: false, score: -1, reason: "judge timeout/error — cannot judge, failing closed" };
222
+ return { pass: false, score: -1, reason: "judge timeout/error — routed to review", reviewNeeded: true };
222
223
  }
223
224
  }
224
225
  /**
@@ -242,8 +243,9 @@ export async function runReflectQualityJudge(config, candidateContent, sourceCon
242
243
  }
243
244
  // ── Quality-rejection helper ─────────────────────────────────────────────────
244
245
  /**
245
- * Write a rejected lesson to `.akm/distill-rejected/`, append a `distill_invoked`
246
- * quality-rejected event, and return the `quality_rejected` envelope.
246
+ * Write a rejected lesson to `$STATE/improve/distill-rejected/<stash>/`
247
+ * (itlackey/akm#890), append a `distill_invoked` quality-rejected event, and
248
+ * return the `quality_rejected` envelope.
247
249
  *
248
250
  * @param stash - Root stash directory.
249
251
  * @param inputRef - The original input ref (for the event).
@@ -257,13 +259,19 @@ export async function runReflectQualityJudge(config, candidateContent, sourceCon
257
259
  export function writeQualityRejection(stash, inputRef, proposalRef, content, score, reason, extraMeta = {}, eligibilitySource, eventsCtx) {
258
260
  // D-5 / #388: reviewNeeded flag selects "review_needed" vs "quality_rejected" outcome.
259
261
  const outcome = extraMeta.reviewNeeded ? "review_needed" : "quality_rejected";
260
- const rejectDir = path.join(stash, ".akm", "distill-rejected");
262
+ const rejectDir = getDistillRejectedDir(stash);
261
263
  fs.mkdirSync(rejectDir, { recursive: true });
262
264
  const ts = timestampForFilename();
263
265
  const rejectPath = path.join(rejectDir, `${ts}-${proposalRef.replace(/[:/\\]/g, "-")}.md`);
264
266
  fs.writeFileSync(rejectPath, `---\nscore: ${score}\nreason: ${reason}\noutcome: ${outcome}\n---\n\n${content}`, "utf8");
265
- // #652: the rejection envelope lands under the managed `.akm/` tree, which
266
- // the pre-provenance sync swept up by pathspec journal it explicitly.
267
+ // #652 / itlackey/akm#890: journal it even though it now lands under
268
+ // `$STATE`, outside the stash's git repo`result.writtenPaths` reports
269
+ // every path a run touched, in or out of the stash (describeRunWrittenPaths
270
+ // in improve.ts falls back to the absolute path for anything outside the
271
+ // stash root), and the auto-sync commit's own containment check
272
+ // (resolveSyncPathSet's `relativeWrittenPath`) already drops anything
273
+ // outside `repoDir` from what gets staged — recording it here cannot cause
274
+ // it to be committed.
267
275
  recordWrittenPath(rejectPath);
268
276
  appendEvent({
269
277
  eventType: "distill_invoked",
@@ -778,7 +778,7 @@ export async function akmDistill(options) {
778
778
  eventsCtx: options.eventsCtx,
779
779
  }));
780
780
  }
781
- const { content, descriptionSwapped } = assembleAndValidateDistillContent({
781
+ const assembled = assembleAndValidateDistillContent({
782
782
  raw,
783
783
  effectiveProposalKind,
784
784
  inputRef,
@@ -789,7 +789,11 @@ export async function akmDistill(options) {
789
789
  filteredFeedbackCount,
790
790
  eligMeta,
791
791
  eventsCtx: options.eventsCtx,
792
+ stash,
792
793
  });
794
+ if ("rejection" in assembled)
795
+ return withNotices(assembled.rejection);
796
+ const { content, descriptionSwapped } = assembled;
793
797
  const gate = await applyDistillQualityGate({
794
798
  config,
795
799
  options,
@@ -968,7 +972,7 @@ async function emitDistillLessonProposal(args) {
968
972
  * throwing a `UsageError` on any finding. Extracted verbatim from `akmDistill`.
969
973
  */
970
974
  function assembleAndValidateDistillContent(args) {
971
- const { raw, effectiveProposalKind, inputRef, durableInputRef, itemRef, effectiveLessonRef, exclusionSet, filteredFeedbackCount, eligMeta, eventsCtx, } = args;
975
+ const { raw, effectiveProposalKind, inputRef, durableInputRef, itemRef, effectiveLessonRef, exclusionSet, filteredFeedbackCount, eligMeta, eventsCtx, stash, } = args;
972
976
  // Structured-output path: when the provider honoured the JSON schema, `raw`
973
977
  // is a JSON object string (not a markdown blob). Try to parse it and assemble
974
978
  // the canonical `---\nfm\n---\n\nbody` form before using the markdown
@@ -1003,18 +1007,19 @@ function assembleAndValidateDistillContent(args) {
1003
1007
  content = repairLessonDescriptionTruncation(content);
1004
1008
  }
1005
1009
  // Parse + lint the lesson before creating the proposal. The lint is the
1006
- // canonical gate for required frontmatter (v1 spec §13). On failure we
1007
- // surface a structured error and exit non-zero but still emit
1008
- // `distill_invoked` so the failure is observable.
1009
- const findings = effectiveProposalKind === "knowledge"
1010
+ // canonical gate for required frontmatter (v1 spec §13): a field that is
1011
+ // genuinely missing or empty means there is no valid asset to write, so
1012
+ // that failure stays a hard reject — but still emit `distill_invoked` so
1013
+ // the failure is observable.
1014
+ const structuralFindings = effectiveProposalKind === "knowledge"
1010
1015
  ? validateKnowledgeContent(content, inputRef)
1011
1016
  : lintLessonContent(content, `distill:${inputRef}`).findings;
1012
1017
  // Additional lesson-only quality validators — reject the systematic failure
1013
1018
  // modes seen across 323 archived rejected proposals (see distill/content-repair).
1014
- if (effectiveProposalKind !== "knowledge" && findings.length === 0) {
1015
- findings.push(...collectLessonQualityFindings(content, inputRef));
1016
- }
1017
- if (findings.length > 0) {
1019
+ const qualityFindings = effectiveProposalKind !== "knowledge" && structuralFindings.length === 0
1020
+ ? collectLessonQualityFindings(content, inputRef)
1021
+ : [];
1022
+ if (structuralFindings.length > 0) {
1018
1023
  appendEvent({
1019
1024
  eventType: "distill_invoked",
1020
1025
  // Use item_ref when resolved, otherwise the input conceptId.
@@ -1023,16 +1028,27 @@ function assembleAndValidateDistillContent(args) {
1023
1028
  outcome: "validation_failed",
1024
1029
  proposalRef: effectiveLessonRef,
1025
1030
  proposalKind: effectiveProposalKind,
1026
- findingKinds: findings.map((f) => f.kind),
1031
+ findingKinds: structuralFindings.map((f) => f.kind),
1027
1032
  ...(exclusionSet.size > 0 ? { filteredFeedbackCount } : {}),
1028
1033
  ...eligMeta,
1029
1034
  },
1030
1035
  }, eventsCtx);
1031
- const message = findings.map((f) => f.message).join("\n");
1036
+ const message = structuralFindings.map((f) => f.message).join("\n");
1032
1037
  throw new UsageError(`Distilled ${effectiveProposalKind} failed validation:\n${message}`, "MISSING_REQUIRED_ARGUMENT", effectiveProposalKind === "knowledge"
1033
1038
  ? "Knowledge proposals require a non-empty markdown body."
1034
1039
  : "Lessons require non-empty `description` and `when_to_use` frontmatter fields. See v1 spec §13.");
1035
1040
  }
1041
+ if (qualityFindings.length > 0) {
1042
+ return {
1043
+ rejection: writeQualityRejection(stash, inputRef, effectiveLessonRef, content, 2.0, // below auto-accept threshold, signals review needed — no judge score exists for a structural/heuristic finding
1044
+ qualityFindings.map((f) => f.message).join("\n"), {
1045
+ reviewNeeded: true,
1046
+ proposalKind: effectiveProposalKind,
1047
+ findingKinds: qualityFindings.map((f) => f.kind),
1048
+ ...(exclusionSet.size > 0 ? { filteredFeedbackCount } : {}),
1049
+ }, eligMeta.eligibilitySource, eventsCtx),
1050
+ };
1051
+ }
1036
1052
  return { content, descriptionSwapped };
1037
1053
  }
1038
1054
  /**
@@ -4,9 +4,10 @@
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import { writeFileAtomic } from "../../core/common.js";
7
+ import { getEvalCasesDir } from "../../core/paths.js";
7
8
  import { recordWrittenPath } from "../../core/write-provenance.js";
8
9
  export function writeEvalCase(stashDir, evalCase) {
9
- const evalDir = path.join(stashDir, ".akm", "eval-cases");
10
+ const evalDir = getEvalCasesDir(stashDir);
10
11
  fs.mkdirSync(evalDir, { recursive: true });
11
12
  const fileName = `${evalCase.slug}.md`;
12
13
  const filePath = path.join(evalDir, fileName);
@@ -29,11 +30,17 @@ Use it as a regression test: future improve runs on this ref should not produce
29
30
  output that would be rejected for the same reason.
30
31
  `;
31
32
  writeFileAtomic(filePath, content);
33
+ // itlackey/akm#890: journal it even though it now lands under `$STATE`,
34
+ // outside the stash's git repo — `result.writtenPaths` still reports it
35
+ // (as an absolute path; see describeRunWrittenPaths in improve.ts), and
36
+ // the auto-sync commit's own containment check already excludes anything
37
+ // outside the stash from what gets staged, so recording it here cannot
38
+ // cause it to be committed.
32
39
  recordWrittenPath(filePath);
33
40
  return filePath;
34
41
  }
35
42
  export function countEvalCases(stashDir) {
36
- const evalDir = path.join(stashDir, ".akm", "eval-cases");
43
+ const evalDir = getEvalCasesDir(stashDir);
37
44
  if (!fs.existsSync(evalDir))
38
45
  return 0;
39
46
  try {
@@ -2,7 +2,6 @@
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import { deepMergeConfig } from "../../core/config/deep-merge.js";
5
- import { ConfigError } from "../../core/errors.js";
6
5
  import { lowerResolvedExecutionRequest } from "../../integrations/agent/execution-lowering.js";
7
6
  import { prepareInlineExecution } from "../../integrations/agent/inline-execution.js";
8
7
  function own(value, key) {
@@ -51,7 +50,7 @@ export function resolveImproveLlmExecution(options) {
51
50
  if (!resolved)
52
51
  return null;
53
52
  if (resolved.runner.kind !== "llm") {
54
- throw new ConfigError(`Engine ${JSON.stringify(resolved.runner.engine ?? "unknown")} is not an LLM engine.`, "INVALID_CONFIG_FILE");
53
+ return null;
55
54
  }
56
55
  return { runner: resolved.runner, notices: resolved.notices };
57
56
  }
@@ -31,6 +31,7 @@ import { getImproveProcessConfig, loadConfig } from "../../core/config/config.js
31
31
  import { ConfigError, UsageError } from "../../core/errors.js";
32
32
  import { appendEvent } from "../../core/events.js";
33
33
  import { createLockPayload, probeLock, reclaimStaleLock, releaseLock, tryAcquireLockSync, } from "../../core/file-lock.js";
34
+ import { EXTRACT_INFRASTRUCTURE_SKIP_REASONS } from "../../core/improve-types.js";
34
35
  import { tryAcquireMaintenanceBarrier } from "../../core/maintenance-barrier.js";
35
36
  import { redactErrorBody } from "../../core/redaction.js";
36
37
  import { resolveStashStandards } from "../../core/standards/resolve-stash-standards.js";
@@ -214,6 +215,24 @@ export function resolveStandaloneExtractPlan(config, selection) {
214
215
  });
215
216
  }
216
217
  // ── Helpers ──────────────────────────────────────────────────────────────────
218
+ /** An extract envelope for a run that processed no sessions; `engine`/`engineKind` only once a runner is resolved. */
219
+ function emptyExtractResult(args) {
220
+ return {
221
+ schemaVersion: 1,
222
+ ok: args.ok,
223
+ shape: "extract-result",
224
+ dryRun: args.dryRun,
225
+ type: args.type,
226
+ sessionsProcessed: 0,
227
+ sessionsSkipped: 0,
228
+ candidatesCreated: 0,
229
+ proposals: [],
230
+ sessions: [],
231
+ warnings: [args.warning],
232
+ durationMs: Date.now() - args.startMs,
233
+ ...(args.llmRunner ? { engine: args.llmRunner.engine, engineKind: args.llmRunner.kind } : {}),
234
+ };
235
+ }
217
236
  /**
218
237
  * Parse a since-string into an absolute ms-epoch cutoff. Accepts:
219
238
  * - ISO timestamps (parsed via Date.parse)
@@ -853,6 +872,7 @@ function recordExtractSessionOutcome(args) {
853
872
  preFilterInputCount: result.preFilter.inputCount,
854
873
  preFilterOutputCount: result.preFilter.outputCount,
855
874
  preFilterTruncatedCount: result.preFilter.truncatedCount,
875
+ engine: result.engine,
856
876
  ...(result.skipReason ? { skipReason: result.skipReason } : {}),
857
877
  ...(result.sessionLogPath ? { logPath: result.sessionLogPath } : {}),
858
878
  ...(result.sessionAssetRef ? { sessionAssetRef: result.sessionAssetRef } : {}),
@@ -864,7 +884,8 @@ function recordExtractSessionOutcome(args) {
864
884
  }
865
885
  }
866
886
  function accountExtractSessionResult(result, triageEnabled, output) {
867
- output.sessions.push(result);
887
+ const stamped = { ...result, engine: output.engine };
888
+ output.sessions.push(stamped);
868
889
  if (triageEnabled) {
869
890
  const preempted = result.skipReason === "read_failed" ||
870
891
  result.skipReason === "too_short" ||
@@ -883,6 +904,7 @@ function accountExtractSessionResult(result, triageEnabled, output) {
883
904
  else
884
905
  output.processedCount += 1;
885
906
  output.allProposalIds.push(...result.proposalIds);
907
+ return stamped;
886
908
  }
887
909
  /**
888
910
  * Iterate the discovered candidate sessions: enforce the per-run cap, take the
@@ -913,6 +935,7 @@ async function runExtractSessionLoop(args) {
913
935
  standardsContext: extractStandardsContext,
914
936
  };
915
937
  const output = {
938
+ engine: llmRunner.engine,
916
939
  sessions: [],
917
940
  processedCount: 0,
918
941
  skippedCount: 0,
@@ -947,14 +970,14 @@ async function runExtractSessionLoop(args) {
947
970
  break;
948
971
  const { summary } = plan;
949
972
  if (plan.kind === "skip") {
950
- accountExtractSessionResult(plan.result, triage.enabled, output);
973
+ const accounted = accountExtractSessionResult(plan.result, triage.enabled, output);
951
974
  recordExtractSessionOutcome({
952
975
  stateDb,
953
976
  trackingEnabled,
954
977
  dryRun,
955
978
  harness: harness.name,
956
979
  summary,
957
- result: plan.result,
980
+ result: accounted,
958
981
  sourceRun,
959
982
  });
960
983
  continue;
@@ -988,14 +1011,14 @@ async function runExtractSessionLoop(args) {
988
1011
  triage,
989
1012
  });
990
1013
  if ("skip" in executionGate) {
991
- accountExtractSessionResult(executionGate.skip, triage.enabled, output);
1014
+ const accounted = accountExtractSessionResult(executionGate.skip, triage.enabled, output);
992
1015
  recordExtractSessionOutcome({
993
1016
  stateDb,
994
1017
  trackingEnabled,
995
1018
  dryRun,
996
1019
  harness: harness.name,
997
1020
  summary,
998
- result: executionGate.skip,
1021
+ result: accounted,
999
1022
  sourceRun,
1000
1023
  });
1001
1024
  refillModelSlot();
@@ -1009,14 +1032,14 @@ async function runExtractSessionLoop(args) {
1009
1032
  for (const warning of result.warnings)
1010
1033
  topLevelWarnings.push(`session ${summary.sessionId}: ${warning}`);
1011
1034
  }
1012
- accountExtractSessionResult(result, triage.enabled, output);
1035
+ const accounted = accountExtractSessionResult(result, triage.enabled, output);
1013
1036
  recordExtractSessionOutcome({
1014
1037
  stateDb,
1015
1038
  trackingEnabled,
1016
1039
  dryRun,
1017
1040
  harness: harness.name,
1018
1041
  summary,
1019
- result,
1042
+ result: accounted,
1020
1043
  sourceRun,
1021
1044
  });
1022
1045
  }
@@ -1098,9 +1121,11 @@ function resolveExtractRunConfig(options, config, extractProcess, activeProfile)
1098
1121
  // Cap on NEW sessions LLM-processed per run; 0 disables. Absent = default.
1099
1122
  // Bounds per-run wall time / LLM cost so a backlog can't push a run past its
1100
1123
  // task timeout — the overflow stays unseen and is picked up by later runs.
1101
- const maxSessionsPerRun = typeof extractProcess?.maxSessionsPerRun === "number"
1102
- ? extractProcess.maxSessionsPerRun
1103
- : DEFAULT_MAX_SESSIONS_PER_RUN;
1124
+ const maxSessionsPerRun = options.since
1125
+ ? 0
1126
+ : typeof extractProcess?.maxSessionsPerRun === "number"
1127
+ ? extractProcess.maxSessionsPerRun
1128
+ : DEFAULT_MAX_SESSIONS_PER_RUN;
1104
1129
  // Default discovery window — process config can override the built-in 24h.
1105
1130
  const effectiveSince = options.since ?? extractProcess?.defaultSince;
1106
1131
  // #626 — resolve the triage gate config once per run. Default-off → the
@@ -1166,7 +1191,7 @@ function resolveExtractRunConfig(options, config, extractProcess, activeProfile)
1166
1191
  * not-found envelope) or the discovery-window listing. Extracted verbatim from
1167
1192
  * `akmExtract`; the 48h default-since floor and location filter are unchanged.
1168
1193
  */
1169
- function discoverExtractCandidates(options, harness, effectiveSince, startMs, dryRun) {
1194
+ function discoverExtractCandidates(options, harness, effectiveSince, startMs, dryRun, llmRunner) {
1170
1195
  if (options.sessionId) {
1171
1196
  const all = harness.listSessions({
1172
1197
  ...(options.location ? { location: options.location } : {}),
@@ -1174,20 +1199,14 @@ function discoverExtractCandidates(options, harness, effectiveSince, startMs, dr
1174
1199
  const target = all.find((s) => s.sessionId === options.sessionId);
1175
1200
  if (!target) {
1176
1201
  return {
1177
- notFound: {
1178
- schemaVersion: 1,
1202
+ notFound: emptyExtractResult({
1179
1203
  ok: false,
1180
- shape: "extract-result",
1181
1204
  dryRun,
1182
1205
  type: options.type,
1183
- sessionsProcessed: 0,
1184
- sessionsSkipped: 0,
1185
- candidatesCreated: 0,
1186
- proposals: [],
1187
- sessions: [],
1188
- warnings: [`session ${options.sessionId} not found for harness ${options.type}`],
1189
- durationMs: Date.now() - startMs,
1190
- },
1206
+ warning: `session ${options.sessionId} not found for harness ${options.type}`,
1207
+ startMs,
1208
+ llmRunner,
1209
+ }),
1191
1210
  };
1192
1211
  }
1193
1212
  return { candidates: [target] };
@@ -1286,6 +1305,28 @@ function emitExtractTriageEvent(args) {
1286
1305
  },
1287
1306
  }, eventsCtx);
1288
1307
  }
1308
+ /**
1309
+ * Count every session's `skipReason` (#912) and push one warning line per
1310
+ * infrastructure reason in {@link EXTRACT_INFRASTRUCTURE_SKIP_REASONS}.
1311
+ * `undefined` when nothing was skipped, so the envelope carries no key.
1312
+ */
1313
+ function buildExtractSkipAggregate(sessions, engine, warnings) {
1314
+ const counts = {};
1315
+ for (const session of sessions) {
1316
+ if (!session.skipReason)
1317
+ continue;
1318
+ counts[session.skipReason] = (counts[session.skipReason] ?? 0) + 1;
1319
+ }
1320
+ if (Object.keys(counts).length === 0)
1321
+ return undefined;
1322
+ const total = sessions.length;
1323
+ for (const reason of EXTRACT_INFRASTRUCTURE_SKIP_REASONS) {
1324
+ const n = counts[reason];
1325
+ if (n)
1326
+ warnings.push(`${n} of ${total} sessions skipped: ${reason} (engine "${engine}")`);
1327
+ }
1328
+ return counts;
1329
+ }
1289
1330
  export async function akmExtract(options) {
1290
1331
  const startMs = Date.now();
1291
1332
  if (!options.type || options.type.trim() === "") {
@@ -1310,20 +1351,13 @@ export async function akmExtract(options) {
1310
1351
  (options.improveProfile ? resolveProcessEnabled("extract", options.improveProfile) : true);
1311
1352
  // Feature-gate early so we get a clean "skipped because disabled" envelope.
1312
1353
  if (!extractEnabled) {
1313
- return {
1314
- schemaVersion: 1,
1354
+ return emptyExtractResult({
1315
1355
  ok: true,
1316
- shape: "extract-result",
1317
1356
  dryRun,
1318
1357
  type: options.type,
1319
- sessionsProcessed: 0,
1320
- sessionsSkipped: 0,
1321
- candidatesCreated: 0,
1322
- proposals: [],
1323
- sessions: [],
1324
- warnings: ["extract is disabled by the selected improve strategy"],
1325
- durationMs: Date.now() - startMs,
1326
- };
1358
+ warning: "extract is disabled by the selected improve strategy",
1359
+ startMs,
1360
+ });
1327
1361
  }
1328
1362
  const { timeoutMs, llmRunner, onNotices, getNotices, maxTotalChars, minContentChars, maxSessionsPerRun, effectiveSince, triage, sessionIndexing, } = resolveExtractRunConfig(options, config, extractProcess, activeProfile);
1329
1363
  // WI-9.10: construct this run's RunContext (extracted to
@@ -1331,39 +1365,27 @@ export async function akmExtract(options) {
1331
1365
  const ctx = buildExtractRunContext({ options, config, stashDir, dryRun, sourceRun, llmRunner });
1332
1366
  const harness = resolveHarness(options.type, options.harnesses);
1333
1367
  if (!harness) {
1334
- return {
1335
- schemaVersion: 1,
1368
+ return emptyExtractResult({
1336
1369
  ok: false,
1337
- shape: "extract-result",
1338
1370
  dryRun,
1339
1371
  type: options.type,
1340
- sessionsProcessed: 0,
1341
- sessionsSkipped: 0,
1342
- candidatesCreated: 0,
1343
- proposals: [],
1344
- sessions: [],
1345
- warnings: [`no available harness matches type "${options.type}" (check that the platform is installed)`],
1346
- durationMs: Date.now() - startMs,
1347
- };
1372
+ warning: `no available harness matches type "${options.type}" (check that the platform is installed)`,
1373
+ startMs,
1374
+ llmRunner,
1375
+ });
1348
1376
  }
1349
1377
  if (!harness.isAvailable()) {
1350
- return {
1351
- schemaVersion: 1,
1378
+ return emptyExtractResult({
1352
1379
  ok: false,
1353
- shape: "extract-result",
1354
1380
  dryRun,
1355
1381
  type: options.type,
1356
- sessionsProcessed: 0,
1357
- sessionsSkipped: 0,
1358
- candidatesCreated: 0,
1359
- proposals: [],
1360
- sessions: [],
1361
- warnings: [`harness ${options.type} is registered but reports not-available (no session data on this machine)`],
1362
- durationMs: Date.now() - startMs,
1363
- };
1382
+ warning: `harness ${options.type} is registered but reports not-available (no session data on this machine)`,
1383
+ startMs,
1384
+ llmRunner,
1385
+ });
1364
1386
  }
1365
1387
  // Decide which sessions to process: explicit sessionId OR discovery via since.
1366
- const discovery = discoverExtractCandidates(options, harness, effectiveSince, startMs, dryRun);
1388
+ const discovery = discoverExtractCandidates(options, harness, effectiveSince, startMs, dryRun, llmRunner);
1367
1389
  if ("notFound" in discovery)
1368
1390
  return discovery.notFound;
1369
1391
  const candidates = discovery.candidates;
@@ -1449,6 +1471,7 @@ export async function akmExtract(options) {
1449
1471
  if (loopResult.deferred > 0) {
1450
1472
  topLevelWarnings.push(`Reached maxSessionsPerRun=${maxSessionsPerRun}; ${loopResult.deferred} session(s) deferred to a later run.`);
1451
1473
  }
1474
+ const skipReasons = buildExtractSkipAggregate(sessions, llmRunner.engine, topLevelWarnings);
1452
1475
  emitExtractTriageEvent({
1453
1476
  modelPlanCount,
1454
1477
  triageEnabled: triage.enabled,
@@ -1475,6 +1498,9 @@ export async function akmExtract(options) {
1475
1498
  warnings: topLevelWarnings,
1476
1499
  durationMs: Date.now() - startMs,
1477
1500
  ...(getNotices().length > 0 ? { notices: getNotices() } : {}),
1501
+ ...(skipReasons ? { skipReasons } : {}),
1502
+ engine: llmRunner.engine,
1503
+ engineKind: llmRunner.kind,
1478
1504
  };
1479
1505
  }
1480
1506
  /**
@@ -88,39 +88,56 @@ export function resolveImprovePlan(name, config, options = {}) {
88
88
  }
89
89
  function buildImprovePlan(strategy, config, options) {
90
90
  const processes = {};
91
+ const engineUnavailable = [];
91
92
  for (const processName of Object.keys(IMPROVE_PROCESS_ENGINE_CAPABILITIES)) {
92
- const processConfig = cloneAndFreeze(strategy.config.processes?.[processName] ?? {});
93
- const enabled = processConfig.enabled === true;
93
+ const sourceProcessConfig = strategy.config.processes?.[processName] ?? {};
94
+ const enabled = sourceProcessConfig.enabled === true;
94
95
  let runner = null;
95
96
  let notices = [];
96
97
  if (IMPROVE_PROCESS_ENGINE_CAPABILITIES[processName] !== "llm" || !enabled) {
97
- processes[processName] = Object.freeze({ enabled, config: processConfig, runner });
98
+ processes[processName] = Object.freeze({ enabled, config: cloneAndFreeze(sourceProcessConfig), runner });
98
99
  continue;
99
100
  }
100
101
  // Validation itself is structural and always runs. Only its optional repair
101
102
  // step needs a model, so disabling repair must not create an LLM preflight.
102
- if (processName !== "validation" || options.repairValidationFailures !== false) {
103
+ const skipsRepairEngine = processName === "validation" && options.repairValidationFailures === false;
104
+ if (!skipsRepairEngine) {
103
105
  const resolved = resolveImproveLlmExecution({
104
106
  config,
105
107
  profile: strategy.config,
106
- process: processConfig,
108
+ process: sourceProcessConfig,
107
109
  processName,
108
110
  });
109
111
  runner = resolved?.runner ?? null;
110
112
  notices = resolved?.notices ?? [];
111
113
  }
112
- if (!runner && !(processName === "validation" && options.repairValidationFailures === false)) {
113
- throw new ConfigError(`Enabled improve process "${processName}" requires an LLM engine. Set defaults.llmEngine or improve.strategies.${strategy.name}.processes.${processName}.engine.`, "LLM_NOT_CONFIGURED");
114
+ if (!runner && !skipsRepairEngine) {
115
+ const configKey = `improve.strategies.${strategy.name}.processes.${processName}.engine`;
116
+ engineUnavailable.push({
117
+ process: processName,
118
+ configKey,
119
+ reason: `requires an LLM engine that is not configured. Set defaults.llmEngine or ${configKey}`,
120
+ });
121
+ processes[processName] = Object.freeze({
122
+ enabled: false,
123
+ config: cloneAndFreeze({ ...sourceProcessConfig, enabled: false }),
124
+ runner: null,
125
+ });
126
+ continue;
114
127
  }
115
128
  if (runner)
116
129
  runner = cloneAndFreeze(runner);
117
130
  processes[processName] = Object.freeze({
118
131
  enabled,
119
- config: processConfig,
132
+ config: cloneAndFreeze(sourceProcessConfig),
120
133
  runner,
121
134
  ...(notices.length > 0 ? { notices: cloneAndFreeze(notices) } : {}),
122
135
  });
123
136
  }
137
+ if (engineUnavailable.length > 0 && !Object.values(processes).some((process) => process.enabled)) {
138
+ const names = engineUnavailable.map((item) => `"${item.process}"`).join(", ");
139
+ throw new ConfigError(`No improve process can run: ${names} ${engineUnavailable.length === 1 ? "requires" : "require"} an LLM engine that is not configured. Set defaults.llmEngine, or the per-process engine key named for each.`, "LLM_NOT_CONFIGURED");
140
+ }
124
141
  const triage = strategy.config.processes?.triage;
125
142
  const judgmentEnabled = triage?.judgment?.enabled === true;
126
143
  const triageJudgmentResolution = processes.triage.enabled && judgmentEnabled
@@ -156,5 +173,6 @@ function buildImprovePlan(strategy, config, options) {
156
173
  ...(triageJudgmentResolution?.notices.length
157
174
  ? { triageJudgmentNotices: cloneAndFreeze(triageJudgmentResolution.notices) }
158
175
  : {}),
176
+ engineUnavailable: Object.freeze(engineUnavailable),
159
177
  });
160
178
  }