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