akm-cli 0.9.18 → 0.9.19-alpha.1

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 (52) hide show
  1. package/CHANGELOG.md +135 -0
  2. package/STABILITY.md +2 -1
  3. package/dist/assets/hints/cli-hints-full.md +4 -2
  4. package/dist/assets/hints/cli-hints-short.md +5 -3
  5. package/dist/assets/prompts/reflect-feedback-framing.md +1 -1
  6. package/dist/commands/feedback-cli.js +1 -1
  7. package/dist/commands/improve/consolidate/coverage.js +132 -0
  8. package/dist/commands/improve/consolidate/pair-pass.js +30 -20
  9. package/dist/commands/improve/consolidate.js +43 -10
  10. package/dist/commands/improve/distill.js +7 -3
  11. package/dist/commands/improve/eligibility.js +39 -9
  12. package/dist/commands/improve/improve-cli.js +9 -6
  13. package/dist/commands/improve/improve.js +13 -4
  14. package/dist/commands/improve/ledger.js +2 -2
  15. package/dist/commands/improve/loop-stages.js +2 -0
  16. package/dist/commands/improve/preparation.js +1 -1
  17. package/dist/commands/improve/reflect.js +1 -1
  18. package/dist/commands/improve/stage.js +38 -9
  19. package/dist/commands/proposal/diff-format.js +21 -0
  20. package/dist/commands/proposal/proposal-cli.js +48 -10
  21. package/dist/commands/proposal/proposal-types.js +11 -0
  22. package/dist/commands/proposal/proposal.js +60 -5
  23. package/dist/commands/proposal/repository.js +245 -17
  24. package/dist/commands/read/knowledge.js +13 -11
  25. package/dist/commands/read/remember-cli.js +7 -3
  26. package/dist/commands/sources/source-clone.js +1 -1
  27. package/dist/commands/tasks/tasks-cli.js +1 -1
  28. package/dist/commands/tasks/tasks.js +10 -3
  29. package/dist/core/mutation-target.js +8 -3
  30. package/dist/core/write-source.js +3 -2
  31. package/dist/indexer/usage/usage-events.js +2 -1
  32. package/dist/output/shapes/helpers.js +7 -0
  33. package/dist/output/shapes/passthrough.js +1 -0
  34. package/dist/output/shapes/proposal/reopen.js +14 -0
  35. package/dist/output/shapes.js +2 -0
  36. package/dist/output/text/helpers.js +1 -1
  37. package/dist/output/text/proposal/proposal.js +3 -1
  38. package/dist/output/text/proposal-format.js +87 -32
  39. package/dist/scripts/akm-migrate-node.js +79 -23
  40. package/dist/scripts/akm-migrate.js +79 -23
  41. package/dist/storage/repositories/improve-ledger-repository.js +65 -6
  42. package/dist/storage/repositories/index-vec-repository.js +13 -8
  43. package/dist/storage/repositories/proposals-repository.js +23 -0
  44. package/dist/tasks/run/load-task.js +5 -1
  45. package/docs/migration/README.md +1 -0
  46. package/docs/migration/release-notes/0.9.19.md +134 -0
  47. package/docs/migration/release-notes/README.md +5 -0
  48. package/docs/migration/v0.8-to-v0.9.md +5 -1
  49. package/docs/reference/cli.md +189 -28
  50. package/docs/reference/configuration.md +9 -8
  51. package/docs/reference/data-and-telemetry.md +19 -14
  52. package/package.json +1 -1
@@ -21,7 +21,7 @@ import { assembleAsset, serializeFrontmatter } from "../../core/asset/asset-seri
21
21
  import { carryForwardBookkeepingFrontmatter, parseFrontmatter } from "../../core/asset/frontmatter.js";
22
22
  import { conceptIdFromTypeName, parseRefInput } from "../../core/asset/resolve-ref.js";
23
23
  import { loadConfig } from "../../core/config/config.js";
24
- import { ConfigError, NotFoundError, UsageError } from "../../core/errors.js";
24
+ import { ConfigError, NotFoundError, rethrowIfTestIsolationError, UsageError } from "../../core/errors.js";
25
25
  import { appendEvent } from "../../core/events.js";
26
26
  import { proposalContent } from "../../core/file-change.js";
27
27
  import { canonicalBundleIdForTarget, resolveBundleWriteTarget } from "../../core/mutation-target.js";
@@ -34,7 +34,7 @@ import { indexWrittenAssets } from "../../indexer/index-written-assets.js";
34
34
  import { deriveInstallations } from "../../indexer/installations.js";
35
35
  import { resolveSourceEntries } from "../../indexer/search/search-source.js";
36
36
  import { insertEventOnce } from "../../storage/repositories/events-repository.js";
37
- import { recordImproveLedger, recordImproveLedgerDecision, } from "../../storage/repositories/improve-ledger-repository.js";
37
+ import { CONSOLIDATE_LEDGER_SOURCE, forgetImproveLedgerDecision, recordImproveLedger, recordImproveLedgerDecision, reopenImproveLedgerDecision, } from "../../storage/repositories/improve-ledger-repository.js";
38
38
  import { getStateProposal, listStateProposalIdsByPrefix, listStateProposals, upsertProposal, } from "../../storage/repositories/proposals-repository.js";
39
39
  import { openSqliteReadSnapshot } from "../../storage/sqlite-read-snapshot.js";
40
40
  import { pkgVersion } from "../../version.js";
@@ -42,8 +42,8 @@ import { contentHash } from "../improve/content-hash.js";
42
42
  import { writeSupersededEdge } from "../improve/memory/memory-belief.js";
43
43
  import { archiveCleanupCandidate, derivedTwinPath } from "../improve/memory/memory-improve.js";
44
44
  import { runBaseChecks } from "../lint/base-linter.js";
45
- import { formatNewAssetDiff, formatUnifiedDiff } from "./diff-format.js";
46
- import { ASSET_MISSING_GATE_REASON, EXPIRED_GATE_REASON, isAutomatedProposalSource, isRetireProposal, isValidProposalSource, PROPOSAL_SOURCES, STALE_TARGET_GATE_REASON, } from "./proposal-types.js";
45
+ import { formatNewAssetDiff, formatRetireDiff, formatUnifiedDiff } from "./diff-format.js";
46
+ import { ASSET_MISSING_GATE_REASON, EXPIRED_GATE_REASON, isAutomatedProposalSource, isRetireProposal, isValidProposalSource, PROPOSAL_SOURCES, proposalWaitingSince, STALE_TARGET_GATE_REASON, } from "./proposal-types.js";
47
47
  import { canonicalOnlyProposalValidators, hasCanonicalProposalValidator, runProposalValidators, } from "./validators/proposal-validators.js";
48
48
  import { repairProposalContent, validateProposal } from "./validators/proposals.js";
49
49
  export { AUTOMATED_PROPOSAL_SOURCES, isAutomatedProposalSource, isValidProposalSource, PROPOSAL_SOURCES, } from "./proposal-types.js";
@@ -141,6 +141,28 @@ export function resolveProposalQueueTarget(stashDir, config = loadConfig()) {
141
141
  }
142
142
  return { source: bundleId, root };
143
143
  }
144
+ /**
145
+ * The configured bundle that owns the asset `itemRef` names when it is not the
146
+ * bundle rooted at `target`; otherwise `undefined`. A differing name is
147
+ * confirmed by the owner's root, so one bundle known by two spellings is never
148
+ * taken for two, and an owner that cannot be resolved is never refused on.
149
+ */
150
+ function otherOwningBundle(itemRef, target) {
151
+ try {
152
+ const owner = parseBundleRef(itemRef).bundle;
153
+ if (owner === undefined || owner === target.source)
154
+ return undefined;
155
+ const sources = resolveSourceEntries(target.root, loadConfig());
156
+ const ownerSource = sources[deriveInstallations(sources).findIndex((installation) => installation.id === owner)];
157
+ return ownerSource !== undefined && path.resolve(ownerSource.path) !== path.resolve(target.root)
158
+ ? owner
159
+ : undefined;
160
+ }
161
+ catch (err) {
162
+ rethrowIfTestIsolationError(err);
163
+ return undefined;
164
+ }
165
+ }
144
166
  /**
145
167
  * Create a pending proposal (a random UUID id). Obviously invalid input is
146
168
  * refused with a typed `proposal_creation_rejected` event. The mint and its
@@ -190,9 +212,13 @@ export function createProposal(stashDir, input, ctx) {
190
212
  return rejectProposal("missing_description", `Proposal for "${input.ref}" (source=consolidate) has empty or missing frontmatter description.`);
191
213
  }
192
214
  }
215
+ const proposalTarget = resolveCreateProposalTarget(stashDir, input.target, parsedRef.origin);
216
+ const owner = input.itemRef ? otherOwningBundle(input.itemRef, proposalTarget) : undefined;
217
+ if (owner) {
218
+ return rejectProposal("cross_bundle", `Proposal for "${input.ref}" rewrites ${input.itemRef}, which bundle "${owner}" owns, but its queue target is bundle "${proposalTarget.source}". Filing it there would fork the asset out of "${owner}" or overwrite this bundle's copy with content taken from the other. Improve it in its own bundle instead (\`akm improve --bundle ${owner}\`).`);
219
+ }
193
220
  // The FileChange envelope, and the target's before-hashes as of mint (the
194
221
  // freshness check at accept compares against them).
195
- const proposalTarget = resolveCreateProposalTarget(stashDir, input.target, parsedRef.origin);
196
222
  const normalizedRef = proposalDurableRef(parsedRef, proposalTarget);
197
223
  const targetRoot = path.resolve(proposalTarget.root);
198
224
  let targetRelPath;
@@ -454,6 +480,25 @@ function ledgerOutcomeForDecision(status, gateDecision) {
454
480
  }
455
481
  return "rejected";
456
482
  }
483
+ /**
484
+ * What a decision's ledger row is about. A promotion's row is keyed by its
485
+ * source memory, so a decision on it names that memory, together with the body
486
+ * hash the promotion was queued against — this is what holds the memory back
487
+ * until it changes (`isContentDrivenDecision`). Any other proposal, and a
488
+ * promotion minted before the hash was recorded, is keyed by its own ref.
489
+ * Every decision that can create a row when none carries the proposal id — a
490
+ * reject, an accept and a drain deferral alike — must key it this way: a stray
491
+ * row under the knowledge ref carries the proposal id, so a later verdict
492
+ * updates that one and never reaches the memory's own row.
493
+ */
494
+ function decisionLedgerSubject(proposal) {
495
+ if (proposal.source === CONSOLIDATE_LEDGER_SOURCE &&
496
+ proposal.promotionSource !== undefined &&
497
+ proposal.promotionSourceHash !== undefined) {
498
+ return { ref: proposal.promotionSource, contentHash: proposal.promotionSourceHash };
499
+ }
500
+ return { ref: proposal.ref };
501
+ }
457
502
  /** Archive a pending proposal as accepted/rejected, recording the decision in the ledger in the same transaction. */
458
503
  export function archiveProposal(stashDir, id, status, reason, ctx, gateDecision) {
459
504
  return withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
@@ -473,7 +518,7 @@ export function archiveProposal(stashDir, id, status, reason, ctx, gateDecision)
473
518
  recordImproveLedgerDecision(db, {
474
519
  proposalId: updated.id,
475
520
  stashDir,
476
- ref: updated.ref,
521
+ ...decisionLedgerSubject(updated),
477
522
  source: updated.source,
478
523
  outcome: ledgerOutcomeForDecision(status, gateDecision),
479
524
  at: decidedAt,
@@ -500,7 +545,7 @@ export function recordGateDecision(stashDir, id, decision, ctx) {
500
545
  recordImproveLedgerDecision(db, {
501
546
  proposalId: updated.id,
502
547
  stashDir,
503
- ref: updated.ref,
548
+ ...decisionLedgerSubject(updated),
504
549
  source: updated.source,
505
550
  outcome: "review_needed",
506
551
  at: decidedAt,
@@ -551,9 +596,10 @@ export function purgeOrphanProposals(stashDir, sourceDirs, ctx) {
551
596
  }
552
597
  /**
553
598
  * Archive pending proposals older than `archiveRetentionDays` (default 90;
554
- * 0 disables) as rejected with an `expired` gate decision and a
555
- * `proposal_expired` event. The ledger records `expired` — a short grace, not
556
- * the rejection window, since nobody judged the content.
599
+ * 0 disables; counted from the last reopen, if any) as rejected with an
600
+ * `expired` gate decision and a `proposal_expired` event. The ledger records
601
+ * `expired` — a short grace, not the rejection window, since nobody judged the
602
+ * content.
557
603
  */
558
604
  export function expireStaleProposals(stashDir, config, ctx) {
559
605
  const t0 = Date.now();
@@ -572,7 +618,9 @@ export function expireStaleProposals(stashDir, config, ctx) {
572
618
  // way back short of the pair pass finding it again from scratch.
573
619
  if (isRetireProposal(p))
574
620
  continue;
575
- const createdMs = new Date(p.createdAt).getTime();
621
+ // A reopened proposal's wait starts over at the reopen (#997): expiring it
622
+ // on its original age would undo the reopen at the next sweep.
623
+ const createdMs = new Date(proposalWaitingSince(p)).getTime();
576
624
  if (!Number.isFinite(createdMs) || nowMs - createdMs < retentionDays * MS_PER_DAY)
577
625
  continue;
578
626
  try {
@@ -690,7 +738,7 @@ function persistProposalDecision(stashDir, proposal, decision, ctx) {
690
738
  recordImproveLedgerDecision(db, {
691
739
  proposalId: next.id,
692
740
  stashDir,
693
- ref: next.ref,
741
+ ...decisionLedgerSubject(next),
694
742
  source: next.source,
695
743
  outcome: ledgerOutcomeForDecision(accept ? "accepted" : "reverted"),
696
744
  at: decision.decidedAt,
@@ -1591,17 +1639,197 @@ async function revertProposalWithLease(stashDir, config, id, options, ctx) {
1591
1639
  await indexWrittenProposalAsset(target, assetPath);
1592
1640
  return { proposal: reverted, assetPath, ref: proposal.ref };
1593
1641
  }
1642
+ /** The message of the stale-target refusal `check` raises, else `undefined`. */
1643
+ function staleRefusal(check) {
1644
+ try {
1645
+ check();
1646
+ return undefined;
1647
+ }
1648
+ catch (error) {
1649
+ if (error instanceof UsageError)
1650
+ return error.message;
1651
+ throw error;
1652
+ }
1653
+ }
1654
+ /**
1655
+ * Why `proposal` cannot be reopened, or `undefined` when it can. Only a
1656
+ * rejected proposal comes back, and only one accept would not refuse as stale:
1657
+ * a retire proposal needs its successor and both recorded body hashes to still
1658
+ * match (B2), any other its target to be what it was minted against
1659
+ * (STALE, R20) — so a reopened proposal is never one the next accept refuses.
1660
+ */
1661
+ function reopenRefusal(config, proposal, queueTarget) {
1662
+ if (proposal.status !== "rejected") {
1663
+ return `it is not rejected (current status: ${proposal.status}); only a rejected proposal can be reopened.`;
1664
+ }
1665
+ if (proposal.changes.length === 0 || proposal.proposedTarget === undefined) {
1666
+ // A pending row must carry both (proposalToRowValues), which a row from
1667
+ // before the change envelope existed cannot.
1668
+ return "it was recorded before proposals carried their change envelope, so it cannot go back in the queue.";
1669
+ }
1670
+ const target = resolveProposalWriteTarget(config, proposal, undefined, queueTarget);
1671
+ const assetPath = resolveAssetFilePathSafe(target.source, parseRefInput(proposal.ref));
1672
+ if (!assetPath)
1673
+ return "its target cannot be resolved.";
1674
+ if (!isRetireProposal(proposal)) {
1675
+ return staleRefusal(() => void readFreshProposalTarget(proposal, assetPath, proposalContent(proposal)));
1676
+ }
1677
+ if (!proposal.retirement)
1678
+ return "it has no retirement metadata.";
1679
+ if (!fs.existsSync(assetPath)) {
1680
+ return "its retired file no longer exists (already retired, or removed by something else).";
1681
+ }
1682
+ const { retirement } = proposal;
1683
+ return staleRefusal(() => assertRetirementStillFresh(proposal.id, proposal.ref, retirement, target.source, fs.readFileSync(assetPath)));
1684
+ }
1685
+ /** The assets a retire proposal speaks for — the one it retires and its successor — as bundle-less concept ids, the way the pair pass keys them. */
1686
+ function retireHeldRefs(proposal) {
1687
+ return [proposal.ref, proposal.retirement?.successorRef].flatMap((ref) => {
1688
+ const conceptId = ref === undefined ? undefined : proposalRefIdentity(ref)?.conceptId;
1689
+ return conceptId === undefined ? [] : [conceptId];
1690
+ });
1691
+ }
1692
+ const REOPEN_REFUSED_HINT = "Only a rejected proposal whose target is unchanged can be reopened; `akm proposal list --status rejected` lists the candidates.";
1693
+ const REOPEN_RETIRE_CONFLICT_HINT = "Only one pending retire proposal can involve a document: accept or reject the pending one (or reopen just one of a clashing pair), then reopen the other.";
1694
+ /**
1695
+ * The pair pass never has two pending retire proposals speak for one asset
1696
+ * (`pendingRetireRefs`): accepting one would strand the other. Reopening must
1697
+ * not break that either — a rejected pair can meanwhile have been re-paired
1698
+ * with something else. `held` maps each asset to the pending retire proposal
1699
+ * that has it; a proposal that clears this claims its assets, so two in one
1700
+ * batch that clash refuse the later.
1701
+ */
1702
+ function retireConflict(proposal, held) {
1703
+ if (!isRetireProposal(proposal))
1704
+ return undefined;
1705
+ const refs = retireHeldRefs(proposal);
1706
+ for (const ref of refs) {
1707
+ const holder = held.get(ref);
1708
+ if (holder !== undefined) {
1709
+ return `${ref} is already part of retire proposal ${holder}, which is pending or being reopened with this one; only one pending retire proposal may involve an asset.`;
1710
+ }
1711
+ }
1712
+ for (const ref of refs)
1713
+ held.set(ref, proposal.id);
1714
+ return undefined;
1715
+ }
1716
+ /**
1717
+ * Put rejected proposals back in the queue (`akm proposal reopen`, #997): a
1718
+ * rejection is otherwise final, and it also suppresses the pair pass from ever
1719
+ * re-proposing a retirement (its record keys the pair), so a mistaken one
1720
+ * could not be undone. Each proposal returns to `pending` with the rejection —
1721
+ * and the gate verdict that came with it — kept in `reviewHistory` (the verdict
1722
+ * itself is cleared, unless it is a `deferred` hand-off to a person), its
1723
+ * ledger row reset (the pair pass keys off proposal status, so the pair is no
1724
+ * longer suppressed and, while pending, cannot be minted twice), and a
1725
+ * `proposal_reopened` event recorded, all in one transaction.
1726
+ *
1727
+ * All-or-nothing: every id is checked (see {@link reopenRefusal} and
1728
+ * {@link retireConflict}) before any is reopened, and one refusal leaves the
1729
+ * whole batch untouched.
1730
+ */
1731
+ export function reopenProposals(stashDir, config, ids, options = {}, ctx) {
1732
+ return withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
1733
+ const proposals = [...new Set(ids)].map((id) => requireProposal(db, stashDir, id));
1734
+ const held = new Map();
1735
+ for (const pending of listStateProposals(db, { stashDir, status: "pending" })) {
1736
+ if (isRetireProposal(pending))
1737
+ for (const ref of retireHeldRefs(pending))
1738
+ held.set(ref, pending.id);
1739
+ }
1740
+ const refusals = proposals.flatMap((proposal) => {
1741
+ const refusal = reopenRefusal(config, proposal, options.queueTarget);
1742
+ const conflict = refusal === undefined ? retireConflict(proposal, held) : undefined;
1743
+ const reason = refusal ?? conflict;
1744
+ return reason === undefined
1745
+ ? []
1746
+ : [{ proposal: `${proposal.id} (${proposal.ref})`, reason, isConflict: conflict !== undefined }];
1747
+ });
1748
+ if (refusals.length > 0) {
1749
+ // A retire conflict is not about the target changing, so it gets its own hint.
1750
+ const hints = [
1751
+ ...(refusals.some((refusal) => !refusal.isConflict) ? [REOPEN_REFUSED_HINT] : []),
1752
+ ...(refusals.some((refusal) => refusal.isConflict) ? [REOPEN_RETIRE_CONFLICT_HINT] : []),
1753
+ ];
1754
+ throw new UsageError(proposals.length === 1
1755
+ ? `Proposal ${refusals[0]?.proposal} cannot be reopened: ${refusals[0]?.reason}`
1756
+ : `Cannot reopen ${refusals.length} of ${proposals.length} proposals; none were reopened:\n${refusals
1757
+ .map((refusal) => ` - ${refusal.proposal}: ${refusal.reason}`)
1758
+ .join("\n")}`, "INVALID_FLAG_VALUE", hints.join(" "));
1759
+ }
1760
+ const decidedAt = nowIso(ctx);
1761
+ return proposals.map((existing) => {
1762
+ const reopened = {
1763
+ ...existing,
1764
+ status: "pending",
1765
+ updatedAt: decidedAt,
1766
+ review: undefined,
1767
+ // A reopened proposal is adjudicated afresh: a `staged` verdict would
1768
+ // let the drain accept it unseen, and another gate's `auto-rejected`
1769
+ // would have the drain skip it. A `deferred` one is the quality
1770
+ // gate's hand-off to a person, which the drain must keep honouring
1771
+ // (drainProposals leaves it alone), so it stays.
1772
+ gateDecision: existing.gateDecision?.outcome === "deferred" ? existing.gateDecision : undefined,
1773
+ reviewHistory: [
1774
+ ...(existing.reviewHistory ?? []),
1775
+ {
1776
+ ...(existing.review !== undefined ? { review: existing.review } : {}),
1777
+ ...(existing.gateDecision !== undefined ? { gateDecision: existing.gateDecision } : {}),
1778
+ reopenedAt: decidedAt,
1779
+ ...(options.reason !== undefined ? { reopenReason: options.reason } : {}),
1780
+ },
1781
+ ],
1782
+ };
1783
+ upsertProposal(db, reopened, stashDir);
1784
+ if (isRetireProposal(existing)) {
1785
+ // A retire mint writes no ledger row, so the one its rejection wrote goes.
1786
+ forgetImproveLedgerDecision(db, stashDir, existing.id);
1787
+ }
1788
+ else {
1789
+ reopenImproveLedgerDecision(db, {
1790
+ proposalId: existing.id,
1791
+ stashDir,
1792
+ source: existing.source,
1793
+ at: decidedAt,
1794
+ detail: options.reason !== undefined ? `reopened: ${options.reason}` : "reopened",
1795
+ });
1796
+ }
1797
+ insertEventOnce(db, {
1798
+ eventType: "proposal_reopened",
1799
+ ts: decidedAt,
1800
+ ref: reopened.ref,
1801
+ metadata: {
1802
+ proposalId: reopened.id,
1803
+ source: reopened.source,
1804
+ ...(reopened.sourceRun !== undefined ? { sourceRun: reopened.sourceRun } : {}),
1805
+ ...(options.reason !== undefined ? { reason: options.reason } : {}),
1806
+ },
1807
+ idempotencyKey: `${reopened.id}:reopened:${decidedAt}`,
1808
+ });
1809
+ return reopened;
1810
+ });
1811
+ }));
1812
+ }
1594
1813
  /** The proposal against the asset its accept would overwrite (same target resolution as accept). */
1595
1814
  export function diffProposal(stashDir, config, id, options = {}, ctx) {
1596
1815
  const proposal = getProposal(stashDir, id, ctx);
1597
1816
  const target = resolveProposalWriteTarget(config, proposal, options.target, options.queueTarget);
1598
1817
  const targetPath = resolveAssetFilePathSafe(target.source, parseRefInput(proposal.ref));
1599
1818
  const existing = targetPath && fs.existsSync(targetPath) ? fs.readFileSync(targetPath, "utf8") : null;
1600
- // A retire proposal's primary change deletes its target rather than
1601
- // writing content: "proposed" is empty and the diff shows the whole body
1602
- // being removed, reusing the ordinary unified-diff formatter instead of
1603
- // proposalContent() (which has nothing to read for a delete).
1604
- const proposed = isRetireProposal(proposal) ? "" : proposalContent(proposal);
1819
+ if (isRetireProposal(proposal)) {
1820
+ // A retire proposal's primary change deletes its target rather than
1821
+ // writing content (proposalContent() has nothing to read for a delete):
1822
+ // accept archives the file, it never replaces it with a blank one, so the
1823
+ // diff shows the file leaving — not a "proposed" side (#997).
1824
+ return {
1825
+ existing,
1826
+ proposed: "",
1827
+ unified: formatRetireDiff(proposal.ref, existing, proposal.retirement?.successorRef),
1828
+ isNew: false,
1829
+ ...(targetPath ? { targetPath } : {}),
1830
+ };
1831
+ }
1832
+ const proposed = proposalContent(proposal);
1605
1833
  return {
1606
1834
  existing,
1607
1835
  proposed,
@@ -168,11 +168,11 @@ function parseWriteRefs(rawRefs, flag) {
168
168
  }
169
169
  return parsedRefs;
170
170
  }
171
- function resolveWriteRefRoots(target) {
171
+ function resolveWriteRefRoots(target, targetFlag) {
172
172
  const cfg = loadConfig();
173
173
  let writeTarget;
174
174
  try {
175
- writeTarget = resolveWriteTarget(cfg, target);
175
+ writeTarget = resolveWriteTarget(cfg, target, { flag: targetFlag });
176
176
  }
177
177
  catch (error) {
178
178
  if (!target)
@@ -297,11 +297,11 @@ export const XREF_SOFT_CAP = 5;
297
297
  * {@link XREF_SOFT_CAP} refs emits a stderr warning (soft cap) but still
298
298
  * returns them all.
299
299
  */
300
- export function resolveXrefsForWrite(rawXrefs, target) {
300
+ export function resolveXrefsForWrite(rawXrefs, target, targetFlag) {
301
301
  const parsedRefs = parseWriteRefs(rawXrefs, "--xref");
302
302
  if (parsedRefs.length === 0)
303
303
  return [];
304
- const { roots } = resolveWriteRefRoots(target);
304
+ const { roots } = resolveWriteRefRoots(target, targetFlag);
305
305
  const unresolved = [];
306
306
  const xrefs = [];
307
307
  for (const parsed of parsedRefs) {
@@ -386,13 +386,13 @@ function isParseableYamlMapping(frontmatter) {
386
386
  }
387
387
  }
388
388
  /** Resolve any qualified supersedes ref as the mutation target for remember/import. */
389
- export function resolveSupersedesWriteTarget(rawRefs, target) {
389
+ export function resolveSupersedesWriteTarget(rawRefs, target, targetFlag) {
390
390
  const config = loadConfig();
391
391
  let effectiveTarget = target;
392
392
  for (const parsed of parseWriteRefs(rawRefs, "--supersedes")) {
393
393
  if (!parsed.origin)
394
394
  continue;
395
- const resolved = resolveMutationTarget(config, { type: parsed.type, name: parsed.name, origin: parsed.origin }, effectiveTarget).target;
395
+ const resolved = resolveMutationTarget(config, { type: parsed.type, name: parsed.name, origin: parsed.origin }, effectiveTarget, { flag: targetFlag }).target;
396
396
  effectiveTarget = resolved.selector ?? resolved.source.name;
397
397
  }
398
398
  return effectiveTarget;
@@ -428,17 +428,17 @@ const SUPERSEDE_REJECTED_TYPES = new Set(["secret", "env", "task", "script"]);
428
428
  * constraint (and never dirtying a non-target source outside its boundary
429
429
  * commit). A ref that resolves only in another configured source — read-only
430
430
  * OR writable-but-not-the-target — is returned with `writable: false` and a
431
- * reason (naming the `--target` remedy when the source is writable); the
431
+ * reason (naming the target-flag remedy when the source is writable); the
432
432
  * caller writes the correction anyway and reports the demotion as not
433
433
  * applied.
434
434
  *
435
435
  * Returns the deduplicated plan in argv order; empty input returns [].
436
436
  */
437
- export function resolveSupersedesForWrite(rawRefs, target) {
437
+ export function resolveSupersedesForWrite(rawRefs, target, targetFlag) {
438
438
  const parsedRefs = parseWriteRefs(rawRefs, "--supersedes");
439
439
  if (parsedRefs.length === 0)
440
440
  return [];
441
- const { roots } = resolveWriteRefRoots(target);
441
+ const { roots } = resolveWriteRefRoots(target, targetFlag);
442
442
  const plan = [];
443
443
  const unresolved = [];
444
444
  for (const parsed of parsedRefs) {
@@ -491,7 +491,7 @@ export function resolveSupersedesForWrite(rawRefs, target) {
491
491
  : {
492
492
  reason: namedWritableSource
493
493
  ? `resolves outside the write target and the working stash, in writable source "${namedWritableSource}" at ${root}; ` +
494
- `re-run with --target ${namedWritableSource} to demote it there`
494
+ `re-run with ${targetFlag ?? "--target"} ${namedWritableSource} to demote it there`
495
495
  : `resolves outside the write target and the working stash, in a read-only source at ${root}; ` +
496
496
  "demotion only applies to assets in the write target or the working stash",
497
497
  }),
@@ -518,7 +518,9 @@ export async function writeMarkdownAsset(options) {
518
518
  const subPath = normalizeCreateSubPath(options.path);
519
519
  const baseName = normalizeMarkdownAssetName(options.name, inferAssetName(options.content, options.fallbackPrefix, options.preferredName));
520
520
  const normalizedName = combineCreatePath(subPath, baseName);
521
- const resolved = resolveMutationTarget(cfg, { type: options.type, name: normalizedName }, options.target);
521
+ const resolved = resolveMutationTarget(cfg, { type: options.type, name: normalizedName }, options.target, {
522
+ flag: options.targetFlag,
523
+ });
522
524
  const { target } = resolved;
523
525
  const { source, config } = target;
524
526
  const typeRoot = path.join(source.path, options.type === "knowledge" ? "knowledge" : "memories");
@@ -33,6 +33,8 @@ async function fetchSimilarMemories(query, excludeRef, eventSource) {
33
33
  return [];
34
34
  }
35
35
  }
36
+ /** The flag `remember` takes for its destination, as its errors spell it. */
37
+ const TARGET_FLAG = "--bundle";
36
38
  /**
37
39
  * `--target` was renamed to `--bundle` on `remember` in 0.9 (S8). citty is
38
40
  * non-strict, so the retired spelling is silently absorbed rather than
@@ -153,8 +155,8 @@ export const rememberCommand = defineJsonCommand({
153
155
  // untouched. Refs resolvable only in a configured extra stash source are
154
156
  // accepted (cross-stash provenance).
155
157
  const rawSupersedes = parseAllFlagValues("--supersedes");
156
- const writeTarget = resolveSupersedesWriteTarget(rawSupersedes, args.bundle);
157
- const xrefs = resolveXrefsForWrite(parseAllFlagValues("--xref"), writeTarget);
158
+ const writeTarget = resolveSupersedesWriteTarget(rawSupersedes, args.bundle, TARGET_FLAG);
159
+ const xrefs = resolveXrefsForWrite(parseAllFlagValues("--xref"), writeTarget, TARGET_FLAG);
158
160
  // Collect and validate --supersedes occurrences (repeatable). Same
159
161
  // before-any-write validation contract: an unresolvable ref exits 2 with
160
162
  // nothing written AND nothing demoted (no partial correction). The
@@ -162,7 +164,7 @@ export const rememberCommand = defineJsonCommand({
162
164
  // (correction provenance per the back-linking conventions); the demotion
163
165
  // itself runs inside writeMarkdownAsset, ordered before the git boundary
164
166
  // commit.
165
- const supersedes = resolveSupersedesForWrite(rawSupersedes, writeTarget);
167
+ const supersedes = resolveSupersedesForWrite(rawSupersedes, writeTarget, TARGET_FLAG);
166
168
  for (const s of supersedes) {
167
169
  if (!xrefs.includes(s.ref))
168
170
  xrefs.push(s.ref);
@@ -206,6 +208,7 @@ export const rememberCommand = defineJsonCommand({
206
208
  preferredName: inferAssetName(body, "memory"),
207
209
  force: args.force,
208
210
  target: writeTarget,
211
+ targetFlag: TARGET_FLAG,
209
212
  path: args.path,
210
213
  supersedes,
211
214
  });
@@ -313,6 +316,7 @@ export const rememberCommand = defineJsonCommand({
313
316
  preferredName: inferAssetName(body, "memory"),
314
317
  force: args.force,
315
318
  target: writeTarget,
319
+ targetFlag: TARGET_FLAG,
316
320
  path: args.path,
317
321
  supersedes,
318
322
  });
@@ -39,7 +39,7 @@ export async function akmClone(options) {
39
39
  // are not bundle slugs).
40
40
  const parsed = parseQualifiedRefInput(options.sourceRef);
41
41
  const config = hasUnmanagedDest ? undefined : loadConfig();
42
- const resolvedWriteTarget = config ? resolveWriteTarget(config, options.target) : undefined;
42
+ const resolvedWriteTarget = config ? resolveWriteTarget(config, options.target, { flag: "--bundle" }) : undefined;
43
43
  const writeTarget = resolvedWriteTarget ? prepareWriteTargetForMutation(resolvedWriteTarget) : undefined;
44
44
  // An unmanaged --dest does not require any configured write target.
45
45
  let allSources;
@@ -212,7 +212,7 @@ const tasksAddCommand = defineJsonCommand({
212
212
  },
213
213
  command: {
214
214
  type: "string",
215
- description: 'Exact shell string to run on the schedule (no AI agent), e.g. "akm improve --strategy frequent".',
215
+ description: 'Exact shell string to run on the schedule (no AI agent), e.g. "akm improve --strategy reflect-distill".',
216
216
  },
217
217
  engine: { type: "string", description: "Engine to use for prompt targets (default: defaults.engine)" },
218
218
  model: { type: "string", description: "Model override for prompt targets" },
@@ -688,7 +688,7 @@ function taskAssetRef(id) {
688
688
  /** The bundle `task add` writes into: its write target, config, stash path, and name. */
689
689
  function resolveTaskBundle(target) {
690
690
  const config = loadConfig();
691
- const resolved = prepareWriteTargetForMutation(resolveWriteTarget(config, target, { requireWritable: true }));
691
+ const resolved = prepareWriteTargetForMutation(resolveWriteTarget(config, target, { requireWritable: true, flag: "--bundle" }));
692
692
  return { resolved, config, stashDir: resolved.source.path, bundleName: resolved.source.name };
693
693
  }
694
694
  function taskProjectionAssetResolver(config, bundleName, bundleRoot) {
@@ -696,7 +696,8 @@ function taskProjectionAssetResolver(config, bundleName, bundleRoot) {
696
696
  if (bundle === bundleName) {
697
697
  return { file: await resolveAssetPath(bundleRoot, type, name), bundleRoot };
698
698
  }
699
- const target = resolveWriteTarget(config, bundle, { requireWritable: false });
699
+ // `bundle` is the qualifier of an asset ref in the task, not a flag.
700
+ const target = resolveWriteTarget(config, bundle, { requireWritable: false, flag: "The asset ref's bundle" });
700
701
  return {
701
702
  file: await resolveAssetPath(target.source.path, type, name),
702
703
  bundleRoot: target.source.path,
@@ -723,7 +724,13 @@ export function resolveTaskReadBundle(refBundle, flagBundle) {
723
724
  else {
724
725
  const configured = resolveActiveConfiguredSources(config).some((source) => source.name === selector);
725
726
  const implicit = configured ? undefined : resolveImplicitScheduledBundleTarget(config, selector);
726
- resolved = implicit ?? resolveWriteTarget(config, selector, { requireWritable: false });
727
+ resolved =
728
+ implicit ??
729
+ resolveWriteTarget(config, selector, {
730
+ requireWritable: false,
731
+ // The selector is `--bundle` when given, else the task ref's own bundle qualifier.
732
+ flag: flagBundle !== undefined ? "--bundle" : "The task ref's bundle",
733
+ });
727
734
  }
728
735
  if (refBundle && resolved.source.name !== refBundle) {
729
736
  throw new UsageError(`Task ref bundle ${JSON.stringify(refBundle)} does not match the resolved source.`, "INVALID_FLAG_VALUE");
@@ -50,9 +50,14 @@ function resolveExplicitMutationTarget(config, explicitTarget, options) {
50
50
  }
51
51
  }
52
52
  }
53
- /** Reconcile a qualified mutation ref with `--target`, then resolve the write destination. */
53
+ /**
54
+ * Reconcile a qualified mutation ref with the explicit target (`--target`, or
55
+ * the `options.flag` the caller's command spells), then resolve the write
56
+ * destination.
57
+ */
54
58
  export function resolveMutationTarget(config, ref, explicitTarget, options = {}) {
55
- const writeOptions = { requireWritable: options.requireWritable };
59
+ const flag = options.flag ?? "--target";
60
+ const writeOptions = { requireWritable: options.requireWritable, flag };
56
61
  const qualifiedTarget = ref.origin ? resolveBundleWriteTarget(config, ref.origin, writeOptions) : undefined;
57
62
  const explicitResolved = explicitTarget
58
63
  ? resolveExplicitMutationTarget(config, explicitTarget, writeOptions)
@@ -60,7 +65,7 @@ export function resolveMutationTarget(config, ref, explicitTarget, options = {})
60
65
  if (qualifiedTarget &&
61
66
  explicitResolved &&
62
67
  path.resolve(qualifiedTarget.source.path) !== path.resolve(explicitResolved.source.path)) {
63
- throw new UsageError(`Qualified ref bundle "${ref.origin}" conflicts with --target "${explicitTarget}".`, "INVALID_FLAG_VALUE", `Drop --target or select the same bundle.`);
68
+ throw new UsageError(`Qualified ref bundle "${ref.origin}" conflicts with ${flag} "${explicitTarget}".`, "INVALID_FLAG_VALUE", `Drop ${flag} or select the same bundle.`);
64
69
  }
65
70
  let target = qualifiedTarget ?? explicitResolved ?? resolveWriteTarget(config, undefined, writeOptions);
66
71
  const bundleId = ref.origin ?? canonicalBundleIdForTarget(config, target);
@@ -59,16 +59,17 @@ export function resolveWriteTarget(akmConfig, explicitTarget, options = {}) {
59
59
  const allConfiguredSources = resolveConfiguredSources(akmConfig);
60
60
  const configuredSources = resolveActiveConfiguredSources(akmConfig);
61
61
  const requireWritable = options.requireWritable !== false;
62
+ const flag = options.flag ?? "--target";
62
63
  if (explicitTarget) {
63
64
  const match = configuredSources.find((s) => s.name === explicitTarget);
64
65
  if (!match) {
65
66
  if (allConfiguredSources.some((source) => source.name === explicitTarget)) {
66
67
  throw new UsageError(`Bundle "${explicitTarget}" is disabled.`, "INVALID_FLAG_VALUE");
67
68
  }
68
- throw new UsageError(`--target must reference a source name from your config. No source named "${explicitTarget}" is configured. Run \`akm bundle list\` to see available sources.`, "INVALID_FLAG_VALUE");
69
+ throw new UsageError(`${flag} must reference a source name from your config. No source named "${explicitTarget}" is configured. Run \`akm bundle list\` to see available sources.`, "INVALID_FLAG_VALUE");
69
70
  }
70
71
  if (requireWritable && !resolveWritable({ type: match.type, writable: match.writable })) {
71
- throw new ConfigError(`source ${explicitTarget} is not writable`, "INVALID_CONFIG_FILE", `Set \`writable: true\` on the "${explicitTarget}" source in your config, or pass --target to a different source.`);
72
+ throw new ConfigError(`source ${explicitTarget} is not writable`, "INVALID_CONFIG_FILE", `Set \`writable: true\` on the "${explicitTarget}" source in your config, or pass ${flag} to a different source.`);
72
73
  }
73
74
  return adaptConfiguredSource(match);
74
75
  }
@@ -85,7 +85,8 @@ export function countFeedbackSignals(db, entryId) {
85
85
  * Count usage events of a given `event_type`.
86
86
  *
87
87
  * Lifted verbatim from `akm improve` (improve.ts) where the show-event count
88
- * was hand-rolled inline to drive the zero-feedback fallback warning.
88
+ * was hand-rolled inline to drive the warning that the retrieval scope matches
89
+ * only search-retrieved assets.
89
90
  */
90
91
  export function countUsageEventsByType(db, eventType) {
91
92
  return db.prepare("SELECT COUNT(*) AS cnt FROM usage_events WHERE event_type = ?").get(eventType)
@@ -86,6 +86,7 @@ export function shapeProposalEntry(entry, detail) {
86
86
  "confidence",
87
87
  "gateDecision",
88
88
  "review",
89
+ "reviewHistory",
89
90
  "retirement",
90
91
  ]);
91
92
  }
@@ -102,6 +103,7 @@ export function shapeProposalEntry(entry, detail) {
102
103
  "gateDecision",
103
104
  "payload",
104
105
  "review",
106
+ "reviewHistory",
105
107
  "retirement",
106
108
  "retiredArchive",
107
109
  "promotionSource",
@@ -167,6 +169,11 @@ export function shapeProposalDiffOutput(result, detail) {
167
169
  isNew: result.isNew,
168
170
  unified: result.unified,
169
171
  ...(result.targetPath !== undefined ? { targetPath: result.targetPath } : {}),
172
+ // A retire proposal (#997): the diff alone is a file leaving, so the pair
173
+ // verdict and the accept/revert note travel with it at every detail level.
174
+ ...(result.op !== undefined ? { op: result.op } : {}),
175
+ ...(result.retirement !== undefined ? { retirement: result.retirement } : {}),
176
+ ...(result.note !== undefined ? { note: result.note } : {}),
170
177
  };
171
178
  if (detail === "full") {
172
179
  return { schemaVersion: result.schemaVersion, ...base };
@@ -53,6 +53,7 @@ const PASSTHROUGH_COMMANDS = [
53
53
  "proposal-accept-batch",
54
54
  "proposal-drain",
55
55
  "proposal-reject-batch",
56
+ "proposal-reopen-batch",
56
57
  "proposal-revert",
57
58
  "registry-add",
58
59
  "registry-list",
@@ -0,0 +1,14 @@
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
+ // Output shape registration for `akm proposal reopen` (#997). One reopened
5
+ // proposal is the same envelope `reject` returns (`ok`, `id`, `ref`, an
6
+ // optional `reason` — here the reopen reason — and the shaped proposal), so it
7
+ // shares that shaper; several are the passthrough `proposal-reopen-batch`.
8
+ import { shapeProposalRejectOutput } from "../helpers.js";
9
+ export const proposalReopenShapes = [
10
+ {
11
+ command: "proposal-reopen",
12
+ handler: (result, detail) => shapeProposalRejectOutput(result, detail),
13
+ },
14
+ ];