akm-cli 0.9.0-beta.1 → 0.9.0-beta.10
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.
- package/CHANGELOG.md +469 -0
- package/dist/assets/templates/html/default.html +78 -0
- package/dist/assets/templates/html/health.html +732 -0
- package/dist/assets/templates/html/vendor/echarts.min.js +45 -0
- package/dist/cli/shared.js +21 -5
- package/dist/cli.js +47 -5
- package/dist/commands/config-cli.js +0 -10
- package/dist/commands/feedback-cli.js +42 -37
- package/dist/commands/graph/graph.js +75 -71
- package/dist/commands/health/checks.js +48 -0
- package/dist/commands/health/html-report.js +666 -0
- package/dist/commands/health.js +186 -13
- package/dist/commands/improve/consolidate.js +39 -6
- package/dist/commands/improve/distill.js +26 -5
- package/dist/commands/improve/extract-prompt.js +1 -1
- package/dist/commands/improve/extract.js +52 -8
- package/dist/commands/improve/improve-auto-accept.js +33 -1
- package/dist/commands/improve/improve-cli.js +7 -0
- package/dist/commands/improve/improve-profiles.js +4 -0
- package/dist/commands/improve/improve.js +874 -447
- package/dist/commands/improve/proactive-maintenance.js +113 -0
- package/dist/commands/improve/reflect-noise.js +0 -0
- package/dist/commands/improve/reflect.js +31 -0
- package/dist/commands/proposal/drain.js +73 -6
- package/dist/commands/proposal/proposal-cli.js +22 -10
- package/dist/commands/proposal/proposal.js +17 -1
- package/dist/commands/proposal/validators/proposals.js +365 -329
- package/dist/commands/read/curate.js +17 -0
- package/dist/commands/remember.js +6 -2
- package/dist/commands/sources/stash-cli.js +10 -2
- package/dist/commands/tasks/tasks.js +32 -8
- package/dist/core/config/config-schema.js +30 -0
- package/dist/core/logs-db.js +304 -0
- package/dist/core/paths.js +3 -0
- package/dist/core/state-db.js +152 -14
- package/dist/indexer/db/db.js +99 -13
- package/dist/indexer/ensure-index.js +152 -17
- package/dist/indexer/index-writer-lock.js +99 -0
- package/dist/indexer/indexer.js +114 -111
- package/dist/indexer/passes/memory-inference.js +61 -22
- package/dist/integrations/harnesses/claude/session-log.js +17 -5
- package/dist/llm/client.js +38 -4
- package/dist/llm/usage-persist.js +77 -0
- package/dist/llm/usage-telemetry.js +103 -0
- package/dist/output/context.js +3 -2
- package/dist/output/html-render.js +73 -0
- package/dist/output/shapes/helpers.js +17 -1
- package/dist/output/text/helpers.js +69 -1
- package/dist/scripts/migrate-storage.js +153 -25
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +21 -2
- package/dist/sources/providers/tar-utils.js +16 -8
- package/dist/tasks/backends/cron.js +46 -9
- package/dist/tasks/runner.js +99 -16
- package/dist/workflows/db.js +4 -0
- package/package.json +3 -2
- package/dist/commands/config-edit.js +0 -344
|
@@ -2,39 +2,46 @@
|
|
|
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).
|
|
5
|
+
* Proposal substrate (#225, storage consolidated in #578).
|
|
6
6
|
*
|
|
7
7
|
* One durable proposal store for every future reflection / generation flow
|
|
8
8
|
* (`akm reflect`, `akm propose`, `akm distill`, lesson distillation, …).
|
|
9
|
-
* Proposals are *queue state*, not source-of-truth assets — they sit
|
|
10
|
-
* waiting for human (or automated) review and only become assets after
|
|
9
|
+
* Proposals are *queue state*, not source-of-truth assets — they sit in the
|
|
10
|
+
* queue waiting for human (or automated) review and only become assets after
|
|
11
11
|
* `akm proposal accept` validates and promotes them via
|
|
12
12
|
* {@link writeAssetToSource}.
|
|
13
13
|
*
|
|
14
|
-
* # Storage
|
|
14
|
+
* # Storage
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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.
|
|
18
23
|
*
|
|
19
|
-
*
|
|
20
|
-
* proposals can target the same `ref` without filesystem collisions.
|
|
24
|
+
* ## Legacy filesystem import
|
|
21
25
|
*
|
|
22
|
-
*
|
|
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.
|
|
23
34
|
*
|
|
24
|
-
*
|
|
25
|
-
* to *assets*. Proposals are **not** assets — they live outside the asset tree
|
|
26
|
-
* (under `.akm/proposals/`, parallel to how `events.jsonl` lives outside the
|
|
27
|
-
* asset tree). Routing them through `writeAssetToSource` would force them into
|
|
28
|
-
* a `TYPE_DIRS` slot, would commit them to git, and would leak unaccepted
|
|
29
|
-
* drafts through the normal indexer. None of that is what we want for queue
|
|
30
|
-
* state. The {@link promoteProposal} step is the bridge: it routes the
|
|
31
|
-
* accepted payload through `writeAssetToSource` so the actual asset write
|
|
32
|
-
* still funnels through the single dispatch point in
|
|
33
|
-
* `src/core/write-source.ts`.
|
|
35
|
+
* # Why the queue bypasses `writeAssetToSource`
|
|
34
36
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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`.
|
|
38
45
|
*/
|
|
39
46
|
import { createHash, randomUUID } from "node:crypto";
|
|
40
47
|
import fs from "node:fs";
|
|
@@ -43,6 +50,7 @@ import { makeAssetRef, parseAssetRef } from "../../../core/asset/asset-ref.js";
|
|
|
43
50
|
import { resolveAssetPathFromName, TYPE_DIRS } from "../../../core/asset/asset-spec.js";
|
|
44
51
|
import { NotFoundError, UsageError } from "../../../core/errors.js";
|
|
45
52
|
import { appendEvent } from "../../../core/events.js";
|
|
53
|
+
import { getStateDbPath, getStateProposal, hasImportedFsProposals, insertProposalIfAbsent, listStateProposalIdsByPrefix, listStateProposals, openStateDatabase, recordFsProposalsImport, upsertProposal, withImmediateTransaction, } from "../../../core/state-db.js";
|
|
46
54
|
import { warn } from "../../../core/warn.js";
|
|
47
55
|
import { commitWriteTargetBoundary, formatRefForMessage, resolveWriteTarget, writeAssetToSource, } from "../../../core/write-source.js";
|
|
48
56
|
import { runProposalValidators } from "./proposal-validators.js";
|
|
@@ -131,21 +139,7 @@ function cooldownMsForSource(source) {
|
|
|
131
139
|
function contentHash(content) {
|
|
132
140
|
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
133
141
|
}
|
|
134
|
-
// ──
|
|
135
|
-
/**
|
|
136
|
-
* Resolve `<stashRoot>/.akm/proposals` (or its archive subdirectory). Direct
|
|
137
|
-
* fs paths because proposal storage is queue state, not asset state — see the
|
|
138
|
-
* module docblock for the architectural carve-out.
|
|
139
|
-
*/
|
|
140
|
-
export function getProposalsRoot(stashDir, archive = false) {
|
|
141
|
-
return archive ? path.join(stashDir, ".akm", "proposals", "archive") : path.join(stashDir, ".akm", "proposals");
|
|
142
|
-
}
|
|
143
|
-
function proposalDir(stashDir, id, archive) {
|
|
144
|
-
return path.join(getProposalsRoot(stashDir, archive), id);
|
|
145
|
-
}
|
|
146
|
-
function proposalFile(stashDir, id, archive) {
|
|
147
|
-
return path.join(proposalDir(stashDir, id, archive), "proposal.json");
|
|
148
|
-
}
|
|
142
|
+
// ── Store access ─────────────────────────────────────────────────────────────
|
|
149
143
|
function nowIso(ctx) {
|
|
150
144
|
const fn = ctx?.now ?? Date.now;
|
|
151
145
|
return new Date(fn()).toISOString();
|
|
@@ -154,35 +148,124 @@ function newId(ctx) {
|
|
|
154
148
|
const fn = ctx?.randomUUID ?? randomUUID;
|
|
155
149
|
return fn();
|
|
156
150
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
151
|
+
/**
|
|
152
|
+
* Open the state database (honouring the `ctx.dbPath` test seam), run the
|
|
153
|
+
* legacy filesystem import for `stashDir` if it has not happened yet, hand the
|
|
154
|
+
* connection to `fn`, and close it in a `finally`. Every public function in
|
|
155
|
+
* this module funnels its store access through here so the legacy import is
|
|
156
|
+
* guaranteed to have run before any read or write.
|
|
157
|
+
*/
|
|
158
|
+
function withProposalsDb(stashDir, ctx, fn) {
|
|
159
|
+
const db = openStateDatabase(ctx?.dbPath ?? getStateDbPath());
|
|
160
160
|
try {
|
|
161
|
-
|
|
161
|
+
importLegacyProposalFiles(db, stashDir);
|
|
162
|
+
return fn(db);
|
|
162
163
|
}
|
|
163
|
-
|
|
164
|
-
|
|
164
|
+
finally {
|
|
165
|
+
db.close();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// ── Legacy filesystem import (#578) ─────────────────────────────────────────
|
|
169
|
+
/** Legacy (pre-0.9.0) proposal directory: `<stashDir>/.akm/proposals[/archive]`. */
|
|
170
|
+
function legacyProposalsRoot(stashDir, archive) {
|
|
171
|
+
const root = path.join(stashDir, ".akm", "proposals");
|
|
172
|
+
return archive ? path.join(root, "archive") : root;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* One-shot import of legacy `proposal.json` files into the `proposals` table.
|
|
176
|
+
*
|
|
177
|
+
* Idempotent at two levels: the `proposal_fs_imports` ledger skips the
|
|
178
|
+
* directory walk after the first successful import, and INSERT OR IGNORE
|
|
179
|
+
* (keyed on the proposal UUID) protects against duplicates even if the walk
|
|
180
|
+
* re-runs. Legacy `backup.<ext>` files are inlined into `backupContent` so
|
|
181
|
+
* `akm proposal revert` keeps working for proposals accepted before 0.9.0.
|
|
182
|
+
*
|
|
183
|
+
* The legacy files are never modified or deleted — after import they are
|
|
184
|
+
* inert artifacts the operator can remove at leisure.
|
|
185
|
+
*/
|
|
186
|
+
function importLegacyProposalFiles(db, stashDir) {
|
|
187
|
+
if (hasImportedFsProposals(db, stashDir))
|
|
188
|
+
return;
|
|
189
|
+
const liveRoot = legacyProposalsRoot(stashDir, false);
|
|
190
|
+
if (!fs.existsSync(liveRoot))
|
|
191
|
+
return;
|
|
192
|
+
let imported = 0;
|
|
193
|
+
for (const archive of [false, true]) {
|
|
194
|
+
const root = legacyProposalsRoot(stashDir, archive);
|
|
195
|
+
let entries;
|
|
196
|
+
try {
|
|
197
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
for (const entry of entries) {
|
|
203
|
+
if (!entry.isDirectory() || entry.name === "archive")
|
|
204
|
+
continue;
|
|
205
|
+
const proposalDir = path.join(root, entry.name);
|
|
206
|
+
const proposal = readLegacyProposalFile(proposalDir);
|
|
207
|
+
if (!proposal)
|
|
208
|
+
continue;
|
|
209
|
+
if (insertProposalIfAbsent(db, proposal, stashDir))
|
|
210
|
+
imported += 1;
|
|
211
|
+
}
|
|
165
212
|
}
|
|
213
|
+
recordFsProposalsImport(db, stashDir, imported);
|
|
214
|
+
if (imported > 0) {
|
|
215
|
+
warn(`[proposals] imported ${imported} legacy proposal file(s) from ${liveRoot} into state.db`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Parse one legacy proposal directory into a {@link Proposal}, inlining the
|
|
220
|
+
* backup file (when present) as `backupContent`. Returns undefined — with a
|
|
221
|
+
* warning — when the `proposal.json` is missing, unreadable, or malformed, so
|
|
222
|
+
* a single corrupt legacy entry never blocks the import of the rest.
|
|
223
|
+
*/
|
|
224
|
+
function readLegacyProposalFile(proposalDir) {
|
|
225
|
+
const filePath = path.join(proposalDir, "proposal.json");
|
|
166
226
|
let parsed;
|
|
167
227
|
try {
|
|
168
|
-
parsed = JSON.parse(
|
|
228
|
+
parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
169
229
|
}
|
|
170
230
|
catch (err) {
|
|
171
|
-
|
|
231
|
+
warn(`[proposals] skipping legacy proposal at ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
232
|
+
return undefined;
|
|
172
233
|
}
|
|
173
|
-
if (typeof parsed !== "object" ||
|
|
174
|
-
|
|
234
|
+
if (typeof parsed !== "object" ||
|
|
235
|
+
parsed === null ||
|
|
236
|
+
typeof parsed.id !== "string" ||
|
|
237
|
+
typeof parsed.ref !== "string") {
|
|
238
|
+
warn(`[proposals] skipping legacy proposal at ${filePath}: not a proposal object`);
|
|
239
|
+
return undefined;
|
|
175
240
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
241
|
+
const { backup, ...rest } = parsed;
|
|
242
|
+
let backupContent;
|
|
243
|
+
if (typeof backup === "string" && backup.length > 0) {
|
|
244
|
+
try {
|
|
245
|
+
backupContent = fs.readFileSync(path.join(proposalDir, backup), "utf8");
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// Backup file lost — import the proposal anyway; revert for it will
|
|
249
|
+
// surface "no backup available", same as a new-asset proposal.
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
...rest,
|
|
254
|
+
payload: {
|
|
255
|
+
content: rest.payload?.content ?? "",
|
|
256
|
+
...(rest.payload?.frontmatter ? { frontmatter: rest.payload.frontmatter } : {}),
|
|
257
|
+
},
|
|
258
|
+
createdAt: rest.createdAt ?? "",
|
|
259
|
+
updatedAt: rest.updatedAt ?? rest.createdAt ?? "",
|
|
260
|
+
status: rest.status ?? "pending",
|
|
261
|
+
source: rest.source ?? "import",
|
|
262
|
+
...(backupContent !== undefined ? { backupContent } : {}),
|
|
263
|
+
};
|
|
181
264
|
}
|
|
182
265
|
// ── Public API ──────────────────────────────────────────────────────────────
|
|
183
266
|
/**
|
|
184
267
|
* Create a new pending proposal. The id is a stable random UUID, so two
|
|
185
|
-
* proposals with the same `ref` never collide
|
|
268
|
+
* proposals with the same `ref` never collide.
|
|
186
269
|
*
|
|
187
270
|
* **Dedup / cooldown guard** (F-2 / #363):
|
|
188
271
|
*
|
|
@@ -196,7 +279,7 @@ function writeProposalFile(filePath, proposal) {
|
|
|
196
279
|
* others: 7 d). Bypass with `force: true`.
|
|
197
280
|
*
|
|
198
281
|
* When a guard fires the function returns a `CreateProposalSkipped` record
|
|
199
|
-
* instead of writing
|
|
282
|
+
* instead of writing. Use {@link isProposalSkipped} to detect it.
|
|
200
283
|
*/
|
|
201
284
|
export function createProposal(stashDir, input, ctx) {
|
|
202
285
|
// F-4 / #385: Validate source against the allow-list. Unknown values are
|
|
@@ -250,173 +333,149 @@ export function createProposal(stashDir, input, ctx) {
|
|
|
250
333
|
}
|
|
251
334
|
}
|
|
252
335
|
const normalizedRef = makeAssetRef(parsedRef.type, parsedRef.name, parsedRef.origin);
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
if (pending.length > 0) {
|
|
260
|
-
// Check for identical content hash first (silent skip).
|
|
261
|
-
const hashMatch = pending.find((p) => contentHash(p.payload.content) === newHash);
|
|
262
|
-
if (hashMatch) {
|
|
263
|
-
return {
|
|
264
|
-
skipped: true,
|
|
265
|
-
reason: "content_hash_match",
|
|
266
|
-
message: `Identical proposal for ${normalizedRef} already pending (id: ${hashMatch.id}).`,
|
|
267
|
-
existingProposalId: hashMatch.id,
|
|
268
|
-
};
|
|
336
|
+
return withProposalsDb(stashDir, ctx, (db) => {
|
|
337
|
+
return withImmediateTransaction(db, () => {
|
|
338
|
+
if (!input.force) {
|
|
339
|
+
const skip = checkDedupAndCooldown(db, stashDir, normalizedRef, input, ctx);
|
|
340
|
+
if (skip)
|
|
341
|
+
return skip;
|
|
269
342
|
}
|
|
270
|
-
|
|
271
|
-
|
|
343
|
+
const created = nowIso(ctx);
|
|
344
|
+
// Phase 6A: validate confidence is a finite number in [0, 1]. Anything else
|
|
345
|
+
// is dropped silently — we never store NaN, Infinity, or out-of-range values.
|
|
346
|
+
// Callers that mis-report confidence should not poison the auto-accept gate.
|
|
347
|
+
const sanitizedConfidence = typeof input.confidence === "number" &&
|
|
348
|
+
Number.isFinite(input.confidence) &&
|
|
349
|
+
input.confidence >= 0 &&
|
|
350
|
+
input.confidence <= 1
|
|
351
|
+
? input.confidence
|
|
352
|
+
: undefined;
|
|
353
|
+
const proposal = {
|
|
354
|
+
id: newId(ctx),
|
|
355
|
+
ref: normalizedRef,
|
|
356
|
+
status: "pending",
|
|
357
|
+
source: input.source,
|
|
358
|
+
...(input.sourceRun !== undefined ? { sourceRun: input.sourceRun } : {}),
|
|
359
|
+
createdAt: created,
|
|
360
|
+
updatedAt: created,
|
|
361
|
+
payload: {
|
|
362
|
+
content: input.payload.content,
|
|
363
|
+
...(input.payload.frontmatter !== undefined ? { frontmatter: input.payload.frontmatter } : {}),
|
|
364
|
+
},
|
|
365
|
+
...(sanitizedConfidence !== undefined ? { confidence: sanitizedConfidence } : {}),
|
|
366
|
+
// Attribution tagging: persist the eligibility lane so it survives to
|
|
367
|
+
// accept/reject/revert time. See EligibilitySource.
|
|
368
|
+
...(input.eligibilitySource !== undefined ? { eligibilitySource: input.eligibilitySource } : {}),
|
|
369
|
+
};
|
|
370
|
+
upsertProposal(db, proposal, stashDir);
|
|
371
|
+
return proposal;
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Evaluate the F-2 dedup / cooldown guards against the store. Returns the
|
|
377
|
+
* skip record when a guard fires, or undefined when the create may proceed.
|
|
378
|
+
*/
|
|
379
|
+
function checkDedupAndCooldown(db, stashDir, normalizedRef, input, ctx) {
|
|
380
|
+
const newHash = contentHash(input.payload.content);
|
|
381
|
+
const nowMs = (ctx?.now ?? Date.now)();
|
|
382
|
+
const cooldownMs = cooldownMsForSource(input.source);
|
|
383
|
+
// Scan pending proposals for ref+source matches.
|
|
384
|
+
const pending = listStateProposals(db, { stashDir, ref: normalizedRef, status: "pending" }).filter((p) => p.source === input.source);
|
|
385
|
+
if (pending.length > 0) {
|
|
386
|
+
// Check for identical content hash first (silent skip).
|
|
387
|
+
const hashMatch = pending.find((p) => contentHash(p.payload.content) === newHash);
|
|
388
|
+
if (hashMatch) {
|
|
272
389
|
return {
|
|
273
390
|
skipped: true,
|
|
274
|
-
reason: "
|
|
275
|
-
message: `
|
|
276
|
-
existingProposalId:
|
|
391
|
+
reason: "content_hash_match",
|
|
392
|
+
message: `Identical proposal for ${normalizedRef} already pending (id: ${hashMatch.id}).`,
|
|
393
|
+
existingProposalId: hashMatch.id,
|
|
277
394
|
};
|
|
278
395
|
}
|
|
279
|
-
//
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
396
|
+
// Duplicate pending for same ref+source (different content).
|
|
397
|
+
const firstPending = pending[0];
|
|
398
|
+
return {
|
|
399
|
+
skipped: true,
|
|
400
|
+
reason: "duplicate_pending",
|
|
401
|
+
message: `A pending proposal for ${normalizedRef} from source "${input.source}" already exists (id: ${firstPending?.id ?? "unknown"}). Pass force:true to enqueue alongside it.`,
|
|
402
|
+
existingProposalId: firstPending?.id,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
// Check cooldown against recently rejected proposals.
|
|
406
|
+
const rejected = listStateProposals(db, { stashDir, ref: normalizedRef, status: "rejected" })
|
|
407
|
+
.filter((p) => p.source === input.source)
|
|
408
|
+
.sort((a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime());
|
|
409
|
+
const mostRecent = rejected[0];
|
|
410
|
+
if (mostRecent !== undefined) {
|
|
411
|
+
// Check content hash against recently rejected.
|
|
412
|
+
if (contentHash(mostRecent.payload.content) === newHash) {
|
|
413
|
+
return {
|
|
414
|
+
skipped: true,
|
|
415
|
+
reason: "content_hash_match",
|
|
416
|
+
message: `Identical proposal for ${normalizedRef} was already rejected (id: ${mostRecent.id}).`,
|
|
417
|
+
existingProposalId: mostRecent.id,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
// Check cooldown window.
|
|
421
|
+
const rejectedAt = new Date(mostRecent.updatedAt ?? 0).getTime();
|
|
422
|
+
if (nowMs - rejectedAt < cooldownMs) {
|
|
423
|
+
const cooldownDays = cooldownMs / MS_PER_DAY;
|
|
424
|
+
const remainingDays = Math.ceil((cooldownMs - (nowMs - rejectedAt)) / MS_PER_DAY);
|
|
425
|
+
return {
|
|
426
|
+
skipped: true,
|
|
427
|
+
reason: "cooldown",
|
|
428
|
+
message: `Proposal for ${normalizedRef} from source "${input.source}" is in cooldown ` +
|
|
429
|
+
`(${cooldownDays}d window, ~${remainingDays}d remaining). Pass force:true to bypass.`,
|
|
430
|
+
existingProposalId: mostRecent.id,
|
|
431
|
+
};
|
|
307
432
|
}
|
|
308
433
|
}
|
|
309
|
-
|
|
310
|
-
const created = nowIso(ctx);
|
|
311
|
-
// Phase 6A: validate confidence is a finite number in [0, 1]. Anything else
|
|
312
|
-
// is dropped silently — we never store NaN, Infinity, or out-of-range values.
|
|
313
|
-
// Callers that mis-report confidence should not poison the auto-accept gate.
|
|
314
|
-
const sanitizedConfidence = typeof input.confidence === "number" &&
|
|
315
|
-
Number.isFinite(input.confidence) &&
|
|
316
|
-
input.confidence >= 0 &&
|
|
317
|
-
input.confidence <= 1
|
|
318
|
-
? input.confidence
|
|
319
|
-
: undefined;
|
|
320
|
-
const proposal = {
|
|
321
|
-
id,
|
|
322
|
-
ref: normalizedRef,
|
|
323
|
-
status: "pending",
|
|
324
|
-
source: input.source,
|
|
325
|
-
...(input.sourceRun !== undefined ? { sourceRun: input.sourceRun } : {}),
|
|
326
|
-
createdAt: created,
|
|
327
|
-
updatedAt: created,
|
|
328
|
-
payload: {
|
|
329
|
-
content: input.payload.content,
|
|
330
|
-
...(input.payload.frontmatter !== undefined ? { frontmatter: input.payload.frontmatter } : {}),
|
|
331
|
-
},
|
|
332
|
-
...(sanitizedConfidence !== undefined ? { confidence: sanitizedConfidence } : {}),
|
|
333
|
-
};
|
|
334
|
-
writeProposalFile(proposalFile(stashDir, id, false), proposal);
|
|
335
|
-
return proposal;
|
|
434
|
+
return undefined;
|
|
336
435
|
}
|
|
337
436
|
/**
|
|
338
|
-
* List
|
|
339
|
-
*
|
|
340
|
-
*
|
|
437
|
+
* List proposals for one stash. By default returns only the live (pending)
|
|
438
|
+
* queue; pass `{ includeArchive: true }` to include accepted / rejected /
|
|
439
|
+
* reverted entries as well.
|
|
341
440
|
*/
|
|
342
|
-
export function listProposals(stashDir, options = {}) {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
if (!fs.existsSync(dir))
|
|
350
|
-
continue;
|
|
351
|
-
let entries;
|
|
352
|
-
try {
|
|
353
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
441
|
+
export function listProposals(stashDir, options = {}, ctx) {
|
|
442
|
+
return withProposalsDb(stashDir, ctx, (db) => {
|
|
443
|
+
// Without includeArchive, only the live queue is visible — an explicit
|
|
444
|
+
// non-pending status filter therefore matches nothing (mirrors the
|
|
445
|
+
// historical live-directory scan).
|
|
446
|
+
if (!options.includeArchive && options.status !== undefined && options.status !== "pending") {
|
|
447
|
+
return [];
|
|
354
448
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
continue;
|
|
364
|
-
const filePath = path.join(dir, entry.name, "proposal.json");
|
|
365
|
-
if (!fs.existsSync(filePath))
|
|
366
|
-
continue;
|
|
449
|
+
const status = options.includeArchive ? options.status : "pending";
|
|
450
|
+
return listStateProposals(db, {
|
|
451
|
+
stashDir,
|
|
452
|
+
...(status !== undefined ? { status } : {}),
|
|
453
|
+
...(options.ref !== undefined ? { ref: options.ref } : {}),
|
|
454
|
+
}).filter((p) => {
|
|
455
|
+
if (!options.type)
|
|
456
|
+
return true;
|
|
367
457
|
try {
|
|
368
|
-
|
|
458
|
+
return parseAssetRef(p.ref).type === options.type;
|
|
369
459
|
}
|
|
370
460
|
catch {
|
|
371
|
-
|
|
372
|
-
// see something in `akm proposal list` rather than the file
|
|
373
|
-
// disappearing silently.
|
|
374
|
-
out.push({
|
|
375
|
-
id: entry.name,
|
|
376
|
-
ref: "unknown:unknown",
|
|
377
|
-
status: "rejected",
|
|
378
|
-
source: "invalid",
|
|
379
|
-
createdAt: "",
|
|
380
|
-
updatedAt: "",
|
|
381
|
-
payload: { content: "" },
|
|
382
|
-
review: {
|
|
383
|
-
outcome: "rejected",
|
|
384
|
-
reason: "Invalid proposal file (could not be parsed).",
|
|
385
|
-
decidedAt: "",
|
|
386
|
-
},
|
|
387
|
-
});
|
|
461
|
+
return false;
|
|
388
462
|
}
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
return out
|
|
392
|
-
.filter((p) => (options.status ? p.status === options.status : true))
|
|
393
|
-
.filter((p) => (options.ref ? p.ref === options.ref : true))
|
|
394
|
-
.filter((p) => {
|
|
395
|
-
if (!options.type)
|
|
396
|
-
return true;
|
|
397
|
-
try {
|
|
398
|
-
return parseAssetRef(p.ref).type === options.type;
|
|
399
|
-
}
|
|
400
|
-
catch {
|
|
401
|
-
// Unparseable ref (e.g. the synthetic "unknown:unknown" stub for an
|
|
402
|
-
// invalid proposal file) never matches a concrete type filter.
|
|
403
|
-
return false;
|
|
404
|
-
}
|
|
405
|
-
})
|
|
406
|
-
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
463
|
+
});
|
|
464
|
+
});
|
|
407
465
|
}
|
|
408
466
|
/**
|
|
409
|
-
* Look up a proposal by id
|
|
410
|
-
* Throws `NotFoundError` when no match exists.
|
|
467
|
+
* Look up a proposal by id (live or archived).
|
|
468
|
+
* Throws `NotFoundError` when no match exists in this stash.
|
|
411
469
|
*/
|
|
412
|
-
export function getProposal(stashDir, id) {
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
const
|
|
417
|
-
if (
|
|
418
|
-
|
|
419
|
-
|
|
470
|
+
export function getProposal(stashDir, id, ctx) {
|
|
471
|
+
return withProposalsDb(stashDir, ctx, (db) => requireProposal(db, stashDir, id));
|
|
472
|
+
}
|
|
473
|
+
function requireProposal(db, stashDir, id) {
|
|
474
|
+
const proposal = getStateProposal(db, id, stashDir);
|
|
475
|
+
if (!proposal) {
|
|
476
|
+
throw new NotFoundError(`Proposal "${id}" not found.`, "FILE_NOT_FOUND");
|
|
477
|
+
}
|
|
478
|
+
return proposal;
|
|
420
479
|
}
|
|
421
480
|
/**
|
|
422
481
|
* Resolve a proposal by full UUID, UUID prefix, or asset ref.
|
|
@@ -425,95 +484,93 @@ export function getProposal(stashDir, id) {
|
|
|
425
484
|
* 1. Exact UUID match (existing behaviour).
|
|
426
485
|
* 2. Asset ref (contains `:`) — finds the most-recent pending proposal for
|
|
427
486
|
* that ref; falls back to archived if nothing is pending.
|
|
428
|
-
* 3. UUID prefix — matches any
|
|
429
|
-
*
|
|
487
|
+
* 3. UUID prefix — matches any PENDING proposal whose id starts with the
|
|
488
|
+
* given string; throws if ambiguous.
|
|
430
489
|
*/
|
|
431
|
-
export function resolveProposalId(stashDir, idOrRef) {
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
490
|
+
export function resolveProposalId(stashDir, idOrRef, ctx) {
|
|
491
|
+
return withProposalsDb(stashDir, ctx, (db) => {
|
|
492
|
+
// 1. Exact UUID.
|
|
493
|
+
const exact = getStateProposal(db, idOrRef, stashDir);
|
|
494
|
+
if (exact)
|
|
495
|
+
return exact;
|
|
496
|
+
// 2. Asset ref (e.g. "skill:akm-dream") — most recent pending, else most
|
|
497
|
+
// recent archived.
|
|
498
|
+
if (idOrRef.includes(":")) {
|
|
499
|
+
const byRecency = (proposals) => proposals.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime())[0];
|
|
500
|
+
const pending = byRecency(listStateProposals(db, { stashDir, ref: idOrRef, status: "pending" }));
|
|
501
|
+
if (pending)
|
|
502
|
+
return pending;
|
|
503
|
+
const archived = byRecency(listStateProposals(db, { stashDir, ref: idOrRef }));
|
|
504
|
+
if (archived)
|
|
505
|
+
return archived;
|
|
506
|
+
throw new NotFoundError(`No proposal found for ref "${idOrRef}".`, "FILE_NOT_FOUND");
|
|
445
507
|
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
508
|
+
// 3. UUID prefix (pending queue only).
|
|
509
|
+
const prefixMatches = listStateProposalIdsByPrefix(db, stashDir, idOrRef);
|
|
510
|
+
if (prefixMatches.length === 1)
|
|
511
|
+
return requireProposal(db, stashDir, prefixMatches[0]);
|
|
512
|
+
if (prefixMatches.length > 1) {
|
|
513
|
+
throw new UsageError(`Ambiguous prefix "${idOrRef}" — matches: ${prefixMatches.join(", ")}`, "INVALID_FLAG_VALUE");
|
|
449
514
|
}
|
|
450
|
-
throw new NotFoundError(`
|
|
451
|
-
}
|
|
452
|
-
// 3. UUID prefix.
|
|
453
|
-
const liveDir = getProposalsRoot(stashDir, false);
|
|
454
|
-
let prefixMatches = [];
|
|
455
|
-
try {
|
|
456
|
-
prefixMatches = fs.readdirSync(liveDir).filter((name) => name.startsWith(idOrRef));
|
|
457
|
-
}
|
|
458
|
-
catch {
|
|
459
|
-
/* live dir may not exist yet */
|
|
460
|
-
}
|
|
461
|
-
if (prefixMatches.length === 1)
|
|
462
|
-
return getProposal(stashDir, prefixMatches[0]);
|
|
463
|
-
if (prefixMatches.length > 1) {
|
|
464
|
-
throw new UsageError(`Ambiguous prefix "${idOrRef}" — matches: ${prefixMatches.join(", ")}`, "INVALID_FLAG_VALUE");
|
|
465
|
-
}
|
|
466
|
-
throw new NotFoundError(`Proposal "${idOrRef}" not found.`, "FILE_NOT_FOUND");
|
|
515
|
+
throw new NotFoundError(`Proposal "${idOrRef}" not found.`, "FILE_NOT_FOUND");
|
|
516
|
+
});
|
|
467
517
|
}
|
|
468
518
|
/**
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*/
|
|
472
|
-
export function isProposalArchived(stashDir, id) {
|
|
473
|
-
return !fs.existsSync(proposalFile(stashDir, id, false)) && fs.existsSync(proposalFile(stashDir, id, true));
|
|
474
|
-
}
|
|
475
|
-
/**
|
|
476
|
-
* Move a proposal directory into the archive subtree and update its status.
|
|
477
|
-
* Used by both accept (status `accepted`) and reject (status `rejected`)
|
|
519
|
+
* Archive a proposal: flip its status to `accepted` / `rejected`, bump
|
|
520
|
+
* `updatedAt`, and record the review block. Used by both accept and reject
|
|
478
521
|
* paths so the live queue only contains pending entries.
|
|
479
522
|
*/
|
|
480
523
|
export function archiveProposal(stashDir, id, status, reason, ctx) {
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
524
|
+
return withProposalsDb(stashDir, ctx, (db) => {
|
|
525
|
+
return withImmediateTransaction(db, () => {
|
|
526
|
+
const existing = requireProposal(db, stashDir, id);
|
|
527
|
+
if (existing.status !== "pending") {
|
|
528
|
+
throw new UsageError(`Proposal ${id} is not pending (current status: ${existing.status}). Only pending proposals can be ${status}.`, "INVALID_FLAG_VALUE");
|
|
529
|
+
}
|
|
530
|
+
const decidedAt = nowIso(ctx);
|
|
487
531
|
const updated = {
|
|
488
532
|
...existing,
|
|
489
533
|
status,
|
|
490
|
-
updatedAt:
|
|
534
|
+
updatedAt: decidedAt,
|
|
491
535
|
review: {
|
|
492
536
|
outcome: status,
|
|
493
537
|
...(reason !== undefined ? { reason } : {}),
|
|
494
|
-
decidedAt
|
|
538
|
+
decidedAt,
|
|
495
539
|
},
|
|
496
540
|
};
|
|
497
|
-
|
|
541
|
+
upsertProposal(db, updated, stashDir);
|
|
498
542
|
return updated;
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Record an automated gate's decision onto a proposal (#577).
|
|
548
|
+
*
|
|
549
|
+
* Stamps `gateDecision` (decision / reason / confidence / thresholds) onto the
|
|
550
|
+
* row so `akm proposal show` and `list` can explain why a proposal landed where
|
|
551
|
+
* it did. The decision is metadata about the adjudication, so this does NOT
|
|
552
|
+
* change `status` or bump `updatedAt` — a `deferred` proposal stays `pending`,
|
|
553
|
+
* and the accept / reject status flips are owned by {@link promoteProposal} /
|
|
554
|
+
* {@link archiveProposal}. `decidedAt` defaults to now when the caller omits it.
|
|
555
|
+
*
|
|
556
|
+
* Best-effort: a proposal that no longer exists (e.g. concurrently archived) is
|
|
557
|
+
* skipped silently rather than throwing, so a gate run never aborts mid-batch.
|
|
558
|
+
* Returns the updated proposal, or undefined when no matching row exists.
|
|
559
|
+
*/
|
|
560
|
+
export function recordGateDecision(stashDir, id, decision, ctx) {
|
|
561
|
+
return withProposalsDb(stashDir, ctx, (db) => {
|
|
562
|
+
return withImmediateTransaction(db, () => {
|
|
563
|
+
const existing = getStateProposal(db, id, stashDir);
|
|
564
|
+
if (!existing || existing.status !== "pending")
|
|
565
|
+
return undefined;
|
|
566
|
+
const updated = {
|
|
567
|
+
...existing,
|
|
568
|
+
gateDecision: { ...decision, decidedAt: decision.decidedAt ?? nowIso(ctx) },
|
|
569
|
+
};
|
|
570
|
+
upsertProposal(db, updated, stashDir);
|
|
571
|
+
return updated;
|
|
572
|
+
});
|
|
573
|
+
});
|
|
517
574
|
}
|
|
518
575
|
/**
|
|
519
576
|
* Scan all pending proposals and reject those whose target asset no longer
|
|
@@ -529,7 +586,7 @@ export function purgeOrphanProposals(stashDir, sourceDirs, ctx) {
|
|
|
529
586
|
const t0 = Date.now();
|
|
530
587
|
const orphans = [];
|
|
531
588
|
const byType = {};
|
|
532
|
-
const pending = listProposals(stashDir, { status: "pending" });
|
|
589
|
+
const pending = listProposals(stashDir, { status: "pending" }, ctx);
|
|
533
590
|
const reflectPending = pending.filter((p) => p.source === "reflect");
|
|
534
591
|
for (const p of reflectPending) {
|
|
535
592
|
let parsed;
|
|
@@ -608,7 +665,7 @@ export function expireStaleProposals(stashDir, config, ctx) {
|
|
|
608
665
|
}
|
|
609
666
|
const retentionMs = retentionDays * MS_PER_DAY;
|
|
610
667
|
const nowMs = (ctx?.now ?? Date.now)();
|
|
611
|
-
const pending = listProposals(stashDir, { status: "pending" });
|
|
668
|
+
const pending = listProposals(stashDir, { status: "pending" }, ctx);
|
|
612
669
|
for (const p of pending) {
|
|
613
670
|
const createdMs = new Date(p.createdAt).getTime();
|
|
614
671
|
if (!Number.isFinite(createdMs))
|
|
@@ -658,18 +715,17 @@ export function validateProposal(proposal) {
|
|
|
658
715
|
/**
|
|
659
716
|
* Validate a proposal, then promote it through the canonical
|
|
660
717
|
* {@link writeAssetToSource} dispatch (the single place that branches on
|
|
661
|
-
* `source.kind`). On success the proposal
|
|
662
|
-
*
|
|
663
|
-
*
|
|
718
|
+
* `source.kind`). On success the proposal is archived with status `accepted`.
|
|
719
|
+
* Validation failures throw a `UsageError` carrying every finding so the CLI
|
|
720
|
+
* can render a single clear error envelope.
|
|
664
721
|
*
|
|
665
722
|
* Phase 6C: when the target asset already exists at the resolved write path,
|
|
666
|
-
*
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
* can restore the prior content. Genuinely-new assets carry no backup.
|
|
723
|
+
* its prior content is captured BEFORE the write and stored on the archived
|
|
724
|
+
* proposal record (`backupContent`) so `akm proposal revert` can restore it.
|
|
725
|
+
* Genuinely-new assets carry no backup.
|
|
670
726
|
*/
|
|
671
727
|
export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
|
|
672
|
-
const proposal = getProposal(stashDir, id);
|
|
728
|
+
const proposal = getProposal(stashDir, id, ctx);
|
|
673
729
|
if (proposal.status !== "pending") {
|
|
674
730
|
throw new UsageError(`Proposal ${id} is not pending (current status: ${proposal.status}). Only pending proposals can be accepted.`, "INVALID_FLAG_VALUE");
|
|
675
731
|
}
|
|
@@ -683,25 +739,15 @@ export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
|
|
|
683
739
|
throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
|
|
684
740
|
}
|
|
685
741
|
const target = resolveWriteTarget(config, options.target);
|
|
686
|
-
// Phase 6C: capture
|
|
687
|
-
//
|
|
742
|
+
// Phase 6C: capture the prior content (if any) BEFORE writing the new
|
|
743
|
+
// asset. We use the resolved write target to compute the exact path the
|
|
688
744
|
// asset would land at — same resolver `writeAssetToSource` uses — so the
|
|
689
745
|
// backup always mirrors what would be overwritten.
|
|
690
|
-
let
|
|
746
|
+
let backupContent;
|
|
691
747
|
try {
|
|
692
748
|
const targetFilePath = resolveAssetFilePathSafe(target.source, ref);
|
|
693
749
|
if (targetFilePath && fs.existsSync(targetFilePath)) {
|
|
694
|
-
|
|
695
|
-
const proposalRoot = proposalDir(stashDir, id, false);
|
|
696
|
-
// Store relative path on the proposal record so the directory remains
|
|
697
|
-
// portable if the stash is moved.
|
|
698
|
-
const backupFilename = `backup${ext}`;
|
|
699
|
-
const backupAbsPath = path.join(proposalRoot, backupFilename);
|
|
700
|
-
fs.mkdirSync(proposalRoot, { recursive: true });
|
|
701
|
-
// Use copyFileSync — file-system atomicity is sufficient here because the
|
|
702
|
-
// backup is single-file and never read concurrently with this write.
|
|
703
|
-
fs.copyFileSync(targetFilePath, backupAbsPath);
|
|
704
|
-
backupRelPath = backupFilename;
|
|
750
|
+
backupContent = fs.readFileSync(targetFilePath, "utf8");
|
|
705
751
|
}
|
|
706
752
|
}
|
|
707
753
|
catch (err) {
|
|
@@ -715,64 +761,54 @@ export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
|
|
|
715
761
|
// targets. No-op for filesystem/primary-stash targets.
|
|
716
762
|
commitWriteTargetBoundary(target, `Update ${formatRefForMessage(ref)}`);
|
|
717
763
|
const archived = archiveProposal(stashDir, id, "accepted", undefined, ctx);
|
|
718
|
-
// Persist the backup
|
|
719
|
-
//
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
const withBackup = { ...archived, backup: backupRelPath };
|
|
724
|
-
writeProposalFile(archivedFile, withBackup);
|
|
764
|
+
// Persist the backup content on the archived proposal record so the revert
|
|
765
|
+
// flow can restore the prior asset state.
|
|
766
|
+
if (backupContent !== undefined) {
|
|
767
|
+
const withBackup = { ...archived, backupContent };
|
|
768
|
+
withProposalsDb(stashDir, ctx, (db) => upsertProposal(db, withBackup, stashDir));
|
|
725
769
|
return { proposal: withBackup, assetPath: written.path, ref: written.ref };
|
|
726
770
|
}
|
|
727
771
|
return { proposal: archived, assetPath: written.path, ref: written.ref };
|
|
728
772
|
}
|
|
729
773
|
/**
|
|
730
|
-
* Restore the prior content of an accepted proposal from
|
|
731
|
-
* (Advantage D6c / Phase 6C).
|
|
774
|
+
* Restore the prior content of an accepted proposal from the backup captured
|
|
775
|
+
* at promotion time (Advantage D6c / Phase 6C).
|
|
732
776
|
*
|
|
733
777
|
* Pre-conditions:
|
|
734
778
|
* - `id` resolves to a proposal with `status === "accepted"`.
|
|
735
|
-
* - The proposal carries
|
|
736
|
-
* the
|
|
779
|
+
* - The proposal carries `backupContent` (captured by promoteProposal when
|
|
780
|
+
* the target asset existed before the write).
|
|
737
781
|
*
|
|
738
782
|
* On success:
|
|
739
783
|
* - The backup content is written back through {@link writeAssetToSource},
|
|
740
784
|
* so the canonical write-dispatch invariant is preserved.
|
|
741
|
-
* - The
|
|
785
|
+
* - The proposal record is updated to `status: "reverted"`.
|
|
742
786
|
* - Caller emits a `proposal_reverted` event in the CLI layer (mirrors how
|
|
743
787
|
* `promoted` / `rejected` are emitted by the CLI command, not the core).
|
|
744
788
|
*
|
|
745
789
|
* Errors are thrown as `UsageError` / `NotFoundError` so the CLI can map them
|
|
746
|
-
* cleanly to exit codes — see `src/commands/proposal.ts` for the
|
|
790
|
+
* cleanly to exit codes — see `src/commands/proposal/proposal.ts` for the
|
|
791
|
+
* wrapper.
|
|
747
792
|
*/
|
|
748
793
|
export async function revertProposal(stashDir, config, id, options = {}, ctx) {
|
|
749
|
-
const proposal = getProposal(stashDir, id);
|
|
794
|
+
const proposal = getProposal(stashDir, id, ctx);
|
|
750
795
|
if (proposal.status !== "accepted") {
|
|
751
796
|
throw new UsageError(`only accepted proposals can be reverted (proposal ${id} status: ${proposal.status})`, "INVALID_FLAG_VALUE");
|
|
752
797
|
}
|
|
753
|
-
if (
|
|
798
|
+
if (proposal.backupContent === undefined) {
|
|
754
799
|
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.");
|
|
755
800
|
}
|
|
756
|
-
// The proposal directory has been moved to the archive subtree (archiveProposal
|
|
757
|
-
// runs at the end of promoteProposal). Reads must resolve against that path.
|
|
758
|
-
const proposalRoot = proposalDir(stashDir, id, true);
|
|
759
|
-
const backupAbsPath = path.join(proposalRoot, proposal.backup);
|
|
760
|
-
if (!fs.existsSync(backupAbsPath)) {
|
|
761
|
-
throw new NotFoundError(`no backup available for this proposal (id: ${id})`, "FILE_NOT_FOUND", `Expected backup file at ${backupAbsPath}; it may have been removed manually.`);
|
|
762
|
-
}
|
|
763
|
-
const backupContent = fs.readFileSync(backupAbsPath, "utf8");
|
|
764
801
|
const ref = parseAssetRef(proposal.ref);
|
|
765
802
|
if (!TYPE_DIRS[ref.type]) {
|
|
766
803
|
throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
|
|
767
804
|
}
|
|
768
805
|
const target = resolveWriteTarget(config, options.target);
|
|
769
|
-
const written = await writeAssetToSource(target.source, target.config, ref, backupContent);
|
|
806
|
+
const written = await writeAssetToSource(target.source, target.config, ref, proposal.backupContent);
|
|
770
807
|
// 0.9.0 (issue #507): single batch commit at the write boundary for git
|
|
771
808
|
// targets. No-op for filesystem/primary-stash targets.
|
|
772
809
|
commitWriteTargetBoundary(target, `Revert ${formatRefForMessage(ref)}`);
|
|
773
|
-
// Update the
|
|
774
|
-
//
|
|
775
|
-
const archivedFile = proposalFile(stashDir, id, true);
|
|
810
|
+
// Update the proposal record to status: "reverted" and bump updatedAt +
|
|
811
|
+
// review so the audit trail reflects the second decision.
|
|
776
812
|
const now = nowIso(ctx);
|
|
777
813
|
const reverted = {
|
|
778
814
|
...proposal,
|
|
@@ -784,7 +820,7 @@ export async function revertProposal(stashDir, config, id, options = {}, ctx) {
|
|
|
784
820
|
decidedAt: now,
|
|
785
821
|
},
|
|
786
822
|
};
|
|
787
|
-
|
|
823
|
+
withProposalsDb(stashDir, ctx, (db) => upsertProposal(db, reverted, stashDir));
|
|
788
824
|
return { proposal: reverted, assetPath: written.path, ref: written.ref };
|
|
789
825
|
}
|
|
790
826
|
/**
|
|
@@ -793,8 +829,8 @@ export async function revertProposal(stashDir, config, id, options = {}, ctx) {
|
|
|
793
829
|
* diff matches exactly what `accept` will write. Falls back to "new asset"
|
|
794
830
|
* when no asset is currently materialised at the target ref.
|
|
795
831
|
*/
|
|
796
|
-
export function diffProposal(stashDir, config, id, options = {}) {
|
|
797
|
-
const proposal = getProposal(stashDir, id);
|
|
832
|
+
export function diffProposal(stashDir, config, id, options = {}, ctx) {
|
|
833
|
+
const proposal = getProposal(stashDir, id, ctx);
|
|
798
834
|
const ref = parseAssetRef(proposal.ref);
|
|
799
835
|
let targetPath;
|
|
800
836
|
let existing = null;
|