akm-cli 0.7.5 → 0.8.0-rc.6
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/{.github/CHANGELOG.md → CHANGELOG.md} +113 -2
- package/README.md +20 -4
- package/SECURITY.md +93 -0
- package/dist/cli/config-migrate.js +144 -0
- package/dist/cli/config-validate.js +39 -0
- package/dist/cli/confirm.js +73 -0
- package/dist/cli/parse-args.js +133 -0
- package/dist/cli.js +1995 -551
- package/dist/commands/agent-dispatch.js +110 -0
- package/dist/commands/agent-support.js +68 -0
- package/dist/commands/completions.js +3 -0
- package/dist/commands/config-cli.js +130 -534
- package/dist/commands/consolidate.js +1531 -0
- package/dist/commands/curate.js +44 -3
- package/dist/commands/db-cli.js +23 -0
- package/dist/commands/distill-promotion-policy.js +660 -0
- package/dist/commands/distill.js +990 -75
- package/dist/commands/eval-cases.js +43 -0
- package/dist/commands/events.js +5 -23
- package/dist/commands/graph.js +477 -0
- package/dist/commands/health.js +400 -0
- package/dist/commands/help/help-accept.md +9 -0
- package/dist/commands/help/help-improve.md +77 -0
- package/dist/commands/help/help-proposals.md +15 -0
- package/dist/commands/help/help-propose.md +17 -0
- package/dist/commands/help/help-reject.md +8 -0
- package/dist/commands/history.js +54 -46
- package/dist/commands/improve-profiles.js +146 -0
- package/dist/commands/improve-result-file.js +103 -0
- package/dist/commands/improve.js +2175 -0
- package/dist/commands/info.js +5 -2
- package/dist/commands/init.js +50 -2
- package/dist/commands/installed-stashes.js +102 -139
- package/dist/commands/knowledge.js +136 -0
- package/dist/commands/lint/agent-linter.js +49 -0
- package/dist/commands/lint/base-linter.js +479 -0
- package/dist/commands/lint/command-linter.js +49 -0
- package/dist/commands/lint/default-linter.js +16 -0
- package/dist/commands/lint/index.js +183 -0
- package/dist/commands/lint/knowledge-linter.js +16 -0
- package/dist/commands/lint/markdown-insertion.js +343 -0
- package/dist/commands/lint/memory-linter.js +61 -0
- package/dist/commands/lint/registry.js +36 -0
- package/dist/commands/lint/skill-linter.js +45 -0
- package/dist/commands/lint/task-linter.js +50 -0
- package/dist/commands/lint/types.js +4 -0
- package/dist/commands/lint/vault-key-rules.js +139 -0
- package/dist/commands/lint/workflow-linter.js +56 -0
- package/dist/commands/lint.js +4 -0
- package/dist/commands/migration-help.js +5 -2
- package/dist/commands/proposal.js +66 -12
- package/dist/commands/propose.js +86 -31
- package/dist/commands/reflect.js +1119 -73
- package/dist/commands/registry-search.js +5 -2
- package/dist/commands/remember.js +69 -6
- package/dist/commands/schema-repair.js +203 -0
- package/dist/commands/search.js +115 -14
- package/dist/commands/self-update.js +3 -0
- package/dist/commands/show.js +144 -25
- package/dist/commands/source-add.js +17 -45
- package/dist/commands/source-clone.js +3 -0
- package/dist/commands/source-manage.js +14 -19
- package/dist/commands/tasks.js +438 -0
- package/dist/commands/url-checker.js +42 -0
- package/dist/commands/vault.js +130 -77
- package/dist/core/action-contributors.js +28 -0
- package/dist/core/asset-ref.js +7 -0
- package/dist/core/asset-registry.js +7 -16
- package/dist/core/asset-serialize.js +88 -0
- package/dist/core/asset-spec.js +22 -0
- package/dist/core/common.js +157 -0
- package/dist/core/concurrent.js +25 -0
- package/dist/core/config-io.js +347 -0
- package/dist/core/config-migration.js +625 -0
- package/dist/core/config-schema.js +501 -0
- package/dist/core/config-sources.js +108 -0
- package/dist/core/config-types.js +4 -0
- package/dist/core/config-walker.js +337 -0
- package/dist/core/config.js +327 -987
- package/dist/core/errors.js +40 -19
- package/dist/core/events.js +91 -138
- package/dist/core/file-lock.js +104 -0
- package/dist/core/frontmatter.js +3 -6
- package/dist/core/lesson-lint.js +3 -0
- package/dist/core/markdown.js +20 -0
- package/dist/core/memory-belief.js +62 -0
- package/dist/core/memory-contradiction-detect.js +274 -0
- package/dist/core/memory-improve.js +806 -0
- package/dist/core/parse.js +158 -0
- package/dist/core/paths.js +326 -14
- package/dist/core/proposal-quality-validators.js +364 -0
- package/dist/core/proposal-validators.js +69 -0
- package/dist/core/proposals.js +498 -42
- package/dist/core/state-db.js +927 -0
- package/dist/core/text-truncation.js +107 -0
- package/dist/core/time.js +54 -0
- package/dist/core/warn.js +62 -1
- package/dist/core/write-source.js +3 -0
- package/dist/indexer/db-backup.js +391 -0
- package/dist/indexer/db-search.js +152 -253
- package/dist/indexer/db.js +933 -103
- package/dist/indexer/ensure-index.js +64 -0
- package/dist/indexer/file-context.js +3 -0
- package/dist/indexer/graph-boost.js +376 -101
- package/dist/indexer/graph-db.js +391 -0
- package/dist/indexer/graph-dedup.js +95 -0
- package/dist/indexer/graph-extraction.js +550 -124
- package/dist/indexer/index-context.js +4 -0
- package/dist/indexer/indexer.js +506 -291
- package/dist/indexer/llm-cache.js +47 -0
- package/dist/indexer/manifest.js +3 -0
- package/dist/indexer/matchers.js +148 -160
- package/dist/indexer/memory-inference.js +99 -74
- package/dist/indexer/metadata-contributors.js +29 -0
- package/dist/indexer/metadata.js +255 -196
- package/dist/indexer/path-resolver.js +92 -0
- package/dist/indexer/project-context.js +192 -0
- package/dist/indexer/ranking-contributors.js +331 -0
- package/dist/indexer/ranking.js +81 -0
- package/dist/indexer/search-fields.js +5 -9
- package/dist/indexer/search-hit-enrichers.js +111 -0
- package/dist/indexer/search-source.js +44 -10
- package/dist/indexer/semantic-status.js +5 -16
- package/dist/indexer/staleness-detect.js +447 -0
- package/dist/indexer/usage-events.js +12 -9
- package/dist/indexer/walker.js +28 -0
- package/dist/integrations/agent/builders.js +135 -0
- package/dist/integrations/agent/config.js +122 -230
- package/dist/integrations/agent/detect.js +3 -0
- package/dist/integrations/agent/index.js +7 -13
- package/dist/integrations/agent/model-aliases.js +55 -0
- package/dist/integrations/agent/profiles.js +70 -5
- package/dist/integrations/agent/prompts.js +150 -74
- package/dist/integrations/agent/runner.js +151 -0
- package/dist/integrations/agent/sdk-runner.js +126 -0
- package/dist/integrations/agent/spawn.js +118 -23
- package/dist/integrations/github.js +3 -0
- package/dist/integrations/lockfile.js +32 -69
- package/dist/integrations/session-logs/index.js +68 -0
- package/dist/integrations/session-logs/providers/claude-code.js +59 -0
- package/dist/integrations/session-logs/providers/opencode.js +55 -0
- package/dist/integrations/session-logs/types.js +4 -0
- package/dist/llm/call-ai.js +62 -0
- package/dist/llm/client.js +72 -124
- package/dist/llm/embedder.js +3 -19
- package/dist/llm/embedders/cache.js +3 -7
- package/dist/llm/embedders/local.js +3 -0
- package/dist/llm/embedders/remote.js +20 -8
- package/dist/llm/embedders/types.js +3 -7
- package/dist/llm/feature-gate.js +89 -48
- package/dist/llm/graph-extract.js +676 -70
- package/dist/llm/index-passes.js +9 -23
- package/dist/llm/memory-infer.js +52 -71
- package/dist/llm/metadata-enhance.js +42 -29
- package/dist/llm/prompts/graph-extract-user-prompt.md +35 -0
- package/dist/output/cli-hints-full.md +281 -0
- package/dist/output/cli-hints-short.md +65 -0
- package/dist/output/cli-hints.js +5 -318
- package/dist/output/context.js +3 -0
- package/dist/output/renderers.js +223 -256
- package/dist/output/shapes.js +150 -105
- package/dist/output/text.js +318 -30
- package/dist/registry/build-index.js +3 -0
- package/dist/registry/create-provider-registry.js +3 -0
- package/dist/registry/factory.js +3 -0
- package/dist/registry/origin-resolve.js +3 -0
- package/dist/registry/providers/index.js +3 -0
- package/dist/registry/providers/skills-sh.js +70 -49
- package/dist/registry/providers/static-index.js +53 -48
- package/dist/registry/providers/types.js +3 -24
- package/dist/registry/resolve.js +11 -16
- package/dist/registry/types.js +3 -0
- package/dist/scripts/migrate-storage.js +17307 -0
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +8900 -0
- package/dist/scripts/migrations/v16-to-v17.js +141 -0
- package/dist/setup/detect.js +3 -0
- package/dist/setup/ripgrep-install.js +3 -0
- package/dist/setup/ripgrep-resolve.js +3 -0
- package/dist/setup/setup.js +775 -37
- package/dist/setup/steps.js +3 -15
- package/dist/sources/include.js +3 -0
- package/dist/sources/provider-factory.js +5 -12
- package/dist/sources/provider.js +3 -20
- package/dist/sources/providers/filesystem.js +19 -23
- package/dist/sources/providers/git.js +7 -5
- package/dist/sources/providers/index.js +3 -0
- package/dist/sources/providers/install-types.js +3 -13
- package/dist/sources/providers/npm.js +3 -4
- package/dist/sources/providers/provider-utils.js +3 -0
- package/dist/sources/providers/sync-from-ref.js +3 -11
- package/dist/sources/providers/tar-utils.js +3 -0
- package/dist/sources/providers/website.js +18 -22
- package/dist/sources/resolve.js +3 -0
- package/dist/sources/types.js +3 -0
- package/dist/sources/website-ingest.js +7 -0
- package/dist/tasks/backends/cron.js +203 -0
- package/dist/tasks/backends/exec-utils.js +28 -0
- package/dist/tasks/backends/index.js +24 -0
- package/dist/tasks/backends/launchd-template.xml +19 -0
- package/dist/tasks/backends/launchd.js +187 -0
- package/dist/tasks/backends/schtasks-template.xml +29 -0
- package/dist/tasks/backends/schtasks.js +215 -0
- package/dist/tasks/parser.js +211 -0
- package/dist/tasks/resolveAkmBin.js +87 -0
- package/dist/tasks/runner.js +458 -0
- package/dist/tasks/schedule.js +211 -0
- package/dist/tasks/schema.js +15 -0
- package/dist/tasks/validator.js +62 -0
- package/dist/version.js +3 -0
- package/dist/wiki/index-template.md +12 -0
- package/dist/wiki/ingest-workflow-template.md +54 -0
- package/dist/wiki/log-template.md +8 -0
- package/dist/wiki/schema-template.md +61 -0
- package/dist/wiki/wiki-templates.js +15 -0
- package/dist/wiki/wiki.js +13 -61
- package/dist/workflows/authoring.js +8 -25
- package/dist/workflows/cli.js +3 -0
- package/dist/workflows/db.js +140 -10
- package/dist/workflows/document-cache.js +3 -10
- package/dist/workflows/parser.js +3 -0
- package/dist/workflows/renderer.js +11 -3
- package/dist/workflows/runs.js +62 -91
- package/dist/workflows/schema.js +3 -0
- package/dist/workflows/scope-key.js +3 -0
- package/dist/workflows/validator.js +4 -8
- package/dist/workflows/workflow-template.md +24 -0
- package/docs/README.md +9 -2
- package/docs/data-and-telemetry.md +225 -0
- package/docs/migration/release-notes/0.7.0.md +1 -1
- package/docs/migration/release-notes/0.7.5.md +2 -2
- package/docs/migration/release-notes/0.8.0.md +48 -0
- package/docs/migration/v0.7-to-v0.8.md +1307 -0
- package/package.json +20 -8
- package/.github/LICENSE +0 -374
- package/dist/commands/install-audit.js +0 -381
- package/dist/templates/wiki-templates.js +0 -100
package/dist/core/proposals.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
1
4
|
/**
|
|
2
5
|
* Proposal substrate (#225).
|
|
3
6
|
*
|
|
@@ -33,15 +36,99 @@
|
|
|
33
36
|
* that bypasses `writeAssetToSource` for "stash-adjacent" durable state. See
|
|
34
37
|
* CLAUDE.md ("Writes" section) for the contract.
|
|
35
38
|
*/
|
|
36
|
-
import { randomUUID } from "node:crypto";
|
|
39
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
37
40
|
import fs from "node:fs";
|
|
38
41
|
import path from "node:path";
|
|
39
42
|
import { makeAssetRef, parseAssetRef } from "./asset-ref";
|
|
40
43
|
import { resolveAssetPathFromName, TYPE_DIRS } from "./asset-spec";
|
|
41
44
|
import { NotFoundError, UsageError } from "./errors";
|
|
42
|
-
import {
|
|
43
|
-
import {
|
|
45
|
+
import { appendEvent } from "./events";
|
|
46
|
+
import { runProposalValidators } from "./proposal-validators";
|
|
47
|
+
import { warn } from "./warn";
|
|
44
48
|
import { resolveWriteTarget, writeAssetToSource } from "./write-source";
|
|
49
|
+
// ── Source allow-list (F-4 / #385) ──────────────────────────────────────────
|
|
50
|
+
/**
|
|
51
|
+
* Curated allow-list of valid `source` values for proposals (F-4 / #385).
|
|
52
|
+
*
|
|
53
|
+
* Rationale (W3C PROV-DM 2013): Provenance records require typed, validated
|
|
54
|
+
* sources for meaningful aggregation. Accept-rate-per-source is the core
|
|
55
|
+
* self-measurement metric for recursive self-improvement: if reflect proposals
|
|
56
|
+
* are accepted at 20% and distill proposals at 60%, that guides resource
|
|
57
|
+
* allocation. Free-text typos (`"reflct"`) produce unaggregatable events.
|
|
58
|
+
*
|
|
59
|
+
* Automated sources (those in {@link AUTOMATED_PROPOSAL_SOURCES}) require a
|
|
60
|
+
* `sourceRun` field for full PROV-DM traceability.
|
|
61
|
+
*/
|
|
62
|
+
export const PROPOSAL_SOURCES = [
|
|
63
|
+
// Automated sources — require sourceRun for traceability.
|
|
64
|
+
"reflect",
|
|
65
|
+
"distill",
|
|
66
|
+
"consolidate",
|
|
67
|
+
"improve",
|
|
68
|
+
// Semi-automated / tool-driven.
|
|
69
|
+
"feedback",
|
|
70
|
+
// Human-initiated / CLI-driven.
|
|
71
|
+
"propose",
|
|
72
|
+
"remember",
|
|
73
|
+
"import",
|
|
74
|
+
// Internal / system.
|
|
75
|
+
"distill_quality_rejected",
|
|
76
|
+
"schema-repair",
|
|
77
|
+
];
|
|
78
|
+
/** Automated sources that SHOULD include a `sourceRun` for PROV-DM traceability. */
|
|
79
|
+
export const AUTOMATED_PROPOSAL_SOURCES = [
|
|
80
|
+
"reflect",
|
|
81
|
+
"distill",
|
|
82
|
+
"consolidate",
|
|
83
|
+
"improve",
|
|
84
|
+
"schema-repair",
|
|
85
|
+
];
|
|
86
|
+
/**
|
|
87
|
+
* Check whether a string is a valid {@link ProposalSource}.
|
|
88
|
+
* Unknown source values are accepted with a runtime warning rather than a hard
|
|
89
|
+
* error, to allow extensions without breaking existing callers.
|
|
90
|
+
*/
|
|
91
|
+
export function isValidProposalSource(source) {
|
|
92
|
+
return PROPOSAL_SOURCES.includes(source);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Check whether a source value is an automated source requiring `sourceRun`.
|
|
96
|
+
*/
|
|
97
|
+
export function isAutomatedProposalSource(source) {
|
|
98
|
+
return AUTOMATED_PROPOSAL_SOURCES.includes(source);
|
|
99
|
+
}
|
|
100
|
+
/** Type guard: true when createProposal returned a skipped record. */
|
|
101
|
+
export function isProposalSkipped(result) {
|
|
102
|
+
return result.skipped === true;
|
|
103
|
+
}
|
|
104
|
+
// ── Dedup / cooldown constants ───────────────────────────────────────────────
|
|
105
|
+
const MS_PER_DAY = 86_400_000;
|
|
106
|
+
/**
|
|
107
|
+
* Post-rejection cooldown windows by source. After a proposal is rejected,
|
|
108
|
+
* `createProposal` silently skips new proposals for the same `ref+source`
|
|
109
|
+
* until the window expires (unless `force: true` is passed).
|
|
110
|
+
*
|
|
111
|
+
* Rationale (Settles 2009 active-learning survey; Argilla/Label Studio HITL):
|
|
112
|
+
* Reviewer fatigue is a blocker for the human-in-the-loop guarantee. Cooldowns
|
|
113
|
+
* prevent nightly improve runs from re-flooding the queue with near-identical
|
|
114
|
+
* proposals the reviewer just declined.
|
|
115
|
+
*
|
|
116
|
+
* - reflect: 14 days (agent-based; slower feedback loops)
|
|
117
|
+
* - distill: 30 days (LLM-based; even more prone to regeneration loops)
|
|
118
|
+
* - default: 7 days (conservative fallback for other sources)
|
|
119
|
+
*/
|
|
120
|
+
const COOLDOWN_MS = {
|
|
121
|
+
reflect: 14 * MS_PER_DAY,
|
|
122
|
+
distill: 30 * MS_PER_DAY,
|
|
123
|
+
};
|
|
124
|
+
const DEFAULT_COOLDOWN_MS = 7 * MS_PER_DAY;
|
|
125
|
+
function cooldownMsForSource(source) {
|
|
126
|
+
return COOLDOWN_MS[source] ?? DEFAULT_COOLDOWN_MS;
|
|
127
|
+
}
|
|
128
|
+
/** Compute a stable SHA-256 hex digest of a proposal's content string. */
|
|
129
|
+
function contentHash(content) {
|
|
130
|
+
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
131
|
+
}
|
|
45
132
|
// ── Path helpers ────────────────────────────────────────────────────────────
|
|
46
133
|
/**
|
|
47
134
|
* Resolve `<stashRoot>/.akm/proposals` (or its archive subdirectory). Direct
|
|
@@ -94,14 +181,140 @@ function writeProposalFile(filePath, proposal) {
|
|
|
94
181
|
/**
|
|
95
182
|
* Create a new pending proposal. The id is a stable random UUID, so two
|
|
96
183
|
* proposals with the same `ref` never collide on disk.
|
|
184
|
+
*
|
|
185
|
+
* **Dedup / cooldown guard** (F-2 / #363):
|
|
186
|
+
*
|
|
187
|
+
* Before writing, this function checks:
|
|
188
|
+
* 1. `duplicate_pending` — a pending proposal already exists for the same
|
|
189
|
+
* `ref+source`. Pass `input.force = true` to bypass.
|
|
190
|
+
* 2. `content_hash_match` — an identical content hash is already pending or
|
|
191
|
+
* was recently rejected for this `ref+source`. Bypass with `force: true`.
|
|
192
|
+
* 3. `cooldown` — a proposal for this `ref+source` was rejected within the
|
|
193
|
+
* source-specific cooldown window (reflect: 14 d, distill: 30 d,
|
|
194
|
+
* others: 7 d). Bypass with `force: true`.
|
|
195
|
+
*
|
|
196
|
+
* When a guard fires the function returns a `CreateProposalSkipped` record
|
|
197
|
+
* instead of writing to disk. Use {@link isProposalSkipped} to detect it.
|
|
97
198
|
*/
|
|
98
199
|
export function createProposal(stashDir, input, ctx) {
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
200
|
+
// F-4 / #385: Validate source against the allow-list. Unknown values are
|
|
201
|
+
// warned (not rejected) for backward compatibility — extension callers
|
|
202
|
+
// that pass custom source strings must not break.
|
|
203
|
+
if (!isValidProposalSource(input.source)) {
|
|
204
|
+
warn(`[proposal] Unknown source "${input.source}". ` +
|
|
205
|
+
`Expected one of: ${PROPOSAL_SOURCES.join(", ")}. ` +
|
|
206
|
+
"Typos in source values produce unaggregatable accept-rate-per-source metrics.");
|
|
207
|
+
}
|
|
208
|
+
else if (isAutomatedProposalSource(input.source) && !input.sourceRun) {
|
|
209
|
+
// Advisory warning: automated sources should include sourceRun for PROV-DM
|
|
210
|
+
// traceability. This is not a hard error to avoid breaking existing callers.
|
|
211
|
+
warn(`[proposal] Automated source "${input.source}" created a proposal without sourceRun. ` +
|
|
212
|
+
"Add sourceRun to enable accept-rate-per-run aggregation (W3C PROV-DM).");
|
|
213
|
+
}
|
|
214
|
+
// Deterministic input validation. Reject obviously-invalid proposals at
|
|
215
|
+
// the source rather than letting them enter the queue and waste reviewer
|
|
216
|
+
// time. Each rejection emits `proposal_creation_rejected` with a typed
|
|
217
|
+
// reason so we can see *which* check is firing in the event stream.
|
|
218
|
+
const rejectProposal = (reason, message) => {
|
|
219
|
+
appendEvent({
|
|
220
|
+
eventType: "proposal_creation_rejected",
|
|
221
|
+
ref: input.ref,
|
|
222
|
+
metadata: { source: input.source, reason },
|
|
223
|
+
});
|
|
224
|
+
throw new UsageError(message, "INVALID_PROPOSAL");
|
|
225
|
+
};
|
|
226
|
+
let parsedRef;
|
|
227
|
+
try {
|
|
228
|
+
parsedRef = parseAssetRef(input.ref);
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
return rejectProposal("invalid_ref", `Invalid proposal ref "${input.ref}": ${err instanceof Error ? err.message : String(err)}`);
|
|
232
|
+
}
|
|
233
|
+
if (!TYPE_DIRS[parsedRef.type]) {
|
|
234
|
+
return rejectProposal("unknown_type", `Unknown asset type "${parsedRef.type}" in proposal ref "${input.ref}". Known types: ${Object.keys(TYPE_DIRS).sort().join(", ")}.`);
|
|
235
|
+
}
|
|
236
|
+
if (!input.payload.content.trim()) {
|
|
237
|
+
return rejectProposal("empty_content", `Proposal for "${input.ref}" has empty content.`);
|
|
238
|
+
}
|
|
239
|
+
// Description check is only enforced for `consolidate` source — that's the
|
|
240
|
+
// automated pipeline that historically produced proposals with missing or
|
|
241
|
+
// malformed frontmatter, polluting the queue with hundreds of unusable
|
|
242
|
+
// entries. Reflect / distill / propose proposals have varied legitimate
|
|
243
|
+
// shapes and should not be rejected here for missing description.
|
|
244
|
+
if (input.source === "consolidate") {
|
|
245
|
+
const desc = input.payload.frontmatter?.description;
|
|
246
|
+
if (typeof desc !== "string" || desc.trim() === "") {
|
|
247
|
+
return rejectProposal("missing_description", `Proposal for "${input.ref}" (source=consolidate) has empty or missing frontmatter description.`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
102
250
|
const normalizedRef = makeAssetRef(parsedRef.type, parsedRef.name, parsedRef.origin);
|
|
251
|
+
if (!input.force) {
|
|
252
|
+
const newHash = contentHash(input.payload.content);
|
|
253
|
+
const nowMs = (ctx?.now ?? Date.now)();
|
|
254
|
+
const cooldownMs = cooldownMsForSource(input.source);
|
|
255
|
+
// Scan pending proposals for ref+source matches.
|
|
256
|
+
const pending = listProposals(stashDir, { ref: normalizedRef, status: "pending" }).filter((p) => p.source === input.source);
|
|
257
|
+
if (pending.length > 0) {
|
|
258
|
+
// Check for identical content hash first (silent skip).
|
|
259
|
+
const hashMatch = pending.find((p) => contentHash(p.payload.content) === newHash);
|
|
260
|
+
if (hashMatch) {
|
|
261
|
+
return {
|
|
262
|
+
skipped: true,
|
|
263
|
+
reason: "content_hash_match",
|
|
264
|
+
message: `Identical proposal for ${normalizedRef} already pending (id: ${hashMatch.id}).`,
|
|
265
|
+
existingProposalId: hashMatch.id,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
// Duplicate pending for same ref+source (different content).
|
|
269
|
+
const firstPending = pending[0];
|
|
270
|
+
return {
|
|
271
|
+
skipped: true,
|
|
272
|
+
reason: "duplicate_pending",
|
|
273
|
+
message: `A pending proposal for ${normalizedRef} from source "${input.source}" already exists (id: ${firstPending?.id ?? "unknown"}). Pass force:true to enqueue alongside it.`,
|
|
274
|
+
existingProposalId: firstPending?.id,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
// Check cooldown against recently archived rejected proposals.
|
|
278
|
+
const rejected = listProposals(stashDir, { ref: normalizedRef, status: "rejected", includeArchive: true })
|
|
279
|
+
.filter((p) => p.source === input.source)
|
|
280
|
+
.sort((a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime());
|
|
281
|
+
if (rejected.length > 0 && rejected[0] !== undefined) {
|
|
282
|
+
const mostRecent = rejected[0];
|
|
283
|
+
// Check content hash against recently rejected.
|
|
284
|
+
if (contentHash(mostRecent.payload.content) === newHash) {
|
|
285
|
+
return {
|
|
286
|
+
skipped: true,
|
|
287
|
+
reason: "content_hash_match",
|
|
288
|
+
message: `Identical proposal for ${normalizedRef} was already rejected (id: ${mostRecent.id}).`,
|
|
289
|
+
existingProposalId: mostRecent.id,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
// Check cooldown window.
|
|
293
|
+
const rejectedAt = new Date(mostRecent.updatedAt ?? 0).getTime();
|
|
294
|
+
if (nowMs - rejectedAt < cooldownMs) {
|
|
295
|
+
const cooldownDays = cooldownMs / MS_PER_DAY;
|
|
296
|
+
const remainingDays = Math.ceil((cooldownMs - (nowMs - rejectedAt)) / MS_PER_DAY);
|
|
297
|
+
return {
|
|
298
|
+
skipped: true,
|
|
299
|
+
reason: "cooldown",
|
|
300
|
+
message: `Proposal for ${normalizedRef} from source "${input.source}" is in cooldown ` +
|
|
301
|
+
`(${cooldownDays}d window, ~${remainingDays}d remaining). Pass force:true to bypass.`,
|
|
302
|
+
existingProposalId: mostRecent.id,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
103
307
|
const id = newId(ctx);
|
|
104
308
|
const created = nowIso(ctx);
|
|
309
|
+
// Phase 6A: validate confidence is a finite number in [0, 1]. Anything else
|
|
310
|
+
// is dropped silently — we never store NaN, Infinity, or out-of-range values.
|
|
311
|
+
// Callers that mis-report confidence should not poison the auto-accept gate.
|
|
312
|
+
const sanitizedConfidence = typeof input.confidence === "number" &&
|
|
313
|
+
Number.isFinite(input.confidence) &&
|
|
314
|
+
input.confidence >= 0 &&
|
|
315
|
+
input.confidence <= 1
|
|
316
|
+
? input.confidence
|
|
317
|
+
: undefined;
|
|
105
318
|
const proposal = {
|
|
106
319
|
id,
|
|
107
320
|
ref: normalizedRef,
|
|
@@ -114,6 +327,7 @@ export function createProposal(stashDir, input, ctx) {
|
|
|
114
327
|
content: input.payload.content,
|
|
115
328
|
...(input.payload.frontmatter !== undefined ? { frontmatter: input.payload.frontmatter } : {}),
|
|
116
329
|
},
|
|
330
|
+
...(sanitizedConfidence !== undefined ? { confidence: sanitizedConfidence } : {}),
|
|
117
331
|
};
|
|
118
332
|
writeProposalFile(proposalFile(stashDir, id, false), proposal);
|
|
119
333
|
return proposal;
|
|
@@ -158,7 +372,7 @@ export function listProposals(stashDir, options = {}) {
|
|
|
158
372
|
out.push({
|
|
159
373
|
id: entry.name,
|
|
160
374
|
ref: "unknown:unknown",
|
|
161
|
-
status: "
|
|
375
|
+
status: "rejected",
|
|
162
376
|
source: "invalid",
|
|
163
377
|
createdAt: "",
|
|
164
378
|
updatedAt: "",
|
|
@@ -190,6 +404,53 @@ export function getProposal(stashDir, id) {
|
|
|
190
404
|
return readProposalFile(archivedPath);
|
|
191
405
|
throw new NotFoundError(`Proposal "${id}" not found.`, "FILE_NOT_FOUND");
|
|
192
406
|
}
|
|
407
|
+
/**
|
|
408
|
+
* Resolve a proposal by full UUID, UUID prefix, or asset ref.
|
|
409
|
+
*
|
|
410
|
+
* Resolution order:
|
|
411
|
+
* 1. Exact UUID match (existing behaviour).
|
|
412
|
+
* 2. Asset ref (contains `:`) — finds the most-recent pending proposal for
|
|
413
|
+
* that ref; falls back to archived if nothing is pending.
|
|
414
|
+
* 3. UUID prefix — matches any live proposal directory whose name starts
|
|
415
|
+
* with the given string; throws if ambiguous.
|
|
416
|
+
*/
|
|
417
|
+
export function resolveProposalId(stashDir, idOrRef) {
|
|
418
|
+
// 1. Exact UUID.
|
|
419
|
+
try {
|
|
420
|
+
return getProposal(stashDir, idOrRef);
|
|
421
|
+
}
|
|
422
|
+
catch (e) {
|
|
423
|
+
if (!(e instanceof NotFoundError))
|
|
424
|
+
throw e;
|
|
425
|
+
}
|
|
426
|
+
// 2. Asset ref (e.g. "skill:akm-dream").
|
|
427
|
+
if (idOrRef.includes(":")) {
|
|
428
|
+
const pending = listProposals(stashDir, { ref: idOrRef });
|
|
429
|
+
if (pending.length > 0) {
|
|
430
|
+
return pending.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime())[0];
|
|
431
|
+
}
|
|
432
|
+
const archived = listProposals(stashDir, { ref: idOrRef, includeArchive: true });
|
|
433
|
+
if (archived.length > 0) {
|
|
434
|
+
return archived.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime())[0];
|
|
435
|
+
}
|
|
436
|
+
throw new NotFoundError(`No proposal found for ref "${idOrRef}".`, "FILE_NOT_FOUND");
|
|
437
|
+
}
|
|
438
|
+
// 3. UUID prefix.
|
|
439
|
+
const liveDir = getProposalsRoot(stashDir, false);
|
|
440
|
+
let prefixMatches = [];
|
|
441
|
+
try {
|
|
442
|
+
prefixMatches = fs.readdirSync(liveDir).filter((name) => name.startsWith(idOrRef));
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
/* live dir may not exist yet */
|
|
446
|
+
}
|
|
447
|
+
if (prefixMatches.length === 1)
|
|
448
|
+
return getProposal(stashDir, prefixMatches[0]);
|
|
449
|
+
if (prefixMatches.length > 1) {
|
|
450
|
+
throw new UsageError(`Ambiguous prefix "${idOrRef}" — matches: ${prefixMatches.join(", ")}`, "INVALID_FLAG_VALUE");
|
|
451
|
+
}
|
|
452
|
+
throw new NotFoundError(`Proposal "${idOrRef}" not found.`, "FILE_NOT_FOUND");
|
|
453
|
+
}
|
|
193
454
|
/**
|
|
194
455
|
* Whether a proposal currently lives in the archive (used by callers that
|
|
195
456
|
* need to know whether to look in the archive root for files / paths).
|
|
@@ -241,49 +502,144 @@ export function archiveProposal(stashDir, id, status, reason, ctx) {
|
|
|
241
502
|
return updated;
|
|
242
503
|
}
|
|
243
504
|
/**
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
505
|
+
* Scan all pending proposals and reject those whose target asset no longer
|
|
506
|
+
* exists on disk across any of `sourceDirs`. Intended to run as a periodic
|
|
507
|
+
* maintenance pass (see `runImproveMaintenancePasses`) — it keeps the queue
|
|
508
|
+
* from accumulating stale reviewer work after large refactors or deletes.
|
|
509
|
+
*
|
|
510
|
+
* Scope rule: only `source=reflect` proposals are subject to orphan rejection.
|
|
511
|
+
* Lessons, propose, distill, and consolidate proposals legitimately target
|
|
512
|
+
* assets that don't exist yet and must never be purged.
|
|
249
513
|
*/
|
|
250
|
-
export function
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
514
|
+
export function purgeOrphanProposals(stashDir, sourceDirs, ctx) {
|
|
515
|
+
const t0 = Date.now();
|
|
516
|
+
const orphans = [];
|
|
517
|
+
const byType = {};
|
|
518
|
+
const pending = listProposals(stashDir, { status: "pending" });
|
|
519
|
+
const reflectPending = pending.filter((p) => p.source === "reflect");
|
|
520
|
+
for (const p of reflectPending) {
|
|
521
|
+
let parsed;
|
|
522
|
+
try {
|
|
523
|
+
parsed = parseAssetRef(p.ref);
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
// Lessons are new-asset proposals by definition — they cannot be orphaned.
|
|
529
|
+
if (parsed.type === "lesson")
|
|
530
|
+
continue;
|
|
531
|
+
const spec = TYPE_DIRS[parsed.type];
|
|
532
|
+
if (!spec)
|
|
533
|
+
continue;
|
|
534
|
+
const exists = sourceDirs.some((root) => {
|
|
535
|
+
const typeRoot = path.join(root, spec);
|
|
536
|
+
const candidate = resolveAssetPathFromName(parsed.type, typeRoot, parsed.name);
|
|
537
|
+
return fs.existsSync(candidate);
|
|
263
538
|
});
|
|
264
|
-
|
|
539
|
+
if (!exists) {
|
|
540
|
+
try {
|
|
541
|
+
archiveProposal(stashDir, p.id, "rejected", "Asset no longer exists on disk", ctx);
|
|
542
|
+
orphans.push({ id: p.id, ref: p.ref, reason: "asset_missing" });
|
|
543
|
+
byType[parsed.type] = (byType[parsed.type] ?? 0) + 1;
|
|
544
|
+
}
|
|
545
|
+
catch (err) {
|
|
546
|
+
// Best-effort — the purge is non-fatal. Log and continue.
|
|
547
|
+
warn(`[proposals] purgeOrphanProposals: failed to reject ${p.id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
265
550
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
551
|
+
return {
|
|
552
|
+
checked: reflectPending.length,
|
|
553
|
+
rejected: orphans.length,
|
|
554
|
+
durationMs: Date.now() - t0,
|
|
555
|
+
byType,
|
|
556
|
+
orphans,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Archive pending proposals older than `config.archiveRetentionDays` (Advantage
|
|
561
|
+
* D6b / Phase 6B).
|
|
562
|
+
*
|
|
563
|
+
* Reviewer fatigue and queue rot are the dominant failure modes of any
|
|
564
|
+
* human-in-the-loop pipeline (Settles 2009 active-learning survey). Pending
|
|
565
|
+
* proposals that have aged past the retention window are very rarely accepted
|
|
566
|
+
* — the reviewer either intentionally declined to act on them, or the asset
|
|
567
|
+
* they target has drifted enough that the proposal is no longer relevant.
|
|
568
|
+
* Auto-expiring them keeps the live queue focused on actionable work; the
|
|
569
|
+
* archive preserves the full audit trail.
|
|
570
|
+
*
|
|
571
|
+
* Each expired proposal is archived with status `rejected` and reason
|
|
572
|
+
* `"expired: no action within retention window"`. A `proposal_expired` event
|
|
573
|
+
* is appended for each expired proposal so downstream observability (events
|
|
574
|
+
* dashboards, source-acceptance-rate aggregations) can see expiry separately
|
|
575
|
+
* from explicit rejections.
|
|
576
|
+
*
|
|
577
|
+
* Idempotent: a second call within the same retention window finds nothing
|
|
578
|
+
* to expire (the archived entries are no longer in the pending queue).
|
|
579
|
+
*/
|
|
580
|
+
export function expireStaleProposals(stashDir, config, ctx) {
|
|
581
|
+
const t0 = Date.now();
|
|
582
|
+
const retentionDays = config.archiveRetentionDays ?? 90;
|
|
583
|
+
const expiredProposals = [];
|
|
584
|
+
// retentionDays === 0 disables TTL cleanup globally (mirrors how
|
|
585
|
+
// consolidate.ts interprets the same config value).
|
|
586
|
+
if (retentionDays <= 0) {
|
|
587
|
+
return {
|
|
588
|
+
checked: 0,
|
|
589
|
+
expired: 0,
|
|
590
|
+
durationMs: Date.now() - t0,
|
|
591
|
+
retentionDays,
|
|
592
|
+
expiredProposals,
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
const retentionMs = retentionDays * MS_PER_DAY;
|
|
596
|
+
const nowMs = (ctx?.now ?? Date.now)();
|
|
597
|
+
const pending = listProposals(stashDir, { status: "pending" });
|
|
598
|
+
for (const p of pending) {
|
|
599
|
+
const createdMs = new Date(p.createdAt).getTime();
|
|
600
|
+
if (!Number.isFinite(createdMs))
|
|
601
|
+
continue;
|
|
602
|
+
const ageMs = nowMs - createdMs;
|
|
603
|
+
if (ageMs < retentionMs)
|
|
604
|
+
continue;
|
|
269
605
|
try {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
606
|
+
archiveProposal(stashDir, p.id, "rejected", "expired: no action within retention window", ctx);
|
|
607
|
+
const ageDays = Math.floor(ageMs / MS_PER_DAY);
|
|
608
|
+
expiredProposals.push({ id: p.id, ref: p.ref, ageDays });
|
|
609
|
+
appendEvent({
|
|
610
|
+
eventType: "proposal_expired",
|
|
611
|
+
ref: p.ref,
|
|
612
|
+
metadata: {
|
|
613
|
+
proposalId: p.id,
|
|
614
|
+
source: p.source,
|
|
615
|
+
...(p.sourceRun !== undefined ? { sourceRun: p.sourceRun } : {}),
|
|
616
|
+
ageDays,
|
|
617
|
+
retentionDays,
|
|
618
|
+
},
|
|
276
619
|
});
|
|
277
620
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const lessonReport = lintLessonContent(proposal.payload.content, `proposal:${proposal.id}`);
|
|
282
|
-
for (const finding of lessonReport.findings) {
|
|
283
|
-
findings.push({ kind: finding.kind, message: finding.message });
|
|
621
|
+
catch (err) {
|
|
622
|
+
// Best-effort — a single failure must not block the pass.
|
|
623
|
+
warn(`[proposals] expireStaleProposals: failed to expire ${p.id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
284
624
|
}
|
|
285
625
|
}
|
|
286
|
-
return {
|
|
626
|
+
return {
|
|
627
|
+
checked: pending.length,
|
|
628
|
+
expired: expiredProposals.length,
|
|
629
|
+
durationMs: Date.now() - t0,
|
|
630
|
+
retentionDays,
|
|
631
|
+
expiredProposals,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Validate a proposal payload before promotion. Generic by default — any
|
|
636
|
+
* proposal must parse cleanly and carry a non-empty body. Lessons get the
|
|
637
|
+
* extra per-type lint from {@link lintLessonContent} so the contract documented
|
|
638
|
+
* in v1 spec §13 is enforced at promotion time. Other asset types can hook
|
|
639
|
+
* here in the future without changing call sites.
|
|
640
|
+
*/
|
|
641
|
+
export function validateProposal(proposal) {
|
|
642
|
+
return runProposalValidators(proposal);
|
|
287
643
|
}
|
|
288
644
|
/**
|
|
289
645
|
* Validate a proposal, then promote it through the canonical
|
|
@@ -291,6 +647,12 @@ export function validateProposal(proposal) {
|
|
|
291
647
|
* `source.kind`). On success the proposal directory is moved to the archive
|
|
292
648
|
* with status `accepted`. Validation failures throw a `UsageError` carrying
|
|
293
649
|
* every finding so the CLI can render a single clear error envelope.
|
|
650
|
+
*
|
|
651
|
+
* Phase 6C: when the target asset already exists at the resolved write path,
|
|
652
|
+
* a snapshot of the prior content is captured under
|
|
653
|
+
* `<proposalsRoot>/<id>/backup.<ext>` BEFORE the write. The relative path is
|
|
654
|
+
* recorded on the proposal record (`backup` field) so `akm proposal revert`
|
|
655
|
+
* can restore the prior content. Genuinely-new assets carry no backup.
|
|
294
656
|
*/
|
|
295
657
|
export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
|
|
296
658
|
const proposal = getProposal(stashDir, id);
|
|
@@ -307,10 +669,104 @@ export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
|
|
|
307
669
|
throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
|
|
308
670
|
}
|
|
309
671
|
const target = resolveWriteTarget(config, options.target);
|
|
672
|
+
// Phase 6C: capture a backup of the prior content (if any) BEFORE writing the
|
|
673
|
+
// new asset. We use the resolved write target to compute the exact path the
|
|
674
|
+
// asset would land at — same resolver `writeAssetToSource` uses — so the
|
|
675
|
+
// backup always mirrors what would be overwritten.
|
|
676
|
+
let backupRelPath;
|
|
677
|
+
try {
|
|
678
|
+
const targetFilePath = resolveAssetFilePathSafe(target.source, ref);
|
|
679
|
+
if (targetFilePath && fs.existsSync(targetFilePath)) {
|
|
680
|
+
const ext = path.extname(targetFilePath) || ".md";
|
|
681
|
+
const proposalRoot = proposalDir(stashDir, id, false);
|
|
682
|
+
// Store relative path on the proposal record so the directory remains
|
|
683
|
+
// portable if the stash is moved.
|
|
684
|
+
const backupFilename = `backup${ext}`;
|
|
685
|
+
const backupAbsPath = path.join(proposalRoot, backupFilename);
|
|
686
|
+
fs.mkdirSync(proposalRoot, { recursive: true });
|
|
687
|
+
// Use copyFileSync — file-system atomicity is sufficient here because the
|
|
688
|
+
// backup is single-file and never read concurrently with this write.
|
|
689
|
+
fs.copyFileSync(targetFilePath, backupAbsPath);
|
|
690
|
+
backupRelPath = backupFilename;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
catch (err) {
|
|
694
|
+
// Backup capture is best-effort. A failure here must not block promotion
|
|
695
|
+
// (the user explicitly asked to accept); we surface a warning so the
|
|
696
|
+
// missing-revert path is visible.
|
|
697
|
+
warn(`[proposals] promoteProposal: failed to capture backup for ${id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
698
|
+
}
|
|
310
699
|
const written = await writeAssetToSource(target.source, target.config, ref, proposal.payload.content);
|
|
311
700
|
const archived = archiveProposal(stashDir, id, "accepted", undefined, ctx);
|
|
701
|
+
// Persist the backup path on the archived proposal record. archiveProposal
|
|
702
|
+
// moves the proposal dir into the archive subtree, so the backup file moves
|
|
703
|
+
// with it (the relative path stays valid).
|
|
704
|
+
if (backupRelPath) {
|
|
705
|
+
const archivedFile = proposalFile(stashDir, id, true);
|
|
706
|
+
const withBackup = { ...archived, backup: backupRelPath };
|
|
707
|
+
writeProposalFile(archivedFile, withBackup);
|
|
708
|
+
return { proposal: withBackup, assetPath: written.path, ref: written.ref };
|
|
709
|
+
}
|
|
312
710
|
return { proposal: archived, assetPath: written.path, ref: written.ref };
|
|
313
711
|
}
|
|
712
|
+
/**
|
|
713
|
+
* Restore the prior content of an accepted proposal from its captured backup
|
|
714
|
+
* (Advantage D6c / Phase 6C).
|
|
715
|
+
*
|
|
716
|
+
* Pre-conditions:
|
|
717
|
+
* - `id` resolves to a proposal with `status === "accepted"`.
|
|
718
|
+
* - The proposal carries a `backup` field pointing to a readable file under
|
|
719
|
+
* the proposal directory.
|
|
720
|
+
*
|
|
721
|
+
* On success:
|
|
722
|
+
* - The backup content is written back through {@link writeAssetToSource},
|
|
723
|
+
* so the canonical write-dispatch invariant is preserved.
|
|
724
|
+
* - The archived proposal record is updated to `status: "reverted"`.
|
|
725
|
+
* - Caller emits a `proposal_reverted` event in the CLI layer (mirrors how
|
|
726
|
+
* `promoted` / `rejected` are emitted by the CLI command, not the core).
|
|
727
|
+
*
|
|
728
|
+
* Errors are thrown as `UsageError` / `NotFoundError` so the CLI can map them
|
|
729
|
+
* cleanly to exit codes — see `src/commands/proposal.ts` for the wrapper.
|
|
730
|
+
*/
|
|
731
|
+
export async function revertProposal(stashDir, config, id, options = {}, ctx) {
|
|
732
|
+
const proposal = getProposal(stashDir, id);
|
|
733
|
+
if (proposal.status !== "accepted") {
|
|
734
|
+
throw new UsageError(`only accepted proposals can be reverted (proposal ${id} status: ${proposal.status})`, "INVALID_FLAG_VALUE");
|
|
735
|
+
}
|
|
736
|
+
if (!proposal.backup) {
|
|
737
|
+
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.");
|
|
738
|
+
}
|
|
739
|
+
// The proposal directory has been moved to the archive subtree (archiveProposal
|
|
740
|
+
// runs at the end of promoteProposal). Reads must resolve against that path.
|
|
741
|
+
const proposalRoot = proposalDir(stashDir, id, true);
|
|
742
|
+
const backupAbsPath = path.join(proposalRoot, proposal.backup);
|
|
743
|
+
if (!fs.existsSync(backupAbsPath)) {
|
|
744
|
+
throw new NotFoundError(`no backup available for this proposal (id: ${id})`, "FILE_NOT_FOUND", `Expected backup file at ${backupAbsPath}; it may have been removed manually.`);
|
|
745
|
+
}
|
|
746
|
+
const backupContent = fs.readFileSync(backupAbsPath, "utf8");
|
|
747
|
+
const ref = parseAssetRef(proposal.ref);
|
|
748
|
+
if (!TYPE_DIRS[ref.type]) {
|
|
749
|
+
throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
|
|
750
|
+
}
|
|
751
|
+
const target = resolveWriteTarget(config, options.target);
|
|
752
|
+
const written = await writeAssetToSource(target.source, target.config, ref, backupContent);
|
|
753
|
+
// Update the archived proposal record to status: "reverted" and bump
|
|
754
|
+
// updatedAt + review so the audit trail reflects the second decision.
|
|
755
|
+
const archivedFile = proposalFile(stashDir, id, true);
|
|
756
|
+
const now = nowIso(ctx);
|
|
757
|
+
const reverted = {
|
|
758
|
+
...proposal,
|
|
759
|
+
status: "reverted",
|
|
760
|
+
updatedAt: now,
|
|
761
|
+
review: {
|
|
762
|
+
outcome: "rejected",
|
|
763
|
+
reason: "reverted: prior content restored from backup",
|
|
764
|
+
decidedAt: now,
|
|
765
|
+
},
|
|
766
|
+
};
|
|
767
|
+
writeProposalFile(archivedFile, reverted);
|
|
768
|
+
return { proposal: reverted, assetPath: written.path, ref: written.ref };
|
|
769
|
+
}
|
|
314
770
|
/**
|
|
315
771
|
* Compute a diff between a proposal payload and the existing on-disk asset.
|
|
316
772
|
* Uses {@link resolveWriteTarget} to find where the asset would land — so the
|