akm-cli 0.8.6 → 0.8.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +442 -0
- package/dist/assets/help/help-proposals.md +1 -2
- package/dist/assets/hints/cli-hints-full.md +34 -19
- package/dist/assets/hints/cli-hints-short.md +1 -1
- package/dist/assets/profiles/catchup.json +13 -0
- package/dist/assets/profiles/consolidate.json +13 -0
- package/dist/assets/profiles/frequent.json +13 -0
- package/dist/assets/tasks/core/backup.yml +4 -0
- package/dist/assets/tasks/core/extract.yml +4 -0
- package/dist/assets/tasks/core/improve.yml +4 -0
- package/dist/assets/tasks/core/index-refresh.yml +4 -0
- package/dist/assets/tasks/core/sync.yml +4 -0
- package/dist/assets/tasks/core/update-stashes.yml +4 -0
- package/dist/assets/tasks/core/version-check.yml +4 -0
- package/dist/assets/templates/html/default.html +78 -0
- package/dist/assets/templates/html/health.html +560 -0
- package/dist/assets/templates/html/vendor/echarts.min.js +45 -0
- package/dist/cli/config-migrate.js +6 -6
- package/dist/cli/config-validate.js +4 -4
- package/dist/cli/confirm.js +3 -3
- package/dist/cli/parse-args.js +1 -1
- package/dist/cli/shared.js +72 -19
- package/dist/cli-node.mjs +26 -0
- package/dist/cli.js +206 -3866
- package/dist/commands/{agent-dispatch.js → agent/agent-dispatch.js} +6 -6
- package/dist/commands/{agent-support.js → agent/agent-support.js} +2 -2
- package/dist/commands/agent/contribute-cli.js +200 -0
- package/dist/commands/completions.js +1 -1
- package/dist/commands/config-cli.js +230 -3
- package/dist/commands/db-cli.js +2 -2
- package/dist/commands/env/env-cli.js +529 -0
- package/dist/commands/env/env.js +410 -0
- package/dist/commands/env/secret-cli.js +259 -0
- package/dist/commands/{secret.js → env/secret.js} +6 -47
- package/dist/commands/events.js +4 -4
- package/dist/commands/feedback-cli.js +18 -34
- package/dist/commands/graph/graph-cli.js +132 -0
- package/dist/commands/{graph.js → graph/graph.js} +22 -16
- package/dist/commands/health/checks.js +279 -0
- package/dist/commands/health/html-report.js +448 -0
- package/dist/commands/health.js +189 -266
- package/dist/commands/{consolidate.js → improve/consolidate.js} +63 -38
- package/dist/commands/{distill-promotion-policy.js → improve/distill-promotion-policy.js} +3 -3
- package/dist/commands/{distill.js → improve/distill.js} +39 -18
- package/dist/commands/{eval-cases.js → improve/eval-cases.js} +1 -1
- package/dist/commands/{extract-cli.js → improve/extract-cli.js} +4 -4
- package/dist/commands/{extract-prompt.js → improve/extract-prompt.js} +2 -2
- package/dist/commands/{extract.js → improve/extract.js} +221 -26
- package/dist/commands/{improve-auto-accept.js → improve/improve-auto-accept.js} +30 -4
- package/dist/commands/{improve-cli.js → improve/improve-cli.js} +44 -22
- package/dist/commands/{improve-profiles.js → improve/improve-profiles.js} +13 -7
- package/dist/commands/{improve-result-file.js → improve/improve-result-file.js} +1 -1
- package/dist/commands/{improve.js → improve/improve.js} +672 -292
- package/dist/{core → commands/improve/memory}/memory-belief.js +2 -2
- package/dist/{core → commands/improve/memory}/memory-contradiction-detect.js +5 -5
- package/dist/{core → commands/improve/memory}/memory-improve.js +4 -4
- package/dist/commands/improve/reflect-noise.js +0 -0
- package/dist/commands/{reflect.js → improve/reflect.js} +58 -28
- package/dist/commands/improve/session-asset.js +248 -0
- package/dist/commands/lint/agent-linter.js +1 -1
- package/dist/commands/lint/base-linter.js +55 -37
- package/dist/commands/lint/command-linter.js +1 -1
- package/dist/commands/lint/default-linter.js +1 -1
- package/dist/commands/lint/env-key-rules.js +1 -1
- package/dist/commands/lint/index.js +19 -25
- package/dist/commands/lint/knowledge-linter.js +1 -1
- package/dist/commands/lint/memory-linter.js +1 -1
- package/dist/commands/lint/registry.js +8 -8
- package/dist/commands/lint/skill-linter.js +1 -1
- package/dist/commands/lint/task-linter.js +1 -1
- package/dist/commands/lint/workflow-linter.js +1 -1
- package/dist/commands/lint.js +1 -1
- package/dist/commands/observability-cli.js +244 -0
- package/dist/commands/proposal/drain-policies.js +3 -3
- package/dist/commands/proposal/drain.js +87 -15
- package/dist/commands/proposal/proposal-cli.js +490 -0
- package/dist/commands/{proposal.js → proposal/proposal.js} +17 -6
- package/dist/commands/{propose.js → proposal/propose.js} +11 -11
- package/dist/{core → commands/proposal/validators}/proposal-quality-validators.js +8 -3
- package/dist/{core → commands/proposal/validators}/proposal-validators.js +5 -5
- package/dist/{core → commands/proposal/validators}/proposals.js +374 -345
- package/dist/commands/{curate.js → read/curate.js} +7 -7
- package/dist/commands/{knowledge.js → read/knowledge.js} +22 -9
- package/dist/commands/{registry-search.js → read/registry-search.js} +5 -5
- package/dist/commands/{remember-cli.js → read/remember-cli.js} +15 -7
- package/dist/commands/read/search-cli.js +207 -0
- package/dist/commands/{search.js → read/search.js} +22 -27
- package/dist/commands/{show.js → read/show.js} +31 -45
- package/dist/commands/registry-cli.js +8 -8
- package/dist/commands/remember.js +14 -10
- package/dist/commands/sources/add-cli.js +293 -0
- package/dist/commands/{history.js → sources/history.js} +27 -25
- package/dist/commands/{info.js → sources/info.js} +6 -6
- package/dist/commands/{init.js → sources/init.js} +6 -6
- package/dist/commands/{installed-stashes.js → sources/installed-stashes.js} +12 -12
- package/dist/commands/{migration-help.js → sources/migration-help.js} +3 -2
- package/dist/commands/{schema-repair.js → sources/schema-repair.js} +8 -8
- package/dist/commands/{self-update.js → sources/self-update.js} +10 -9
- package/dist/commands/{source-add.js → sources/source-add.js} +10 -10
- package/dist/commands/{source-clone.js → sources/source-clone.js} +7 -7
- package/dist/commands/{source-manage.js → sources/source-manage.js} +4 -4
- package/dist/commands/sources/sources-cli.js +305 -0
- package/dist/commands/sources/stash-cli.js +219 -0
- package/dist/commands/{stash-skeleton.js → sources/stash-skeleton.js} +2 -1
- package/dist/commands/tasks/default-tasks.js +173 -0
- package/dist/commands/tasks/tasks-cli.js +210 -0
- package/dist/commands/{tasks.js → tasks/tasks.js} +14 -14
- package/dist/commands/wiki-cli.js +307 -0
- package/dist/commands/workflow-cli.js +329 -0
- package/dist/core/action-contributors.js +1 -1
- package/dist/core/assert.js +40 -0
- package/dist/core/asset/asset-create.js +54 -0
- package/dist/core/{asset-ref.js → asset/asset-ref.js} +21 -4
- package/dist/core/{asset-registry.js → asset/asset-registry.js} +3 -3
- package/dist/core/{asset-spec.js → asset/asset-spec.js} +17 -31
- package/dist/core/{markdown.js → asset/markdown.js} +1 -1
- package/dist/core/{stash-meta.js → asset/stash-meta.js} +1 -1
- package/dist/core/best-effort.js +64 -0
- package/dist/core/common.js +32 -18
- package/dist/core/{config-io.js → config/config-io.js} +29 -19
- package/dist/core/{config-migration.js → config/config-migration.js} +11 -9
- package/dist/core/{config-schema.js → config/config-schema.js} +50 -7
- package/dist/core/config/config-types.js +16 -0
- package/dist/core/{config-walker.js → config/config-walker.js} +2 -2
- package/dist/core/{config.js → config/config.js} +10 -8
- package/dist/core/env-secret-ref.js +90 -0
- package/dist/core/errors.js +13 -3
- package/dist/core/events.js +27 -4
- package/dist/core/file-lock.js +1 -1
- package/dist/core/improve-types.js +48 -0
- package/dist/core/lesson-lint.js +2 -2
- package/dist/core/logs-db.js +304 -0
- package/dist/core/paths.js +2 -2
- package/dist/core/ripgrep/install.js +2 -2
- package/dist/core/ripgrep/resolve.js +2 -2
- package/dist/core/state-db.js +195 -60
- package/dist/core/text-truncation.js +148 -0
- package/dist/core/time.js +1 -1
- package/dist/core/write-source.js +98 -85
- package/dist/indexer/{db-backup.js → db/db-backup.js} +9 -24
- package/dist/indexer/{db.js → db/db.js} +128 -118
- package/dist/indexer/{graph-db.js → db/graph-db.js} +9 -4
- package/dist/indexer/{llm-cache.js → db/llm-cache.js} +15 -12
- package/dist/indexer/ensure-index.js +4 -4
- package/dist/indexer/{graph-boost.js → graph/graph-boost.js} +1 -1
- package/dist/indexer/{graph-extraction.js → graph/graph-extraction.js} +55 -13
- package/dist/indexer/indexer.js +37 -30
- package/dist/indexer/init.js +54 -0
- package/dist/indexer/manifest.js +10 -10
- package/dist/indexer/{memory-inference.js → passes/memory-inference.js} +141 -33
- package/dist/indexer/{metadata-contributors.js → passes/metadata-contributors.js} +10 -8
- package/dist/indexer/{metadata.js → passes/metadata.js} +15 -19
- package/dist/indexer/{staleness-detect.js → passes/staleness-detect.js} +53 -12
- package/dist/indexer/{db-search.js → search/db-search.js} +28 -16
- package/dist/indexer/{ranking-contributors.js → search/ranking-contributors.js} +1 -1
- package/dist/indexer/{ranking.js → search/ranking.js} +2 -2
- package/dist/indexer/{search-hit-enrichers.js → search/search-hit-enrichers.js} +3 -3
- package/dist/indexer/{search-source.js → search/search-source.js} +8 -8
- package/dist/indexer/{semantic-status.js → search/semantic-status.js} +3 -3
- package/dist/indexer/usage/unmigrated-vaults-guard.js +94 -0
- package/dist/indexer/{usage-events.js → usage/usage-events.js} +32 -0
- package/dist/indexer/{file-context.js → walk/file-context.js} +10 -15
- package/dist/indexer/{matchers.js → walk/matchers.js} +13 -9
- package/dist/indexer/{path-resolver.js → walk/path-resolver.js} +6 -6
- package/dist/indexer/{project-context.js → walk/project-context.js} +1 -1
- package/dist/indexer/{walker.js → walk/walker.js} +4 -3
- package/dist/integrations/agent/builder-shared.js +39 -0
- package/dist/integrations/agent/builders.js +14 -81
- package/dist/integrations/agent/config.js +6 -4
- package/dist/integrations/agent/detect.js +1 -1
- package/dist/integrations/agent/index.js +23 -8
- package/dist/integrations/agent/prompts.js +2 -3
- package/dist/integrations/agent/runner.js +22 -3
- package/dist/integrations/agent/spawn.js +9 -10
- package/dist/integrations/harnesses/claude/agent-builder.js +48 -0
- package/dist/integrations/harnesses/claude/config-import.js +70 -0
- package/dist/integrations/harnesses/claude/index.js +64 -0
- package/dist/integrations/{session-logs/providers/claude-code.js → harnesses/claude/session-log.js} +32 -5
- package/dist/integrations/harnesses/index.js +144 -0
- package/dist/integrations/harnesses/opencode/agent-builder.js +43 -0
- package/dist/integrations/harnesses/opencode/config-import.js +82 -0
- package/dist/integrations/harnesses/opencode/index.js +59 -0
- package/dist/integrations/{session-logs/providers/opencode.js → harnesses/opencode/session-log.js} +1 -1
- package/dist/integrations/harnesses/opencode-sdk/index.js +49 -0
- package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +234 -0
- package/dist/integrations/harnesses/types.js +43 -0
- package/dist/integrations/lockfile.js +7 -16
- package/dist/integrations/session-logs/index.js +82 -9
- package/dist/llm/call-ai.js +4 -4
- package/dist/llm/client.js +146 -6
- package/dist/llm/embedder.js +6 -6
- package/dist/llm/embedders/local.js +9 -22
- package/dist/llm/embedders/remote.js +2 -2
- package/dist/llm/embedders/types.js +1 -1
- package/dist/llm/graph-extract.js +31 -12
- package/dist/llm/index-passes.js +1 -1
- package/dist/llm/memory-infer.js +12 -5
- package/dist/llm/metadata-enhance.js +2 -2
- package/dist/llm/usage-persist.js +77 -0
- package/dist/llm/usage-telemetry.js +103 -0
- package/dist/output/context.js +9 -46
- package/dist/output/html-render.js +73 -0
- package/dist/output/renderers.js +88 -58
- package/dist/output/shapes/curate.js +7 -3
- package/dist/output/shapes/distill.js +7 -3
- package/dist/output/shapes/env-list.js +18 -16
- package/dist/output/shapes/events.js +5 -4
- package/dist/output/shapes/helpers.js +19 -5
- package/dist/output/shapes/history.js +7 -3
- package/dist/output/shapes/passthrough.js +8 -11
- package/dist/output/shapes/{proposal-accept.js → proposal/accept.js} +7 -3
- package/dist/output/shapes/{proposal-diff.js → proposal/diff.js} +7 -3
- package/dist/output/shapes/{proposal-list.js → proposal/list.js} +7 -3
- package/dist/output/shapes/{proposal-producer.js → proposal/producer.js} +5 -4
- package/dist/output/shapes/{proposal-reject.js → proposal/reject.js} +7 -3
- package/dist/output/shapes/{proposal-show.js → proposal/show.js} +7 -3
- package/dist/output/shapes/registry-search.js +7 -3
- package/dist/output/shapes/registry.js +12 -0
- package/dist/output/shapes/search.js +7 -3
- package/dist/output/shapes/secret-list.js +18 -16
- package/dist/output/shapes/show.js +7 -3
- package/dist/output/shapes.js +55 -30
- package/dist/output/text/add.js +2 -3
- package/dist/output/text/clone.js +2 -3
- package/dist/output/text/config.js +2 -3
- package/dist/output/text/curate.js +4 -3
- package/dist/output/text/distill.js +2 -3
- package/dist/output/text/enable-disable.js +5 -4
- package/dist/output/text/env.js +13 -0
- package/dist/output/text/events.js +5 -4
- package/dist/output/text/feedback.js +4 -3
- package/dist/output/text/helpers.js +123 -40
- package/dist/output/text/history.js +2 -3
- package/dist/output/text/import.js +2 -3
- package/dist/output/text/index.js +2 -3
- package/dist/output/text/info.js +2 -3
- package/dist/output/text/init.js +2 -3
- package/dist/output/text/list.js +2 -3
- package/dist/output/text/proposal/producer.js +9 -0
- package/dist/output/text/proposal/proposal.js +13 -0
- package/dist/output/text/registry-commands.js +8 -7
- package/dist/output/text/registry.js +12 -0
- package/dist/output/text/remember.js +4 -3
- package/dist/output/text/remove.js +2 -3
- package/dist/output/text/save.js +2 -3
- package/dist/output/text/search.js +4 -3
- package/dist/output/text/show.js +4 -3
- package/dist/output/text/update.js +2 -3
- package/dist/output/text/upgrade.js +2 -3
- package/dist/output/text/wiki.js +12 -11
- package/dist/output/text/workflow.js +12 -10
- package/dist/output/text.js +66 -32
- package/dist/registry/build-index.js +11 -10
- package/dist/registry/factory.js +1 -1
- package/dist/registry/origin-resolve.js +1 -1
- package/dist/registry/providers/index.js +2 -2
- package/dist/registry/providers/skills-sh.js +91 -72
- package/dist/registry/providers/static-index.js +75 -52
- package/dist/registry/resolve.js +3 -3
- package/dist/runtime.js +242 -0
- package/dist/scripts/migrate-storage.js +1654 -683
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +254 -168
- package/dist/setup/detect.js +311 -9
- package/dist/setup/harness-config-import.js +6 -120
- package/dist/setup/setup.js +454 -43
- package/dist/sources/include.js +1 -1
- package/dist/sources/provider-factory.js +2 -2
- package/dist/sources/providers/filesystem.js +3 -3
- package/dist/sources/providers/git.js +9 -9
- package/dist/sources/providers/index.js +4 -4
- package/dist/sources/providers/npm.js +6 -6
- package/dist/sources/providers/provider-utils.js +13 -20
- package/dist/sources/providers/sync-from-ref.js +5 -5
- package/dist/sources/providers/tar-utils.js +2 -2
- package/dist/sources/providers/website.js +2 -2
- package/dist/sources/resolve.js +5 -5
- package/dist/sources/website-ingest.js +5 -5
- package/dist/storage/database.js +102 -0
- package/dist/storage/engines/sqlite-migrations.js +42 -0
- package/dist/storage/locations.js +25 -0
- package/dist/storage/repositories/index-db.js +43 -0
- package/dist/storage/repositories/workflow-runs-repository.js +141 -0
- package/dist/tasks/backends/cron.js +4 -4
- package/dist/tasks/backends/exec-utils.js +32 -0
- package/dist/tasks/backends/index.js +3 -3
- package/dist/tasks/backends/launchd.js +7 -14
- package/dist/tasks/backends/schtasks.js +7 -16
- package/dist/tasks/embedded.js +71 -0
- package/dist/tasks/parser.js +2 -2
- package/dist/tasks/resolveAkmBin.js +1 -1
- package/dist/tasks/runner.js +127 -31
- package/dist/tasks/schedule.js +1 -1
- package/dist/tasks/validator.js +7 -7
- package/dist/text-import-hook.mjs +51 -0
- package/dist/version.js +2 -1
- package/dist/wiki/wiki.js +7 -7
- package/dist/workflows/{authoring.js → authoring/authoring.js} +6 -6
- package/dist/workflows/{scope-key.js → authoring/scope-key.js} +1 -1
- package/dist/workflows/cli.js +1 -1
- package/dist/workflows/db.js +54 -32
- package/dist/workflows/parser.js +4 -4
- package/dist/workflows/renderer.js +5 -5
- package/dist/workflows/runtime/agent-identity.js +56 -0
- package/dist/workflows/runtime/checkin.js +57 -0
- package/dist/workflows/{runs.js → runtime/runs.js} +197 -101
- package/dist/workflows/validate-summary.js +82 -0
- package/docs/README.md +1 -1
- package/docs/data-and-telemetry.md +6 -6
- package/package.json +17 -8
- package/dist/commands/add-cli.js +0 -279
- package/dist/commands/env.js +0 -213
- package/dist/integrations/agent/sdk-runner.js +0 -126
- package/dist/output/shapes/vault-list.js +0 -19
- package/dist/output/text/proposal-producer.js +0 -8
- package/dist/output/text/proposal.js +0 -12
- package/dist/output/text/vault.js +0 -16
- /package/dist/core/{asset-serialize.js → asset/asset-serialize.js} +0 -0
- /package/dist/core/{frontmatter.js → asset/frontmatter.js} +0 -0
- /package/dist/core/{config-sources.js → config/config-sources.js} +0 -0
- /package/dist/indexer/{graph-dedup.js → graph/graph-dedup.js} +0 -0
- /package/dist/{core/config-types.js → indexer/passes/pass-context.js} +0 -0
- /package/dist/indexer/{search-fields.js → search/search-fields.js} +0 -0
- /package/dist/indexer/{index-context.js → walk/index-context.js} +0 -0
- /package/dist/workflows/{document-cache.js → runtime/document-cache.js} +0 -0
|
@@ -2,50 +2,58 @@
|
|
|
2
2
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
3
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
4
|
/**
|
|
5
|
-
* Proposal substrate (#225).
|
|
5
|
+
* Proposal substrate (#225, storage consolidated in #578).
|
|
6
6
|
*
|
|
7
7
|
* One durable proposal store for every future reflection / generation flow
|
|
8
8
|
* (`akm reflect`, `akm propose`, `akm distill`, lesson distillation, …).
|
|
9
|
-
* Proposals are *queue state*, not source-of-truth assets — they sit
|
|
10
|
-
* waiting for human (or automated) review and only become assets after
|
|
9
|
+
* Proposals are *queue state*, not source-of-truth assets — they sit in the
|
|
10
|
+
* queue waiting for human (or automated) review and only become assets after
|
|
11
11
|
* `akm proposal accept` validates and promotes them via
|
|
12
12
|
* {@link writeAssetToSource}.
|
|
13
13
|
*
|
|
14
|
-
* # Storage
|
|
14
|
+
* # Storage
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* The canonical store is the `proposals` table in `state.db` (SQLite, WAL
|
|
17
|
+
* mode — see `src/core/state-db.ts`). Rows are partitioned by `stash_dir` so
|
|
18
|
+
* multi-stash installs keep independent queues, and the `status` column
|
|
19
|
+
* distinguishes the live queue (`pending`) from the archive (`accepted` /
|
|
20
|
+
* `rejected` / `reverted`). There is no separate archive location — archival
|
|
21
|
+
* is a status flip, and the full audit trail (review outcome, reason, backup
|
|
22
|
+
* content for revert) lives on the row.
|
|
18
23
|
*
|
|
19
|
-
*
|
|
20
|
-
* proposals can target the same `ref` without filesystem collisions.
|
|
24
|
+
* ## Legacy filesystem import
|
|
21
25
|
*
|
|
22
|
-
*
|
|
26
|
+
* Before 0.9.0 proposals lived as per-uuid JSON directories under
|
|
27
|
+
* `<stashDir>/.akm/proposals/` (live) and `…/proposals/archive/` (archived).
|
|
28
|
+
* The first proposal operation against a stash imports any legacy
|
|
29
|
+
* `proposal.json` files into the table (INSERT OR IGNORE keyed on the UUID,
|
|
30
|
+
* so re-runs never duplicate) and records the stash in `proposal_fs_imports`
|
|
31
|
+
* so later invocations skip the directory walk. The legacy files are left in
|
|
32
|
+
* place untouched — they are inert after import and may be removed by the
|
|
33
|
+
* operator at leisure.
|
|
23
34
|
*
|
|
24
|
-
*
|
|
25
|
-
* to *assets*. Proposals are **not** assets — they live outside the asset tree
|
|
26
|
-
* (under `.akm/proposals/`, parallel to how `events.jsonl` lives outside the
|
|
27
|
-
* asset tree). Routing them through `writeAssetToSource` would force them into
|
|
28
|
-
* a `TYPE_DIRS` slot, would commit them to git, and would leak unaccepted
|
|
29
|
-
* drafts through the normal indexer. None of that is what we want for queue
|
|
30
|
-
* state. The {@link promoteProposal} step is the bridge: it routes the
|
|
31
|
-
* accepted payload through `writeAssetToSource` so the actual asset write
|
|
32
|
-
* still funnels through the single dispatch point in
|
|
33
|
-
* `src/core/write-source.ts`.
|
|
35
|
+
* # Why the queue bypasses `writeAssetToSource`
|
|
34
36
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
37
|
+
* The architectural rule "all writes go through `writeAssetToSource`" applies
|
|
38
|
+
* to *assets*. Proposals are **not** assets — they live outside the asset
|
|
39
|
+
* tree (in state.db, parallel to how events do). Routing them through
|
|
40
|
+
* `writeAssetToSource` would force them into a `TYPE_DIRS` slot, would commit
|
|
41
|
+
* them to git, and would leak unaccepted drafts through the normal indexer.
|
|
42
|
+
* The {@link promoteProposal} step is the bridge: it routes the accepted
|
|
43
|
+
* payload through `writeAssetToSource` so the actual asset write still
|
|
44
|
+
* funnels through the single dispatch point in `src/core/write-source.ts`.
|
|
38
45
|
*/
|
|
39
46
|
import { createHash, randomUUID } from "node:crypto";
|
|
40
47
|
import fs from "node:fs";
|
|
41
48
|
import path from "node:path";
|
|
42
|
-
import { makeAssetRef, parseAssetRef } from "
|
|
43
|
-
import { resolveAssetPathFromName, TYPE_DIRS } from "
|
|
44
|
-
import { NotFoundError, UsageError } from "
|
|
45
|
-
import { appendEvent } from "
|
|
46
|
-
import {
|
|
47
|
-
import { warn } from "
|
|
48
|
-
import { resolveWriteTarget, writeAssetToSource } from "
|
|
49
|
+
import { makeAssetRef, parseAssetRef } from "../../../core/asset/asset-ref.js";
|
|
50
|
+
import { resolveAssetPathFromName, TYPE_DIRS } from "../../../core/asset/asset-spec.js";
|
|
51
|
+
import { NotFoundError, UsageError } from "../../../core/errors.js";
|
|
52
|
+
import { appendEvent } from "../../../core/events.js";
|
|
53
|
+
import { getStateDbPath, getStateProposal, hasImportedFsProposals, insertProposalIfAbsent, listStateProposalIdsByPrefix, listStateProposals, openStateDatabase, recordFsProposalsImport, upsertProposal, } from "../../../core/state-db.js";
|
|
54
|
+
import { warn } from "../../../core/warn.js";
|
|
55
|
+
import { commitWriteTargetBoundary, formatRefForMessage, resolveWriteTarget, writeAssetToSource, } from "../../../core/write-source.js";
|
|
56
|
+
import { runProposalValidators } from "./proposal-validators.js";
|
|
49
57
|
// ── Source allow-list (F-4 / #385) ──────────────────────────────────────────
|
|
50
58
|
/**
|
|
51
59
|
* Curated allow-list of valid `source` values for proposals (F-4 / #385).
|
|
@@ -131,21 +139,7 @@ function cooldownMsForSource(source) {
|
|
|
131
139
|
function contentHash(content) {
|
|
132
140
|
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
133
141
|
}
|
|
134
|
-
// ──
|
|
135
|
-
/**
|
|
136
|
-
* Resolve `<stashRoot>/.akm/proposals` (or its archive subdirectory). Direct
|
|
137
|
-
* fs paths because proposal storage is queue state, not asset state — see the
|
|
138
|
-
* module docblock for the architectural carve-out.
|
|
139
|
-
*/
|
|
140
|
-
export function getProposalsRoot(stashDir, archive = false) {
|
|
141
|
-
return archive ? path.join(stashDir, ".akm", "proposals", "archive") : path.join(stashDir, ".akm", "proposals");
|
|
142
|
-
}
|
|
143
|
-
function proposalDir(stashDir, id, archive) {
|
|
144
|
-
return path.join(getProposalsRoot(stashDir, archive), id);
|
|
145
|
-
}
|
|
146
|
-
function proposalFile(stashDir, id, archive) {
|
|
147
|
-
return path.join(proposalDir(stashDir, id, archive), "proposal.json");
|
|
148
|
-
}
|
|
142
|
+
// ── Store access ─────────────────────────────────────────────────────────────
|
|
149
143
|
function nowIso(ctx) {
|
|
150
144
|
const fn = ctx?.now ?? Date.now;
|
|
151
145
|
return new Date(fn()).toISOString();
|
|
@@ -154,35 +148,124 @@ function newId(ctx) {
|
|
|
154
148
|
const fn = ctx?.randomUUID ?? randomUUID;
|
|
155
149
|
return fn();
|
|
156
150
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
151
|
+
/**
|
|
152
|
+
* Open the state database (honouring the `ctx.dbPath` test seam), run the
|
|
153
|
+
* legacy filesystem import for `stashDir` if it has not happened yet, hand the
|
|
154
|
+
* connection to `fn`, and close it in a `finally`. Every public function in
|
|
155
|
+
* this module funnels its store access through here so the legacy import is
|
|
156
|
+
* guaranteed to have run before any read or write.
|
|
157
|
+
*/
|
|
158
|
+
function withProposalsDb(stashDir, ctx, fn) {
|
|
159
|
+
const db = openStateDatabase(ctx?.dbPath ?? getStateDbPath());
|
|
160
160
|
try {
|
|
161
|
-
|
|
161
|
+
importLegacyProposalFiles(db, stashDir);
|
|
162
|
+
return fn(db);
|
|
162
163
|
}
|
|
163
|
-
|
|
164
|
-
|
|
164
|
+
finally {
|
|
165
|
+
db.close();
|
|
165
166
|
}
|
|
167
|
+
}
|
|
168
|
+
// ── Legacy filesystem import (#578) ─────────────────────────────────────────
|
|
169
|
+
/** Legacy (pre-0.9.0) proposal directory: `<stashDir>/.akm/proposals[/archive]`. */
|
|
170
|
+
function legacyProposalsRoot(stashDir, archive) {
|
|
171
|
+
const root = path.join(stashDir, ".akm", "proposals");
|
|
172
|
+
return archive ? path.join(root, "archive") : root;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* One-shot import of legacy `proposal.json` files into the `proposals` table.
|
|
176
|
+
*
|
|
177
|
+
* Idempotent at two levels: the `proposal_fs_imports` ledger skips the
|
|
178
|
+
* directory walk after the first successful import, and INSERT OR IGNORE
|
|
179
|
+
* (keyed on the proposal UUID) protects against duplicates even if the walk
|
|
180
|
+
* re-runs. Legacy `backup.<ext>` files are inlined into `backupContent` so
|
|
181
|
+
* `akm proposal revert` keeps working for proposals accepted before 0.9.0.
|
|
182
|
+
*
|
|
183
|
+
* The legacy files are never modified or deleted — after import they are
|
|
184
|
+
* inert artifacts the operator can remove at leisure.
|
|
185
|
+
*/
|
|
186
|
+
function importLegacyProposalFiles(db, stashDir) {
|
|
187
|
+
if (hasImportedFsProposals(db, stashDir))
|
|
188
|
+
return;
|
|
189
|
+
const liveRoot = legacyProposalsRoot(stashDir, false);
|
|
190
|
+
if (!fs.existsSync(liveRoot))
|
|
191
|
+
return;
|
|
192
|
+
let imported = 0;
|
|
193
|
+
for (const archive of [false, true]) {
|
|
194
|
+
const root = legacyProposalsRoot(stashDir, archive);
|
|
195
|
+
let entries;
|
|
196
|
+
try {
|
|
197
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
for (const entry of entries) {
|
|
203
|
+
if (!entry.isDirectory() || entry.name === "archive")
|
|
204
|
+
continue;
|
|
205
|
+
const proposalDir = path.join(root, entry.name);
|
|
206
|
+
const proposal = readLegacyProposalFile(proposalDir);
|
|
207
|
+
if (!proposal)
|
|
208
|
+
continue;
|
|
209
|
+
if (insertProposalIfAbsent(db, proposal, stashDir))
|
|
210
|
+
imported += 1;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
recordFsProposalsImport(db, stashDir, imported);
|
|
214
|
+
if (imported > 0) {
|
|
215
|
+
warn(`[proposals] imported ${imported} legacy proposal file(s) from ${liveRoot} into state.db`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Parse one legacy proposal directory into a {@link Proposal}, inlining the
|
|
220
|
+
* backup file (when present) as `backupContent`. Returns undefined — with a
|
|
221
|
+
* warning — when the `proposal.json` is missing, unreadable, or malformed, so
|
|
222
|
+
* a single corrupt legacy entry never blocks the import of the rest.
|
|
223
|
+
*/
|
|
224
|
+
function readLegacyProposalFile(proposalDir) {
|
|
225
|
+
const filePath = path.join(proposalDir, "proposal.json");
|
|
166
226
|
let parsed;
|
|
167
227
|
try {
|
|
168
|
-
parsed = JSON.parse(
|
|
228
|
+
parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
169
229
|
}
|
|
170
230
|
catch (err) {
|
|
171
|
-
|
|
231
|
+
warn(`[proposals] skipping legacy proposal at ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
232
|
+
return undefined;
|
|
172
233
|
}
|
|
173
|
-
if (typeof parsed !== "object" ||
|
|
174
|
-
|
|
234
|
+
if (typeof parsed !== "object" ||
|
|
235
|
+
parsed === null ||
|
|
236
|
+
typeof parsed.id !== "string" ||
|
|
237
|
+
typeof parsed.ref !== "string") {
|
|
238
|
+
warn(`[proposals] skipping legacy proposal at ${filePath}: not a proposal object`);
|
|
239
|
+
return undefined;
|
|
175
240
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
241
|
+
const { backup, ...rest } = parsed;
|
|
242
|
+
let backupContent;
|
|
243
|
+
if (typeof backup === "string" && backup.length > 0) {
|
|
244
|
+
try {
|
|
245
|
+
backupContent = fs.readFileSync(path.join(proposalDir, backup), "utf8");
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// Backup file lost — import the proposal anyway; revert for it will
|
|
249
|
+
// surface "no backup available", same as a new-asset proposal.
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
...rest,
|
|
254
|
+
payload: {
|
|
255
|
+
content: rest.payload?.content ?? "",
|
|
256
|
+
...(rest.payload?.frontmatter ? { frontmatter: rest.payload.frontmatter } : {}),
|
|
257
|
+
},
|
|
258
|
+
createdAt: rest.createdAt ?? "",
|
|
259
|
+
updatedAt: rest.updatedAt ?? rest.createdAt ?? "",
|
|
260
|
+
status: rest.status ?? "pending",
|
|
261
|
+
source: rest.source ?? "import",
|
|
262
|
+
...(backupContent !== undefined ? { backupContent } : {}),
|
|
263
|
+
};
|
|
181
264
|
}
|
|
182
265
|
// ── Public API ──────────────────────────────────────────────────────────────
|
|
183
266
|
/**
|
|
184
267
|
* Create a new pending proposal. The id is a stable random UUID, so two
|
|
185
|
-
* proposals with the same `ref` never collide
|
|
268
|
+
* proposals with the same `ref` never collide.
|
|
186
269
|
*
|
|
187
270
|
* **Dedup / cooldown guard** (F-2 / #363):
|
|
188
271
|
*
|
|
@@ -196,7 +279,7 @@ function writeProposalFile(filePath, proposal) {
|
|
|
196
279
|
* others: 7 d). Bypass with `force: true`.
|
|
197
280
|
*
|
|
198
281
|
* When a guard fires the function returns a `CreateProposalSkipped` record
|
|
199
|
-
* instead of writing
|
|
282
|
+
* instead of writing. Use {@link isProposalSkipped} to detect it.
|
|
200
283
|
*/
|
|
201
284
|
export function createProposal(stashDir, input, ctx) {
|
|
202
285
|
// F-4 / #385: Validate source against the allow-list. Unknown values are
|
|
@@ -250,173 +333,144 @@ export function createProposal(stashDir, input, ctx) {
|
|
|
250
333
|
}
|
|
251
334
|
}
|
|
252
335
|
const normalizedRef = makeAssetRef(parsedRef.type, parsedRef.name, parsedRef.origin);
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
336
|
+
return withProposalsDb(stashDir, ctx, (db) => {
|
|
337
|
+
if (!input.force) {
|
|
338
|
+
const skip = checkDedupAndCooldown(db, stashDir, normalizedRef, input, ctx);
|
|
339
|
+
if (skip)
|
|
340
|
+
return skip;
|
|
341
|
+
}
|
|
342
|
+
const created = nowIso(ctx);
|
|
343
|
+
// Phase 6A: validate confidence is a finite number in [0, 1]. Anything else
|
|
344
|
+
// is dropped silently — we never store NaN, Infinity, or out-of-range values.
|
|
345
|
+
// Callers that mis-report confidence should not poison the auto-accept gate.
|
|
346
|
+
const sanitizedConfidence = typeof input.confidence === "number" &&
|
|
347
|
+
Number.isFinite(input.confidence) &&
|
|
348
|
+
input.confidence >= 0 &&
|
|
349
|
+
input.confidence <= 1
|
|
350
|
+
? input.confidence
|
|
351
|
+
: undefined;
|
|
352
|
+
const proposal = {
|
|
353
|
+
id: newId(ctx),
|
|
354
|
+
ref: normalizedRef,
|
|
355
|
+
status: "pending",
|
|
356
|
+
source: input.source,
|
|
357
|
+
...(input.sourceRun !== undefined ? { sourceRun: input.sourceRun } : {}),
|
|
358
|
+
createdAt: created,
|
|
359
|
+
updatedAt: created,
|
|
360
|
+
payload: {
|
|
361
|
+
content: input.payload.content,
|
|
362
|
+
...(input.payload.frontmatter !== undefined ? { frontmatter: input.payload.frontmatter } : {}),
|
|
363
|
+
},
|
|
364
|
+
...(sanitizedConfidence !== undefined ? { confidence: sanitizedConfidence } : {}),
|
|
365
|
+
};
|
|
366
|
+
upsertProposal(db, proposal, stashDir);
|
|
367
|
+
return proposal;
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Evaluate the F-2 dedup / cooldown guards against the store. Returns the
|
|
372
|
+
* skip record when a guard fires, or undefined when the create may proceed.
|
|
373
|
+
*/
|
|
374
|
+
function checkDedupAndCooldown(db, stashDir, normalizedRef, input, ctx) {
|
|
375
|
+
const newHash = contentHash(input.payload.content);
|
|
376
|
+
const nowMs = (ctx?.now ?? Date.now)();
|
|
377
|
+
const cooldownMs = cooldownMsForSource(input.source);
|
|
378
|
+
// Scan pending proposals for ref+source matches.
|
|
379
|
+
const pending = listStateProposals(db, { stashDir, ref: normalizedRef, status: "pending" }).filter((p) => p.source === input.source);
|
|
380
|
+
if (pending.length > 0) {
|
|
381
|
+
// Check for identical content hash first (silent skip).
|
|
382
|
+
const hashMatch = pending.find((p) => contentHash(p.payload.content) === newHash);
|
|
383
|
+
if (hashMatch) {
|
|
272
384
|
return {
|
|
273
385
|
skipped: true,
|
|
274
|
-
reason: "
|
|
275
|
-
message: `
|
|
276
|
-
existingProposalId:
|
|
386
|
+
reason: "content_hash_match",
|
|
387
|
+
message: `Identical proposal for ${normalizedRef} already pending (id: ${hashMatch.id}).`,
|
|
388
|
+
existingProposalId: hashMatch.id,
|
|
277
389
|
};
|
|
278
390
|
}
|
|
279
|
-
//
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
391
|
+
// Duplicate pending for same ref+source (different content).
|
|
392
|
+
const firstPending = pending[0];
|
|
393
|
+
return {
|
|
394
|
+
skipped: true,
|
|
395
|
+
reason: "duplicate_pending",
|
|
396
|
+
message: `A pending proposal for ${normalizedRef} from source "${input.source}" already exists (id: ${firstPending?.id ?? "unknown"}). Pass force:true to enqueue alongside it.`,
|
|
397
|
+
existingProposalId: firstPending?.id,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
// Check cooldown against recently rejected proposals.
|
|
401
|
+
const rejected = listStateProposals(db, { stashDir, ref: normalizedRef, status: "rejected" })
|
|
402
|
+
.filter((p) => p.source === input.source)
|
|
403
|
+
.sort((a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime());
|
|
404
|
+
const mostRecent = rejected[0];
|
|
405
|
+
if (mostRecent !== undefined) {
|
|
406
|
+
// Check content hash against recently rejected.
|
|
407
|
+
if (contentHash(mostRecent.payload.content) === newHash) {
|
|
408
|
+
return {
|
|
409
|
+
skipped: true,
|
|
410
|
+
reason: "content_hash_match",
|
|
411
|
+
message: `Identical proposal for ${normalizedRef} was already rejected (id: ${mostRecent.id}).`,
|
|
412
|
+
existingProposalId: mostRecent.id,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
// Check cooldown window.
|
|
416
|
+
const rejectedAt = new Date(mostRecent.updatedAt ?? 0).getTime();
|
|
417
|
+
if (nowMs - rejectedAt < cooldownMs) {
|
|
418
|
+
const cooldownDays = cooldownMs / MS_PER_DAY;
|
|
419
|
+
const remainingDays = Math.ceil((cooldownMs - (nowMs - rejectedAt)) / MS_PER_DAY);
|
|
420
|
+
return {
|
|
421
|
+
skipped: true,
|
|
422
|
+
reason: "cooldown",
|
|
423
|
+
message: `Proposal for ${normalizedRef} from source "${input.source}" is in cooldown ` +
|
|
424
|
+
`(${cooldownDays}d window, ~${remainingDays}d remaining). Pass force:true to bypass.`,
|
|
425
|
+
existingProposalId: mostRecent.id,
|
|
426
|
+
};
|
|
307
427
|
}
|
|
308
428
|
}
|
|
309
|
-
|
|
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;
|
|
320
|
-
const proposal = {
|
|
321
|
-
id,
|
|
322
|
-
ref: normalizedRef,
|
|
323
|
-
status: "pending",
|
|
324
|
-
source: input.source,
|
|
325
|
-
...(input.sourceRun !== undefined ? { sourceRun: input.sourceRun } : {}),
|
|
326
|
-
createdAt: created,
|
|
327
|
-
updatedAt: created,
|
|
328
|
-
payload: {
|
|
329
|
-
content: input.payload.content,
|
|
330
|
-
...(input.payload.frontmatter !== undefined ? { frontmatter: input.payload.frontmatter } : {}),
|
|
331
|
-
},
|
|
332
|
-
...(sanitizedConfidence !== undefined ? { confidence: sanitizedConfidence } : {}),
|
|
333
|
-
};
|
|
334
|
-
writeProposalFile(proposalFile(stashDir, id, false), proposal);
|
|
335
|
-
return proposal;
|
|
429
|
+
return undefined;
|
|
336
430
|
}
|
|
337
431
|
/**
|
|
338
|
-
* List
|
|
339
|
-
*
|
|
340
|
-
*
|
|
432
|
+
* List proposals for one stash. By default returns only the live (pending)
|
|
433
|
+
* queue; pass `{ includeArchive: true }` to include accepted / rejected /
|
|
434
|
+
* reverted entries as well.
|
|
341
435
|
*/
|
|
342
|
-
export function listProposals(stashDir, options = {}) {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
if (!fs.existsSync(dir))
|
|
350
|
-
continue;
|
|
351
|
-
let entries;
|
|
352
|
-
try {
|
|
353
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
354
|
-
}
|
|
355
|
-
catch {
|
|
356
|
-
continue;
|
|
436
|
+
export function listProposals(stashDir, options = {}, ctx) {
|
|
437
|
+
return withProposalsDb(stashDir, ctx, (db) => {
|
|
438
|
+
// Without includeArchive, only the live queue is visible — an explicit
|
|
439
|
+
// non-pending status filter therefore matches nothing (mirrors the
|
|
440
|
+
// historical live-directory scan).
|
|
441
|
+
if (!options.includeArchive && options.status !== undefined && options.status !== "pending") {
|
|
442
|
+
return [];
|
|
357
443
|
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
continue;
|
|
444
|
+
const status = options.includeArchive ? options.status : "pending";
|
|
445
|
+
return listStateProposals(db, {
|
|
446
|
+
stashDir,
|
|
447
|
+
...(status !== undefined ? { status } : {}),
|
|
448
|
+
...(options.ref !== undefined ? { ref: options.ref } : {}),
|
|
449
|
+
}).filter((p) => {
|
|
450
|
+
if (!options.type)
|
|
451
|
+
return true;
|
|
367
452
|
try {
|
|
368
|
-
|
|
453
|
+
return parseAssetRef(p.ref).type === options.type;
|
|
369
454
|
}
|
|
370
455
|
catch {
|
|
371
|
-
|
|
372
|
-
// see something in `akm proposal list` rather than the file
|
|
373
|
-
// disappearing silently.
|
|
374
|
-
out.push({
|
|
375
|
-
id: entry.name,
|
|
376
|
-
ref: "unknown:unknown",
|
|
377
|
-
status: "rejected",
|
|
378
|
-
source: "invalid",
|
|
379
|
-
createdAt: "",
|
|
380
|
-
updatedAt: "",
|
|
381
|
-
payload: { content: "" },
|
|
382
|
-
review: {
|
|
383
|
-
outcome: "rejected",
|
|
384
|
-
reason: "Invalid proposal file (could not be parsed).",
|
|
385
|
-
decidedAt: "",
|
|
386
|
-
},
|
|
387
|
-
});
|
|
456
|
+
return false;
|
|
388
457
|
}
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
return out
|
|
392
|
-
.filter((p) => (options.status ? p.status === options.status : true))
|
|
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
|
-
})
|
|
406
|
-
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
458
|
+
});
|
|
459
|
+
});
|
|
407
460
|
}
|
|
408
461
|
/**
|
|
409
|
-
* Look up a proposal by id
|
|
410
|
-
* Throws `NotFoundError` when no match exists.
|
|
462
|
+
* Look up a proposal by id (live or archived).
|
|
463
|
+
* Throws `NotFoundError` when no match exists in this stash.
|
|
411
464
|
*/
|
|
412
|
-
export function getProposal(stashDir, id) {
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
const
|
|
417
|
-
if (
|
|
418
|
-
|
|
419
|
-
|
|
465
|
+
export function getProposal(stashDir, id, ctx) {
|
|
466
|
+
return withProposalsDb(stashDir, ctx, (db) => requireProposal(db, stashDir, id));
|
|
467
|
+
}
|
|
468
|
+
function requireProposal(db, stashDir, id) {
|
|
469
|
+
const proposal = getStateProposal(db, id, stashDir);
|
|
470
|
+
if (!proposal) {
|
|
471
|
+
throw new NotFoundError(`Proposal "${id}" not found.`, "FILE_NOT_FOUND");
|
|
472
|
+
}
|
|
473
|
+
return proposal;
|
|
420
474
|
}
|
|
421
475
|
/**
|
|
422
476
|
* Resolve a proposal by full UUID, UUID prefix, or asset ref.
|
|
@@ -425,95 +479,85 @@ export function getProposal(stashDir, id) {
|
|
|
425
479
|
* 1. Exact UUID match (existing behaviour).
|
|
426
480
|
* 2. Asset ref (contains `:`) — finds the most-recent pending proposal for
|
|
427
481
|
* that ref; falls back to archived if nothing is pending.
|
|
428
|
-
* 3. UUID prefix — matches any
|
|
429
|
-
*
|
|
482
|
+
* 3. UUID prefix — matches any PENDING proposal whose id starts with the
|
|
483
|
+
* given string; throws if ambiguous.
|
|
430
484
|
*/
|
|
431
|
-
export function resolveProposalId(stashDir, idOrRef) {
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
485
|
+
export function resolveProposalId(stashDir, idOrRef, ctx) {
|
|
486
|
+
return withProposalsDb(stashDir, ctx, (db) => {
|
|
487
|
+
// 1. Exact UUID.
|
|
488
|
+
const exact = getStateProposal(db, idOrRef, stashDir);
|
|
489
|
+
if (exact)
|
|
490
|
+
return exact;
|
|
491
|
+
// 2. Asset ref (e.g. "skill:akm-dream") — most recent pending, else most
|
|
492
|
+
// recent archived.
|
|
493
|
+
if (idOrRef.includes(":")) {
|
|
494
|
+
const byRecency = (proposals) => proposals.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime())[0];
|
|
495
|
+
const pending = byRecency(listStateProposals(db, { stashDir, ref: idOrRef, status: "pending" }));
|
|
496
|
+
if (pending)
|
|
497
|
+
return pending;
|
|
498
|
+
const archived = byRecency(listStateProposals(db, { stashDir, ref: idOrRef }));
|
|
499
|
+
if (archived)
|
|
500
|
+
return archived;
|
|
501
|
+
throw new NotFoundError(`No proposal found for ref "${idOrRef}".`, "FILE_NOT_FOUND");
|
|
445
502
|
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
503
|
+
// 3. UUID prefix (pending queue only).
|
|
504
|
+
const prefixMatches = listStateProposalIdsByPrefix(db, stashDir, idOrRef);
|
|
505
|
+
if (prefixMatches.length === 1)
|
|
506
|
+
return requireProposal(db, stashDir, prefixMatches[0]);
|
|
507
|
+
if (prefixMatches.length > 1) {
|
|
508
|
+
throw new UsageError(`Ambiguous prefix "${idOrRef}" — matches: ${prefixMatches.join(", ")}`, "INVALID_FLAG_VALUE");
|
|
449
509
|
}
|
|
450
|
-
throw new NotFoundError(`
|
|
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");
|
|
510
|
+
throw new NotFoundError(`Proposal "${idOrRef}" not found.`, "FILE_NOT_FOUND");
|
|
511
|
+
});
|
|
467
512
|
}
|
|
468
513
|
/**
|
|
469
|
-
*
|
|
470
|
-
*
|
|
514
|
+
* Archive a proposal: flip its status to `accepted` / `rejected`, bump
|
|
515
|
+
* `updatedAt`, and record the review block. Used by both accept and reject
|
|
516
|
+
* paths so the live queue only contains pending entries.
|
|
471
517
|
*/
|
|
472
|
-
export function
|
|
473
|
-
return
|
|
518
|
+
export function archiveProposal(stashDir, id, status, reason, ctx) {
|
|
519
|
+
return withProposalsDb(stashDir, ctx, (db) => {
|
|
520
|
+
const existing = requireProposal(db, stashDir, id);
|
|
521
|
+
const updated = {
|
|
522
|
+
...existing,
|
|
523
|
+
status,
|
|
524
|
+
updatedAt: nowIso(ctx),
|
|
525
|
+
review: {
|
|
526
|
+
outcome: status,
|
|
527
|
+
...(reason !== undefined ? { reason } : {}),
|
|
528
|
+
decidedAt: nowIso(ctx),
|
|
529
|
+
},
|
|
530
|
+
};
|
|
531
|
+
upsertProposal(db, updated, stashDir);
|
|
532
|
+
return updated;
|
|
533
|
+
});
|
|
474
534
|
}
|
|
475
535
|
/**
|
|
476
|
-
*
|
|
477
|
-
*
|
|
478
|
-
*
|
|
536
|
+
* Record an automated gate's decision onto a proposal (#577).
|
|
537
|
+
*
|
|
538
|
+
* Stamps `gateDecision` (decision / reason / confidence / thresholds) onto the
|
|
539
|
+
* row so `akm proposal show` and `list` can explain why a proposal landed where
|
|
540
|
+
* it did. The decision is metadata about the adjudication, so this does NOT
|
|
541
|
+
* change `status` or bump `updatedAt` — a `deferred` proposal stays `pending`,
|
|
542
|
+
* and the accept / reject status flips are owned by {@link promoteProposal} /
|
|
543
|
+
* {@link archiveProposal}. `decidedAt` defaults to now when the caller omits it.
|
|
544
|
+
*
|
|
545
|
+
* Best-effort: a proposal that no longer exists (e.g. concurrently archived) is
|
|
546
|
+
* skipped silently rather than throwing, so a gate run never aborts mid-batch.
|
|
547
|
+
* Returns the updated proposal, or undefined when no matching row exists.
|
|
479
548
|
*/
|
|
480
|
-
export function
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
outcome: status,
|
|
493
|
-
...(reason !== undefined ? { reason } : {}),
|
|
494
|
-
decidedAt: nowIso(ctx),
|
|
495
|
-
},
|
|
496
|
-
};
|
|
497
|
-
writeProposalFile(archived, updated);
|
|
498
|
-
return updated;
|
|
499
|
-
}
|
|
500
|
-
throw new NotFoundError(`Proposal "${id}" not found.`, "FILE_NOT_FOUND");
|
|
501
|
-
}
|
|
502
|
-
const targetDir = proposalDir(stashDir, id, true);
|
|
503
|
-
fs.mkdirSync(path.dirname(targetDir), { recursive: true });
|
|
504
|
-
fs.renameSync(sourceDir, targetDir);
|
|
505
|
-
const updated = {
|
|
506
|
-
...readProposalFile(proposalFile(stashDir, id, true)),
|
|
507
|
-
status,
|
|
508
|
-
updatedAt: nowIso(ctx),
|
|
509
|
-
review: {
|
|
510
|
-
outcome: status,
|
|
511
|
-
...(reason !== undefined ? { reason } : {}),
|
|
512
|
-
decidedAt: nowIso(ctx),
|
|
513
|
-
},
|
|
514
|
-
};
|
|
515
|
-
writeProposalFile(proposalFile(stashDir, id, true), updated);
|
|
516
|
-
return updated;
|
|
549
|
+
export function recordGateDecision(stashDir, id, decision, ctx) {
|
|
550
|
+
return withProposalsDb(stashDir, ctx, (db) => {
|
|
551
|
+
const existing = getStateProposal(db, id, stashDir);
|
|
552
|
+
if (!existing)
|
|
553
|
+
return undefined;
|
|
554
|
+
const updated = {
|
|
555
|
+
...existing,
|
|
556
|
+
gateDecision: { ...decision, decidedAt: decision.decidedAt ?? nowIso(ctx) },
|
|
557
|
+
};
|
|
558
|
+
upsertProposal(db, updated, stashDir);
|
|
559
|
+
return updated;
|
|
560
|
+
});
|
|
517
561
|
}
|
|
518
562
|
/**
|
|
519
563
|
* Scan all pending proposals and reject those whose target asset no longer
|
|
@@ -529,7 +573,7 @@ export function purgeOrphanProposals(stashDir, sourceDirs, ctx) {
|
|
|
529
573
|
const t0 = Date.now();
|
|
530
574
|
const orphans = [];
|
|
531
575
|
const byType = {};
|
|
532
|
-
const pending = listProposals(stashDir, { status: "pending" });
|
|
576
|
+
const pending = listProposals(stashDir, { status: "pending" }, ctx);
|
|
533
577
|
const reflectPending = pending.filter((p) => p.source === "reflect");
|
|
534
578
|
for (const p of reflectPending) {
|
|
535
579
|
let parsed;
|
|
@@ -608,7 +652,7 @@ export function expireStaleProposals(stashDir, config, ctx) {
|
|
|
608
652
|
}
|
|
609
653
|
const retentionMs = retentionDays * MS_PER_DAY;
|
|
610
654
|
const nowMs = (ctx?.now ?? Date.now)();
|
|
611
|
-
const pending = listProposals(stashDir, { status: "pending" });
|
|
655
|
+
const pending = listProposals(stashDir, { status: "pending" }, ctx);
|
|
612
656
|
for (const p of pending) {
|
|
613
657
|
const createdMs = new Date(p.createdAt).getTime();
|
|
614
658
|
if (!Number.isFinite(createdMs))
|
|
@@ -658,18 +702,17 @@ export function validateProposal(proposal) {
|
|
|
658
702
|
/**
|
|
659
703
|
* Validate a proposal, then promote it through the canonical
|
|
660
704
|
* {@link writeAssetToSource} dispatch (the single place that branches on
|
|
661
|
-
* `source.kind`). On success the proposal
|
|
662
|
-
*
|
|
663
|
-
*
|
|
705
|
+
* `source.kind`). On success the proposal is archived with status `accepted`.
|
|
706
|
+
* Validation failures throw a `UsageError` carrying every finding so the CLI
|
|
707
|
+
* can render a single clear error envelope.
|
|
664
708
|
*
|
|
665
709
|
* Phase 6C: when the target asset already exists at the resolved write path,
|
|
666
|
-
*
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
* can restore the prior content. Genuinely-new assets carry no backup.
|
|
710
|
+
* its prior content is captured BEFORE the write and stored on the archived
|
|
711
|
+
* proposal record (`backupContent`) so `akm proposal revert` can restore it.
|
|
712
|
+
* Genuinely-new assets carry no backup.
|
|
670
713
|
*/
|
|
671
714
|
export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
|
|
672
|
-
const proposal = getProposal(stashDir, id);
|
|
715
|
+
const proposal = getProposal(stashDir, id, ctx);
|
|
673
716
|
if (proposal.status !== "pending") {
|
|
674
717
|
throw new UsageError(`Proposal ${id} is not pending (current status: ${proposal.status}). Only pending proposals can be accepted.`, "INVALID_FLAG_VALUE");
|
|
675
718
|
}
|
|
@@ -683,25 +726,15 @@ export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
|
|
|
683
726
|
throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
|
|
684
727
|
}
|
|
685
728
|
const target = resolveWriteTarget(config, options.target);
|
|
686
|
-
// Phase 6C: capture
|
|
687
|
-
//
|
|
729
|
+
// Phase 6C: capture the prior content (if any) BEFORE writing the new
|
|
730
|
+
// asset. We use the resolved write target to compute the exact path the
|
|
688
731
|
// asset would land at — same resolver `writeAssetToSource` uses — so the
|
|
689
732
|
// backup always mirrors what would be overwritten.
|
|
690
|
-
let
|
|
733
|
+
let backupContent;
|
|
691
734
|
try {
|
|
692
735
|
const targetFilePath = resolveAssetFilePathSafe(target.source, ref);
|
|
693
736
|
if (targetFilePath && fs.existsSync(targetFilePath)) {
|
|
694
|
-
|
|
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;
|
|
737
|
+
backupContent = fs.readFileSync(targetFilePath, "utf8");
|
|
705
738
|
}
|
|
706
739
|
}
|
|
707
740
|
catch (err) {
|
|
@@ -711,62 +744,58 @@ export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
|
|
|
711
744
|
warn(`[proposals] promoteProposal: failed to capture backup for ${id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
712
745
|
}
|
|
713
746
|
const written = await writeAssetToSource(target.source, target.config, ref, proposal.payload.content);
|
|
747
|
+
// 0.9.0 (issue #507): single batch commit at the write boundary for git
|
|
748
|
+
// targets. No-op for filesystem/primary-stash targets.
|
|
749
|
+
commitWriteTargetBoundary(target, `Update ${formatRefForMessage(ref)}`);
|
|
714
750
|
const archived = archiveProposal(stashDir, id, "accepted", undefined, ctx);
|
|
715
|
-
// Persist the backup
|
|
716
|
-
//
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
const withBackup = { ...archived, backup: backupRelPath };
|
|
721
|
-
writeProposalFile(archivedFile, withBackup);
|
|
751
|
+
// Persist the backup content on the archived proposal record so the revert
|
|
752
|
+
// flow can restore the prior asset state.
|
|
753
|
+
if (backupContent !== undefined) {
|
|
754
|
+
const withBackup = { ...archived, backupContent };
|
|
755
|
+
withProposalsDb(stashDir, ctx, (db) => upsertProposal(db, withBackup, stashDir));
|
|
722
756
|
return { proposal: withBackup, assetPath: written.path, ref: written.ref };
|
|
723
757
|
}
|
|
724
758
|
return { proposal: archived, assetPath: written.path, ref: written.ref };
|
|
725
759
|
}
|
|
726
760
|
/**
|
|
727
|
-
* Restore the prior content of an accepted proposal from
|
|
728
|
-
* (Advantage D6c / Phase 6C).
|
|
761
|
+
* Restore the prior content of an accepted proposal from the backup captured
|
|
762
|
+
* at promotion time (Advantage D6c / Phase 6C).
|
|
729
763
|
*
|
|
730
764
|
* Pre-conditions:
|
|
731
765
|
* - `id` resolves to a proposal with `status === "accepted"`.
|
|
732
|
-
* - The proposal carries
|
|
733
|
-
* the
|
|
766
|
+
* - The proposal carries `backupContent` (captured by promoteProposal when
|
|
767
|
+
* the target asset existed before the write).
|
|
734
768
|
*
|
|
735
769
|
* On success:
|
|
736
770
|
* - The backup content is written back through {@link writeAssetToSource},
|
|
737
771
|
* so the canonical write-dispatch invariant is preserved.
|
|
738
|
-
* - The
|
|
772
|
+
* - The proposal record is updated to `status: "reverted"`.
|
|
739
773
|
* - Caller emits a `proposal_reverted` event in the CLI layer (mirrors how
|
|
740
774
|
* `promoted` / `rejected` are emitted by the CLI command, not the core).
|
|
741
775
|
*
|
|
742
776
|
* Errors are thrown as `UsageError` / `NotFoundError` so the CLI can map them
|
|
743
|
-
* cleanly to exit codes — see `src/commands/proposal.ts` for the
|
|
777
|
+
* cleanly to exit codes — see `src/commands/proposal/proposal.ts` for the
|
|
778
|
+
* wrapper.
|
|
744
779
|
*/
|
|
745
780
|
export async function revertProposal(stashDir, config, id, options = {}, ctx) {
|
|
746
|
-
const proposal = getProposal(stashDir, id);
|
|
781
|
+
const proposal = getProposal(stashDir, id, ctx);
|
|
747
782
|
if (proposal.status !== "accepted") {
|
|
748
783
|
throw new UsageError(`only accepted proposals can be reverted (proposal ${id} status: ${proposal.status})`, "INVALID_FLAG_VALUE");
|
|
749
784
|
}
|
|
750
|
-
if (
|
|
785
|
+
if (proposal.backupContent === undefined) {
|
|
751
786
|
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
787
|
}
|
|
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
788
|
const ref = parseAssetRef(proposal.ref);
|
|
762
789
|
if (!TYPE_DIRS[ref.type]) {
|
|
763
790
|
throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
|
|
764
791
|
}
|
|
765
792
|
const target = resolveWriteTarget(config, options.target);
|
|
766
|
-
const written = await writeAssetToSource(target.source, target.config, ref, backupContent);
|
|
767
|
-
//
|
|
768
|
-
//
|
|
769
|
-
|
|
793
|
+
const written = await writeAssetToSource(target.source, target.config, ref, proposal.backupContent);
|
|
794
|
+
// 0.9.0 (issue #507): single batch commit at the write boundary for git
|
|
795
|
+
// targets. No-op for filesystem/primary-stash targets.
|
|
796
|
+
commitWriteTargetBoundary(target, `Revert ${formatRefForMessage(ref)}`);
|
|
797
|
+
// Update the proposal record to status: "reverted" and bump updatedAt +
|
|
798
|
+
// review so the audit trail reflects the second decision.
|
|
770
799
|
const now = nowIso(ctx);
|
|
771
800
|
const reverted = {
|
|
772
801
|
...proposal,
|
|
@@ -778,7 +807,7 @@ export async function revertProposal(stashDir, config, id, options = {}, ctx) {
|
|
|
778
807
|
decidedAt: now,
|
|
779
808
|
},
|
|
780
809
|
};
|
|
781
|
-
|
|
810
|
+
withProposalsDb(stashDir, ctx, (db) => upsertProposal(db, reverted, stashDir));
|
|
782
811
|
return { proposal: reverted, assetPath: written.path, ref: written.ref };
|
|
783
812
|
}
|
|
784
813
|
/**
|
|
@@ -787,8 +816,8 @@ export async function revertProposal(stashDir, config, id, options = {}, ctx) {
|
|
|
787
816
|
* diff matches exactly what `accept` will write. Falls back to "new asset"
|
|
788
817
|
* when no asset is currently materialised at the target ref.
|
|
789
818
|
*/
|
|
790
|
-
export function diffProposal(stashDir, config, id, options = {}) {
|
|
791
|
-
const proposal = getProposal(stashDir, id);
|
|
819
|
+
export function diffProposal(stashDir, config, id, options = {}, ctx) {
|
|
820
|
+
const proposal = getProposal(stashDir, id, ctx);
|
|
792
821
|
const ref = parseAssetRef(proposal.ref);
|
|
793
822
|
let targetPath;
|
|
794
823
|
let existing = null;
|