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.
- package/dist/cli/clack.js +56 -0
- package/dist/cli/confirm.js +1 -1
- package/dist/cli.js +5 -3
- package/dist/commands/agent/contribute-cli.js +2 -3
- package/dist/commands/env/env-cli.js +187 -202
- package/dist/commands/env/secret-cli.js +109 -121
- package/dist/commands/feedback-cli.js +152 -155
- package/dist/commands/health/advisories.js +151 -0
- package/dist/commands/health/html-report.js +33 -10
- package/dist/commands/health/improve-metrics.js +754 -0
- package/dist/commands/health/llm-usage.js +65 -0
- package/dist/commands/health/md-report.js +103 -0
- package/dist/commands/health/metrics.js +278 -0
- package/dist/commands/health/task-runs.js +135 -0
- package/dist/commands/health/types.js +18 -0
- package/dist/commands/health/windows.js +196 -0
- package/dist/commands/health.js +15 -1492
- package/dist/commands/improve/anti-collapse.js +170 -0
- package/dist/commands/improve/collapse-detector.js +3 -2
- package/dist/commands/improve/consolidate.js +636 -633
- package/dist/commands/improve/dedup.js +1 -1
- package/dist/commands/improve/distill/content-repair.js +202 -0
- package/dist/commands/improve/distill/promote-memory.js +228 -0
- package/dist/commands/improve/distill/quality-gate.js +233 -0
- package/dist/commands/improve/distill-guards.js +127 -0
- package/dist/commands/improve/distill.js +49 -575
- package/dist/commands/improve/extract-cli.js +74 -76
- package/dist/commands/improve/extract.js +6 -4
- package/dist/commands/improve/hot-probation.js +45 -0
- package/dist/commands/improve/improve-auto-accept.js +3 -2
- package/dist/commands/improve/improve-cli.js +14 -13
- package/dist/commands/improve/improve-result-file.js +2 -1
- package/dist/commands/improve/improve.js +6 -5
- package/dist/commands/improve/loop-stages.js +19 -21
- package/dist/commands/improve/outcome-loop.js +18 -16
- package/dist/commands/improve/preparation.js +23 -5
- package/dist/commands/improve/procedural.js +10 -31
- package/dist/commands/improve/recombine.js +19 -43
- package/dist/commands/improve/reflect.js +1 -1
- package/dist/commands/improve/schema-similarity-gate.js +168 -0
- package/dist/commands/improve/shared.js +48 -0
- package/dist/commands/observability-cli.js +4 -4
- package/dist/commands/proposal/drain-policies.js +2 -2
- package/dist/commands/proposal/drain.js +1 -1
- package/dist/commands/proposal/legacy-import.js +115 -0
- package/dist/commands/proposal/proposal-cli.js +3 -3
- package/dist/commands/proposal/proposal.js +2 -1
- package/dist/commands/proposal/propose.js +1 -1
- package/dist/commands/proposal/repository.js +829 -0
- package/dist/commands/proposal/validators/proposals.js +5 -920
- package/dist/commands/read/curate.js +4 -4
- package/dist/commands/read/remember-cli.js +132 -137
- package/dist/commands/read/search-cli.js +7 -5
- package/dist/commands/read/search.js +7 -3
- package/dist/commands/read/show.js +3 -5
- package/dist/commands/registry-cli.js +76 -87
- package/dist/commands/sources/add-cli.js +91 -95
- package/dist/commands/sources/history.js +1 -1
- package/dist/commands/sources/init.js +12 -0
- package/dist/commands/sources/schema-repair.js +1 -1
- package/dist/commands/sources/sources-cli.js +3 -3
- package/dist/commands/sources/stash-cli.js +2 -2
- package/dist/commands/tasks/default-tasks.js +12 -0
- package/dist/commands/tasks/tasks-cli.js +1 -2
- package/dist/commands/wiki-cli.js +2 -3
- package/dist/core/common.js +3 -3
- package/dist/core/config/config-schema.js +6 -0
- package/dist/core/config/config.js +12 -0
- package/dist/core/deep-merge.js +38 -0
- package/dist/core/events.js +2 -1
- package/dist/core/logs-db.js +8 -13
- package/dist/core/paths.js +14 -14
- package/dist/core/state-db.js +13 -1140
- package/dist/core/warn.js +21 -0
- package/dist/indexer/db/db.js +72 -709
- package/dist/indexer/db/entry-mapper.js +41 -0
- package/dist/indexer/db/schema.js +516 -0
- package/dist/indexer/ensure-index.js +3 -2
- package/dist/indexer/feedback/utility-policy.js +85 -0
- package/dist/indexer/graph/graph-extraction.js +2 -1
- package/dist/indexer/index-writer-lock.js +18 -0
- package/dist/indexer/indexer.js +94 -27
- package/dist/indexer/read-preflight.js +23 -0
- package/dist/indexer/search/fts-query.js +51 -0
- package/dist/indexer/walk/walker.js +21 -13
- package/dist/integrations/agent/detect.js +9 -0
- package/dist/integrations/agent/index.js +1 -1
- package/dist/integrations/agent/spawn.js +15 -66
- package/dist/llm/client.js +12 -0
- package/dist/llm/embedder.js +26 -2
- package/dist/llm/embedders/local.js +7 -1
- package/dist/output/text/helpers.js +13 -0
- package/dist/scripts/migrate-storage.js +6903 -7424
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +49 -44
- package/dist/setup/detect.js +9 -0
- package/dist/setup/legacy-config.js +106 -0
- package/dist/setup/prompt.js +57 -0
- package/dist/setup/providers.js +14 -0
- package/dist/setup/registry-stash-loader.js +12 -0
- package/dist/setup/semantic-assets.js +124 -0
- package/dist/setup/setup.js +25 -1608
- package/dist/setup/steps/connection.js +734 -0
- package/dist/setup/steps/output.js +31 -0
- package/dist/setup/steps/platforms.js +124 -0
- package/dist/setup/steps/semantic.js +27 -0
- package/dist/setup/steps/sources.js +222 -0
- package/dist/setup/steps/stashdir.js +42 -0
- package/dist/setup/steps/tasks.js +152 -0
- package/dist/storage/repositories/canaries-repository.js +107 -0
- package/dist/storage/repositories/consolidation-repository.js +38 -0
- package/dist/storage/repositories/embeddings-repository.js +72 -0
- package/dist/storage/repositories/events-repository.js +187 -0
- package/dist/storage/repositories/extract-sessions-repository.js +96 -0
- package/dist/storage/repositories/improve-runs-repository.js +130 -0
- package/dist/storage/repositories/index-db.js +4 -7
- package/dist/storage/repositories/proposals-repository.js +220 -0
- package/dist/storage/repositories/recombine-repository.js +213 -0
- package/dist/storage/repositories/task-history-repository.js +93 -0
- package/dist/storage/sqlite-pragmas.js +3 -3
- package/dist/tasks/backends/index.js +9 -0
- package/dist/tasks/runner.js +11 -1
- package/package.json +2 -2
- 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
|
|
5
|
+
* Proposal validation and content repair.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
}
|