akm-cli 0.7.5 → 0.8.0-rc.11
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} +192 -2
- 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 +133 -0
- package/dist/cli/shared.js +129 -0
- package/dist/cli.js +2569 -1449
- package/dist/commands/add-cli.js +279 -0
- 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 +2122 -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 +1075 -77
- package/dist/commands/env.js +213 -0
- package/dist/commands/eval-cases.js +43 -0
- package/dist/commands/events.js +5 -23
- 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 +477 -0
- package/dist/commands/health.js +1302 -0
- package/dist/commands/help/help-accept.md +12 -0
- package/dist/commands/help/help-improve.md +69 -0
- package/dist/commands/help/help-proposals.md +18 -0
- package/dist/commands/help/help-propose.md +17 -0
- package/dist/commands/help/help-reject.md +11 -0
- package/dist/commands/history.js +54 -46
- package/dist/commands/improve-auto-accept.js +97 -0
- package/dist/commands/improve-cli.js +217 -0
- package/dist/commands/improve-profiles.js +166 -0
- package/dist/commands/improve-result-file.js +167 -0
- package/dist/commands/improve.js +2373 -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/env-key-rules.js +154 -0
- package/dist/commands/lint/index.js +196 -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/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 +67 -12
- package/dist/commands/propose.js +86 -31
- package/dist/commands/reflect.js +1091 -73
- package/dist/commands/registry-cli.js +150 -0
- package/dist/commands/registry-search.js +5 -2
- package/dist/commands/remember-cli.js +257 -0
- 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/secret.js +173 -0
- package/dist/commands/self-update.js +3 -0
- package/dist/commands/show.js +148 -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 +437 -0
- package/dist/commands/url-checker.js +42 -0
- package/dist/core/action-contributors.js +28 -0
- package/dist/core/asset-ref.js +17 -2
- package/dist/core/asset-registry.js +12 -17
- package/dist/core/asset-serialize.js +88 -0
- package/dist/core/asset-spec.js +67 -1
- package/dist/core/common.js +182 -0
- package/dist/core/concurrent.js +25 -0
- package/dist/core/config-io.js +347 -0
- package/dist/core/config-migration.js +622 -0
- package/dist/core/config-schema.js +534 -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 +364 -981
- package/dist/core/errors.js +42 -20
- package/dist/core/events.js +91 -138
- package/dist/core/file-lock.js +104 -0
- package/dist/core/frontmatter.js +75 -8
- 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 +280 -14
- package/dist/core/proposal-quality-validators.js +380 -0
- package/dist/core/proposal-validators.js +69 -0
- package/dist/core/proposals.js +512 -42
- package/dist/core/state-db.js +1068 -0
- package/dist/core/text-truncation.js +107 -0
- package/dist/core/time.js +54 -0
- package/dist/core/tty.js +59 -0
- package/dist/core/warn.js +64 -1
- package/dist/core/write-source.js +3 -0
- package/dist/indexer/db-backup.js +391 -0
- package/dist/indexer/db-search.js +178 -256
- package/dist/indexer/db.js +975 -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 +523 -301
- package/dist/indexer/llm-cache.js +52 -0
- package/dist/indexer/manifest.js +3 -0
- package/dist/indexer/matchers.js +167 -160
- package/dist/indexer/memory-inference.js +152 -74
- package/dist/indexer/metadata-contributors.js +29 -0
- package/dist/indexer/metadata.js +275 -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 +6 -17
- 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 +214 -80
- 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 +69 -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 +282 -0
- package/dist/integrations/session-logs/providers/opencode.js +258 -0
- package/dist/integrations/session-logs/types.js +4 -0
- package/dist/llm/call-ai.js +62 -0
- package/dist/llm/client.js +77 -124
- 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 +95 -48
- package/dist/llm/graph-extract.js +676 -70
- package/dist/llm/index-passes.js +44 -29
- package/dist/llm/memory-infer.js +77 -71
- package/dist/llm/metadata-enhance.js +42 -29
- package/dist/llm/prompts/extract-session.md +80 -0
- package/dist/llm/prompts/graph-extract-user-prompt.md +35 -0
- package/dist/output/cli-hints-full.md +292 -0
- package/dist/output/cli-hints-short.md +66 -0
- package/dist/output/cli-hints.js +7 -320
- package/dist/output/context.js +60 -8
- package/dist/output/renderers.js +300 -257
- 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 +102 -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 -516
- 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 +1039 -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 +11 -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 -1092
- 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 +71 -50
- 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 +17750 -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 +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 +138 -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 +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 +227 -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 +77 -92
- 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 +10 -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 +30 -12
- package/.github/LICENSE +0 -374
- package/dist/commands/install-audit.js +0 -381
- package/dist/commands/vault.js +0 -328
- 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,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";
|
|
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
|
+
"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
|
+
}
|
|
45
134
|
// ── Path helpers ────────────────────────────────────────────────────────────
|
|
46
135
|
/**
|
|
47
136
|
* Resolve `<stashRoot>/.akm/proposals` (or its archive subdirectory). Direct
|
|
@@ -94,14 +183,140 @@ function writeProposalFile(filePath, proposal) {
|
|
|
94
183
|
/**
|
|
95
184
|
* Create a new pending proposal. The id is a stable random UUID, so two
|
|
96
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.
|
|
97
200
|
*/
|
|
98
201
|
export function createProposal(stashDir, input, ctx) {
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
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
|
+
}
|
|
102
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
|
+
}
|
|
103
309
|
const id = newId(ctx);
|
|
104
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;
|
|
105
320
|
const proposal = {
|
|
106
321
|
id,
|
|
107
322
|
ref: normalizedRef,
|
|
@@ -114,6 +329,7 @@ export function createProposal(stashDir, input, ctx) {
|
|
|
114
329
|
content: input.payload.content,
|
|
115
330
|
...(input.payload.frontmatter !== undefined ? { frontmatter: input.payload.frontmatter } : {}),
|
|
116
331
|
},
|
|
332
|
+
...(sanitizedConfidence !== undefined ? { confidence: sanitizedConfidence } : {}),
|
|
117
333
|
};
|
|
118
334
|
writeProposalFile(proposalFile(stashDir, id, false), proposal);
|
|
119
335
|
return proposal;
|
|
@@ -158,7 +374,7 @@ export function listProposals(stashDir, options = {}) {
|
|
|
158
374
|
out.push({
|
|
159
375
|
id: entry.name,
|
|
160
376
|
ref: "unknown:unknown",
|
|
161
|
-
status: "
|
|
377
|
+
status: "rejected",
|
|
162
378
|
source: "invalid",
|
|
163
379
|
createdAt: "",
|
|
164
380
|
updatedAt: "",
|
|
@@ -175,6 +391,18 @@ export function listProposals(stashDir, options = {}) {
|
|
|
175
391
|
return out
|
|
176
392
|
.filter((p) => (options.status ? p.status === options.status : true))
|
|
177
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
|
+
})
|
|
178
406
|
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
179
407
|
}
|
|
180
408
|
/**
|
|
@@ -190,6 +418,53 @@ export function getProposal(stashDir, id) {
|
|
|
190
418
|
return readProposalFile(archivedPath);
|
|
191
419
|
throw new NotFoundError(`Proposal "${id}" not found.`, "FILE_NOT_FOUND");
|
|
192
420
|
}
|
|
421
|
+
/**
|
|
422
|
+
* Resolve a proposal by full UUID, UUID prefix, or asset ref.
|
|
423
|
+
*
|
|
424
|
+
* Resolution order:
|
|
425
|
+
* 1. Exact UUID match (existing behaviour).
|
|
426
|
+
* 2. Asset ref (contains `:`) — finds the most-recent pending proposal for
|
|
427
|
+
* that ref; falls back to archived if nothing is pending.
|
|
428
|
+
* 3. UUID prefix — matches any live proposal directory whose name starts
|
|
429
|
+
* with the given string; throws if ambiguous.
|
|
430
|
+
*/
|
|
431
|
+
export function resolveProposalId(stashDir, idOrRef) {
|
|
432
|
+
// 1. Exact UUID.
|
|
433
|
+
try {
|
|
434
|
+
return getProposal(stashDir, idOrRef);
|
|
435
|
+
}
|
|
436
|
+
catch (e) {
|
|
437
|
+
if (!(e instanceof NotFoundError))
|
|
438
|
+
throw e;
|
|
439
|
+
}
|
|
440
|
+
// 2. Asset ref (e.g. "skill:akm-dream").
|
|
441
|
+
if (idOrRef.includes(":")) {
|
|
442
|
+
const pending = listProposals(stashDir, { ref: idOrRef });
|
|
443
|
+
if (pending.length > 0) {
|
|
444
|
+
return pending.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime())[0];
|
|
445
|
+
}
|
|
446
|
+
const archived = listProposals(stashDir, { ref: idOrRef, includeArchive: true });
|
|
447
|
+
if (archived.length > 0) {
|
|
448
|
+
return archived.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime())[0];
|
|
449
|
+
}
|
|
450
|
+
throw new NotFoundError(`No proposal found for ref "${idOrRef}".`, "FILE_NOT_FOUND");
|
|
451
|
+
}
|
|
452
|
+
// 3. UUID prefix.
|
|
453
|
+
const liveDir = getProposalsRoot(stashDir, false);
|
|
454
|
+
let prefixMatches = [];
|
|
455
|
+
try {
|
|
456
|
+
prefixMatches = fs.readdirSync(liveDir).filter((name) => name.startsWith(idOrRef));
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
/* live dir may not exist yet */
|
|
460
|
+
}
|
|
461
|
+
if (prefixMatches.length === 1)
|
|
462
|
+
return getProposal(stashDir, prefixMatches[0]);
|
|
463
|
+
if (prefixMatches.length > 1) {
|
|
464
|
+
throw new UsageError(`Ambiguous prefix "${idOrRef}" — matches: ${prefixMatches.join(", ")}`, "INVALID_FLAG_VALUE");
|
|
465
|
+
}
|
|
466
|
+
throw new NotFoundError(`Proposal "${idOrRef}" not found.`, "FILE_NOT_FOUND");
|
|
467
|
+
}
|
|
193
468
|
/**
|
|
194
469
|
* Whether a proposal currently lives in the archive (used by callers that
|
|
195
470
|
* need to know whether to look in the archive root for files / paths).
|
|
@@ -241,49 +516,144 @@ export function archiveProposal(stashDir, id, status, reason, ctx) {
|
|
|
241
516
|
return updated;
|
|
242
517
|
}
|
|
243
518
|
/**
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
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.
|
|
249
527
|
*/
|
|
250
|
-
export function
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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);
|
|
263
552
|
});
|
|
264
|
-
|
|
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
|
+
};
|
|
265
608
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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;
|
|
269
619
|
try {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
+
},
|
|
276
633
|
});
|
|
277
634
|
}
|
|
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 });
|
|
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)}`);
|
|
284
638
|
}
|
|
285
639
|
}
|
|
286
|
-
return {
|
|
640
|
+
return {
|
|
641
|
+
checked: pending.length,
|
|
642
|
+
expired: expiredProposals.length,
|
|
643
|
+
durationMs: Date.now() - t0,
|
|
644
|
+
retentionDays,
|
|
645
|
+
expiredProposals,
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Validate a proposal payload before promotion. Generic by default — any
|
|
650
|
+
* proposal must parse cleanly and carry a non-empty body. Lessons get the
|
|
651
|
+
* extra per-type lint from {@link lintLessonContent} so the contract documented
|
|
652
|
+
* in v1 spec §13 is enforced at promotion time. Other asset types can hook
|
|
653
|
+
* here in the future without changing call sites.
|
|
654
|
+
*/
|
|
655
|
+
export function validateProposal(proposal) {
|
|
656
|
+
return runProposalValidators(proposal);
|
|
287
657
|
}
|
|
288
658
|
/**
|
|
289
659
|
* Validate a proposal, then promote it through the canonical
|
|
@@ -291,6 +661,12 @@ export function validateProposal(proposal) {
|
|
|
291
661
|
* `source.kind`). On success the proposal directory is moved to the archive
|
|
292
662
|
* with status `accepted`. Validation failures throw a `UsageError` carrying
|
|
293
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.
|
|
294
670
|
*/
|
|
295
671
|
export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
|
|
296
672
|
const proposal = getProposal(stashDir, id);
|
|
@@ -307,10 +683,104 @@ export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
|
|
|
307
683
|
throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
|
|
308
684
|
}
|
|
309
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
|
+
}
|
|
310
713
|
const written = await writeAssetToSource(target.source, target.config, ref, proposal.payload.content);
|
|
311
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
|
+
}
|
|
312
724
|
return { proposal: archived, assetPath: written.path, ref: written.ref };
|
|
313
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
|
+
}
|
|
314
784
|
/**
|
|
315
785
|
* Compute a diff between a proposal payload and the existing on-disk asset.
|
|
316
786
|
* Uses {@link resolveWriteTarget} to find where the asset would land — so the
|