akm-cli 0.9.0-beta.53 → 0.9.0-beta.55

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 (123) hide show
  1. package/dist/cli/clack.js +56 -0
  2. package/dist/cli/confirm.js +1 -1
  3. package/dist/cli.js +5 -3
  4. package/dist/commands/agent/contribute-cli.js +2 -3
  5. package/dist/commands/env/env-cli.js +187 -202
  6. package/dist/commands/env/secret-cli.js +109 -121
  7. package/dist/commands/feedback-cli.js +152 -155
  8. package/dist/commands/health/advisories.js +151 -0
  9. package/dist/commands/health/html-report.js +33 -10
  10. package/dist/commands/health/improve-metrics.js +754 -0
  11. package/dist/commands/health/llm-usage.js +65 -0
  12. package/dist/commands/health/md-report.js +103 -0
  13. package/dist/commands/health/metrics.js +278 -0
  14. package/dist/commands/health/task-runs.js +135 -0
  15. package/dist/commands/health/types.js +18 -0
  16. package/dist/commands/health/windows.js +196 -0
  17. package/dist/commands/health.js +15 -1492
  18. package/dist/commands/improve/anti-collapse.js +170 -0
  19. package/dist/commands/improve/collapse-detector.js +3 -2
  20. package/dist/commands/improve/consolidate.js +636 -633
  21. package/dist/commands/improve/dedup.js +1 -1
  22. package/dist/commands/improve/distill/content-repair.js +202 -0
  23. package/dist/commands/improve/distill/promote-memory.js +228 -0
  24. package/dist/commands/improve/distill/quality-gate.js +233 -0
  25. package/dist/commands/improve/distill-guards.js +127 -0
  26. package/dist/commands/improve/distill.js +49 -575
  27. package/dist/commands/improve/extract-cli.js +74 -76
  28. package/dist/commands/improve/extract.js +6 -4
  29. package/dist/commands/improve/hot-probation.js +45 -0
  30. package/dist/commands/improve/improve-auto-accept.js +3 -2
  31. package/dist/commands/improve/improve-cli.js +14 -13
  32. package/dist/commands/improve/improve-result-file.js +2 -1
  33. package/dist/commands/improve/improve.js +6 -5
  34. package/dist/commands/improve/loop-stages.js +19 -21
  35. package/dist/commands/improve/outcome-loop.js +18 -16
  36. package/dist/commands/improve/preparation.js +23 -5
  37. package/dist/commands/improve/procedural.js +10 -31
  38. package/dist/commands/improve/recombine.js +19 -43
  39. package/dist/commands/improve/reflect.js +1 -1
  40. package/dist/commands/improve/schema-similarity-gate.js +168 -0
  41. package/dist/commands/improve/shared.js +48 -0
  42. package/dist/commands/observability-cli.js +4 -4
  43. package/dist/commands/proposal/drain-policies.js +2 -2
  44. package/dist/commands/proposal/drain.js +1 -1
  45. package/dist/commands/proposal/legacy-import.js +115 -0
  46. package/dist/commands/proposal/proposal-cli.js +3 -3
  47. package/dist/commands/proposal/proposal.js +2 -1
  48. package/dist/commands/proposal/propose.js +1 -1
  49. package/dist/commands/proposal/repository.js +829 -0
  50. package/dist/commands/proposal/validators/proposals.js +5 -920
  51. package/dist/commands/read/curate.js +4 -4
  52. package/dist/commands/read/remember-cli.js +132 -137
  53. package/dist/commands/read/search-cli.js +7 -5
  54. package/dist/commands/read/search.js +7 -3
  55. package/dist/commands/read/show.js +3 -5
  56. package/dist/commands/registry-cli.js +76 -87
  57. package/dist/commands/sources/add-cli.js +91 -95
  58. package/dist/commands/sources/history.js +1 -1
  59. package/dist/commands/sources/init.js +12 -0
  60. package/dist/commands/sources/schema-repair.js +1 -1
  61. package/dist/commands/sources/sources-cli.js +3 -3
  62. package/dist/commands/sources/stash-cli.js +2 -2
  63. package/dist/commands/tasks/default-tasks.js +12 -0
  64. package/dist/commands/tasks/tasks-cli.js +1 -2
  65. package/dist/commands/wiki-cli.js +2 -3
  66. package/dist/core/common.js +3 -3
  67. package/dist/core/config/config-schema.js +6 -0
  68. package/dist/core/config/config.js +12 -0
  69. package/dist/core/deep-merge.js +38 -0
  70. package/dist/core/events.js +2 -1
  71. package/dist/core/logs-db.js +8 -13
  72. package/dist/core/paths.js +14 -14
  73. package/dist/core/state-db.js +13 -1140
  74. package/dist/core/warn.js +21 -0
  75. package/dist/indexer/db/db.js +72 -709
  76. package/dist/indexer/db/entry-mapper.js +41 -0
  77. package/dist/indexer/db/schema.js +516 -0
  78. package/dist/indexer/ensure-index.js +3 -2
  79. package/dist/indexer/feedback/utility-policy.js +85 -0
  80. package/dist/indexer/graph/graph-extraction.js +2 -1
  81. package/dist/indexer/index-writer-lock.js +18 -0
  82. package/dist/indexer/indexer.js +94 -27
  83. package/dist/indexer/read-preflight.js +23 -0
  84. package/dist/indexer/search/fts-query.js +51 -0
  85. package/dist/indexer/walk/walker.js +21 -13
  86. package/dist/integrations/agent/detect.js +9 -0
  87. package/dist/integrations/agent/index.js +1 -1
  88. package/dist/integrations/agent/spawn.js +15 -66
  89. package/dist/llm/client.js +12 -0
  90. package/dist/llm/embedder.js +26 -2
  91. package/dist/llm/embedders/local.js +7 -1
  92. package/dist/output/text/helpers.js +13 -0
  93. package/dist/scripts/migrate-storage.js +6903 -7424
  94. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +49 -44
  95. package/dist/setup/detect.js +9 -0
  96. package/dist/setup/legacy-config.js +106 -0
  97. package/dist/setup/prompt.js +57 -0
  98. package/dist/setup/providers.js +14 -0
  99. package/dist/setup/registry-stash-loader.js +12 -0
  100. package/dist/setup/semantic-assets.js +124 -0
  101. package/dist/setup/setup.js +25 -1608
  102. package/dist/setup/steps/connection.js +734 -0
  103. package/dist/setup/steps/output.js +31 -0
  104. package/dist/setup/steps/platforms.js +124 -0
  105. package/dist/setup/steps/semantic.js +27 -0
  106. package/dist/setup/steps/sources.js +222 -0
  107. package/dist/setup/steps/stashdir.js +42 -0
  108. package/dist/setup/steps/tasks.js +152 -0
  109. package/dist/storage/repositories/canaries-repository.js +107 -0
  110. package/dist/storage/repositories/consolidation-repository.js +38 -0
  111. package/dist/storage/repositories/embeddings-repository.js +72 -0
  112. package/dist/storage/repositories/events-repository.js +187 -0
  113. package/dist/storage/repositories/extract-sessions-repository.js +96 -0
  114. package/dist/storage/repositories/improve-runs-repository.js +130 -0
  115. package/dist/storage/repositories/index-db.js +4 -7
  116. package/dist/storage/repositories/proposals-repository.js +220 -0
  117. package/dist/storage/repositories/recombine-repository.js +213 -0
  118. package/dist/storage/repositories/task-history-repository.js +93 -0
  119. package/dist/storage/sqlite-pragmas.js +3 -3
  120. package/dist/tasks/backends/index.js +9 -0
  121. package/dist/tasks/runner.js +11 -1
  122. package/package.json +2 -2
  123. package/dist/commands/improve/homeostatic.js +0 -497
@@ -2,707 +2,15 @@
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
  /**
5
- * Proposal substrate (#225, storage consolidated in #578).
5
+ * Proposal validation and content repair.
6
6
  *
7
- * One durable proposal store for every future reflection / generation flow
8
- * (`akm reflect`, `akm propose`, `akm distill`, lesson distillation, …).
9
- * Proposals are *queue state*, not source-of-truth assetsthey sit in the
10
- * queue waiting for human (or automated) review and only become assets after
11
- * `akm proposal accept` validates and promotes them via
12
- * {@link writeAssetToSource}.
13
- *
14
- * # Storage
15
- *
16
- * The canonical store is the `proposals` table in `state.db` (SQLite, WAL
17
- * mode — see `src/core/state-db.ts`). Rows are partitioned by `stash_dir` so
18
- * multi-stash installs keep independent queues, and the `status` column
19
- * distinguishes the live queue (`pending`) from the archive (`accepted` /
20
- * `rejected` / `reverted`). There is no separate archive location — archival
21
- * is a status flip, and the full audit trail (review outcome, reason, backup
22
- * content for revert) lives on the row.
23
- *
24
- * ## Legacy filesystem import
25
- *
26
- * Before 0.9.0 proposals lived as per-uuid JSON directories under
27
- * `<stashDir>/.akm/proposals/` (live) and `…/proposals/archive/` (archived).
28
- * The first proposal operation against a stash imports any legacy
29
- * `proposal.json` files into the table (INSERT OR IGNORE keyed on the UUID,
30
- * so re-runs never duplicate) and records the stash in `proposal_fs_imports`
31
- * so later invocations skip the directory walk. The legacy files are left in
32
- * place untouched — they are inert after import and may be removed by the
33
- * operator at leisure.
34
- *
35
- * # Why the queue bypasses `writeAssetToSource`
36
- *
37
- * The architectural rule "all writes go through `writeAssetToSource`" applies
38
- * to *assets*. Proposals are **not** assets — they live outside the asset
39
- * tree (in state.db, parallel to how events do). Routing them through
40
- * `writeAssetToSource` would force them into a `TYPE_DIRS` slot, would commit
41
- * them to git, and would leak unaccepted drafts through the normal indexer.
42
- * The {@link promoteProposal} step is the bridge: it routes the accepted
43
- * payload through `writeAssetToSource` so the actual asset write still
44
- * funnels through the single dispatch point in `src/core/write-source.ts`.
7
+ * The proposal repository, domain service, and legacy filesystem import moved
8
+ * to `../repository.ts` and `../legacy-import.ts` (#578 storage consolidation).
9
+ * This module keeps only the two proposal *validators* {@link validateProposal}
10
+ * and {@link repairProposalContent}.
45
11
  */
46
- import { createHash, randomUUID } from "node:crypto";
47
- import fs from "node:fs";
48
- import path from "node:path";
49
- import { makeAssetRef, parseAssetRef } from "../../../core/asset/asset-ref.js";
50
- import { resolveAssetPathFromName, TYPE_DIRS } from "../../../core/asset/asset-spec.js";
51
- import { NotFoundError, UsageError } from "../../../core/errors.js";
52
- import { appendEvent } from "../../../core/events.js";
53
- import { getStateProposal, hasImportedFsProposals, insertProposalIfAbsent, listStateProposalIdsByPrefix, listStateProposals, recordFsProposalsImport, upsertProposal, withImmediateTransaction, withStateDb, } from "../../../core/state-db.js";
54
12
  import { repairTruncatedDescription } from "../../../core/text-truncation.js";
55
- import { warn } from "../../../core/warn.js";
56
- import { commitWriteTargetBoundary, formatRefForMessage, resolveWriteTarget, writeAssetToSource, } from "../../../core/write-source.js";
57
13
  import { runProposalValidators } from "./proposal-validators.js";
58
- // ── Source allow-list (F-4 / #385) ──────────────────────────────────────────
59
- /**
60
- * Curated allow-list of valid `source` values for proposals (F-4 / #385).
61
- *
62
- * Rationale (W3C PROV-DM 2013): Provenance records require typed, validated
63
- * sources for meaningful aggregation. Accept-rate-per-source is the core
64
- * self-measurement metric for recursive self-improvement: if reflect proposals
65
- * are accepted at 20% and distill proposals at 60%, that guides resource
66
- * allocation. Free-text typos (`"reflct"`) produce unaggregatable events.
67
- *
68
- * Automated sources (those in {@link AUTOMATED_PROPOSAL_SOURCES}) require a
69
- * `sourceRun` field for full PROV-DM traceability.
70
- */
71
- export const PROPOSAL_SOURCES = [
72
- // Automated sources — require sourceRun for traceability.
73
- "reflect",
74
- "distill",
75
- "consolidate",
76
- "extract",
77
- "improve",
78
- "recombine",
79
- "procedural",
80
- // Semi-automated / tool-driven.
81
- "feedback",
82
- // Human-initiated / CLI-driven.
83
- "propose",
84
- "remember",
85
- "import",
86
- // Internal / system.
87
- "distill_quality_rejected",
88
- "schema-repair",
89
- ];
90
- /** Automated sources that SHOULD include a `sourceRun` for PROV-DM traceability. */
91
- export const AUTOMATED_PROPOSAL_SOURCES = [
92
- "reflect",
93
- "distill",
94
- "consolidate",
95
- "extract",
96
- "improve",
97
- "recombine",
98
- "procedural",
99
- "schema-repair",
100
- ];
101
- /**
102
- * Check whether a string is a valid {@link ProposalSource}.
103
- * Unknown source values are accepted with a runtime warning rather than a hard
104
- * error, to allow extensions without breaking existing callers.
105
- */
106
- export function isValidProposalSource(source) {
107
- return PROPOSAL_SOURCES.includes(source);
108
- }
109
- /**
110
- * Check whether a source value is an automated source requiring `sourceRun`.
111
- */
112
- export function isAutomatedProposalSource(source) {
113
- return AUTOMATED_PROPOSAL_SOURCES.includes(source);
114
- }
115
- /** Type guard: true when createProposal returned a skipped record. */
116
- export function isProposalSkipped(result) {
117
- return result.skipped === true;
118
- }
119
- // ── Dedup / cooldown constants ───────────────────────────────────────────────
120
- const MS_PER_DAY = 86_400_000;
121
- /**
122
- * Post-rejection cooldown windows by source. After a proposal is rejected,
123
- * `createProposal` silently skips new proposals for the same `ref+source`
124
- * until the window expires (unless `force: true` is passed).
125
- *
126
- * Rationale (Settles 2009 active-learning survey; Argilla/Label Studio HITL):
127
- * Reviewer fatigue is a blocker for the human-in-the-loop guarantee. Cooldowns
128
- * prevent nightly improve runs from re-flooding the queue with near-identical
129
- * proposals the reviewer just declined.
130
- *
131
- * - reflect: 14 days (agent-based; slower feedback loops)
132
- * - distill: 30 days (LLM-based; even more prone to regeneration loops)
133
- * - default: 7 days (conservative fallback for other sources)
134
- */
135
- const COOLDOWN_MS = {
136
- reflect: 14 * MS_PER_DAY,
137
- distill: 30 * MS_PER_DAY,
138
- };
139
- const DEFAULT_COOLDOWN_MS = 7 * MS_PER_DAY;
140
- function cooldownMsForSource(source) {
141
- return COOLDOWN_MS[source] ?? DEFAULT_COOLDOWN_MS;
142
- }
143
- /** Compute a stable SHA-256 hex digest of a proposal's content string. */
144
- function contentHash(content) {
145
- return createHash("sha256").update(content, "utf8").digest("hex");
146
- }
147
- // ── Store access ─────────────────────────────────────────────────────────────
148
- function nowIso(ctx) {
149
- const fn = ctx?.now ?? Date.now;
150
- return new Date(fn()).toISOString();
151
- }
152
- function newId(ctx) {
153
- const fn = ctx?.randomUUID ?? randomUUID;
154
- return fn();
155
- }
156
- /**
157
- * Open the state database (honouring the `ctx.dbPath` test seam), run the
158
- * legacy filesystem import for `stashDir` if it has not happened yet, hand the
159
- * connection to `fn`, and close it in a `finally`. Every public function in
160
- * this module funnels its store access through here so the legacy import is
161
- * guaranteed to have run before any read or write.
162
- */
163
- function withProposalsDb(stashDir, ctx, fn) {
164
- return withStateDb((db) => {
165
- importLegacyProposalFiles(db, stashDir);
166
- return fn(db);
167
- }, { path: ctx?.dbPath });
168
- }
169
- // ── Legacy filesystem import (#578) ─────────────────────────────────────────
170
- /** Legacy (pre-0.9.0) proposal directory: `<stashDir>/.akm/proposals[/archive]`. */
171
- function legacyProposalsRoot(stashDir, archive) {
172
- const root = path.join(stashDir, ".akm", "proposals");
173
- return archive ? path.join(root, "archive") : root;
174
- }
175
- /**
176
- * One-shot import of legacy `proposal.json` files into the `proposals` table.
177
- *
178
- * Idempotent at two levels: the `proposal_fs_imports` ledger skips the
179
- * directory walk after the first successful import, and INSERT OR IGNORE
180
- * (keyed on the proposal UUID) protects against duplicates even if the walk
181
- * re-runs. Legacy `backup.<ext>` files are inlined into `backupContent` so
182
- * `akm proposal revert` keeps working for proposals accepted before 0.9.0.
183
- *
184
- * The legacy files are never modified or deleted — after import they are
185
- * inert artifacts the operator can remove at leisure.
186
- */
187
- function importLegacyProposalFiles(db, stashDir) {
188
- if (hasImportedFsProposals(db, stashDir))
189
- return;
190
- const liveRoot = legacyProposalsRoot(stashDir, false);
191
- if (!fs.existsSync(liveRoot))
192
- return;
193
- let imported = 0;
194
- for (const archive of [false, true]) {
195
- const root = legacyProposalsRoot(stashDir, archive);
196
- let entries;
197
- try {
198
- entries = fs.readdirSync(root, { withFileTypes: true });
199
- }
200
- catch {
201
- continue;
202
- }
203
- for (const entry of entries) {
204
- if (!entry.isDirectory() || entry.name === "archive")
205
- continue;
206
- const proposalDir = path.join(root, entry.name);
207
- const proposal = readLegacyProposalFile(proposalDir);
208
- if (!proposal)
209
- continue;
210
- if (insertProposalIfAbsent(db, proposal, stashDir))
211
- imported += 1;
212
- }
213
- }
214
- recordFsProposalsImport(db, stashDir, imported);
215
- if (imported > 0) {
216
- warn(`[proposals] imported ${imported} legacy proposal file(s) from ${liveRoot} into state.db`);
217
- }
218
- }
219
- /**
220
- * Parse one legacy proposal directory into a {@link Proposal}, inlining the
221
- * backup file (when present) as `backupContent`. Returns undefined — with a
222
- * warning — when the `proposal.json` is missing, unreadable, or malformed, so
223
- * a single corrupt legacy entry never blocks the import of the rest.
224
- */
225
- function readLegacyProposalFile(proposalDir) {
226
- const filePath = path.join(proposalDir, "proposal.json");
227
- let parsed;
228
- try {
229
- parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
230
- }
231
- catch (err) {
232
- warn(`[proposals] skipping legacy proposal at ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
233
- return undefined;
234
- }
235
- if (typeof parsed !== "object" ||
236
- parsed === null ||
237
- typeof parsed.id !== "string" ||
238
- typeof parsed.ref !== "string") {
239
- warn(`[proposals] skipping legacy proposal at ${filePath}: not a proposal object`);
240
- return undefined;
241
- }
242
- const { backup, ...rest } = parsed;
243
- let backupContent;
244
- if (typeof backup === "string" && backup.length > 0) {
245
- try {
246
- backupContent = fs.readFileSync(path.join(proposalDir, backup), "utf8");
247
- }
248
- catch {
249
- // Backup file lost — import the proposal anyway; revert for it will
250
- // surface "no backup available", same as a new-asset proposal.
251
- }
252
- }
253
- return {
254
- ...rest,
255
- payload: {
256
- content: rest.payload?.content ?? "",
257
- ...(rest.payload?.frontmatter ? { frontmatter: rest.payload.frontmatter } : {}),
258
- },
259
- createdAt: rest.createdAt ?? "",
260
- updatedAt: rest.updatedAt ?? rest.createdAt ?? "",
261
- status: rest.status ?? "pending",
262
- source: rest.source ?? "import",
263
- ...(backupContent !== undefined ? { backupContent } : {}),
264
- };
265
- }
266
- // ── Public API ──────────────────────────────────────────────────────────────
267
- /**
268
- * Create a new pending proposal. The id is a stable random UUID, so two
269
- * proposals with the same `ref` never collide.
270
- *
271
- * **Dedup / cooldown guard** (F-2 / #363):
272
- *
273
- * Before writing, this function checks:
274
- * 1. `duplicate_pending` — a pending proposal already exists for the same
275
- * `ref+source`. Pass `input.force = true` to bypass.
276
- * 2. `content_hash_match` — an identical content hash is already pending or
277
- * was recently rejected for this `ref+source`. Bypass with `force: true`.
278
- * 3. `cooldown` — a proposal for this `ref+source` was rejected within the
279
- * source-specific cooldown window (reflect: 14 d, distill: 30 d,
280
- * others: 7 d). Bypass with `force: true`.
281
- *
282
- * When a guard fires the function returns a `CreateProposalSkipped` record
283
- * instead of writing. Use {@link isProposalSkipped} to detect it.
284
- */
285
- export function createProposal(stashDir, input, ctx) {
286
- // F-4 / #385: Validate source against the allow-list. Unknown values are
287
- // warned (not rejected) for backward compatibility — extension callers
288
- // that pass custom source strings must not break.
289
- if (!isValidProposalSource(input.source)) {
290
- warn(`[proposal] Unknown source "${input.source}". ` +
291
- `Expected one of: ${PROPOSAL_SOURCES.join(", ")}. ` +
292
- "Typos in source values produce unaggregatable accept-rate-per-source metrics.");
293
- }
294
- else if (isAutomatedProposalSource(input.source) && !input.sourceRun) {
295
- // Advisory warning: automated sources should include sourceRun for PROV-DM
296
- // traceability. This is not a hard error to avoid breaking existing callers.
297
- warn(`[proposal] Automated source "${input.source}" created a proposal without sourceRun. ` +
298
- "Add sourceRun to enable accept-rate-per-run aggregation (W3C PROV-DM).");
299
- }
300
- // Deterministic input validation. Reject obviously-invalid proposals at
301
- // the source rather than letting them enter the queue and waste reviewer
302
- // time. Each rejection emits `proposal_creation_rejected` with a typed
303
- // reason so we can see *which* check is firing in the event stream.
304
- const rejectProposal = (reason, message) => {
305
- appendEvent({
306
- eventType: "proposal_creation_rejected",
307
- ref: input.ref,
308
- metadata: { source: input.source, reason },
309
- });
310
- throw new UsageError(message, "INVALID_PROPOSAL");
311
- };
312
- let parsedRef;
313
- try {
314
- parsedRef = parseAssetRef(input.ref);
315
- }
316
- catch (err) {
317
- return rejectProposal("invalid_ref", `Invalid proposal ref "${input.ref}": ${err instanceof Error ? err.message : String(err)}`);
318
- }
319
- if (!TYPE_DIRS[parsedRef.type]) {
320
- return rejectProposal("unknown_type", `Unknown asset type "${parsedRef.type}" in proposal ref "${input.ref}". Known types: ${Object.keys(TYPE_DIRS).sort().join(", ")}.`);
321
- }
322
- if (!input.payload.content.trim()) {
323
- return rejectProposal("empty_content", `Proposal for "${input.ref}" has empty content.`);
324
- }
325
- // Description check is only enforced for `consolidate` source — that's the
326
- // automated pipeline that historically produced proposals with missing or
327
- // malformed frontmatter, polluting the queue with hundreds of unusable
328
- // entries. Reflect / distill / propose proposals have varied legitimate
329
- // shapes and should not be rejected here for missing description.
330
- if (input.source === "consolidate") {
331
- const desc = input.payload.frontmatter?.description;
332
- if (typeof desc !== "string" || desc.trim() === "") {
333
- return rejectProposal("missing_description", `Proposal for "${input.ref}" (source=consolidate) has empty or missing frontmatter description.`);
334
- }
335
- }
336
- const normalizedRef = makeAssetRef(parsedRef.type, parsedRef.name, parsedRef.origin);
337
- return withProposalsDb(stashDir, ctx, (db) => {
338
- return withImmediateTransaction(db, () => {
339
- if (!input.force) {
340
- const skip = checkDedupAndCooldown(db, stashDir, normalizedRef, input, ctx);
341
- if (skip)
342
- return skip;
343
- }
344
- const created = nowIso(ctx);
345
- // Phase 6A: validate confidence is a finite number in [0, 1]. Anything else
346
- // is dropped silently — we never store NaN, Infinity, or out-of-range values.
347
- // Callers that mis-report confidence should not poison the auto-accept gate.
348
- const sanitizedConfidence = typeof input.confidence === "number" &&
349
- Number.isFinite(input.confidence) &&
350
- input.confidence >= 0 &&
351
- input.confidence <= 1
352
- ? input.confidence
353
- : undefined;
354
- const proposal = {
355
- id: newId(ctx),
356
- ref: normalizedRef,
357
- status: "pending",
358
- source: input.source,
359
- ...(input.sourceRun !== undefined ? { sourceRun: input.sourceRun } : {}),
360
- createdAt: created,
361
- updatedAt: created,
362
- payload: {
363
- content: input.payload.content,
364
- ...(input.payload.frontmatter !== undefined ? { frontmatter: input.payload.frontmatter } : {}),
365
- },
366
- ...(sanitizedConfidence !== undefined ? { confidence: sanitizedConfidence } : {}),
367
- // Attribution tagging: persist the eligibility lane so it survives to
368
- // accept/reject/revert time. See EligibilitySource.
369
- ...(input.eligibilitySource !== undefined ? { eligibilitySource: input.eligibilitySource } : {}),
370
- };
371
- upsertProposal(db, proposal, stashDir);
372
- return proposal;
373
- });
374
- });
375
- }
376
- /**
377
- * Evaluate the F-2 dedup / cooldown guards against the store. Returns the
378
- * skip record when a guard fires, or undefined when the create may proceed.
379
- */
380
- function checkDedupAndCooldown(db, stashDir, normalizedRef, input, ctx) {
381
- const newHash = contentHash(input.payload.content);
382
- const nowMs = (ctx?.now ?? Date.now)();
383
- const cooldownMs = cooldownMsForSource(input.source);
384
- // Scan pending proposals for ref+source matches.
385
- const pending = listStateProposals(db, { stashDir, ref: normalizedRef, status: "pending" }).filter((p) => p.source === input.source);
386
- if (pending.length > 0) {
387
- // Check for identical content hash first (silent skip).
388
- const hashMatch = pending.find((p) => contentHash(p.payload.content) === newHash);
389
- if (hashMatch) {
390
- return {
391
- skipped: true,
392
- reason: "content_hash_match",
393
- message: `Identical proposal for ${normalizedRef} already pending (id: ${hashMatch.id}).`,
394
- existingProposalId: hashMatch.id,
395
- };
396
- }
397
- // Duplicate pending for same ref+source (different content).
398
- const firstPending = pending[0];
399
- return {
400
- skipped: true,
401
- reason: "duplicate_pending",
402
- message: `A pending proposal for ${normalizedRef} from source "${input.source}" already exists (id: ${firstPending?.id ?? "unknown"}). Pass force:true to enqueue alongside it.`,
403
- existingProposalId: firstPending?.id,
404
- };
405
- }
406
- // Check cooldown against recently rejected proposals.
407
- const rejected = listStateProposals(db, { stashDir, ref: normalizedRef, status: "rejected" })
408
- .filter((p) => p.source === input.source)
409
- .sort((a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime());
410
- const mostRecent = rejected[0];
411
- if (mostRecent !== undefined) {
412
- // Check content hash against recently rejected.
413
- if (contentHash(mostRecent.payload.content) === newHash) {
414
- return {
415
- skipped: true,
416
- reason: "content_hash_match",
417
- message: `Identical proposal for ${normalizedRef} was already rejected (id: ${mostRecent.id}).`,
418
- existingProposalId: mostRecent.id,
419
- };
420
- }
421
- // Check cooldown window.
422
- const rejectedAt = new Date(mostRecent.updatedAt ?? 0).getTime();
423
- if (nowMs - rejectedAt < cooldownMs) {
424
- const cooldownDays = cooldownMs / MS_PER_DAY;
425
- const remainingDays = Math.ceil((cooldownMs - (nowMs - rejectedAt)) / MS_PER_DAY);
426
- return {
427
- skipped: true,
428
- reason: "cooldown",
429
- message: `Proposal for ${normalizedRef} from source "${input.source}" is in cooldown ` +
430
- `(${cooldownDays}d window, ~${remainingDays}d remaining). Pass force:true to bypass.`,
431
- existingProposalId: mostRecent.id,
432
- };
433
- }
434
- }
435
- return undefined;
436
- }
437
- /**
438
- * List proposals for one stash. By default returns only the live (pending)
439
- * queue; pass `{ includeArchive: true }` to include accepted / rejected /
440
- * reverted entries as well.
441
- */
442
- export function listProposals(stashDir, options = {}, ctx) {
443
- return withProposalsDb(stashDir, ctx, (db) => {
444
- // Without includeArchive, only the live queue is visible — an explicit
445
- // non-pending status filter therefore matches nothing (mirrors the
446
- // historical live-directory scan).
447
- if (!options.includeArchive && options.status !== undefined && options.status !== "pending") {
448
- return [];
449
- }
450
- const status = options.includeArchive ? options.status : "pending";
451
- return listStateProposals(db, {
452
- stashDir,
453
- ...(status !== undefined ? { status } : {}),
454
- ...(options.ref !== undefined ? { ref: options.ref } : {}),
455
- }).filter((p) => {
456
- if (!options.type)
457
- return true;
458
- try {
459
- return parseAssetRef(p.ref).type === options.type;
460
- }
461
- catch {
462
- return false;
463
- }
464
- });
465
- });
466
- }
467
- /**
468
- * Look up a proposal by id (live or archived).
469
- * Throws `NotFoundError` when no match exists in this stash.
470
- */
471
- export function getProposal(stashDir, id, ctx) {
472
- return withProposalsDb(stashDir, ctx, (db) => requireProposal(db, stashDir, id));
473
- }
474
- function requireProposal(db, stashDir, id) {
475
- const proposal = getStateProposal(db, id, stashDir);
476
- if (!proposal) {
477
- throw new NotFoundError(`Proposal "${id}" not found.`, "FILE_NOT_FOUND");
478
- }
479
- return proposal;
480
- }
481
- /**
482
- * Resolve a proposal by full UUID, UUID prefix, or asset ref.
483
- *
484
- * Resolution order:
485
- * 1. Exact UUID match (existing behaviour).
486
- * 2. Asset ref (contains `:`) — finds the most-recent pending proposal for
487
- * that ref; falls back to archived if nothing is pending.
488
- * 3. UUID prefix — matches any PENDING proposal whose id starts with the
489
- * given string; throws if ambiguous.
490
- */
491
- export function resolveProposalId(stashDir, idOrRef, ctx) {
492
- return withProposalsDb(stashDir, ctx, (db) => {
493
- // 1. Exact UUID.
494
- const exact = getStateProposal(db, idOrRef, stashDir);
495
- if (exact)
496
- return exact;
497
- // 2. Asset ref (e.g. "skill:akm-dream") — most recent pending, else most
498
- // recent archived.
499
- if (idOrRef.includes(":")) {
500
- const byRecency = (proposals) => proposals.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime())[0];
501
- const pending = byRecency(listStateProposals(db, { stashDir, ref: idOrRef, status: "pending" }));
502
- if (pending)
503
- return pending;
504
- const archived = byRecency(listStateProposals(db, { stashDir, ref: idOrRef }));
505
- if (archived)
506
- return archived;
507
- throw new NotFoundError(`No proposal found for ref "${idOrRef}".`, "FILE_NOT_FOUND");
508
- }
509
- // 3. UUID prefix (pending queue only).
510
- const prefixMatches = listStateProposalIdsByPrefix(db, stashDir, idOrRef);
511
- if (prefixMatches.length === 1)
512
- return requireProposal(db, stashDir, prefixMatches[0]);
513
- if (prefixMatches.length > 1) {
514
- throw new UsageError(`Ambiguous prefix "${idOrRef}" — matches: ${prefixMatches.join(", ")}`, "INVALID_FLAG_VALUE");
515
- }
516
- throw new NotFoundError(`Proposal "${idOrRef}" not found.`, "FILE_NOT_FOUND");
517
- });
518
- }
519
- /**
520
- * Archive a proposal: flip its status to `accepted` / `rejected`, bump
521
- * `updatedAt`, and record the review block. Used by both accept and reject
522
- * paths so the live queue only contains pending entries.
523
- */
524
- export function archiveProposal(stashDir, id, status, reason, ctx) {
525
- return withProposalsDb(stashDir, ctx, (db) => {
526
- return withImmediateTransaction(db, () => {
527
- const existing = requireProposal(db, stashDir, id);
528
- if (existing.status !== "pending") {
529
- throw new UsageError(`Proposal ${id} is not pending (current status: ${existing.status}). Only pending proposals can be ${status}.`, "INVALID_FLAG_VALUE");
530
- }
531
- const decidedAt = nowIso(ctx);
532
- const updated = {
533
- ...existing,
534
- status,
535
- updatedAt: decidedAt,
536
- review: {
537
- outcome: status,
538
- ...(reason !== undefined ? { reason } : {}),
539
- decidedAt,
540
- },
541
- };
542
- upsertProposal(db, updated, stashDir);
543
- return updated;
544
- });
545
- });
546
- }
547
- /**
548
- * Record an automated gate's decision onto a proposal (#577).
549
- *
550
- * Stamps `gateDecision` (decision / reason / confidence / thresholds) onto the
551
- * row so `akm proposal show` and `list` can explain why a proposal landed where
552
- * it did. The decision is metadata about the adjudication, so this does NOT
553
- * change `status` or bump `updatedAt` — a `deferred` proposal stays `pending`,
554
- * and the accept / reject status flips are owned by {@link promoteProposal} /
555
- * {@link archiveProposal}. `decidedAt` defaults to now when the caller omits it.
556
- *
557
- * Best-effort: a proposal that no longer exists (e.g. concurrently archived) is
558
- * skipped silently rather than throwing, so a gate run never aborts mid-batch.
559
- * Returns the updated proposal, or undefined when no matching row exists.
560
- */
561
- export function recordGateDecision(stashDir, id, decision, ctx) {
562
- return withProposalsDb(stashDir, ctx, (db) => {
563
- return withImmediateTransaction(db, () => {
564
- const existing = getStateProposal(db, id, stashDir);
565
- if (!existing || existing.status !== "pending")
566
- return undefined;
567
- const updated = {
568
- ...existing,
569
- gateDecision: { ...decision, decidedAt: decision.decidedAt ?? nowIso(ctx) },
570
- };
571
- upsertProposal(db, updated, stashDir);
572
- return updated;
573
- });
574
- });
575
- }
576
- /**
577
- * Scan all pending proposals and reject those whose target asset no longer
578
- * exists on disk across any of `sourceDirs`. Intended to run as a periodic
579
- * maintenance pass (see `runImproveMaintenancePasses`) — it keeps the queue
580
- * from accumulating stale reviewer work after large refactors or deletes.
581
- *
582
- * Scope rule: only `source=reflect` proposals are subject to orphan rejection.
583
- * Lessons, propose, distill, and consolidate proposals legitimately target
584
- * assets that don't exist yet and must never be purged.
585
- */
586
- export function purgeOrphanProposals(stashDir, sourceDirs, ctx) {
587
- const t0 = Date.now();
588
- const orphans = [];
589
- const byType = {};
590
- const pending = listProposals(stashDir, { status: "pending" }, ctx);
591
- const reflectPending = pending.filter((p) => p.source === "reflect");
592
- for (const p of reflectPending) {
593
- let parsed;
594
- try {
595
- parsed = parseAssetRef(p.ref);
596
- }
597
- catch {
598
- continue;
599
- }
600
- // Lessons are new-asset proposals by definition — they cannot be orphaned.
601
- if (parsed.type === "lesson")
602
- continue;
603
- const spec = TYPE_DIRS[parsed.type];
604
- if (!spec)
605
- continue;
606
- const exists = sourceDirs.some((root) => {
607
- const typeRoot = path.join(root, spec);
608
- const candidate = resolveAssetPathFromName(parsed.type, typeRoot, parsed.name);
609
- return fs.existsSync(candidate);
610
- });
611
- if (!exists) {
612
- try {
613
- archiveProposal(stashDir, p.id, "rejected", "Asset no longer exists on disk", ctx);
614
- orphans.push({ id: p.id, ref: p.ref, reason: "asset_missing" });
615
- byType[parsed.type] = (byType[parsed.type] ?? 0) + 1;
616
- }
617
- catch (err) {
618
- // Best-effort — the purge is non-fatal. Log and continue.
619
- warn(`[proposals] purgeOrphanProposals: failed to reject ${p.id}: ${err instanceof Error ? err.message : String(err)}`);
620
- }
621
- }
622
- }
623
- return {
624
- checked: reflectPending.length,
625
- rejected: orphans.length,
626
- durationMs: Date.now() - t0,
627
- byType,
628
- orphans,
629
- };
630
- }
631
- /**
632
- * Archive pending proposals older than `config.archiveRetentionDays` (Advantage
633
- * D6b / Phase 6B).
634
- *
635
- * Reviewer fatigue and queue rot are the dominant failure modes of any
636
- * human-in-the-loop pipeline (Settles 2009 active-learning survey). Pending
637
- * proposals that have aged past the retention window are very rarely accepted
638
- * — the reviewer either intentionally declined to act on them, or the asset
639
- * they target has drifted enough that the proposal is no longer relevant.
640
- * Auto-expiring them keeps the live queue focused on actionable work; the
641
- * archive preserves the full audit trail.
642
- *
643
- * Each expired proposal is archived with status `rejected` and reason
644
- * `"expired: no action within retention window"`. A `proposal_expired` event
645
- * is appended for each expired proposal so downstream observability (events
646
- * dashboards, source-acceptance-rate aggregations) can see expiry separately
647
- * from explicit rejections.
648
- *
649
- * Idempotent: a second call within the same retention window finds nothing
650
- * to expire (the archived entries are no longer in the pending queue).
651
- */
652
- export function expireStaleProposals(stashDir, config, ctx) {
653
- const t0 = Date.now();
654
- const retentionDays = config.archiveRetentionDays ?? 90;
655
- const expiredProposals = [];
656
- // retentionDays === 0 disables TTL cleanup globally (mirrors how
657
- // consolidate.ts interprets the same config value).
658
- if (retentionDays <= 0) {
659
- return {
660
- checked: 0,
661
- expired: 0,
662
- durationMs: Date.now() - t0,
663
- retentionDays,
664
- expiredProposals,
665
- };
666
- }
667
- const retentionMs = retentionDays * MS_PER_DAY;
668
- const nowMs = (ctx?.now ?? Date.now)();
669
- const pending = listProposals(stashDir, { status: "pending" }, ctx);
670
- for (const p of pending) {
671
- const createdMs = new Date(p.createdAt).getTime();
672
- if (!Number.isFinite(createdMs))
673
- continue;
674
- const ageMs = nowMs - createdMs;
675
- if (ageMs < retentionMs)
676
- continue;
677
- try {
678
- archiveProposal(stashDir, p.id, "rejected", "expired: no action within retention window", ctx);
679
- const ageDays = Math.floor(ageMs / MS_PER_DAY);
680
- expiredProposals.push({ id: p.id, ref: p.ref, ageDays });
681
- appendEvent({
682
- eventType: "proposal_expired",
683
- ref: p.ref,
684
- metadata: {
685
- proposalId: p.id,
686
- source: p.source,
687
- ...(p.sourceRun !== undefined ? { sourceRun: p.sourceRun } : {}),
688
- ageDays,
689
- retentionDays,
690
- },
691
- });
692
- }
693
- catch (err) {
694
- // Best-effort — a single failure must not block the pass.
695
- warn(`[proposals] expireStaleProposals: failed to expire ${p.id}: ${err instanceof Error ? err.message : String(err)}`);
696
- }
697
- }
698
- return {
699
- checked: pending.length,
700
- expired: expiredProposals.length,
701
- durationMs: Date.now() - t0,
702
- retentionDays,
703
- expiredProposals,
704
- };
705
- }
706
14
  /**
707
15
  * Validate a proposal payload before promotion. Generic by default — any
708
16
  * proposal must parse cleanly and carry a non-empty body. Lessons get the
@@ -806,226 +114,3 @@ export function repairProposalContent(content) {
806
114
  }
807
115
  return repaired;
808
116
  }
809
- /**
810
- * Validate a proposal, then promote it through the canonical
811
- * {@link writeAssetToSource} dispatch (the single place that branches on
812
- * `source.kind`). On success the proposal is archived with status `accepted`.
813
- * Validation failures throw a `UsageError` carrying every finding so the CLI
814
- * can render a single clear error envelope.
815
- *
816
- * Phase 6C: when the target asset already exists at the resolved write path,
817
- * its prior content is captured BEFORE the write and stored on the archived
818
- * proposal record (`backupContent`) so `akm proposal revert` can restore it.
819
- * Genuinely-new assets carry no backup.
820
- */
821
- export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
822
- const proposal = getProposal(stashDir, id, ctx);
823
- if (proposal.status !== "pending") {
824
- throw new UsageError(`Proposal ${id} is not pending (current status: ${proposal.status}). Only pending proposals can be accepted.`, "INVALID_FLAG_VALUE");
825
- }
826
- // Attempt bounded auto-repair of mechanically-fixable structural defects
827
- // (pseudo-frontmatter-in-body, stray `---` fences, truncated description)
828
- // BEFORE running validation. If the repair produces valid content, we
829
- // promote the repaired version; if validation still fails, the original
830
- // error path throws as before. The repair is content-preserving and
831
- // deterministic — it never invents text.
832
- const repairedContent = repairProposalContent(proposal.payload.content);
833
- const proposalToValidate = repairedContent !== proposal.payload.content
834
- ? { ...proposal, payload: { ...proposal.payload, content: repairedContent } }
835
- : proposal;
836
- const report = validateProposal(proposalToValidate);
837
- if (!report.ok) {
838
- const message = report.findings.map((f) => `[${f.kind}] ${f.message}`).join("\n");
839
- throw new UsageError(`Proposal ${id} failed validation:\n${message}`, "MISSING_REQUIRED_ARGUMENT", "Fix the proposal payload (frontmatter / content) and try again, or reject the proposal with a reason.");
840
- }
841
- // Use the (possibly repaired) payload for the promotion write. Persist the
842
- // repaired content back onto the DB row so the audit trail reflects the
843
- // final promoted payload (not the defective original).
844
- if (repairedContent !== proposal.payload.content) {
845
- withProposalsDb(stashDir, ctx, (db) => {
846
- const updated = { ...proposal, payload: { ...proposal.payload, content: repairedContent } };
847
- upsertProposal(db, updated, stashDir);
848
- });
849
- }
850
- const ref = parseAssetRef(proposalToValidate.ref);
851
- if (!TYPE_DIRS[ref.type]) {
852
- throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
853
- }
854
- const target = resolveWriteTarget(config, options.target);
855
- // Phase 6C: capture the prior content (if any) BEFORE writing the new
856
- // asset. We use the resolved write target to compute the exact path the
857
- // asset would land at — same resolver `writeAssetToSource` uses — so the
858
- // backup always mirrors what would be overwritten.
859
- let backupContent;
860
- try {
861
- const targetFilePath = resolveAssetFilePathSafe(target.source, ref);
862
- if (targetFilePath && fs.existsSync(targetFilePath)) {
863
- backupContent = fs.readFileSync(targetFilePath, "utf8");
864
- }
865
- }
866
- catch (err) {
867
- // Backup capture is best-effort. A failure here must not block promotion
868
- // (the user explicitly asked to accept); we surface a warning so the
869
- // missing-revert path is visible.
870
- warn(`[proposals] promoteProposal: failed to capture backup for ${id}: ${err instanceof Error ? err.message : String(err)}`);
871
- }
872
- const written = await writeAssetToSource(target.source, target.config, ref, repairedContent);
873
- // 0.9.0 (issue #507): single batch commit at the write boundary for git
874
- // targets. No-op for filesystem/primary-stash targets.
875
- commitWriteTargetBoundary(target, `Update ${formatRefForMessage(ref)}`);
876
- const archived = archiveProposal(stashDir, id, "accepted", undefined, ctx);
877
- // Persist the backup content on the archived proposal record so the revert
878
- // flow can restore the prior asset state.
879
- if (backupContent !== undefined) {
880
- const withBackup = { ...archived, backupContent };
881
- withProposalsDb(stashDir, ctx, (db) => upsertProposal(db, withBackup, stashDir));
882
- return { proposal: withBackup, assetPath: written.path, ref: written.ref };
883
- }
884
- return { proposal: archived, assetPath: written.path, ref: written.ref };
885
- }
886
- /**
887
- * Restore the prior content of an accepted proposal from the backup captured
888
- * at promotion time (Advantage D6c / Phase 6C).
889
- *
890
- * Pre-conditions:
891
- * - `id` resolves to a proposal with `status === "accepted"`.
892
- * - The proposal carries `backupContent` (captured by promoteProposal when
893
- * the target asset existed before the write).
894
- *
895
- * On success:
896
- * - The backup content is written back through {@link writeAssetToSource},
897
- * so the canonical write-dispatch invariant is preserved.
898
- * - The proposal record is updated to `status: "reverted"`.
899
- * - Caller emits a `proposal_reverted` event in the CLI layer (mirrors how
900
- * `promoted` / `rejected` are emitted by the CLI command, not the core).
901
- *
902
- * Errors are thrown as `UsageError` / `NotFoundError` so the CLI can map them
903
- * cleanly to exit codes — see `src/commands/proposal/proposal.ts` for the
904
- * wrapper.
905
- */
906
- export async function revertProposal(stashDir, config, id, options = {}, ctx) {
907
- const proposal = getProposal(stashDir, id, ctx);
908
- if (proposal.status !== "accepted") {
909
- throw new UsageError(`only accepted proposals can be reverted (proposal ${id} status: ${proposal.status})`, "INVALID_FLAG_VALUE");
910
- }
911
- if (proposal.backupContent === undefined) {
912
- throw new UsageError(`no backup available for this proposal (id: ${id})`, "MISSING_REQUIRED_ARGUMENT", "Backups are only captured when a proposal overwrites an existing asset — new-asset proposals cannot be reverted via this path; delete the asset directly instead.");
913
- }
914
- const ref = parseAssetRef(proposal.ref);
915
- if (!TYPE_DIRS[ref.type]) {
916
- throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
917
- }
918
- const target = resolveWriteTarget(config, options.target);
919
- const written = await writeAssetToSource(target.source, target.config, ref, proposal.backupContent);
920
- // 0.9.0 (issue #507): single batch commit at the write boundary for git
921
- // targets. No-op for filesystem/primary-stash targets.
922
- commitWriteTargetBoundary(target, `Revert ${formatRefForMessage(ref)}`);
923
- // Update the proposal record to status: "reverted" and bump updatedAt +
924
- // review so the audit trail reflects the second decision.
925
- const now = nowIso(ctx);
926
- const reverted = {
927
- ...proposal,
928
- status: "reverted",
929
- updatedAt: now,
930
- review: {
931
- outcome: "rejected",
932
- reason: "reverted: prior content restored from backup",
933
- decidedAt: now,
934
- },
935
- };
936
- withProposalsDb(stashDir, ctx, (db) => upsertProposal(db, reverted, stashDir));
937
- return { proposal: reverted, assetPath: written.path, ref: written.ref };
938
- }
939
- /**
940
- * Compute a diff between a proposal payload and the existing on-disk asset.
941
- * Uses {@link resolveWriteTarget} to find where the asset would land — so the
942
- * diff matches exactly what `accept` will write. Falls back to "new asset"
943
- * when no asset is currently materialised at the target ref.
944
- */
945
- export function diffProposal(stashDir, config, id, options = {}, ctx) {
946
- const proposal = getProposal(stashDir, id, ctx);
947
- const ref = parseAssetRef(proposal.ref);
948
- let targetPath;
949
- let existing = null;
950
- try {
951
- const target = resolveWriteTarget(config, options.target);
952
- targetPath = resolveAssetFilePathSafe(target.source, ref);
953
- if (targetPath && fs.existsSync(targetPath)) {
954
- existing = fs.readFileSync(targetPath, "utf8");
955
- }
956
- }
957
- catch {
958
- // No writable target configured — still return a "new asset" diff so
959
- // callers can see the proposed payload without erroring out.
960
- }
961
- const proposed = proposal.payload.content;
962
- if (existing === null) {
963
- return {
964
- existing: null,
965
- proposed,
966
- unified: formatNewAssetDiff(proposal.ref, proposed),
967
- isNew: true,
968
- ...(targetPath ? { targetPath } : {}),
969
- };
970
- }
971
- return {
972
- existing,
973
- proposed,
974
- unified: formatUnifiedDiff(existing, proposed, proposal.ref),
975
- isNew: false,
976
- ...(targetPath ? { targetPath } : {}),
977
- };
978
- }
979
- function resolveAssetFilePathSafe(source, ref) {
980
- const typeDir = TYPE_DIRS[ref.type];
981
- if (!typeDir)
982
- return undefined;
983
- const typeRoot = path.join(source.path, typeDir);
984
- try {
985
- return resolveAssetPathFromName(ref.type, typeRoot, ref.name);
986
- }
987
- catch {
988
- return undefined;
989
- }
990
- }
991
- /**
992
- * Minimal unified-diff renderer. We deliberately avoid pulling a runtime
993
- * dependency just for this — proposals diffs are usually small (a single
994
- * lesson / skill file), so the LCS-free greedy renderer below is plenty for
995
- * humans to review. The output mirrors `git diff --no-index` for the first
996
- * `@@ … @@` hunk: enough to be familiar, not so detailed that we re-implement
997
- * a full LCS table.
998
- */
999
- export function formatUnifiedDiff(left, right, label) {
1000
- if (left === right)
1001
- return "";
1002
- const leftLines = left.split("\n");
1003
- const rightLines = right.split("\n");
1004
- const lines = [`--- ${label} (existing)`, `+++ ${label} (proposed)`];
1005
- // Pad to the longer side so alignment is one-to-one. Real diff tools use
1006
- // LCS to align matching runs; we don't need that fidelity for a review
1007
- // surface — both halves are visible regardless.
1008
- const max = Math.max(leftLines.length, rightLines.length);
1009
- lines.push(`@@ 1,${leftLines.length} 1,${rightLines.length} @@`);
1010
- for (let i = 0; i < max; i += 1) {
1011
- const l = leftLines[i];
1012
- const r = rightLines[i];
1013
- if (l === r && l !== undefined) {
1014
- lines.push(` ${l}`);
1015
- continue;
1016
- }
1017
- if (l !== undefined)
1018
- lines.push(`-${l}`);
1019
- if (r !== undefined)
1020
- lines.push(`+${r}`);
1021
- }
1022
- return lines.join("\n");
1023
- }
1024
- function formatNewAssetDiff(ref, content) {
1025
- const lines = [`--- /dev/null`, `+++ ${ref} (proposed, new asset)`];
1026
- lines.push(`@@ 0,0 1,${content.split("\n").length} @@`);
1027
- for (const line of content.split("\n")) {
1028
- lines.push(`+${line}`);
1029
- }
1030
- return lines.join("\n");
1031
- }