akm-cli 0.9.0-beta.1 → 0.9.0-beta.11

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 (56) hide show
  1. package/CHANGELOG.md +492 -0
  2. package/dist/assets/templates/html/default.html +78 -0
  3. package/dist/assets/templates/html/health.html +732 -0
  4. package/dist/assets/templates/html/vendor/echarts.min.js +45 -0
  5. package/dist/cli/shared.js +21 -5
  6. package/dist/cli.js +47 -5
  7. package/dist/commands/config-cli.js +0 -10
  8. package/dist/commands/feedback-cli.js +42 -37
  9. package/dist/commands/graph/graph.js +75 -71
  10. package/dist/commands/health/checks.js +48 -0
  11. package/dist/commands/health/html-report.js +666 -0
  12. package/dist/commands/health.js +201 -14
  13. package/dist/commands/improve/consolidate.js +39 -6
  14. package/dist/commands/improve/distill.js +26 -5
  15. package/dist/commands/improve/extract-prompt.js +1 -1
  16. package/dist/commands/improve/extract.js +73 -8
  17. package/dist/commands/improve/improve-auto-accept.js +63 -3
  18. package/dist/commands/improve/improve-cli.js +7 -0
  19. package/dist/commands/improve/improve-profiles.js +4 -0
  20. package/dist/commands/improve/improve.js +874 -447
  21. package/dist/commands/improve/proactive-maintenance.js +113 -0
  22. package/dist/commands/improve/reflect-noise.js +0 -0
  23. package/dist/commands/improve/reflect.js +31 -0
  24. package/dist/commands/proposal/drain.js +73 -6
  25. package/dist/commands/proposal/proposal-cli.js +22 -10
  26. package/dist/commands/proposal/proposal.js +17 -1
  27. package/dist/commands/proposal/validators/proposals.js +365 -329
  28. package/dist/commands/read/curate.js +17 -0
  29. package/dist/commands/remember.js +6 -2
  30. package/dist/commands/sources/stash-cli.js +10 -2
  31. package/dist/commands/tasks/tasks.js +32 -8
  32. package/dist/core/config/config-schema.js +33 -0
  33. package/dist/core/logs-db.js +304 -0
  34. package/dist/core/paths.js +3 -0
  35. package/dist/core/state-db.js +152 -14
  36. package/dist/indexer/db/db.js +99 -13
  37. package/dist/indexer/ensure-index.js +152 -17
  38. package/dist/indexer/index-writer-lock.js +99 -0
  39. package/dist/indexer/indexer.js +114 -111
  40. package/dist/indexer/passes/memory-inference.js +61 -22
  41. package/dist/integrations/harnesses/claude/session-log.js +17 -5
  42. package/dist/llm/client.js +38 -4
  43. package/dist/llm/usage-persist.js +77 -0
  44. package/dist/llm/usage-telemetry.js +103 -0
  45. package/dist/output/context.js +3 -2
  46. package/dist/output/html-render.js +73 -0
  47. package/dist/output/shapes/helpers.js +17 -1
  48. package/dist/output/text/helpers.js +69 -1
  49. package/dist/scripts/migrate-storage.js +154 -25
  50. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +21 -2
  51. package/dist/sources/providers/tar-utils.js +16 -8
  52. package/dist/tasks/backends/cron.js +46 -9
  53. package/dist/tasks/runner.js +99 -16
  54. package/dist/workflows/db.js +4 -0
  55. package/package.json +3 -2
  56. package/dist/commands/config-edit.js +0 -344
@@ -0,0 +1,113 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /** One day in milliseconds. */
5
+ const DAY_MS = 86_400_000;
6
+ /**
7
+ * Importance multipliers by asset type. Higher = more worth maintaining. These
8
+ * are the design defaults; callers may override any subset via config.
9
+ */
10
+ export const DEFAULT_IMPORTANCE_WEIGHTS = Object.freeze({
11
+ skill: 1.5,
12
+ agent: 1.5,
13
+ command: 1.3,
14
+ workflow: 1.3,
15
+ lesson: 1.2,
16
+ knowledge: 1.0,
17
+ script: 0.9,
18
+ memory: 0.7,
19
+ });
20
+ /** Default staleness gate: an asset is due when last reflected > this many days ago (or never). */
21
+ export const DEFAULT_DUE_DAYS = 30;
22
+ /** Default bound on how many assets the selector surfaces per run. */
23
+ export const DEFAULT_MAX_PER_RUN = 25;
24
+ /**
25
+ * Half-life (days) for the recency-of-use decay term. An asset used today
26
+ * contributes a full recency multiplier; one unused for one half-life
27
+ * contributes half. Mirrors the validated prototype (21 days).
28
+ */
29
+ const RECENCY_HALFLIFE_DAYS = 21;
30
+ /** Lower bound on size used in the cost denominator so tiny files don't divide by ~0. */
31
+ const SIZE_FLOOR_BYTES = 200;
32
+ /** Parse the bare asset type out of a `type:name` ref. Returns "" when unparseable. */
33
+ function refType(ref) {
34
+ const i = ref.indexOf(":");
35
+ return i > 0 ? ref.slice(0, i) : "";
36
+ }
37
+ /**
38
+ * Score and select due assets for proactive maintenance.
39
+ *
40
+ * Priority formula (mirrors the validated prototype):
41
+ *
42
+ * priority = (importance × log(1 + retrievalFreq) × (0.1 + 0.5^(useAgeDays/21)))
43
+ * / log10(max(size, 200))
44
+ *
45
+ * DUE gate: an asset is eligible only if it was never reflected OR last
46
+ * reflected/distilled more than `dueDays` ago. The same gate doubles as the
47
+ * ROTATION cooldown — a freshly-reflected asset is excluded until it ages back
48
+ * past `dueDays`, so successive runs rotate through the due pool rather than
49
+ * re-selecting the same heads. Non-due assets never enter the selection.
50
+ */
51
+ export function selectProactiveMaintenanceRefs(params) {
52
+ const now = params.now ?? Date.now();
53
+ const dueDays = params.dueDays ?? DEFAULT_DUE_DAYS;
54
+ const maxPerRun = params.maxPerRun ?? DEFAULT_MAX_PER_RUN;
55
+ const weights = { ...DEFAULT_IMPORTANCE_WEIGHTS, ...(params.importanceWeights ?? {}) };
56
+ const scored = [];
57
+ for (const candidate of params.candidates) {
58
+ const ref = candidate.ref;
59
+ const type = refType(ref);
60
+ // Staleness from the most recent of reflect/distill — either one touching
61
+ // the asset resets its maintenance clock.
62
+ const reflectIso = params.lastReflectTs.get(ref);
63
+ const distillIso = params.lastDistillTs.get(ref);
64
+ let lastTouchMs = 0;
65
+ if (reflectIso)
66
+ lastTouchMs = Math.max(lastTouchMs, Date.parse(reflectIso) || 0);
67
+ if (distillIso)
68
+ lastTouchMs = Math.max(lastTouchMs, Date.parse(distillIso) || 0);
69
+ const neverReflected = lastTouchMs === 0;
70
+ const staleDays = neverReflected ? Number.POSITIVE_INFINITY : (now - lastTouchMs) / DAY_MS;
71
+ // DUE / rotation gate.
72
+ const due = neverReflected || staleDays > dueDays;
73
+ // Retrieval frequency + recency decay.
74
+ const retrievalFreq = params.retrievalCounts.get(ref) ?? 0;
75
+ const lastUse = params.lastUseMs?.get(ref) ?? 0;
76
+ const useAgeDays = lastUse > 0 ? (now - lastUse) / DAY_MS : 9999;
77
+ const recencyDecay = 0.1 + 0.5 ** (useAgeDays / RECENCY_HALFLIFE_DAYS);
78
+ // Size proxy (cost): larger assets are slightly deprioritized, but only by
79
+ // log10 so a big-but-hot asset is never starved.
80
+ let sizeBytes = params.sizeBytesOf?.(candidate) ?? 0;
81
+ if (!sizeBytes || sizeBytes < 0)
82
+ sizeBytes = SIZE_FLOOR_BYTES;
83
+ const sizeProxy = Math.max(SIZE_FLOOR_BYTES, sizeBytes);
84
+ const importance = weights[type] ?? 1.0;
85
+ const priority = (importance * Math.log(1 + retrievalFreq) * recencyDecay) / Math.log10(sizeProxy);
86
+ scored.push({
87
+ ref: candidate,
88
+ type,
89
+ staleDays,
90
+ neverReflected,
91
+ retrievalFreq,
92
+ recencyDecay,
93
+ sizeBytes,
94
+ importance,
95
+ priority,
96
+ due,
97
+ });
98
+ }
99
+ const dueScored = scored.filter((s) => s.due);
100
+ const dueTotal = dueScored.length;
101
+ const neverReflected = dueScored.filter((s) => s.neverReflected).length;
102
+ // Rank due assets by composite priority (desc). Ties broken by staleness
103
+ // (older first) then ref string for deterministic ordering.
104
+ const ranked = dueScored.slice().sort((a, b) => {
105
+ if (b.priority !== a.priority)
106
+ return b.priority - a.priority;
107
+ if (b.staleDays !== a.staleDays)
108
+ return b.staleDays - a.staleDays;
109
+ return a.ref.ref < b.ref.ref ? -1 : a.ref.ref > b.ref.ref ? 1 : 0;
110
+ });
111
+ const selected = ranked.slice(0, Math.max(0, maxPerRun)).map((s) => s.ref);
112
+ return { selected, dueTotal, neverReflected, scored };
113
+ }
@@ -46,6 +46,7 @@ import { baseFailureFields, enoentHintMessage, isEnoentFailure, loadAgentConfigF
46
46
  import { checkReflectSize } from "../proposal/validators/proposal-quality-validators.js";
47
47
  import { createProposal, isProposalSkipped, listProposals, } from "../proposal/validators/proposals.js";
48
48
  import { deriveLessonRef, runLessonQualityJudge } from "./distill.js";
49
+ import { classifyReflectChange } from "./reflect-noise.js";
49
50
  const MAX_FEEDBACK_LINES = 10;
50
51
  const MAX_GLOBAL_FEEDBACK_LINES = 20;
51
52
  /**
@@ -584,6 +585,9 @@ export async function akmReflect(options = {}) {
584
585
  metadata: {
585
586
  ...(options.task ? { task: options.task } : {}),
586
587
  ...(options.profile ? { profile: options.profile } : {}),
588
+ // Attribution tagging: stamp the eligibility lane so reflect_invoked can be
589
+ // sliced by lane downstream. See EligibilitySource.
590
+ ...(options.eligibilitySource ? { eligibilitySource: options.eligibilitySource } : {}),
587
591
  },
588
592
  });
589
593
  // Fix #3 (observability 0.8.0): every failure path below MUST emit
@@ -1138,6 +1142,30 @@ export async function akmReflect(options = {}) {
1138
1142
  content: sanitizeOutcome.content,
1139
1143
  ...(sanitizeOutcome.frontmatter ? { frontmatter: sanitizeOutcome.frontmatter } : {}),
1140
1144
  };
1145
+ // 7c. Noise gate (#580): never queue a proposal whose sanitized content is
1146
+ // identical to the current asset (empty diff) or differs only cosmetically
1147
+ // (whitespace reflow, code-fence language hints, YAML scalar re-folding).
1148
+ // Pure deterministic text comparison — see `reflect-noise.ts`. Runs before
1149
+ // the draftMode branch so self-consistency sampling never votes a no-op
1150
+ // candidate into the queue either. Skipped when there is no source asset
1151
+ // (new-asset proposals have nothing to diff against).
1152
+ if (assetContent !== undefined) {
1153
+ const changeKind = classifyReflectChange(assetContent, payload.content);
1154
+ if (changeKind !== "substantive") {
1155
+ const subreason = changeKind === "noop" ? "reflect_skipped_noop" : "reflect_skipped_cosmetic";
1156
+ emitReflectFailed("no_change", subreason, options.ref, { changeKind });
1157
+ return {
1158
+ schemaVersion: 1,
1159
+ ok: false,
1160
+ reason: "no_change",
1161
+ error: changeKind === "noop"
1162
+ ? `Reflect skipped: proposed content for ${payload.ref} is identical to the current asset (empty diff); no proposal created.`
1163
+ : `Reflect skipped: proposed content for ${payload.ref} is a cosmetic-only reformat of the current asset (whitespace/fence/YAML-folding changes); no proposal created.`,
1164
+ ...(options.ref ? { ref: options.ref } : {}),
1165
+ exitCode: result.exitCode,
1166
+ };
1167
+ }
1168
+ }
1141
1169
  // 8. Create the proposal. The proposal queue is the ONLY thing reflect
1142
1170
  // writes — promotion to a real asset is gated by `akm proposal accept`.
1143
1171
  //
@@ -1203,6 +1231,9 @@ export async function akmReflect(options = {}) {
1203
1231
  // `parseAgentProposalPayload` already clamps to [0, 1] and drops non-
1204
1232
  // finite values; `createProposal` runs its own sanitizer as a safety net.
1205
1233
  ...(typeof payload.confidence === "number" ? { confidence: payload.confidence } : {}),
1234
+ // Attribution tagging: persist the eligibility lane on the proposal so it
1235
+ // survives to accept/reject/revert time even across runs. See EligibilitySource.
1236
+ ...(options.eligibilitySource ? { eligibilitySource: options.eligibilitySource } : {}),
1206
1237
  };
1207
1238
  const proposalResult = createProposal(stash, createInput, options.ctx);
1208
1239
  if (isProposalSkipped(proposalResult)) {
@@ -46,7 +46,7 @@ import { runAgent } from "../../integrations/agent/index.js";
46
46
  import { runOpencodeSdk } from "../../integrations/harnesses/opencode-sdk/index.js";
47
47
  import { chatCompletion, stripJsonFences } from "../../llm/client.js";
48
48
  import { akmProposalAccept, akmProposalReject } from "./proposal.js";
49
- import { listProposals } from "./validators/proposals.js";
49
+ import { listProposals, recordGateDecision } from "./validators/proposals.js";
50
50
  // ---------------------------------------------------------------------------
51
51
  // Content helpers
52
52
  // ---------------------------------------------------------------------------
@@ -78,7 +78,7 @@ export function classifyProposal(proposal, policy, maxDiffLines) {
78
78
  const content = proposal.payload.content ?? "";
79
79
  // Empty / near-empty diffs reject first (the reject-empty floor).
80
80
  if (policy.rejectEmpty && isEmptyDiff(proposal)) {
81
- return { verdict: "reject", reason: "empty diff" };
81
+ return { verdict: "reject", reason: "empty diff", gate: { reason: "empty-diff" } };
82
82
  }
83
83
  const rule = policy.accept.find((r) => r.generator === proposal.source);
84
84
  if (rule) {
@@ -87,16 +87,25 @@ export function classifyProposal(proposal, policy, maxDiffLines) {
87
87
  // Per-rule and global diff bounds defer large accepts (no silent rewrites).
88
88
  const effectiveMax = Math.min(rule.maxDiffLines ?? Number.POSITIVE_INFINITY, maxDiffLines ?? Number.POSITIVE_INFINITY);
89
89
  if (lines > effectiveMax) {
90
- return { verdict: "defer", reason: "mid-band" };
90
+ return {
91
+ verdict: "defer",
92
+ reason: "mid-band",
93
+ gate: { reason: "max-diff-lines", measured: lines, thresholds: { maxDiffLines: effectiveMax } },
94
+ };
91
95
  }
92
96
  if (rule.minContentLines !== undefined && body < rule.minContentLines) {
93
97
  // Too little content to confidently auto-accept — leave for judgment.
94
- return { verdict: "defer", reason: "mid-band" };
98
+ return {
99
+ verdict: "defer",
100
+ reason: "mid-band",
101
+ gate: { reason: "min-content-lines", measured: body, thresholds: { minContentLines: rule.minContentLines } },
102
+ };
95
103
  }
96
- return { verdict: "accept" };
104
+ return { verdict: "accept", gate: { reason: "policy-accept" } };
97
105
  }
98
106
  if (policy.defer.includes(proposal.source)) {
99
- return { verdict: "defer", reason: deferReasonForSource(proposal.source) };
107
+ const reason = deferReasonForSource(proposal.source);
108
+ return { verdict: "defer", reason, gate: { reason } };
100
109
  }
101
110
  // No matching rule — leave pending, untouched.
102
111
  return null;
@@ -347,10 +356,31 @@ export async function drainProposals(opts, promoteFn = akmProposalAccept, reject
347
356
  // First, classify every proposal deterministically.
348
357
  const acceptIds = [];
349
358
  const rejectTargets = [];
359
+ const gateLabel = `triage:${opts.policy.name}`;
360
+ // Items deferred purely because they need a judge (no threshold-based reason)
361
+ // — these are re-stamped `no-judge-configured` when no runner resolves them.
362
+ const needsJudge = new Set();
350
363
  for (const proposal of pending) {
351
364
  const decision = classifyProposal(proposal, opts.policy, opts.maxDiffLines);
352
365
  if (decision === null)
353
366
  continue;
367
+ // #577: stamp the gate's verdict onto the proposal so `akm proposal show`
368
+ // can explain WHY it landed here. A dry-run performs zero writes, so it
369
+ // records nothing.
370
+ const outcome = decision.verdict === "accept" ? "auto-accepted" : decision.verdict === "reject" ? "auto-rejected" : "deferred";
371
+ stampGateDecision(opts, proposal.id, {
372
+ outcome,
373
+ reason: decision.gate.reason,
374
+ ...(decision.gate.measured !== undefined ? { measured: decision.gate.measured } : {}),
375
+ ...(decision.gate.thresholds ? { thresholds: decision.gate.thresholds } : {}),
376
+ gate: gateLabel,
377
+ });
378
+ // A defer with no threshold (mid-band / possible-dup from the defer list) is
379
+ // pending only because it needs adjudication — re-stampable to
380
+ // `no-judge-configured`. A band-based defer keeps its specific reason.
381
+ if (decision.verdict === "defer" && !decision.gate.thresholds) {
382
+ needsJudge.add(proposal.id);
383
+ }
354
384
  if (decision.verdict === "accept") {
355
385
  acceptIds.push(proposal.id);
356
386
  }
@@ -434,14 +464,51 @@ export async function drainProposals(opts, promoteFn = akmProposalAccept, reject
434
464
  if (tier.skippedByCap.length > 0) {
435
465
  info(`[triage] accept ceiling reached in judgment tier: ${tier.skippedByCap.length} judged-accept items skipped by cap (maxAccepts=${opts.maxAccepts})`);
436
466
  }
467
+ // #577: re-stamp the gate decision for items the judgment tier resolved so
468
+ // `akm proposal show` reflects the judge's verdict, not the earlier
469
+ // deterministic defer.
470
+ for (const id of tier.promoted) {
471
+ stampGateDecision(opts, id, { outcome: "auto-accepted", reason: "judgment-accept", gate: gateLabel });
472
+ }
473
+ for (const id of tier.rejected) {
474
+ stampGateDecision(opts, id, { outcome: "auto-rejected", reason: "judgment-reject", gate: gateLabel });
475
+ }
437
476
  // Replace the deferred list with only the items the judgment tier could NOT
438
477
  // resolve (verdict "defer", parse failure, or runner error). Staged
439
478
  // queue-mode accepts are RESOLVED and tracked in result.staged instead.
440
479
  result.deferred = tier.stillDeferred;
441
480
  }
481
+ else if (result.deferred.length > 0) {
482
+ // #577: no judgment runner configured — items deferred *because they need a
483
+ // judge* (mid-band / possible-dup, no threshold reason) stay pending solely
484
+ // for lack of one. Re-stamp those as `no-judge-configured` so the operator
485
+ // sees a per-proposal reason instead of inferring it from the run-level
486
+ // triage_deferred aggregate. Band-deferred items keep their specific reason
487
+ // (e.g. `max-diff-lines`), which is more actionable than "no judge".
488
+ for (const item of result.deferred) {
489
+ if (needsJudge.has(item.id)) {
490
+ stampGateDecision(opts, item.id, { outcome: "deferred", reason: "no-judge-configured", gate: gateLabel });
491
+ }
492
+ }
493
+ }
442
494
  emitDrainEvents(opts, result);
443
495
  return result;
444
496
  }
497
+ /**
498
+ * Persist a gate decision onto a proposal, honouring the dry-run contract
499
+ * (a dry run performs zero writes, so it records nothing) and never letting a
500
+ * persistence failure abort the drain (#577). Best-effort by design.
501
+ */
502
+ function stampGateDecision(opts, id, decision) {
503
+ if (opts.dryRun)
504
+ return;
505
+ try {
506
+ recordGateDecision(opts.stashDir, id, decision);
507
+ }
508
+ catch (err) {
509
+ warn(`[triage] failed to record gate decision for ${id}: ${err instanceof Error ? err.message : String(err)}`);
510
+ }
511
+ }
445
512
  // ---------------------------------------------------------------------------
446
513
  // Events
447
514
  // ---------------------------------------------------------------------------
@@ -16,6 +16,8 @@ import { resolveStashDir } from "../../core/common.js";
16
16
  import { loadConfig } from "../../core/config/config.js";
17
17
  import { UsageError } from "../../core/errors.js";
18
18
  import { resolveTriageJudgmentRunner } from "../../integrations/agent/runner.js";
19
+ import { installLlmUsagePersistenceIfAbsent } from "../../llm/usage-persist.js";
20
+ import { withLlmStage } from "../../llm/usage-telemetry.js";
19
21
  import { resolveImproveProfile } from "../improve/improve-profiles.js";
20
22
  import { drainProposals } from "./drain.js";
21
23
  import { resolveDrainPolicy } from "./drain-policies.js";
@@ -407,16 +409,26 @@ const proposalDrainCommand = defineJsonCommand({
407
409
  // nothing is configured → the engine leaves deferred items unresolved and
408
410
  // emits triage_deferred.
409
411
  const judgment = args.judgment === true ? resolveTriageJudgmentRunner(triageConfig?.judgment, cfg) : null;
410
- const result = await drainProposals({
411
- stashDir,
412
- policy,
413
- applyMode,
414
- maxAccepts,
415
- dryRun,
416
- ...(maxDiffLines !== undefined ? { maxDiffLines } : {}),
417
- ...(excludeIds ? { excludeIds } : {}),
418
- judgment,
419
- });
412
+ // #576: persist + attribute per-call LLM usage for the standalone drain
413
+ // path. `IfAbsent` keeps an enclosing `akm improve` sink in charge when
414
+ // drain runs as a sub-step; the disposer clears only a sink we installed.
415
+ const disposeDrainUsageSink = installLlmUsagePersistenceIfAbsent();
416
+ let result;
417
+ try {
418
+ result = await withLlmStage("drain", () => drainProposals({
419
+ stashDir,
420
+ policy,
421
+ applyMode,
422
+ maxAccepts,
423
+ dryRun,
424
+ ...(maxDiffLines !== undefined ? { maxDiffLines } : {}),
425
+ ...(excludeIds ? { excludeIds } : {}),
426
+ judgment,
427
+ }));
428
+ }
429
+ finally {
430
+ disposeDrainUsageSink();
431
+ }
420
432
  output("proposal-drain", {
421
433
  schemaVersion: 1,
422
434
  ok: true,
@@ -22,6 +22,17 @@ function resolveStash(stashDir) {
22
22
  return stashDir;
23
23
  return resolveStashDir();
24
24
  }
25
+ /**
26
+ * Thin in-process read of the pending proposal queue, used by the health HTML
27
+ * report builder (#582) so it never shells out to `akm proposal list`.
28
+ *
29
+ * Deliberately narrow (one optional arg, returns the storage-layer rows) so
30
+ * the parallel proposal-storage-to-SQLite consolidation only has to swap this
31
+ * one function's body.
32
+ */
33
+ export function listPendingProposals(stashDir) {
34
+ return listProposals(resolveStash(stashDir), { status: "pending" });
35
+ }
25
36
  export function akmProposalList(options = {}) {
26
37
  const stash = resolveStash(options.stashDir);
27
38
  // `--status accepted|rejected|reverted` implies archive-inclusion since the
@@ -64,6 +75,11 @@ export async function akmProposalAccept(options) {
64
75
  source: result.proposal.source,
65
76
  ...(result.proposal.sourceRun !== undefined ? { sourceRun: result.proposal.sourceRun } : {}),
66
77
  assetPath: result.assetPath,
78
+ // Attribution tagging: carry the eligibility lane from the proposal record
79
+ // onto the promoted event so accept outcomes can be sliced by lane.
80
+ ...(result.proposal.eligibilitySource !== undefined
81
+ ? { eligibilitySource: result.proposal.eligibilitySource }
82
+ : {}),
67
83
  },
68
84
  });
69
85
  return {
@@ -141,7 +157,7 @@ export function akmProposalCreate(options) {
141
157
  * (raised by `resolveProposalId` / `getProposal`).
142
158
  * - Proposal is not `status === "accepted"` → `UsageError("INVALID_FLAG_VALUE")`
143
159
  * with message `"only accepted proposals can be reverted ..."`.
144
- * - No `backup` field, or the backup file is missing on disk
160
+ * - No backup content on the record (new-asset proposals capture none)
145
161
  * `UsageError` with message `"no backup available for this proposal ..."`.
146
162
  *
147
163
  * On success, emits a `proposal_reverted` event for observability, mirroring