akm-cli 0.9.17 → 0.9.19-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +157 -0
- package/STABILITY.md +2 -1
- package/dist/assets/hints/cli-hints-full.md +4 -2
- package/dist/assets/hints/cli-hints-short.md +5 -3
- package/dist/assets/prompts/reflect-feedback-framing.md +1 -1
- package/dist/commands/feedback-cli.js +1 -1
- package/dist/commands/health/checks.js +6 -6
- package/dist/commands/health.js +3 -3
- package/dist/commands/improve/consolidate/coverage.js +132 -0
- package/dist/commands/improve/consolidate/pair-pass.js +31 -21
- package/dist/commands/improve/consolidate.js +46 -13
- package/dist/commands/improve/distill.js +8 -4
- package/dist/commands/improve/eligibility.js +39 -9
- package/dist/commands/improve/improve-cli.js +9 -6
- package/dist/commands/improve/improve.js +13 -4
- package/dist/commands/improve/ledger.js +2 -2
- package/dist/commands/improve/loop-stages.js +2 -0
- package/dist/commands/improve/preparation.js +3 -1
- package/dist/commands/improve/reflect.js +2 -2
- package/dist/commands/improve/stage.js +43 -12
- package/dist/commands/proposal/diff-format.js +21 -0
- package/dist/commands/proposal/proposal-cli.js +48 -10
- package/dist/commands/proposal/proposal-types.js +11 -0
- package/dist/commands/proposal/proposal.js +60 -5
- package/dist/commands/proposal/repository.js +250 -18
- package/dist/commands/read/knowledge.js +13 -11
- package/dist/commands/read/remember-cli.js +7 -3
- package/dist/commands/sources/source-clone.js +1 -1
- package/dist/commands/tasks/tasks-cli.js +1 -1
- package/dist/commands/tasks/tasks.js +10 -3
- package/dist/core/mutation-target.js +8 -3
- package/dist/core/write-source.js +3 -2
- package/dist/indexer/usage/usage-events.js +2 -1
- package/dist/output/shapes/helpers.js +7 -0
- package/dist/output/shapes/passthrough.js +1 -0
- package/dist/output/shapes/proposal/reopen.js +14 -0
- package/dist/output/shapes.js +2 -0
- package/dist/output/text/helpers.js +1 -1
- package/dist/output/text/proposal/proposal.js +3 -1
- package/dist/output/text/proposal-format.js +87 -32
- package/dist/scripts/akm-migrate-node.js +102 -25
- package/dist/scripts/akm-migrate.js +102 -25
- package/dist/storage/repositories/improve-ledger-repository.js +65 -6
- package/dist/storage/repositories/index-vec-repository.js +13 -8
- package/dist/storage/repositories/proposals-repository.js +23 -0
- package/dist/storage/sqlite-read-snapshot.js +46 -2
- package/dist/storage/state-db-integrity.js +12 -9
- package/dist/tasks/run/load-task.js +5 -1
- package/docs/migration/README.md +1 -0
- package/docs/migration/release-notes/0.9.19.md +134 -0
- package/docs/migration/release-notes/README.md +5 -0
- package/docs/migration/v0.7-to-v0.8.md +2 -2
- package/docs/migration/v0.8-to-v0.9.md +5 -1
- package/docs/reference/cli.md +189 -28
- package/docs/reference/configuration.md +9 -8
- package/docs/reference/data-and-telemetry.md +24 -16
- package/package.json +1 -1
|
@@ -25,7 +25,8 @@ import { resolveImproveExecution } from "../improve/execution.js";
|
|
|
25
25
|
import { extractCommand } from "../improve/extract-cli.js";
|
|
26
26
|
import { resolveImproveStrategy } from "../improve/improve-strategies.js";
|
|
27
27
|
import { drainProposals } from "./drain.js";
|
|
28
|
-
import { akmProposalAccept, akmProposalDiff, akmProposalList, akmProposalReject, akmProposalRevert, akmProposalShow, bulkAdjudicateProposals, } from "./proposal.js";
|
|
28
|
+
import { akmProposalAccept, akmProposalDiff, akmProposalList, akmProposalReject, akmProposalReopen, akmProposalRevert, akmProposalShow, bulkAdjudicateProposals, } from "./proposal.js";
|
|
29
|
+
import { proposalWaitingSince } from "./proposal-types.js";
|
|
29
30
|
import { proposeCommand } from "./propose-cli.js";
|
|
30
31
|
export function mergeProposalDrainNotices(resolutionNotices, dispatchNotices) {
|
|
31
32
|
const byKey = new Map();
|
|
@@ -128,7 +129,7 @@ const proposalAcceptCommand = defineJsonCommand({
|
|
|
128
129
|
},
|
|
129
130
|
"older-than": {
|
|
130
131
|
type: "string",
|
|
131
|
-
description: "When bulk-accepting, only accept proposals created more than this many days ago (e.g. '7' for 7 days).",
|
|
132
|
+
description: "When bulk-accepting, only accept proposals created (or last reopened) more than this many days ago (e.g. '7' for 7 days).",
|
|
132
133
|
},
|
|
133
134
|
"dry-run": {
|
|
134
135
|
type: "boolean",
|
|
@@ -207,7 +208,7 @@ const proposalRejectCommand = defineJsonCommand({
|
|
|
207
208
|
},
|
|
208
209
|
"older-than": {
|
|
209
210
|
type: "string",
|
|
210
|
-
description: "When bulk-rejecting, only reject proposals created more than this many days ago (e.g. '7' for 7 days).",
|
|
211
|
+
description: "When bulk-rejecting, only reject proposals created (or last reopened) more than this many days ago (e.g. '7' for 7 days).",
|
|
211
212
|
},
|
|
212
213
|
"dry-run": {
|
|
213
214
|
type: "boolean",
|
|
@@ -230,7 +231,7 @@ const proposalRejectCommand = defineJsonCommand({
|
|
|
230
231
|
// F-6 / #393: Bulk-reject when --generator is provided without a positional id.
|
|
231
232
|
if (generator && !args.id) {
|
|
232
233
|
const { confirmDestructive } = await import("../../cli/confirm.js");
|
|
233
|
-
const confirmed = await confirmDestructive(`Bulk-reject all matching proposals from generator "${generator}"?
|
|
234
|
+
const confirmed = await confirmDestructive(`Bulk-reject all matching proposals from generator "${generator}"? A rejection can be undone with akm proposal reopen.`, { yes: args.yes === true || args["dry-run"] === true });
|
|
234
235
|
if (!confirmed) {
|
|
235
236
|
process.stderr.write("Aborted.\n");
|
|
236
237
|
return;
|
|
@@ -252,7 +253,7 @@ const proposalRejectCommand = defineJsonCommand({
|
|
|
252
253
|
throw new UsageError("Usage: akm proposal reject <id> --reason '<reason>' OR akm proposal reject --generator <generator> --reason '<reason>'", "MISSING_REQUIRED_ARGUMENT");
|
|
253
254
|
}
|
|
254
255
|
const { confirmDestructive } = await import("../../cli/confirm.js");
|
|
255
|
-
const confirmed = await confirmDestructive(`Reject proposal "${args.id}"?
|
|
256
|
+
const confirmed = await confirmDestructive(`Reject proposal "${args.id}"? A rejection can be undone with akm proposal reopen.`, {
|
|
256
257
|
yes: args.yes === true,
|
|
257
258
|
});
|
|
258
259
|
if (!confirmed) {
|
|
@@ -267,6 +268,41 @@ const proposalRejectCommand = defineJsonCommand({
|
|
|
267
268
|
output("proposal-reject", result);
|
|
268
269
|
},
|
|
269
270
|
});
|
|
271
|
+
// `proposal reopen` (#997): the undo a rejection lacked. Unlike `reject` it is
|
|
272
|
+
// reversible (reject again), so it asks no confirmation.
|
|
273
|
+
const proposalReopenCommand = defineJsonCommand({
|
|
274
|
+
meta: {
|
|
275
|
+
name: "reopen",
|
|
276
|
+
description: "Reopen rejected proposals: move them back to pending, keeping the rejection in their history. " +
|
|
277
|
+
"Takes full proposal ids; refused (and none reopened) if any is not rejected or its target changed since it was created.",
|
|
278
|
+
},
|
|
279
|
+
args: {
|
|
280
|
+
id: {
|
|
281
|
+
type: "positional",
|
|
282
|
+
description: "Rejected proposal id (full uuid); repeat it to reopen several at once",
|
|
283
|
+
required: true,
|
|
284
|
+
},
|
|
285
|
+
reason: {
|
|
286
|
+
type: "string",
|
|
287
|
+
description: "Why the rejection is being undone (kept in the proposal's review history)",
|
|
288
|
+
},
|
|
289
|
+
queue: { type: "string", description: "Select the proposal queue by source name" },
|
|
290
|
+
},
|
|
291
|
+
async run({ args }) {
|
|
292
|
+
// citty keeps every positional token in `_`, though it declares only `id`.
|
|
293
|
+
const ids = (Array.isArray(args._) && args._.length > 0 ? args._ : [args.id]).map(String);
|
|
294
|
+
const reason = typeof args.reason === "string" && args.reason.trim() ? args.reason.trim() : undefined;
|
|
295
|
+
const results = await akmProposalReopen({
|
|
296
|
+
ids,
|
|
297
|
+
queue: args.queue,
|
|
298
|
+
...(reason !== undefined ? { reason } : {}),
|
|
299
|
+
});
|
|
300
|
+
if (results.length === 1)
|
|
301
|
+
output("proposal-reopen", results[0]);
|
|
302
|
+
else
|
|
303
|
+
output("proposal-reopen-batch", { reopened: results.length, results });
|
|
304
|
+
},
|
|
305
|
+
});
|
|
270
306
|
const proposalDiffCommand = defineJsonCommand({
|
|
271
307
|
meta: { name: "diff", description: "Show the diff for a proposal (accepts full UUID, UUID prefix, or asset ref)" },
|
|
272
308
|
args: {
|
|
@@ -356,7 +392,7 @@ const proposalDrainCommand = defineJsonCommand({
|
|
|
356
392
|
},
|
|
357
393
|
"older-than": {
|
|
358
394
|
type: "string",
|
|
359
|
-
description: "Only consider proposals created more than this many days ago.",
|
|
395
|
+
description: "Only consider proposals created (or last reopened) more than this many days ago.",
|
|
360
396
|
},
|
|
361
397
|
promote: {
|
|
362
398
|
type: "boolean",
|
|
@@ -410,10 +446,11 @@ const proposalDrainCommand = defineJsonCommand({
|
|
|
410
446
|
const now = Date.now();
|
|
411
447
|
excludeIds = new Set(listProposals(stashDir, { status: "pending" })
|
|
412
448
|
// Fail SAFE: exclude a proposal when its age cannot be computed
|
|
413
|
-
// (NaN
|
|
414
|
-
//
|
|
449
|
+
// (NaN date) OR it is too fresh. An unparseable date must never be
|
|
450
|
+
// treated as old enough to drain/promote. Age counts from creation,
|
|
451
|
+
// or from the last reopen (#997) — a proposal just reopened is fresh.
|
|
415
452
|
.filter((proposal) => {
|
|
416
|
-
const age = now - new Date(proposal
|
|
453
|
+
const age = now - new Date(proposalWaitingSince(proposal)).getTime();
|
|
417
454
|
return Number.isNaN(age) || age < olderThanMs;
|
|
418
455
|
})
|
|
419
456
|
.map((proposal) => proposal.id));
|
|
@@ -480,7 +517,7 @@ const proposalDrainCommand = defineJsonCommand({
|
|
|
480
517
|
export const proposalCommand = defineGroupCommand({
|
|
481
518
|
meta: {
|
|
482
519
|
name: "proposal",
|
|
483
|
-
description: "Manage the proposal queue: list, show, diff, accept, reject, revert, extract, new, drain",
|
|
520
|
+
description: "Manage the proposal queue: list, show, diff, accept, reject, reopen, revert, extract, new, drain",
|
|
484
521
|
},
|
|
485
522
|
// The group declared `--queue`/`--status`/`--ref`/`--type` only so the bare
|
|
486
523
|
// form could act as `proposal list`. That form is gone (see below), and
|
|
@@ -493,6 +530,7 @@ export const proposalCommand = defineGroupCommand({
|
|
|
493
530
|
diff: proposalDiffCommand,
|
|
494
531
|
accept: proposalAcceptCommand,
|
|
495
532
|
reject: proposalRejectCommand,
|
|
533
|
+
reopen: proposalReopenCommand,
|
|
496
534
|
revert: proposalRevertCommand,
|
|
497
535
|
drain: proposalDrainCommand,
|
|
498
536
|
extract: extractCommand,
|
|
@@ -36,6 +36,17 @@ export function isValidProposalSource(source) {
|
|
|
36
36
|
export function isAutomatedProposalSource(source) {
|
|
37
37
|
return AUTOMATED_PROPOSAL_SOURCES.includes(source);
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* When a pending proposal's wait for review began: its last reopen (#997), else
|
|
41
|
+
* its creation. Every age-based sweep — retention expiry, and `--older-than` on
|
|
42
|
+
* bulk accept/reject and on `drain` — counts from here, so a proposal just put
|
|
43
|
+
* back in the queue is not swept as if it had been waiting since it was first
|
|
44
|
+
* created (a scheduled `drain --older-than 7 --promote` would otherwise take
|
|
45
|
+
* it at once).
|
|
46
|
+
*/
|
|
47
|
+
export function proposalWaitingSince(proposal) {
|
|
48
|
+
return proposal.reviewHistory?.at(-1)?.reopenedAt ?? proposal.createdAt;
|
|
49
|
+
}
|
|
39
50
|
/** A pending or accepted proposal whose primary change deletes its target (a consolidate retire proposal, alpha.9). */
|
|
40
51
|
export function isRetireProposal(proposal) {
|
|
41
52
|
return proposal.changes[0]?.op === "delete";
|
|
@@ -2,7 +2,7 @@
|
|
|
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
|
-
* `akm proposal {list,show,accept,reject,diff}` — review surface for the
|
|
5
|
+
* `akm proposal {list,show,accept,reject,reopen,diff}` — review surface for the
|
|
6
6
|
* proposal substrate (#225).
|
|
7
7
|
*
|
|
8
8
|
* Each function returns a plain JSON envelope; the CLI dispatcher in
|
|
@@ -13,10 +13,11 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { resolveStashDir } from "../../core/common.js";
|
|
15
15
|
import { loadConfig } from "../../core/config/config.js";
|
|
16
|
+
import { NotFoundError } from "../../core/errors.js";
|
|
16
17
|
import { resolveWriteTarget } from "../../core/write-source.js";
|
|
17
18
|
import { withAssetMutationLease } from "../../indexer/index-writer-lock.js";
|
|
18
|
-
import { isRetireProposal } from "./proposal-types.js";
|
|
19
|
-
import { diffProposal, listProposals, preflightProposalPromotion, promoteProposal, proposalContent, rejectProposalDurably, resolveProposalId, revertProposal, } from "./repository.js";
|
|
19
|
+
import { isRetireProposal, proposalWaitingSince } from "./proposal-types.js";
|
|
20
|
+
import { diffProposal, listProposals, preflightProposalPromotion, promoteProposal, proposalContent, rejectProposalDurably, reopenProposals, resolveProposalId, revertProposal, } from "./repository.js";
|
|
20
21
|
import { validateProposal } from "./validators/proposals.js";
|
|
21
22
|
// ── Shared helpers ──────────────────────────────────────────────────────────
|
|
22
23
|
function resolveStash(stashDir) {
|
|
@@ -29,7 +30,7 @@ function resolveProposalQueue(stashDir, queue, config) {
|
|
|
29
30
|
return { stashDir };
|
|
30
31
|
if (!queue)
|
|
31
32
|
return { stashDir: resolveStash() };
|
|
32
|
-
const target = resolveWriteTarget(config ?? loadConfig(), queue);
|
|
33
|
+
const target = resolveWriteTarget(config ?? loadConfig(), queue, { flag: "--queue" });
|
|
33
34
|
return { stashDir: target.source.path, target };
|
|
34
35
|
}
|
|
35
36
|
/**
|
|
@@ -120,6 +121,59 @@ export async function akmProposalReject(options) {
|
|
|
120
121
|
};
|
|
121
122
|
});
|
|
122
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* Put rejected proposals back in the queue as `pending` (#997) — the undo the
|
|
126
|
+
* proposal queue lacked. All-or-nothing: see {@link reopenProposals}. An
|
|
127
|
+
* archived proposal is found by its full id (a prefix only matches the pending
|
|
128
|
+
* queue), the same as `revert`.
|
|
129
|
+
*/
|
|
130
|
+
export async function akmProposalReopen(options) {
|
|
131
|
+
return withAssetMutationLease("proposal-reopen", async () => {
|
|
132
|
+
const config = options.config ?? loadConfig();
|
|
133
|
+
const queue = resolveProposalQueue(options.stashDir, options.queue, config);
|
|
134
|
+
const ids = options.ids.map((id) => {
|
|
135
|
+
try {
|
|
136
|
+
return resolveProposalId(queue.stashDir, id, options.ctx).id;
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (!(error instanceof NotFoundError))
|
|
140
|
+
throw error;
|
|
141
|
+
throw new NotFoundError(error.message, error.code, "A rejected proposal is addressed by its full id (a prefix only matches pending proposals): `akm proposal list --status rejected` lists them.");
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
const reopened = reopenProposals(queue.stashDir, config, ids, { queueTarget: queue.target, ...(options.reason !== undefined ? { reason: options.reason } : {}) }, options.ctx);
|
|
145
|
+
return reopened.map((proposal) => ({
|
|
146
|
+
schemaVersion: 1,
|
|
147
|
+
ok: true,
|
|
148
|
+
id: proposal.id,
|
|
149
|
+
ref: proposal.ref,
|
|
150
|
+
...(options.reason !== undefined ? { reason: options.reason } : {}),
|
|
151
|
+
proposal,
|
|
152
|
+
}));
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
/** What a reviewer of a retire proposal needs to know that its diff (a file leaving) does not say. */
|
|
156
|
+
const RETIRE_DIFF_NOTE = "Accepting archives the retired file under .akm/memory-cleanup/archive/ (nothing is deleted); " +
|
|
157
|
+
"`akm proposal revert` restores it byte-exactly.";
|
|
158
|
+
/** The fields a retire proposal's diff result adds to an ordinary one (#997). */
|
|
159
|
+
function retireDiffFields(retirement) {
|
|
160
|
+
return {
|
|
161
|
+
op: "delete",
|
|
162
|
+
...(retirement
|
|
163
|
+
? {
|
|
164
|
+
retirement: {
|
|
165
|
+
retiredRef: retirement.retiredRef,
|
|
166
|
+
successorRef: retirement.successorRef,
|
|
167
|
+
judgeLabel: retirement.judgeLabel,
|
|
168
|
+
judgeReason: retirement.judgeReason,
|
|
169
|
+
cosine: retirement.cosine,
|
|
170
|
+
...(retirement.continuityRisk ? { continuityRisk: retirement.continuityRisk } : {}),
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
: {}),
|
|
174
|
+
note: RETIRE_DIFF_NOTE,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
123
177
|
export function akmProposalDiff(options) {
|
|
124
178
|
const config = options.config ?? loadConfig();
|
|
125
179
|
const queue = resolveProposalQueue(options.stashDir, options.queue, config);
|
|
@@ -133,6 +187,7 @@ export function akmProposalDiff(options) {
|
|
|
133
187
|
isNew: diff.isNew,
|
|
134
188
|
unified: diff.unified,
|
|
135
189
|
...(diff.targetPath ? { targetPath: diff.targetPath } : {}),
|
|
190
|
+
...(isRetireProposal(proposal) ? retireDiffFields(proposal.retirement) : {}),
|
|
136
191
|
};
|
|
137
192
|
}
|
|
138
193
|
/**
|
|
@@ -204,7 +259,7 @@ export async function bulkAdjudicateProposals(options) {
|
|
|
204
259
|
return false;
|
|
205
260
|
}
|
|
206
261
|
if (options.olderThanMs !== undefined) {
|
|
207
|
-
const age = Date.now() - new Date(p
|
|
262
|
+
const age = Date.now() - new Date(proposalWaitingSince(p)).getTime();
|
|
208
263
|
if (age < options.olderThanMs)
|
|
209
264
|
return false;
|
|
210
265
|
}
|
|
@@ -21,7 +21,7 @@ import { assembleAsset, serializeFrontmatter } from "../../core/asset/asset-seri
|
|
|
21
21
|
import { carryForwardBookkeepingFrontmatter, parseFrontmatter } from "../../core/asset/frontmatter.js";
|
|
22
22
|
import { conceptIdFromTypeName, parseRefInput } from "../../core/asset/resolve-ref.js";
|
|
23
23
|
import { loadConfig } from "../../core/config/config.js";
|
|
24
|
-
import { ConfigError, NotFoundError, UsageError } from "../../core/errors.js";
|
|
24
|
+
import { ConfigError, NotFoundError, rethrowIfTestIsolationError, UsageError } from "../../core/errors.js";
|
|
25
25
|
import { appendEvent } from "../../core/events.js";
|
|
26
26
|
import { proposalContent } from "../../core/file-change.js";
|
|
27
27
|
import { canonicalBundleIdForTarget, resolveBundleWriteTarget } from "../../core/mutation-target.js";
|
|
@@ -34,7 +34,7 @@ import { indexWrittenAssets } from "../../indexer/index-written-assets.js";
|
|
|
34
34
|
import { deriveInstallations } from "../../indexer/installations.js";
|
|
35
35
|
import { resolveSourceEntries } from "../../indexer/search/search-source.js";
|
|
36
36
|
import { insertEventOnce } from "../../storage/repositories/events-repository.js";
|
|
37
|
-
import { recordImproveLedger, recordImproveLedgerDecision, } from "../../storage/repositories/improve-ledger-repository.js";
|
|
37
|
+
import { CONSOLIDATE_LEDGER_SOURCE, forgetImproveLedgerDecision, recordImproveLedger, recordImproveLedgerDecision, reopenImproveLedgerDecision, } from "../../storage/repositories/improve-ledger-repository.js";
|
|
38
38
|
import { getStateProposal, listStateProposalIdsByPrefix, listStateProposals, upsertProposal, } from "../../storage/repositories/proposals-repository.js";
|
|
39
39
|
import { openSqliteReadSnapshot } from "../../storage/sqlite-read-snapshot.js";
|
|
40
40
|
import { pkgVersion } from "../../version.js";
|
|
@@ -42,8 +42,8 @@ import { contentHash } from "../improve/content-hash.js";
|
|
|
42
42
|
import { writeSupersededEdge } from "../improve/memory/memory-belief.js";
|
|
43
43
|
import { archiveCleanupCandidate, derivedTwinPath } from "../improve/memory/memory-improve.js";
|
|
44
44
|
import { runBaseChecks } from "../lint/base-linter.js";
|
|
45
|
-
import { formatNewAssetDiff, formatUnifiedDiff } from "./diff-format.js";
|
|
46
|
-
import { ASSET_MISSING_GATE_REASON, EXPIRED_GATE_REASON, isAutomatedProposalSource, isRetireProposal, isValidProposalSource, PROPOSAL_SOURCES, STALE_TARGET_GATE_REASON, } from "./proposal-types.js";
|
|
45
|
+
import { formatNewAssetDiff, formatRetireDiff, formatUnifiedDiff } from "./diff-format.js";
|
|
46
|
+
import { ASSET_MISSING_GATE_REASON, EXPIRED_GATE_REASON, isAutomatedProposalSource, isRetireProposal, isValidProposalSource, PROPOSAL_SOURCES, proposalWaitingSince, STALE_TARGET_GATE_REASON, } from "./proposal-types.js";
|
|
47
47
|
import { canonicalOnlyProposalValidators, hasCanonicalProposalValidator, runProposalValidators, } from "./validators/proposal-validators.js";
|
|
48
48
|
import { repairProposalContent, validateProposal } from "./validators/proposals.js";
|
|
49
49
|
export { AUTOMATED_PROPOSAL_SOURCES, isAutomatedProposalSource, isValidProposalSource, PROPOSAL_SOURCES, } from "./proposal-types.js";
|
|
@@ -141,6 +141,28 @@ export function resolveProposalQueueTarget(stashDir, config = loadConfig()) {
|
|
|
141
141
|
}
|
|
142
142
|
return { source: bundleId, root };
|
|
143
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* The configured bundle that owns the asset `itemRef` names when it is not the
|
|
146
|
+
* bundle rooted at `target`; otherwise `undefined`. A differing name is
|
|
147
|
+
* confirmed by the owner's root, so one bundle known by two spellings is never
|
|
148
|
+
* taken for two, and an owner that cannot be resolved is never refused on.
|
|
149
|
+
*/
|
|
150
|
+
function otherOwningBundle(itemRef, target) {
|
|
151
|
+
try {
|
|
152
|
+
const owner = parseBundleRef(itemRef).bundle;
|
|
153
|
+
if (owner === undefined || owner === target.source)
|
|
154
|
+
return undefined;
|
|
155
|
+
const sources = resolveSourceEntries(target.root, loadConfig());
|
|
156
|
+
const ownerSource = sources[deriveInstallations(sources).findIndex((installation) => installation.id === owner)];
|
|
157
|
+
return ownerSource !== undefined && path.resolve(ownerSource.path) !== path.resolve(target.root)
|
|
158
|
+
? owner
|
|
159
|
+
: undefined;
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
rethrowIfTestIsolationError(err);
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
144
166
|
/**
|
|
145
167
|
* Create a pending proposal (a random UUID id). Obviously invalid input is
|
|
146
168
|
* refused with a typed `proposal_creation_rejected` event. The mint and its
|
|
@@ -190,9 +212,13 @@ export function createProposal(stashDir, input, ctx) {
|
|
|
190
212
|
return rejectProposal("missing_description", `Proposal for "${input.ref}" (source=consolidate) has empty or missing frontmatter description.`);
|
|
191
213
|
}
|
|
192
214
|
}
|
|
215
|
+
const proposalTarget = resolveCreateProposalTarget(stashDir, input.target, parsedRef.origin);
|
|
216
|
+
const owner = input.itemRef ? otherOwningBundle(input.itemRef, proposalTarget) : undefined;
|
|
217
|
+
if (owner) {
|
|
218
|
+
return rejectProposal("cross_bundle", `Proposal for "${input.ref}" rewrites ${input.itemRef}, which bundle "${owner}" owns, but its queue target is bundle "${proposalTarget.source}". Filing it there would fork the asset out of "${owner}" or overwrite this bundle's copy with content taken from the other. Improve it in its own bundle instead (\`akm improve --bundle ${owner}\`).`);
|
|
219
|
+
}
|
|
193
220
|
// The FileChange envelope, and the target's before-hashes as of mint (the
|
|
194
221
|
// freshness check at accept compares against them).
|
|
195
|
-
const proposalTarget = resolveCreateProposalTarget(stashDir, input.target, parsedRef.origin);
|
|
196
222
|
const normalizedRef = proposalDurableRef(parsedRef, proposalTarget);
|
|
197
223
|
const targetRoot = path.resolve(proposalTarget.root);
|
|
198
224
|
let targetRelPath;
|
|
@@ -373,9 +399,13 @@ export function listProposals(stashDir, options = {}, ctx) {
|
|
|
373
399
|
/**
|
|
374
400
|
* {@link listProposals} on a read snapshot that never creates or migrates
|
|
375
401
|
* state.db: prompt building runs before the first dispatch has validated its
|
|
376
|
-
* credentials, and a missing store is simply empty.
|
|
402
|
+
* credentials, and a missing store is simply empty. A caller that already
|
|
403
|
+
* holds a live connection (`ctx.db`) reads through it: a snapshot would copy
|
|
404
|
+
* the whole database for nothing.
|
|
377
405
|
*/
|
|
378
406
|
export function listProposalsReadOnly(stashDir, options = {}, ctx) {
|
|
407
|
+
if (ctx?.db)
|
|
408
|
+
return queryProposals(ctx.db, stashDir, options);
|
|
379
409
|
const dbPath = ctx?.dbPath ?? getStateDbPath();
|
|
380
410
|
if (!fs.existsSync(dbPath))
|
|
381
411
|
return [];
|
|
@@ -450,6 +480,25 @@ function ledgerOutcomeForDecision(status, gateDecision) {
|
|
|
450
480
|
}
|
|
451
481
|
return "rejected";
|
|
452
482
|
}
|
|
483
|
+
/**
|
|
484
|
+
* What a decision's ledger row is about. A promotion's row is keyed by its
|
|
485
|
+
* source memory, so a decision on it names that memory, together with the body
|
|
486
|
+
* hash the promotion was queued against — this is what holds the memory back
|
|
487
|
+
* until it changes (`isContentDrivenDecision`). Any other proposal, and a
|
|
488
|
+
* promotion minted before the hash was recorded, is keyed by its own ref.
|
|
489
|
+
* Every decision that can create a row when none carries the proposal id — a
|
|
490
|
+
* reject, an accept and a drain deferral alike — must key it this way: a stray
|
|
491
|
+
* row under the knowledge ref carries the proposal id, so a later verdict
|
|
492
|
+
* updates that one and never reaches the memory's own row.
|
|
493
|
+
*/
|
|
494
|
+
function decisionLedgerSubject(proposal) {
|
|
495
|
+
if (proposal.source === CONSOLIDATE_LEDGER_SOURCE &&
|
|
496
|
+
proposal.promotionSource !== undefined &&
|
|
497
|
+
proposal.promotionSourceHash !== undefined) {
|
|
498
|
+
return { ref: proposal.promotionSource, contentHash: proposal.promotionSourceHash };
|
|
499
|
+
}
|
|
500
|
+
return { ref: proposal.ref };
|
|
501
|
+
}
|
|
453
502
|
/** Archive a pending proposal as accepted/rejected, recording the decision in the ledger in the same transaction. */
|
|
454
503
|
export function archiveProposal(stashDir, id, status, reason, ctx, gateDecision) {
|
|
455
504
|
return withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
|
|
@@ -469,7 +518,7 @@ export function archiveProposal(stashDir, id, status, reason, ctx, gateDecision)
|
|
|
469
518
|
recordImproveLedgerDecision(db, {
|
|
470
519
|
proposalId: updated.id,
|
|
471
520
|
stashDir,
|
|
472
|
-
|
|
521
|
+
...decisionLedgerSubject(updated),
|
|
473
522
|
source: updated.source,
|
|
474
523
|
outcome: ledgerOutcomeForDecision(status, gateDecision),
|
|
475
524
|
at: decidedAt,
|
|
@@ -496,7 +545,7 @@ export function recordGateDecision(stashDir, id, decision, ctx) {
|
|
|
496
545
|
recordImproveLedgerDecision(db, {
|
|
497
546
|
proposalId: updated.id,
|
|
498
547
|
stashDir,
|
|
499
|
-
|
|
548
|
+
...decisionLedgerSubject(updated),
|
|
500
549
|
source: updated.source,
|
|
501
550
|
outcome: "review_needed",
|
|
502
551
|
at: decidedAt,
|
|
@@ -547,9 +596,10 @@ export function purgeOrphanProposals(stashDir, sourceDirs, ctx) {
|
|
|
547
596
|
}
|
|
548
597
|
/**
|
|
549
598
|
* Archive pending proposals older than `archiveRetentionDays` (default 90;
|
|
550
|
-
* 0 disables) as rejected with an
|
|
551
|
-
* `
|
|
552
|
-
* the rejection window, since nobody judged the
|
|
599
|
+
* 0 disables; counted from the last reopen, if any) as rejected with an
|
|
600
|
+
* `expired` gate decision and a `proposal_expired` event. The ledger records
|
|
601
|
+
* `expired` — a short grace, not the rejection window, since nobody judged the
|
|
602
|
+
* content.
|
|
553
603
|
*/
|
|
554
604
|
export function expireStaleProposals(stashDir, config, ctx) {
|
|
555
605
|
const t0 = Date.now();
|
|
@@ -568,7 +618,9 @@ export function expireStaleProposals(stashDir, config, ctx) {
|
|
|
568
618
|
// way back short of the pair pass finding it again from scratch.
|
|
569
619
|
if (isRetireProposal(p))
|
|
570
620
|
continue;
|
|
571
|
-
|
|
621
|
+
// A reopened proposal's wait starts over at the reopen (#997): expiring it
|
|
622
|
+
// on its original age would undo the reopen at the next sweep.
|
|
623
|
+
const createdMs = new Date(proposalWaitingSince(p)).getTime();
|
|
572
624
|
if (!Number.isFinite(createdMs) || nowMs - createdMs < retentionDays * MS_PER_DAY)
|
|
573
625
|
continue;
|
|
574
626
|
try {
|
|
@@ -686,7 +738,7 @@ function persistProposalDecision(stashDir, proposal, decision, ctx) {
|
|
|
686
738
|
recordImproveLedgerDecision(db, {
|
|
687
739
|
proposalId: next.id,
|
|
688
740
|
stashDir,
|
|
689
|
-
|
|
741
|
+
...decisionLedgerSubject(next),
|
|
690
742
|
source: next.source,
|
|
691
743
|
outcome: ledgerOutcomeForDecision(accept ? "accepted" : "reverted"),
|
|
692
744
|
at: decision.decidedAt,
|
|
@@ -1587,17 +1639,197 @@ async function revertProposalWithLease(stashDir, config, id, options, ctx) {
|
|
|
1587
1639
|
await indexWrittenProposalAsset(target, assetPath);
|
|
1588
1640
|
return { proposal: reverted, assetPath, ref: proposal.ref };
|
|
1589
1641
|
}
|
|
1642
|
+
/** The message of the stale-target refusal `check` raises, else `undefined`. */
|
|
1643
|
+
function staleRefusal(check) {
|
|
1644
|
+
try {
|
|
1645
|
+
check();
|
|
1646
|
+
return undefined;
|
|
1647
|
+
}
|
|
1648
|
+
catch (error) {
|
|
1649
|
+
if (error instanceof UsageError)
|
|
1650
|
+
return error.message;
|
|
1651
|
+
throw error;
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
/**
|
|
1655
|
+
* Why `proposal` cannot be reopened, or `undefined` when it can. Only a
|
|
1656
|
+
* rejected proposal comes back, and only one accept would not refuse as stale:
|
|
1657
|
+
* a retire proposal needs its successor and both recorded body hashes to still
|
|
1658
|
+
* match (B2), any other its target to be what it was minted against
|
|
1659
|
+
* (STALE, R20) — so a reopened proposal is never one the next accept refuses.
|
|
1660
|
+
*/
|
|
1661
|
+
function reopenRefusal(config, proposal, queueTarget) {
|
|
1662
|
+
if (proposal.status !== "rejected") {
|
|
1663
|
+
return `it is not rejected (current status: ${proposal.status}); only a rejected proposal can be reopened.`;
|
|
1664
|
+
}
|
|
1665
|
+
if (proposal.changes.length === 0 || proposal.proposedTarget === undefined) {
|
|
1666
|
+
// A pending row must carry both (proposalToRowValues), which a row from
|
|
1667
|
+
// before the change envelope existed cannot.
|
|
1668
|
+
return "it was recorded before proposals carried their change envelope, so it cannot go back in the queue.";
|
|
1669
|
+
}
|
|
1670
|
+
const target = resolveProposalWriteTarget(config, proposal, undefined, queueTarget);
|
|
1671
|
+
const assetPath = resolveAssetFilePathSafe(target.source, parseRefInput(proposal.ref));
|
|
1672
|
+
if (!assetPath)
|
|
1673
|
+
return "its target cannot be resolved.";
|
|
1674
|
+
if (!isRetireProposal(proposal)) {
|
|
1675
|
+
return staleRefusal(() => void readFreshProposalTarget(proposal, assetPath, proposalContent(proposal)));
|
|
1676
|
+
}
|
|
1677
|
+
if (!proposal.retirement)
|
|
1678
|
+
return "it has no retirement metadata.";
|
|
1679
|
+
if (!fs.existsSync(assetPath)) {
|
|
1680
|
+
return "its retired file no longer exists (already retired, or removed by something else).";
|
|
1681
|
+
}
|
|
1682
|
+
const { retirement } = proposal;
|
|
1683
|
+
return staleRefusal(() => assertRetirementStillFresh(proposal.id, proposal.ref, retirement, target.source, fs.readFileSync(assetPath)));
|
|
1684
|
+
}
|
|
1685
|
+
/** The assets a retire proposal speaks for — the one it retires and its successor — as bundle-less concept ids, the way the pair pass keys them. */
|
|
1686
|
+
function retireHeldRefs(proposal) {
|
|
1687
|
+
return [proposal.ref, proposal.retirement?.successorRef].flatMap((ref) => {
|
|
1688
|
+
const conceptId = ref === undefined ? undefined : proposalRefIdentity(ref)?.conceptId;
|
|
1689
|
+
return conceptId === undefined ? [] : [conceptId];
|
|
1690
|
+
});
|
|
1691
|
+
}
|
|
1692
|
+
const REOPEN_REFUSED_HINT = "Only a rejected proposal whose target is unchanged can be reopened; `akm proposal list --status rejected` lists the candidates.";
|
|
1693
|
+
const REOPEN_RETIRE_CONFLICT_HINT = "Only one pending retire proposal can involve a document: accept or reject the pending one (or reopen just one of a clashing pair), then reopen the other.";
|
|
1694
|
+
/**
|
|
1695
|
+
* The pair pass never has two pending retire proposals speak for one asset
|
|
1696
|
+
* (`pendingRetireRefs`): accepting one would strand the other. Reopening must
|
|
1697
|
+
* not break that either — a rejected pair can meanwhile have been re-paired
|
|
1698
|
+
* with something else. `held` maps each asset to the pending retire proposal
|
|
1699
|
+
* that has it; a proposal that clears this claims its assets, so two in one
|
|
1700
|
+
* batch that clash refuse the later.
|
|
1701
|
+
*/
|
|
1702
|
+
function retireConflict(proposal, held) {
|
|
1703
|
+
if (!isRetireProposal(proposal))
|
|
1704
|
+
return undefined;
|
|
1705
|
+
const refs = retireHeldRefs(proposal);
|
|
1706
|
+
for (const ref of refs) {
|
|
1707
|
+
const holder = held.get(ref);
|
|
1708
|
+
if (holder !== undefined) {
|
|
1709
|
+
return `${ref} is already part of retire proposal ${holder}, which is pending or being reopened with this one; only one pending retire proposal may involve an asset.`;
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
for (const ref of refs)
|
|
1713
|
+
held.set(ref, proposal.id);
|
|
1714
|
+
return undefined;
|
|
1715
|
+
}
|
|
1716
|
+
/**
|
|
1717
|
+
* Put rejected proposals back in the queue (`akm proposal reopen`, #997): a
|
|
1718
|
+
* rejection is otherwise final, and it also suppresses the pair pass from ever
|
|
1719
|
+
* re-proposing a retirement (its record keys the pair), so a mistaken one
|
|
1720
|
+
* could not be undone. Each proposal returns to `pending` with the rejection —
|
|
1721
|
+
* and the gate verdict that came with it — kept in `reviewHistory` (the verdict
|
|
1722
|
+
* itself is cleared, unless it is a `deferred` hand-off to a person), its
|
|
1723
|
+
* ledger row reset (the pair pass keys off proposal status, so the pair is no
|
|
1724
|
+
* longer suppressed and, while pending, cannot be minted twice), and a
|
|
1725
|
+
* `proposal_reopened` event recorded, all in one transaction.
|
|
1726
|
+
*
|
|
1727
|
+
* All-or-nothing: every id is checked (see {@link reopenRefusal} and
|
|
1728
|
+
* {@link retireConflict}) before any is reopened, and one refusal leaves the
|
|
1729
|
+
* whole batch untouched.
|
|
1730
|
+
*/
|
|
1731
|
+
export function reopenProposals(stashDir, config, ids, options = {}, ctx) {
|
|
1732
|
+
return withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
|
|
1733
|
+
const proposals = [...new Set(ids)].map((id) => requireProposal(db, stashDir, id));
|
|
1734
|
+
const held = new Map();
|
|
1735
|
+
for (const pending of listStateProposals(db, { stashDir, status: "pending" })) {
|
|
1736
|
+
if (isRetireProposal(pending))
|
|
1737
|
+
for (const ref of retireHeldRefs(pending))
|
|
1738
|
+
held.set(ref, pending.id);
|
|
1739
|
+
}
|
|
1740
|
+
const refusals = proposals.flatMap((proposal) => {
|
|
1741
|
+
const refusal = reopenRefusal(config, proposal, options.queueTarget);
|
|
1742
|
+
const conflict = refusal === undefined ? retireConflict(proposal, held) : undefined;
|
|
1743
|
+
const reason = refusal ?? conflict;
|
|
1744
|
+
return reason === undefined
|
|
1745
|
+
? []
|
|
1746
|
+
: [{ proposal: `${proposal.id} (${proposal.ref})`, reason, isConflict: conflict !== undefined }];
|
|
1747
|
+
});
|
|
1748
|
+
if (refusals.length > 0) {
|
|
1749
|
+
// A retire conflict is not about the target changing, so it gets its own hint.
|
|
1750
|
+
const hints = [
|
|
1751
|
+
...(refusals.some((refusal) => !refusal.isConflict) ? [REOPEN_REFUSED_HINT] : []),
|
|
1752
|
+
...(refusals.some((refusal) => refusal.isConflict) ? [REOPEN_RETIRE_CONFLICT_HINT] : []),
|
|
1753
|
+
];
|
|
1754
|
+
throw new UsageError(proposals.length === 1
|
|
1755
|
+
? `Proposal ${refusals[0]?.proposal} cannot be reopened: ${refusals[0]?.reason}`
|
|
1756
|
+
: `Cannot reopen ${refusals.length} of ${proposals.length} proposals; none were reopened:\n${refusals
|
|
1757
|
+
.map((refusal) => ` - ${refusal.proposal}: ${refusal.reason}`)
|
|
1758
|
+
.join("\n")}`, "INVALID_FLAG_VALUE", hints.join(" "));
|
|
1759
|
+
}
|
|
1760
|
+
const decidedAt = nowIso(ctx);
|
|
1761
|
+
return proposals.map((existing) => {
|
|
1762
|
+
const reopened = {
|
|
1763
|
+
...existing,
|
|
1764
|
+
status: "pending",
|
|
1765
|
+
updatedAt: decidedAt,
|
|
1766
|
+
review: undefined,
|
|
1767
|
+
// A reopened proposal is adjudicated afresh: a `staged` verdict would
|
|
1768
|
+
// let the drain accept it unseen, and another gate's `auto-rejected`
|
|
1769
|
+
// would have the drain skip it. A `deferred` one is the quality
|
|
1770
|
+
// gate's hand-off to a person, which the drain must keep honouring
|
|
1771
|
+
// (drainProposals leaves it alone), so it stays.
|
|
1772
|
+
gateDecision: existing.gateDecision?.outcome === "deferred" ? existing.gateDecision : undefined,
|
|
1773
|
+
reviewHistory: [
|
|
1774
|
+
...(existing.reviewHistory ?? []),
|
|
1775
|
+
{
|
|
1776
|
+
...(existing.review !== undefined ? { review: existing.review } : {}),
|
|
1777
|
+
...(existing.gateDecision !== undefined ? { gateDecision: existing.gateDecision } : {}),
|
|
1778
|
+
reopenedAt: decidedAt,
|
|
1779
|
+
...(options.reason !== undefined ? { reopenReason: options.reason } : {}),
|
|
1780
|
+
},
|
|
1781
|
+
],
|
|
1782
|
+
};
|
|
1783
|
+
upsertProposal(db, reopened, stashDir);
|
|
1784
|
+
if (isRetireProposal(existing)) {
|
|
1785
|
+
// A retire mint writes no ledger row, so the one its rejection wrote goes.
|
|
1786
|
+
forgetImproveLedgerDecision(db, stashDir, existing.id);
|
|
1787
|
+
}
|
|
1788
|
+
else {
|
|
1789
|
+
reopenImproveLedgerDecision(db, {
|
|
1790
|
+
proposalId: existing.id,
|
|
1791
|
+
stashDir,
|
|
1792
|
+
source: existing.source,
|
|
1793
|
+
at: decidedAt,
|
|
1794
|
+
detail: options.reason !== undefined ? `reopened: ${options.reason}` : "reopened",
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
1797
|
+
insertEventOnce(db, {
|
|
1798
|
+
eventType: "proposal_reopened",
|
|
1799
|
+
ts: decidedAt,
|
|
1800
|
+
ref: reopened.ref,
|
|
1801
|
+
metadata: {
|
|
1802
|
+
proposalId: reopened.id,
|
|
1803
|
+
source: reopened.source,
|
|
1804
|
+
...(reopened.sourceRun !== undefined ? { sourceRun: reopened.sourceRun } : {}),
|
|
1805
|
+
...(options.reason !== undefined ? { reason: options.reason } : {}),
|
|
1806
|
+
},
|
|
1807
|
+
idempotencyKey: `${reopened.id}:reopened:${decidedAt}`,
|
|
1808
|
+
});
|
|
1809
|
+
return reopened;
|
|
1810
|
+
});
|
|
1811
|
+
}));
|
|
1812
|
+
}
|
|
1590
1813
|
/** The proposal against the asset its accept would overwrite (same target resolution as accept). */
|
|
1591
1814
|
export function diffProposal(stashDir, config, id, options = {}, ctx) {
|
|
1592
1815
|
const proposal = getProposal(stashDir, id, ctx);
|
|
1593
1816
|
const target = resolveProposalWriteTarget(config, proposal, options.target, options.queueTarget);
|
|
1594
1817
|
const targetPath = resolveAssetFilePathSafe(target.source, parseRefInput(proposal.ref));
|
|
1595
1818
|
const existing = targetPath && fs.existsSync(targetPath) ? fs.readFileSync(targetPath, "utf8") : null;
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1819
|
+
if (isRetireProposal(proposal)) {
|
|
1820
|
+
// A retire proposal's primary change deletes its target rather than
|
|
1821
|
+
// writing content (proposalContent() has nothing to read for a delete):
|
|
1822
|
+
// accept archives the file, it never replaces it with a blank one, so the
|
|
1823
|
+
// diff shows the file leaving — not a "proposed" side (#997).
|
|
1824
|
+
return {
|
|
1825
|
+
existing,
|
|
1826
|
+
proposed: "",
|
|
1827
|
+
unified: formatRetireDiff(proposal.ref, existing, proposal.retirement?.successorRef),
|
|
1828
|
+
isNew: false,
|
|
1829
|
+
...(targetPath ? { targetPath } : {}),
|
|
1830
|
+
};
|
|
1831
|
+
}
|
|
1832
|
+
const proposed = proposalContent(proposal);
|
|
1601
1833
|
return {
|
|
1602
1834
|
existing,
|
|
1603
1835
|
proposed,
|