akm-cli 0.9.0-rc.8 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1063 -44
- package/README.md +51 -25
- package/SECURITY.md +14 -1
- package/STABILITY.md +497 -0
- package/dist/akm +148 -35
- package/dist/{akm-migrate-storage → akm-migrate} +6 -9
- package/dist/assets/hints/cli-hints-full.md +223 -95
- package/dist/assets/hints/cli-hints-short.md +85 -22
- package/dist/assets/improve-strategies/default.json +1 -1
- package/dist/assets/improve-strategies/reflect-distill.json +1 -1
- package/dist/assets/prompts/memory-infer-user.md +2 -3
- package/dist/assets/stash-skeleton/README.md +6 -5
- package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/domains.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/organization.md +20 -9
- package/dist/assets/tasks/core/extract.yml +1 -1
- package/dist/assets/tasks/core/version-check.yml +1 -1
- package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +5 -0
- package/dist/assets/tasks/improve/akm-improve-catchup.yml +8 -0
- package/dist/assets/tasks/improve/akm-improve-consolidate.yml +5 -0
- package/dist/assets/tasks/improve/akm-improve-frequent.yml +5 -0
- package/dist/assets/tasks/improve/akm-improve-nightly.yml +5 -0
- package/dist/assets/templates/html/health.html +1 -3
- package/dist/assets/workflows/workflow-template.md +32 -15
- package/dist/cli/invocation.js +40 -15
- package/dist/cli/parse-args.js +0 -22
- package/dist/cli/retired-commands.js +121 -0
- package/dist/cli/shared.js +154 -22
- package/dist/cli/unknown-flags.js +236 -0
- package/dist/cli-node.mjs +2 -1
- package/dist/cli.js +696 -258
- package/dist/commands/agent/agent-dispatch.js +14 -3
- package/dist/commands/agent/contribute-cli.js +73 -88
- package/dist/commands/completions.js +79 -22
- package/dist/commands/config-cli.js +17 -150
- package/dist/commands/env/env-cli.js +59 -143
- package/dist/commands/env/env.js +12 -163
- package/dist/commands/env/marker-path.js +6 -0
- package/dist/commands/env/secret-cli.js +36 -66
- package/dist/commands/env/secret.js +24 -57
- package/dist/commands/feedback-cli.js +141 -87
- package/dist/commands/health/accept-rate.js +58 -0
- package/dist/commands/health/advisories.js +3 -4
- package/dist/commands/health/checks.js +85 -23
- package/dist/commands/health/html-report.js +7 -10
- package/dist/commands/health/improve-metrics.js +25 -83
- package/dist/commands/health/md-report.js +5 -9
- package/dist/commands/health/metrics.js +62 -20
- package/dist/commands/health/renderers.js +47 -0
- package/dist/commands/health/report-view-model.js +4 -5
- package/dist/commands/health/stash-exposure.js +1 -1
- package/dist/commands/health/surfaces.js +3 -48
- package/dist/commands/health/task-runs.js +3 -67
- package/dist/commands/health/types-improve.js +7 -0
- package/dist/commands/health.js +99 -28
- package/dist/commands/improve/anti-collapse.js +2 -2
- package/dist/commands/improve/autonomy-gate.js +68 -0
- package/dist/commands/improve/collapse-detector.js +41 -40
- package/dist/commands/improve/consolidate/eligibility.js +1 -23
- package/dist/commands/improve/consolidate/merge.js +4 -0
- package/dist/commands/improve/consolidate.js +140 -1000
- package/dist/commands/improve/distill/promote-memory.js +12 -12
- package/dist/commands/improve/distill/quality-gate.js +6 -6
- package/dist/commands/improve/distill.js +58 -69
- package/dist/commands/improve/eligibility.js +105 -57
- package/dist/commands/improve/extract-cli.js +14 -133
- package/dist/commands/improve/improve-cli.js +98 -114
- package/dist/commands/improve/improve-result-file.js +1 -28
- package/dist/commands/improve/improve-strategies.js +8 -5
- package/dist/commands/improve/improve.js +128 -91
- package/dist/commands/improve/loop-stages.js +182 -20
- package/dist/commands/improve/memory/derived-ref.js +45 -43
- package/dist/commands/improve/memory/memory-belief.js +1 -1
- package/dist/commands/improve/memory/memory-contradiction-detect.js +4 -12
- package/dist/commands/improve/memory/memory-improve.js +6 -5
- package/dist/commands/improve/outcome-loop.js +22 -65
- package/dist/commands/improve/preparation.js +114 -123
- package/dist/commands/improve/proactive-maintenance.js +2 -5
- package/dist/commands/improve/reflect.js +56 -160
- package/dist/commands/improve/salience.js +11 -122
- package/dist/commands/improve/source-identity.js +10 -38
- package/dist/commands/lint/base-linter.js +20 -124
- package/dist/commands/lint/env-key-rules.js +31 -47
- package/dist/commands/lint/index.js +249 -43
- package/dist/commands/{events.js → log.js} +33 -38
- package/dist/commands/migrate-cli.js +92 -12
- package/dist/commands/migration-tool.js +46 -0
- package/dist/commands/observability-cli.js +70 -209
- package/dist/commands/proposal/drain.js +101 -29
- package/dist/commands/proposal/proposal-cli.js +76 -48
- package/dist/commands/proposal/proposal.js +54 -18
- package/dist/commands/proposal/propose-cli.js +88 -0
- package/dist/commands/proposal/propose.js +23 -15
- package/dist/commands/proposal/repository.js +701 -278
- package/dist/commands/proposal/validators/proposal-quality-validators.js +2 -8
- package/dist/commands/proposal/validators/proposal-validators.js +55 -7
- package/dist/commands/proposal/validators/proposals.js +4 -7
- package/dist/commands/read/curate.js +34 -53
- package/dist/commands/read/knowledge.js +150 -95
- package/dist/commands/read/registry-search.js +2 -2
- package/dist/commands/read/remember-cli.js +42 -15
- package/dist/commands/read/search-cli.js +180 -78
- package/dist/commands/read/search.js +58 -43
- package/dist/commands/read/show.js +197 -141
- package/dist/commands/registry-cli.js +12 -51
- package/dist/commands/remember.js +14 -57
- package/dist/commands/sources/add-cli.js +100 -31
- package/dist/commands/sources/bundle-cli.js +166 -0
- package/dist/commands/sources/bundle-config-ops.js +7 -2
- package/dist/commands/sources/info.js +18 -5
- package/dist/commands/sources/init.js +12 -12
- package/dist/commands/sources/installed-stashes.js +382 -98
- package/dist/commands/sources/schema-repair.js +3 -2
- package/dist/commands/sources/self-update.js +131 -38
- package/dist/commands/sources/source-add.js +72 -17
- package/dist/commands/sources/source-clone.js +129 -45
- package/dist/commands/sources/source-manage.js +43 -23
- package/dist/commands/sources/sources-cli.js +57 -208
- package/dist/commands/sources/stash-cli.js +46 -53
- package/dist/commands/tasks/tasks-cli.js +91 -97
- package/dist/commands/tasks/tasks.js +276 -421
- package/dist/commands/workflow-cli.js +175 -450
- package/dist/core/adapter/adapters/akm-adapter.js +47 -28
- package/dist/core/adapter/adapters/akm-lint.js +42 -27
- package/dist/core/adapter/adapters/akm-metadata.js +15 -44
- package/dist/core/adapter/adapters/akm-task-adapter.js +15 -13
- package/dist/core/adapter/adapters/akm-workflow-adapter.js +55 -71
- package/dist/core/adapter/adapters/dotenv-adapter.js +1 -1
- package/dist/core/adapter/adapters/generic-files-adapter.js +2 -0
- package/dist/core/adapter/adapters/index.js +6 -6
- package/dist/core/adapter/adapters/llm-wiki-adapter.js +14 -8
- package/dist/core/adapter/adapters/okf-adapter.js +187 -19
- package/dist/core/adapter/adapters/shared.js +3 -19
- package/dist/core/adapter/adapters/tool-dir-shared.js +8 -3
- package/dist/core/adapter/adapters/website-snapshot-adapter.js +1 -0
- package/dist/core/adapter/detect-adapter.js +17 -0
- package/dist/core/adapter/recognize-match.js +6 -4
- package/dist/core/adapter/validate-context.js +214 -0
- package/dist/core/asset/akm-markdown.js +63 -0
- package/dist/core/asset/asset-placement.js +20 -6
- package/dist/core/asset/asset-ref.js +11 -9
- package/dist/core/asset/frontmatter-lint.js +30 -0
- package/dist/core/asset/frontmatter.js +37 -9
- package/dist/core/asset/markdown.js +40 -51
- package/dist/core/asset/resolve-ref.js +89 -18
- package/dist/core/asset/stash-meta.js +1 -1
- package/dist/core/bundle-id.js +51 -0
- package/dist/core/common.js +152 -38
- package/dist/core/config/config-io.js +12 -1
- package/dist/core/config/config-schema.js +35 -8
- package/dist/core/config/config-sources.js +55 -11
- package/dist/core/config/config-walker.js +25 -9
- package/dist/core/config/config.js +9 -48
- package/dist/core/config/experimental.js +21 -0
- package/dist/core/config/schema/embedding.js +5 -1
- package/dist/core/config/schema/experimental.js +30 -0
- package/dist/core/config/schema/improve-processes.js +0 -6
- package/dist/core/config/schema/improve.js +21 -3
- package/dist/core/config/schema/index-config.js +8 -15
- package/dist/core/config/schema/output.js +4 -1
- package/dist/core/config/schema/setup.js +9 -18
- package/dist/core/config/schema/sources-bundles.js +49 -33
- package/dist/core/config/schema/workflow.js +3 -3
- package/dist/core/env-secret-ref.js +76 -46
- package/dist/core/errors.js +18 -12
- package/dist/core/events.js +46 -128
- package/dist/core/file-change.js +6 -5
- package/dist/core/fs-txn.js +83 -7
- package/dist/core/git-message.js +2 -2
- package/dist/core/improve-result.js +1 -100
- package/dist/core/lesson-lint.js +1 -17
- package/dist/core/logs-db.js +2 -1
- package/dist/core/migration-operation.js +16 -0
- package/dist/core/mutation-target.js +78 -0
- package/dist/core/parse.js +4 -1
- package/dist/core/paths.js +17 -20
- package/dist/core/recognition-util.js +12 -14
- package/dist/core/redaction.js +34 -0
- package/dist/core/standards/resolve-standards-context.js +2 -14
- package/dist/core/standards/resolve-stash-standards.js +2 -2
- package/dist/core/standards/resolve-type-conventions.js +2 -2
- package/dist/core/state/migrations.js +41 -18
- package/dist/core/state-db.js +5 -14
- package/dist/core/structured.js +1 -1
- package/dist/core/subprocess.js +6 -4
- package/dist/core/text-truncation.js +9 -5
- package/dist/core/type-presentation.js +3 -3
- package/dist/core/warn.js +0 -3
- package/dist/core/write-source.js +771 -95
- package/dist/indexer/bundle-identity-guard.js +3 -2
- package/dist/indexer/db/graph-db.js +0 -24
- package/dist/indexer/ensure-index.js +1 -0
- package/dist/indexer/graph/graph-boost.js +9 -34
- package/dist/indexer/graph/graph-extraction.js +8 -5
- package/dist/indexer/index-writer-lock.js +53 -17
- package/dist/indexer/index-written-assets.js +16 -22
- package/dist/indexer/indexer.js +497 -239
- package/dist/indexer/installations.js +14 -96
- package/dist/indexer/passes/dir-staleness.js +16 -9
- package/dist/indexer/passes/memory-inference.js +11 -9
- package/dist/indexer/passes/metadata.js +113 -47
- package/dist/indexer/scan/doc-to-entry.js +38 -1
- package/dist/indexer/scan/drain-dir.js +13 -23
- package/dist/indexer/search/db-search.js +99 -54
- package/dist/indexer/search/fts-query.js +47 -24
- package/dist/indexer/search/ranking-contributors.js +42 -20
- package/dist/indexer/search/ranking.js +18 -99
- package/dist/indexer/search/search-fields.js +7 -2
- package/dist/indexer/search/search-source.js +82 -93
- package/dist/indexer/usage/usage-events.js +0 -89
- package/dist/indexer/walk/file-context.js +2 -1
- package/dist/indexer/walk/matchers.js +30 -43
- package/dist/indexer/walk/path-resolver.js +7 -2
- package/dist/indexer/walk/walker.js +38 -12
- package/dist/integrations/agent/builders.js +0 -6
- package/dist/integrations/agent/config.js +2 -2
- package/dist/integrations/agent/detect.js +49 -19
- package/dist/integrations/agent/engine-fallback.js +76 -0
- package/dist/integrations/agent/profiles.js +14 -0
- package/dist/integrations/agent/prompts.js +12 -8
- package/dist/integrations/agent/runner-dispatch.js +4 -2
- package/dist/integrations/agent/runner.js +0 -1
- package/dist/integrations/agent/spawn.js +5 -6
- package/dist/integrations/github.js +1 -1
- package/dist/integrations/harnesses/aider/agent-builder.js +6 -4
- package/dist/integrations/harnesses/amazonq/agent-builder.js +7 -4
- package/dist/integrations/harnesses/claude/session-log.js +0 -10
- package/dist/integrations/harnesses/codex/agent-builder.js +5 -2
- package/dist/integrations/harnesses/copilot/agent-builder.js +5 -3
- package/dist/integrations/harnesses/gemini/agent-builder.js +5 -3
- package/dist/integrations/harnesses/index.js +3 -7
- package/dist/integrations/harnesses/opencode/agent-builder.js +21 -2
- package/dist/integrations/harnesses/opencode/session-log.js +0 -15
- package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +13 -4
- package/dist/integrations/harnesses/openhands/agent-builder.js +9 -6
- package/dist/integrations/harnesses/pi/agent-builder.js +6 -4
- package/dist/integrations/lockfile.js +101 -6
- package/dist/integrations/session-logs/index.js +3 -28
- package/dist/llm/client.js +136 -100
- package/dist/llm/embedders/remote.js +13 -5
- package/dist/llm/feature-gate.js +4 -12
- package/dist/llm/graph-extract.js +5 -11
- package/dist/llm/memory-infer.js +144 -1
- package/dist/llm/metadata-enhance.js +5 -7
- package/dist/llm/structured-call.js +1 -1
- package/dist/llm/usage-persist.js +26 -5
- package/dist/llm/usage-telemetry.js +25 -2
- package/dist/output/cli-hints.js +1 -2
- package/dist/output/context.js +22 -7
- package/dist/output/format-exempt.js +80 -0
- package/dist/output/generic-render.js +259 -0
- package/dist/output/render-registry.js +57 -0
- package/dist/output/renderers.js +14 -36
- package/dist/output/shapes/curate.js +10 -1
- package/dist/output/shapes/events.js +12 -7
- package/dist/output/shapes/helpers.js +56 -83
- package/dist/output/shapes/migrate.js +8 -0
- package/dist/output/shapes/passthrough.js +7 -41
- package/dist/output/shapes/proposal/producer.js +15 -7
- package/dist/output/shapes.js +2 -9
- package/dist/output/text/{init.js → bundle-create.js} +3 -1
- package/dist/output/text/bundle-show.js +7 -0
- package/dist/output/text/command-format.js +164 -96
- package/dist/output/text/env.js +1 -3
- package/dist/output/text/events.js +8 -7
- package/dist/output/text/health-format.js +103 -0
- package/dist/output/text/health.js +7 -0
- package/dist/output/text/helpers.js +10 -8
- package/dist/output/text/lint-format.js +43 -0
- package/dist/output/text/{save.js → lint.js} +2 -2
- package/dist/output/text/migrate.js +88 -0
- package/dist/output/text/proposal/producer.js +4 -2
- package/dist/output/text/proposal-format.js +44 -72
- package/dist/output/text/registry-commands.js +1 -2
- package/dist/output/text/show-directives.js +15 -7
- package/dist/output/text/status-list.js +32 -0
- package/dist/output/text/sync.js +5 -0
- package/dist/output/text/workflow-format.js +24 -203
- package/dist/output/text/workflow.js +1 -7
- package/dist/output/text.js +16 -17
- package/dist/registry/factory.js +4 -6
- package/dist/registry/origin-resolve.js +16 -27
- package/dist/registry/providers/skills-sh.js +3 -3
- package/dist/registry/providers/static-index.js +13 -23
- package/dist/registry/resolve.js +42 -7
- package/dist/registry/semver.js +34 -84
- package/dist/runtime.js +2 -23
- package/dist/scripts/akm-migrate-node.js +60290 -0
- package/dist/scripts/akm-migrate.js +59628 -0
- package/dist/setup/detect.js +42 -15
- package/dist/setup/registry-stash-loader.js +2 -2
- package/dist/setup/setup.js +236 -136
- package/dist/setup/steps/connection.js +7 -9
- package/dist/setup/steps/platforms.js +9 -9
- package/dist/setup/steps/semantic.js +15 -3
- package/dist/setup/steps/sources.js +12 -13
- package/dist/setup/steps/stashdir.js +2 -3
- package/dist/setup/steps/tasks.js +237 -120
- package/dist/sources/freshness.js +1 -1
- package/dist/sources/provider-factory.js +11 -17
- package/dist/sources/providers/filesystem.js +2 -3
- package/dist/sources/providers/git-install.js +278 -34
- package/dist/sources/providers/git-provider.js +25 -23
- package/dist/sources/providers/git-stash.js +395 -106
- package/dist/sources/providers/git.js +2 -2
- package/dist/sources/providers/npm.js +16 -19
- package/dist/sources/providers/provider-utils.js +7 -4
- package/dist/sources/providers/sync-from-ref.js +3 -9
- package/dist/sources/providers/website.js +6 -1
- package/dist/sources/resolve.js +6 -5
- package/dist/sources/snapshot-fetchers/bluesky.js +146 -0
- package/dist/sources/snapshot-fetchers/content-extract.js +566 -0
- package/dist/sources/snapshot-fetchers/fetcher-util.js +41 -0
- package/dist/sources/snapshot-fetchers/github.js +100 -0
- package/dist/sources/snapshot-fetchers/host-guard.js +291 -0
- package/dist/sources/snapshot-fetchers/registry.js +17 -1
- package/dist/sources/snapshot-fetchers/robots.js +348 -0
- package/dist/sources/snapshot-fetchers/rss.js +282 -0
- package/dist/sources/snapshot-fetchers/secret-seam.js +42 -0
- package/dist/sources/snapshot-fetchers/website-ingest.js +566 -268
- package/dist/sources/snapshot-fetchers/x.js +910 -0
- package/dist/storage/database.js +7 -0
- package/dist/storage/engines/sqlite-migrations.js +23 -111
- package/dist/storage/managed-db.js +2 -2
- package/dist/storage/repositories/canaries-repository.js +1 -1
- package/dist/storage/repositories/events-repository.js +27 -11
- package/dist/storage/repositories/improve-runs-repository.js +6 -12
- package/dist/storage/repositories/index-connection.js +17 -6
- package/dist/storage/repositories/index-entries-repository.js +151 -240
- package/dist/storage/repositories/index-entry-mapper.js +15 -11
- package/dist/storage/repositories/index-fts-repository.js +5 -2
- package/dist/storage/repositories/index-llm-cache-repository.js +0 -1
- package/dist/storage/repositories/index-meta-repository.js +2 -3
- package/dist/storage/repositories/index-schema.js +10 -25
- package/dist/storage/repositories/index-utility-repository.js +15 -28
- package/dist/storage/repositories/index-vec-repository.js +6 -1
- package/dist/storage/repositories/outcome-repository.js +119 -0
- package/dist/storage/repositories/proposals-repository.js +296 -59
- package/dist/storage/repositories/registry-cache.js +19 -0
- package/dist/storage/repositories/salience-repository.js +172 -0
- package/dist/storage/repositories/task-history-repository.js +15 -13
- package/dist/storage/repositories/workflow-runs-repository.js +52 -40
- package/dist/tasks/backends/cron.js +105 -15
- package/dist/tasks/backends/index.js +1 -1
- package/dist/tasks/backends/launchd.js +85 -38
- package/dist/tasks/backends/schtasks.js +135 -15
- package/dist/tasks/embedded.js +56 -40
- package/dist/tasks/parser.js +7 -157
- package/dist/tasks/resolve-akm-bin.js +137 -59
- package/dist/tasks/runner.js +79 -42
- package/dist/tasks/scheduler-invocation.js +220 -10
- package/dist/tasks/schema.js +24 -1
- package/dist/tasks/task-id.js +1 -3
- package/dist/tasks/validator.js +20 -6
- package/dist/workflows/authoring/authoring.js +94 -143
- package/dist/workflows/authoring/scope-key.js +1 -1
- package/dist/workflows/exec/frozen-judge.js +28 -2
- package/dist/workflows/exec/native-executor.js +77 -57
- package/dist/workflows/exec/param-secrets.js +9 -9
- package/dist/workflows/exec/run-workflow.js +133 -79
- package/dist/workflows/exec/step-work.js +219 -346
- package/dist/{migrate-storage-node.mjs → workflows/exec/unit-dispatch.js} +1 -5
- package/dist/workflows/ir/compile.js +141 -270
- package/dist/workflows/ir/freeze.js +40 -30
- package/dist/workflows/ir/params.js +135 -11
- package/dist/workflows/ir/plan-hash.js +1 -1
- package/dist/workflows/ir/schema.js +25 -26
- package/dist/workflows/parser.js +872 -307
- package/dist/workflows/program/expressions.js +20 -208
- package/dist/workflows/program/schema.js +7 -10
- package/dist/workflows/renderer.js +95 -68
- package/dist/workflows/resource-limits.js +2 -0
- package/dist/workflows/runtime/checkin.js +3 -3
- package/dist/workflows/runtime/plan-classifier.js +16 -75
- package/dist/workflows/runtime/runs.js +186 -127
- package/dist/workflows/runtime/unit-checkin.js +1 -1
- package/dist/workflows/runtime/unit-phases.js +2 -2
- package/dist/workflows/runtime/workflow-asset-loader.js +232 -83
- package/dist/workflows/schema.js +1 -11
- package/dist/workflows/validate-summary.js +30 -36
- package/dist/workflows/validator.js +21 -62
- package/docs/README.md +68 -0
- package/docs/migration/README.md +8 -0
- package/docs/migration/release-notes/0.7.0.md +11 -11
- package/docs/migration/release-notes/0.9.0.md +208 -27
- package/docs/migration/v0.7-to-v0.8.md +46 -47
- package/docs/migration/v0.8-to-v0.9.md +564 -208
- package/docs/migration/v0.9.0-troubleshooting.md +561 -0
- package/docs/reference/README.md +12 -0
- package/docs/reference/cli.md +2253 -0
- package/docs/reference/configuration.md +358 -0
- package/docs/reference/data-and-telemetry.md +105 -42
- package/docs/reference/workflows.md +647 -0
- package/package.json +22 -11
- package/schemas/akm-asset-envelope.json +93 -0
- package/schemas/akm-config.json +81 -128
- package/schemas/akm-workflow.json +74 -73
- package/dist/assets/tasks/core/backup.yml +0 -5
- package/dist/assets/tasks/graph-refresh-weekly.yml +0 -10
- package/dist/cli/config-migrate.js +0 -1806
- package/dist/cli/config-validate.js +0 -41
- package/dist/commands/backup-cli.js +0 -56
- package/dist/commands/bundle/bundle-cli.js +0 -68
- package/dist/commands/bundle/bundle.js +0 -219
- package/dist/commands/graph/graph-cli.js +0 -124
- package/dist/commands/graph/graph.js +0 -489
- package/dist/commands/improve/extract-watch.js +0 -140
- package/dist/commands/mv-cli.js +0 -1221
- package/dist/commands/sources/history.js +0 -201
- package/dist/commands/tasks/default-tasks.js +0 -186
- package/dist/core/migration-backup.js +0 -1234
- package/dist/indexer/usage/unmigrated-vaults-guard.js +0 -95
- package/dist/llm/memory-infer-impl.js +0 -138
- package/dist/migrate/legacy/config-source-migration.js +0 -223
- package/dist/migrate/legacy/content-migration.js +0 -305
- package/dist/migrate/legacy/legacy-layout.js +0 -779
- package/dist/migrate/legacy/legacy-paths.js +0 -25
- package/dist/migrate/legacy/legacy-stash-json.js +0 -72
- package/dist/migrate/legacy/proposal-fs-import.js +0 -168
- package/dist/migrate/legacy/task-target-ref-migration.js +0 -272
- package/dist/migrate/legacy/three-db-cutover.js +0 -841
- package/dist/migrate/legacy/workflow-migrations-bodies.js +0 -52
- package/dist/migrate/legacy/workflow-migrations-frozen.js +0 -21
- package/dist/migrate/legacy-ref-grammar.js +0 -214
- package/dist/output/shapes/distill.js +0 -14
- package/dist/output/shapes/history.js +0 -11
- package/dist/output/text/distill.js +0 -6
- package/dist/output/text/enable-disable.js +0 -8
- package/dist/output/text/history.js +0 -6
- package/dist/registry/build-index.js +0 -382
- package/dist/schemas/akm-config.json +0 -4704
- package/dist/schemas/akm-task.json +0 -87
- package/dist/schemas/akm-workflow.json +0 -372
- package/dist/scripts/migrate-storage.js +0 -3816
- package/dist/workflows/authoring/workflow-program-template.yaml +0 -31
- package/dist/workflows/cli.js +0 -53
- package/dist/workflows/exec/brief.js +0 -481
- package/dist/workflows/exec/report.js +0 -1460
- package/dist/workflows/exec/watch.js +0 -116
- package/dist/workflows/program/parser.js +0 -813
- package/dist/workflows/program/project.js +0 -104
|
@@ -21,17 +21,6 @@
|
|
|
21
21
|
* is a status flip, and the full audit trail (review outcome, reason, backup
|
|
22
22
|
* content for revert) lives on the row.
|
|
23
23
|
*
|
|
24
|
-
* ## Legacy filesystem import
|
|
25
|
-
*
|
|
26
|
-
* Before 0.9.0 proposals lived as per-uuid JSON directories under
|
|
27
|
-
* `<stashDir>/.akm/proposals/`. That import used to run on EVERY proposal
|
|
28
|
-
* operation here (through {@link withProposalsDb}, disk-probing the legacy tree
|
|
29
|
-
* forever). It has been FOLDED OUT of the live path into the one-time migrator:
|
|
30
|
-
* `akm migrate apply` runs it once as an additive step (see
|
|
31
|
-
* `src/migrate/legacy/proposal-fs-import.ts`). Idempotency is now INSERT OR
|
|
32
|
-
* IGNORE on the proposal UUID plus migrate-apply's own journal — no live-path
|
|
33
|
-
* probe, no `proposal_fs_imports` ledger.
|
|
34
|
-
*
|
|
35
24
|
* # Why the queue bypasses `writeAssetToSource`
|
|
36
25
|
*
|
|
37
26
|
* The architectural rule "all writes go through `writeAssetToSource`" applies
|
|
@@ -45,27 +34,40 @@
|
|
|
45
34
|
*/
|
|
46
35
|
import { createHash, randomUUID } from "node:crypto";
|
|
47
36
|
import fs from "node:fs";
|
|
37
|
+
import os from "node:os";
|
|
48
38
|
import path from "node:path";
|
|
39
|
+
import { parse as parseYaml } from "yaml";
|
|
40
|
+
import { adapterForId } from "../../core/adapter/registry.js";
|
|
41
|
+
import { createValidateContext } from "../../core/adapter/validate-context.js";
|
|
42
|
+
import { ensureAkmMarkdownType } from "../../core/asset/akm-markdown.js";
|
|
49
43
|
import { assetPathForName, placementTypes, stashDirFor } from "../../core/asset/asset-placement.js";
|
|
50
|
-
import { isBundleSlug } from "../../core/asset/asset-ref.js";
|
|
44
|
+
import { isBundleSlug, parseBundleRef } from "../../core/asset/asset-ref.js";
|
|
45
|
+
import { assembleAsset, serializeFrontmatter } from "../../core/asset/asset-serialize.js";
|
|
46
|
+
import { parseFrontmatter } from "../../core/asset/frontmatter.js";
|
|
51
47
|
import { conceptIdFromTypeName, parseRefInput } from "../../core/asset/resolve-ref.js";
|
|
52
48
|
import { isWithin } from "../../core/common.js";
|
|
53
49
|
import { loadConfig } from "../../core/config/config.js";
|
|
54
|
-
import { NotFoundError, UsageError } from "../../core/errors.js";
|
|
50
|
+
import { ConfigError, NotFoundError, UsageError } from "../../core/errors.js";
|
|
55
51
|
import { appendEvent } from "../../core/events.js";
|
|
56
52
|
import { proposalContent } from "../../core/file-change.js";
|
|
57
|
-
import { _setTxnMutationHookForTests, advanceTxn, beginTxn, canonicalTxnRoot, cleanupTxn, fsyncTxnDir, fsyncTxnFile, listTxnJournals, mintTxnId,
|
|
53
|
+
import { _setTxnMutationHookForTests, advanceTxn, beginTxn, canonicalTxnRoot, cleanupTxn, fsyncTxnDir, fsyncTxnFile, listTxnJournals, mintTxnId, registerTxnKind, sweepJournallessTxnDir, txnDirFor, txnMutationHook, txnNamespaceDir, } from "../../core/fs-txn.js";
|
|
54
|
+
import { canonicalBundleIdForTarget, resolveBundleWriteTarget } from "../../core/mutation-target.js";
|
|
58
55
|
import { withImmediateTransaction, withStateDb } from "../../core/state-db.js";
|
|
59
56
|
import { warn } from "../../core/warn.js";
|
|
60
|
-
import {
|
|
57
|
+
import { assertAkmAssetWrite, assertWriteTargetPathsClean, captureGitPublication, captureWriteTargetPathSnapshot, prepareWriteTargetForMutation, publishWriteTargetTransaction, resolveWriteTarget, } from "../../core/write-source.js";
|
|
61
58
|
import { withAssetMutationLease } from "../../indexer/index-writer-lock.js";
|
|
62
59
|
import { indexWrittenAssets } from "../../indexer/index-written-assets.js";
|
|
63
|
-
import { deriveInstallations
|
|
60
|
+
import { deriveInstallations } from "../../indexer/installations.js";
|
|
61
|
+
import { resolveSourceEntries } from "../../indexer/search/search-source.js";
|
|
64
62
|
import { insertEventOnce } from "../../storage/repositories/events-repository.js";
|
|
65
|
-
import { getStateProposal, listStateProposalIdsByPrefix, listStateProposals, upsertProposal, } from "../../storage/repositories/proposals-repository.js";
|
|
63
|
+
import { getStateProposal, getStateProposalLenient, listStateProposalIdsByPrefix, listStateProposals, upsertProposal, } from "../../storage/repositories/proposals-repository.js";
|
|
64
|
+
import { pkgVersion } from "../../version.js";
|
|
65
|
+
import { runBaseChecks } from "../lint/base-linter.js";
|
|
66
66
|
import { formatNewAssetDiff, formatUnifiedDiff } from "./diff-format.js";
|
|
67
67
|
import { isAutomatedProposalSource, isValidProposalSource, PROPOSAL_SOURCES, } from "./proposal-types.js";
|
|
68
|
+
import { hasCanonicalProposalValidator } from "./validators/proposal-validators.js";
|
|
68
69
|
import { repairProposalContent, validateProposal } from "./validators/proposals.js";
|
|
70
|
+
const PROMOTION_LINT_BLOCKERS = new Set(["unquoted-colon", "missing-ref", "stale-path"]);
|
|
69
71
|
// ── Proposal domain types (moved to ./proposal-types.ts, WI-9.8 KILL 1) ─────
|
|
70
72
|
//
|
|
71
73
|
// Proposal / ProposalStatus / ProposalPayload / ProposalReview /
|
|
@@ -73,8 +75,8 @@ import { repairProposalContent, validateProposal } from "./validators/proposals.
|
|
|
73
75
|
// AUTOMATED_PROPOSAL_SOURCES / isValidProposalSource / isAutomatedProposalSource
|
|
74
76
|
// moved to the dependency-free leaf so validators/proposals.ts,
|
|
75
77
|
// validators/proposal-validators.ts, storage/repositories/proposals-repository.ts,
|
|
76
|
-
// and
|
|
77
|
-
//
|
|
78
|
+
// and storage repositories can import the `Proposal` type without importing
|
|
79
|
+
// this heavier transaction-engine module back. That back-edge was the
|
|
78
80
|
// repository↔validators import cycle (plan §10.7 D.3). Every symbol this
|
|
79
81
|
// module used to export directly is re-exported here verbatim so existing
|
|
80
82
|
// import sites (`from "../proposal/repository.js"`) are unchanged.
|
|
@@ -92,7 +94,7 @@ function withProposalContent(p, content) {
|
|
|
92
94
|
return {
|
|
93
95
|
...p,
|
|
94
96
|
payload: { ...p.payload, content },
|
|
95
|
-
changes:
|
|
97
|
+
changes: p.changes.map((c, i) =>
|
|
96
98
|
// A delete-op primary change carries no `after` (file-change.ts contract).
|
|
97
99
|
i === 0 && c.op !== "delete" ? { ...c, after: content } : c),
|
|
98
100
|
};
|
|
@@ -144,81 +146,138 @@ function newId(ctx) {
|
|
|
144
146
|
* connection to `fn`, and close it in a `finally`. Every public function in
|
|
145
147
|
* this module funnels its store access through here.
|
|
146
148
|
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
* `akm migrate apply` (`src/migrate/legacy/proposal-fs-import.ts`). `stashDir`
|
|
150
|
-
* is still threaded through the public API for the store's per-stash partition.
|
|
149
|
+
* `stashDir` is threaded through the public API for the store's per-stash
|
|
150
|
+
* partition.
|
|
151
151
|
*/
|
|
152
152
|
function withProposalsDb(_stashDir, ctx, fn) {
|
|
153
153
|
return withStateDb(fn, { path: ctx?.dbPath });
|
|
154
154
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
155
|
+
function persistProposalUpdate(db, proposal, stashDir) {
|
|
156
|
+
// Only take the full re-serialize (upsertProposal -> proposalToRowValues)
|
|
157
|
+
// path when `changes` is well-formed enough to survive it — a non-empty
|
|
158
|
+
// path on every entry, matching proposalToRowValues' own validation. A
|
|
159
|
+
// legacy row with a present `proposedTarget` but an empty/missing
|
|
160
|
+
// `changes[].path` (e.g. a malformed 0.8-era row) would otherwise die in
|
|
161
|
+
// proposalToRowValues; falling through to the targeted UPDATE below keeps
|
|
162
|
+
// `changes`/`proposedTarget` untouched in metadata_json and only flips the
|
|
163
|
+
// status/review/etc. fields the terminal transition actually needs.
|
|
164
|
+
const changesAreWellFormed = proposal.changes.length > 0 && proposal.changes.every((change) => change.path.length > 0);
|
|
165
|
+
if (changesAreWellFormed && proposal.proposedTarget) {
|
|
166
|
+
upsertProposal(db, proposal, stashDir);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const row = db
|
|
170
|
+
.prepare("SELECT metadata_json FROM proposals WHERE id = ? AND stash_dir = ?")
|
|
171
|
+
.get(proposal.id, stashDir);
|
|
172
|
+
if (!row)
|
|
173
|
+
throw new NotFoundError(`Proposal "${proposal.id}" not found.`, "PROPOSAL_NOT_FOUND");
|
|
174
|
+
const metadata = JSON.parse(row.metadata_json);
|
|
175
|
+
for (const field of [
|
|
176
|
+
"sourceRun",
|
|
177
|
+
"beforeHash",
|
|
178
|
+
"review",
|
|
179
|
+
"confidence",
|
|
180
|
+
"gateDecision",
|
|
181
|
+
"backupContent",
|
|
182
|
+
"acceptedTarget",
|
|
183
|
+
"eligibilitySource",
|
|
184
|
+
]) {
|
|
185
|
+
const value = proposal[field];
|
|
186
|
+
if (value === undefined)
|
|
187
|
+
delete metadata[field];
|
|
188
|
+
else
|
|
189
|
+
metadata[field] = value;
|
|
190
|
+
}
|
|
191
|
+
db.prepare(`UPDATE proposals
|
|
192
|
+
SET ref = ?, status = ?, source = ?, updated_at = ?, content = ?, frontmatter_json = ?, metadata_json = ?
|
|
193
|
+
WHERE id = ? AND stash_dir = ?`).run(proposal.ref, proposal.status, proposal.source, proposal.updatedAt, proposal.payload.content, proposal.payload.frontmatter ? JSON.stringify(proposal.payload.frontmatter) : null, JSON.stringify(metadata), proposal.id, stashDir);
|
|
194
|
+
}
|
|
195
|
+
function proposalRefIdentity(ref) {
|
|
188
196
|
try {
|
|
189
|
-
const
|
|
190
|
-
|
|
197
|
+
const parsed = parseBundleRef(ref);
|
|
198
|
+
if (parsed.fragment !== undefined || isRetiredProposalConceptId(parsed.conceptId))
|
|
199
|
+
return undefined;
|
|
200
|
+
return {
|
|
201
|
+
conceptId: parsed.conceptId,
|
|
202
|
+
...(parsed.bundle !== undefined ? { bundle: parsed.bundle } : {}),
|
|
203
|
+
};
|
|
191
204
|
}
|
|
192
205
|
catch {
|
|
193
206
|
return undefined;
|
|
194
207
|
}
|
|
195
208
|
}
|
|
196
|
-
|
|
197
|
-
* The conceptId a USER-SUPPLIED `--ref` / `idOrRef` filter maps to. Unlike the
|
|
198
|
-
* tolerant {@link proposalConceptId} (which reads STORED rows), an unparseable
|
|
199
|
-
* filter throws a typed {@link UsageError} rather than resolving to `undefined`
|
|
200
|
-
* and silently matching nothing — an invalid filter should fail loudly, naming
|
|
201
|
-
* the 0.9.0 grammar (D-R3: the legacy `type:name` grammar is gone). Delegates the
|
|
202
|
-
* grammar to `parseRefInput`, so a legacy `skill:x` input surfaces the same loud
|
|
203
|
-
* error as any other unparseable filter.
|
|
204
|
-
*/
|
|
205
|
-
function filterConceptId(ref) {
|
|
209
|
+
function filterRefIdentity(ref) {
|
|
206
210
|
try {
|
|
207
|
-
const p =
|
|
208
|
-
|
|
211
|
+
const p = parseBundleRef(ref);
|
|
212
|
+
if (p.fragment !== undefined || isRetiredProposalConceptId(p.conceptId)) {
|
|
213
|
+
throw new Error("not a current proposal identity");
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
conceptId: p.conceptId,
|
|
217
|
+
...(p.bundle !== undefined ? { bundle: p.bundle } : {}),
|
|
218
|
+
};
|
|
209
219
|
}
|
|
210
220
|
catch {
|
|
211
221
|
throw new UsageError(`Invalid asset-ref filter "${ref}". Use the 0.9.0 grammar [bundle//]conceptId, e.g. knowledge/guide.md or lessons/deploy.`, "INVALID_FLAG_VALUE");
|
|
212
222
|
}
|
|
213
223
|
}
|
|
214
|
-
function
|
|
224
|
+
function isRetiredProposalConceptId(conceptId) {
|
|
225
|
+
const colon = conceptId.indexOf(":");
|
|
226
|
+
return colon > 0 && stashDirFor(conceptId.slice(0, colon)) !== undefined;
|
|
227
|
+
}
|
|
228
|
+
function proposalMatchesRef(proposalRef, filter) {
|
|
229
|
+
const proposal = proposalRefIdentity(proposalRef);
|
|
230
|
+
return (proposal !== undefined &&
|
|
231
|
+
proposal.conceptId === filter.conceptId &&
|
|
232
|
+
(filter.bundle === undefined || proposal.bundle === filter.bundle));
|
|
233
|
+
}
|
|
234
|
+
function proposalDurableRef(parsedRef, target) {
|
|
215
235
|
const conceptId = conceptIdFromTypeName(parsedRef.type, parsedRef.name);
|
|
216
236
|
const { origin } = parsedRef;
|
|
217
|
-
if (
|
|
218
|
-
|
|
237
|
+
if (!isBundleSlug(target.source)) {
|
|
238
|
+
throw new UsageError(`Proposal target source "${target.source}" is not a valid bundle name.`, "INVALID_FLAG_VALUE");
|
|
219
239
|
}
|
|
220
|
-
|
|
221
|
-
|
|
240
|
+
if (origin !== undefined) {
|
|
241
|
+
if (target.source !== origin) {
|
|
242
|
+
throw new UsageError(`Proposal ref bundle "${origin}" conflicts with target source "${target.source}".`, "INVALID_FLAG_VALUE");
|
|
243
|
+
}
|
|
244
|
+
return `${origin}//${conceptId}`;
|
|
245
|
+
}
|
|
246
|
+
return `${target.source}//${conceptId}`;
|
|
247
|
+
}
|
|
248
|
+
function resolveCreateProposalTarget(stashDir, explicit, bundle) {
|
|
249
|
+
if (explicit) {
|
|
250
|
+
if (!isBundleSlug(explicit.source) || !explicit.root.trim()) {
|
|
251
|
+
throw new UsageError("Proposal targets require a current bundle name and materialized root.", "INVALID_FLAG_VALUE");
|
|
252
|
+
}
|
|
253
|
+
return { source: explicit.source, root: path.resolve(explicit.root) };
|
|
254
|
+
}
|
|
255
|
+
const config = loadConfig();
|
|
256
|
+
const local = resolveProposalQueueTarget(stashDir, config);
|
|
257
|
+
if (!bundle || bundle === local.source)
|
|
258
|
+
return local;
|
|
259
|
+
if (bundle) {
|
|
260
|
+
const target = resolveBundleWriteTarget(config, bundle);
|
|
261
|
+
return { source: target.source.name, root: target.source.path };
|
|
262
|
+
}
|
|
263
|
+
return local;
|
|
264
|
+
}
|
|
265
|
+
export function resolveProposalQueueTarget(stashDir, config = loadConfig()) {
|
|
266
|
+
const root = path.resolve(stashDir);
|
|
267
|
+
const sources = resolveSourceEntries(root, config);
|
|
268
|
+
const sourceIndex = sources.findIndex((source) => path.resolve(source.path) === root);
|
|
269
|
+
const source = sources[sourceIndex];
|
|
270
|
+
const bundleId = sourceIndex >= 0 ? deriveInstallations(sources)[sourceIndex]?.id : undefined;
|
|
271
|
+
if (!source || !bundleId) {
|
|
272
|
+
throw new ConfigError(`No bundle owns proposal queue ${root}.`, "INVALID_CONFIG_FILE");
|
|
273
|
+
}
|
|
274
|
+
if (!source.registryId && Object.keys(config.bundles ?? {}).length > 0) {
|
|
275
|
+
throw new ConfigError(`No configured bundle owns proposal queue ${root}.`, "INVALID_CONFIG_FILE");
|
|
276
|
+
}
|
|
277
|
+
if (source.writable !== true) {
|
|
278
|
+
throw new UsageError(`Proposal bundle "${bundleId}" is not writable.`, "INVALID_FLAG_VALUE");
|
|
279
|
+
}
|
|
280
|
+
return { source: bundleId, root };
|
|
222
281
|
}
|
|
223
282
|
// ── Public API ──────────────────────────────────────────────────────────────
|
|
224
283
|
/**
|
|
@@ -241,17 +300,12 @@ function proposalDurableRef(parsedRef, stashDir) {
|
|
|
241
300
|
* instead of writing. Use {@link isProposalSkipped} to detect it.
|
|
242
301
|
*/
|
|
243
302
|
export function createProposal(stashDir, input, ctx) {
|
|
244
|
-
// F-4 / #385: Validate source against the allow-list. Unknown values are
|
|
245
|
-
// warned (not rejected) for backward compatibility — extension callers
|
|
246
|
-
// that pass custom source strings must not break.
|
|
247
303
|
if (!isValidProposalSource(input.source)) {
|
|
248
304
|
warn(`[proposal] Unknown source "${input.source}". ` +
|
|
249
305
|
`Expected one of: ${PROPOSAL_SOURCES.join(", ")}. ` +
|
|
250
306
|
"Typos in source values produce unaggregatable accept-rate-per-source metrics.");
|
|
251
307
|
}
|
|
252
308
|
else if (isAutomatedProposalSource(input.source) && !input.sourceRun) {
|
|
253
|
-
// Advisory warning: automated sources should include sourceRun for PROV-DM
|
|
254
|
-
// traceability. This is not a hard error to avoid breaking existing callers.
|
|
255
309
|
warn(`[proposal] Automated source "${input.source}" created a proposal without sourceRun. ` +
|
|
256
310
|
"Add sourceRun to enable accept-rate-per-run aggregation (W3C PROV-DM).");
|
|
257
311
|
}
|
|
@@ -280,6 +334,9 @@ export function createProposal(stashDir, input, ctx) {
|
|
|
280
334
|
if (!input.payload.content.trim()) {
|
|
281
335
|
return rejectProposal("empty_content", `Proposal for "${input.ref}" has empty content.`);
|
|
282
336
|
}
|
|
337
|
+
if (input.target && parsedRef.origin && input.target.source !== parsedRef.origin) {
|
|
338
|
+
return rejectProposal("invalid_ref", `Qualified proposal ref bundle "${parsedRef.origin}" conflicts with queue target "${input.target.source}".`);
|
|
339
|
+
}
|
|
283
340
|
// Description check is only enforced for `consolidate` source — that's the
|
|
284
341
|
// automated pipeline that historically produced proposals with missing or
|
|
285
342
|
// malformed frontmatter, polluting the queue with hundreds of unusable
|
|
@@ -291,7 +348,9 @@ export function createProposal(stashDir, input, ctx) {
|
|
|
291
348
|
return rejectProposal("missing_description", `Proposal for "${input.ref}" (source=consolidate) has empty or missing frontmatter description.`);
|
|
292
349
|
}
|
|
293
350
|
}
|
|
294
|
-
const
|
|
351
|
+
const proposalTarget = resolveCreateProposalTarget(stashDir, input.target, parsedRef.origin);
|
|
352
|
+
const normalizedRef = proposalDurableRef(parsedRef, proposalTarget);
|
|
353
|
+
const targetRoot = path.resolve(proposalTarget.root);
|
|
295
354
|
// WI-6.2: derive the FileChange[] envelope + mint-time beforeHash. The
|
|
296
355
|
// target is resolved against the proposal's OWN stash (a local snapshot —
|
|
297
356
|
// accept re-resolves the write target from config at apply time), and only
|
|
@@ -300,9 +359,9 @@ export function createProposal(stashDir, input, ctx) {
|
|
|
300
359
|
let targetRelPath;
|
|
301
360
|
let mintBeforeContent;
|
|
302
361
|
try {
|
|
303
|
-
const typeRoot = path.join(
|
|
362
|
+
const typeRoot = path.join(targetRoot, stashDirFor(parsedRef.type));
|
|
304
363
|
const targetAbs = assetPathForName(parsedRef.type, typeRoot, parsedRef.name);
|
|
305
|
-
targetRelPath = path.relative(
|
|
364
|
+
targetRelPath = path.relative(targetRoot, targetAbs);
|
|
306
365
|
if (fs.existsSync(targetAbs))
|
|
307
366
|
mintBeforeContent = fs.readFileSync(targetAbs, "utf8");
|
|
308
367
|
}
|
|
@@ -310,14 +369,35 @@ export function createProposal(stashDir, input, ctx) {
|
|
|
310
369
|
// Resolution failure degrades to a best-effort create — never blocks the mint.
|
|
311
370
|
targetRelPath = path.join(stashDirFor(parsedRef.type), parsedRef.name);
|
|
312
371
|
}
|
|
372
|
+
const proposalContent = targetRelPath.toLowerCase().endsWith(".md")
|
|
373
|
+
? ensureAkmMarkdownType(input.payload.content, parsedRef.type)
|
|
374
|
+
: input.payload.content;
|
|
313
375
|
const mintedChanges = [
|
|
314
376
|
{
|
|
315
377
|
path: targetRelPath,
|
|
316
|
-
after:
|
|
378
|
+
after: proposalContent,
|
|
317
379
|
op: mintBeforeContent !== undefined ? "update" : "create",
|
|
318
380
|
},
|
|
319
381
|
];
|
|
320
382
|
const mintedBeforeHash = mintBeforeContent !== undefined ? contentHash(mintBeforeContent) : undefined;
|
|
383
|
+
if (hasCanonicalProposalValidator(parsedRef.type)) {
|
|
384
|
+
const report = validateProposal({
|
|
385
|
+
id: "pending",
|
|
386
|
+
ref: normalizedRef,
|
|
387
|
+
status: "pending",
|
|
388
|
+
source: input.source,
|
|
389
|
+
createdAt: "",
|
|
390
|
+
updatedAt: "",
|
|
391
|
+
payload: { ...input.payload, content: proposalContent },
|
|
392
|
+
changes: mintedChanges,
|
|
393
|
+
proposedTarget: { source: proposalTarget.source, root: targetRoot },
|
|
394
|
+
});
|
|
395
|
+
if (!report.ok) {
|
|
396
|
+
return rejectProposal("invalid_canonical_structure", `Proposal for "${input.ref}" has invalid ${parsedRef.type} structure:\n${report.findings
|
|
397
|
+
.map((finding) => `[${finding.kind}] ${finding.message}`)
|
|
398
|
+
.join("\n")}`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
321
401
|
const fingerprint = computeProposalFingerprint({
|
|
322
402
|
ref: normalizedRef,
|
|
323
403
|
source: input.source,
|
|
@@ -350,10 +430,11 @@ export function createProposal(stashDir, input, ctx) {
|
|
|
350
430
|
createdAt: created,
|
|
351
431
|
updatedAt: created,
|
|
352
432
|
payload: {
|
|
353
|
-
content:
|
|
433
|
+
content: proposalContent,
|
|
354
434
|
...(input.payload.frontmatter !== undefined ? { frontmatter: input.payload.frontmatter } : {}),
|
|
355
435
|
},
|
|
356
436
|
changes: mintedChanges,
|
|
437
|
+
proposedTarget: { source: proposalTarget.source, root: targetRoot },
|
|
357
438
|
...(mintedBeforeHash !== undefined ? { beforeHash: mintedBeforeHash } : {}),
|
|
358
439
|
...(sanitizedConfidence !== undefined ? { confidence: sanitizedConfidence } : {}),
|
|
359
440
|
// Attribution tagging: persist the eligibility lane so it survives to
|
|
@@ -461,15 +542,15 @@ export function listProposals(stashDir, options = {}, ctx) {
|
|
|
461
542
|
return [];
|
|
462
543
|
}
|
|
463
544
|
const status = options.includeArchive ? options.status : "pending";
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
const
|
|
545
|
+
// Short filters match by conceptId; qualified filters additionally retain
|
|
546
|
+
// bundle identity. Applied in JS because a short query does not equal the
|
|
547
|
+
// fully-qualified stored ref.
|
|
548
|
+
const wantRef = options.ref !== undefined ? filterRefIdentity(options.ref) : undefined;
|
|
468
549
|
return listStateProposals(db, {
|
|
469
550
|
stashDir,
|
|
470
551
|
...(status !== undefined ? { status } : {}),
|
|
471
552
|
}).filter((p) => {
|
|
472
|
-
if (
|
|
553
|
+
if (wantRef !== undefined && !proposalMatchesRef(p.ref, wantRef)) {
|
|
473
554
|
return false;
|
|
474
555
|
}
|
|
475
556
|
if (!options.type)
|
|
@@ -493,16 +574,35 @@ export function getProposal(stashDir, id, ctx) {
|
|
|
493
574
|
function requireProposal(db, stashDir, id) {
|
|
494
575
|
const proposal = getStateProposal(db, id, stashDir);
|
|
495
576
|
if (!proposal) {
|
|
496
|
-
throw new NotFoundError(`Proposal "${id}" not found.`, "
|
|
577
|
+
throw new NotFoundError(`Proposal "${id}" not found.`, "PROPOSAL_NOT_FOUND");
|
|
497
578
|
}
|
|
498
579
|
return proposal;
|
|
499
580
|
}
|
|
581
|
+
/**
|
|
582
|
+
* Lenient counterpart to {@link requireProposal} / {@link getProposal}, used
|
|
583
|
+
* ONLY on the terminal-status (reject/archive) read paths — `archiveProposal`
|
|
584
|
+
* and the reject-transaction machinery (`rejectProposalDurably`,
|
|
585
|
+
* `finalizeRejectTransaction`). Those paths must succeed even when the row's
|
|
586
|
+
* metadata fails strict decoding (see {@link proposalRowToProposalLenient});
|
|
587
|
+
* every other reader (accept, revert, show, list's pending branch) keeps
|
|
588
|
+
* strict decoding via `getProposal`/`requireProposal` unchanged.
|
|
589
|
+
*/
|
|
590
|
+
function requireProposalLenient(db, stashDir, id) {
|
|
591
|
+
const proposal = getStateProposalLenient(db, id, stashDir);
|
|
592
|
+
if (!proposal) {
|
|
593
|
+
throw new NotFoundError(`Proposal "${id}" not found.`, "PROPOSAL_NOT_FOUND");
|
|
594
|
+
}
|
|
595
|
+
return proposal;
|
|
596
|
+
}
|
|
597
|
+
function getProposalLenient(stashDir, id, ctx) {
|
|
598
|
+
return withProposalsDb(stashDir, ctx, (db) => requireProposalLenient(db, stashDir, id));
|
|
599
|
+
}
|
|
500
600
|
/**
|
|
501
601
|
* Resolve a proposal by full UUID, UUID prefix, or asset ref.
|
|
502
602
|
*
|
|
503
603
|
* Resolution order:
|
|
504
604
|
* 1. Exact UUID match (existing behaviour).
|
|
505
|
-
* 2. Asset ref (contains
|
|
605
|
+
* 2. Asset ref (contains `/`) — finds the most-recent pending proposal for
|
|
506
606
|
* that ref; falls back to archived if nothing is pending.
|
|
507
607
|
* 3. UUID prefix — matches any PENDING proposal whose id starts with the
|
|
508
608
|
* given string; throws if ambiguous.
|
|
@@ -513,21 +613,19 @@ export function resolveProposalId(stashDir, idOrRef, ctx) {
|
|
|
513
613
|
const exact = getStateProposal(db, idOrRef, stashDir);
|
|
514
614
|
if (exact)
|
|
515
615
|
return exact;
|
|
516
|
-
// 2. Asset ref
|
|
517
|
-
//
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
const wantConceptId = idOrRef.includes(":") || idOrRef.includes("/") ? filterConceptId(idOrRef) : undefined;
|
|
521
|
-
if (wantConceptId !== undefined) {
|
|
616
|
+
// 2. Asset ref — most recent pending, else most recent archived. Qualified
|
|
617
|
+
// refs retain bundle identity; short refs match by conceptId in this queue.
|
|
618
|
+
const wantRef = idOrRef.includes(":") || idOrRef.includes("/") ? filterRefIdentity(idOrRef) : undefined;
|
|
619
|
+
if (wantRef !== undefined) {
|
|
522
620
|
const byRecency = (proposals) => proposals.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime())[0];
|
|
523
|
-
const forConcept = (status) => listStateProposals(db, { stashDir, ...(status !== undefined ? { status } : {}) }).filter((p) =>
|
|
621
|
+
const forConcept = (status) => listStateProposals(db, { stashDir, ...(status !== undefined ? { status } : {}) }).filter((p) => proposalMatchesRef(p.ref, wantRef));
|
|
524
622
|
const pending = byRecency(forConcept("pending"));
|
|
525
623
|
if (pending)
|
|
526
624
|
return pending;
|
|
527
625
|
const archived = byRecency(forConcept());
|
|
528
626
|
if (archived)
|
|
529
627
|
return archived;
|
|
530
|
-
throw new NotFoundError(`No proposal found for ref "${idOrRef}".`, "
|
|
628
|
+
throw new NotFoundError(`No proposal found for ref "${idOrRef}".`, "PROPOSAL_NOT_FOUND");
|
|
531
629
|
}
|
|
532
630
|
// 3. UUID prefix (pending queue only).
|
|
533
631
|
const prefixMatches = listStateProposalIdsByPrefix(db, stashDir, idOrRef);
|
|
@@ -536,18 +634,37 @@ export function resolveProposalId(stashDir, idOrRef, ctx) {
|
|
|
536
634
|
if (prefixMatches.length > 1) {
|
|
537
635
|
throw new UsageError(`Ambiguous prefix "${idOrRef}" — matches: ${prefixMatches.join(", ")}`, "INVALID_FLAG_VALUE");
|
|
538
636
|
}
|
|
539
|
-
throw new NotFoundError(`Proposal "${idOrRef}" not found.`, "
|
|
637
|
+
throw new NotFoundError(`Proposal "${idOrRef}" not found.`, "PROPOSAL_NOT_FOUND");
|
|
540
638
|
});
|
|
541
639
|
}
|
|
640
|
+
/**
|
|
641
|
+
* Resolve a reject target's id, tolerating a legacy row whose metadata fails
|
|
642
|
+
* strict decoding. `akm proposal reject` only needs the id — but
|
|
643
|
+
* {@link resolveProposalId}'s exact-UUID fast path decodes the FULL proposal
|
|
644
|
+
* just to hand it back, which throws on those rows before the
|
|
645
|
+
* terminal-status machinery (which reads leniently — see
|
|
646
|
+
* `requireProposalLenient`) ever runs. Falls back to `resolveProposalId`
|
|
647
|
+
* unchanged for ref / uuid-prefix inputs, where existing resolution
|
|
648
|
+
* (including its error semantics) is preserved.
|
|
649
|
+
*/
|
|
650
|
+
export function resolveProposalIdForReject(stashDir, idOrRef, ctx) {
|
|
651
|
+
const exact = withProposalsDb(stashDir, ctx, (db) => getStateProposalLenient(db, idOrRef, stashDir));
|
|
652
|
+
if (exact)
|
|
653
|
+
return exact.id;
|
|
654
|
+
return resolveProposalId(stashDir, idOrRef, ctx).id;
|
|
655
|
+
}
|
|
542
656
|
/**
|
|
543
657
|
* Archive a proposal: flip its status to `accepted` / `rejected`, bump
|
|
544
658
|
* `updatedAt`, and record the review block. Used by both accept and reject
|
|
545
659
|
* paths so the live queue only contains pending entries.
|
|
546
660
|
*/
|
|
547
|
-
export function archiveProposal(stashDir, id, status, reason, ctx) {
|
|
661
|
+
export function archiveProposal(stashDir, id, status, reason, ctx, gateDecision) {
|
|
548
662
|
return withProposalsDb(stashDir, ctx, (db) => {
|
|
549
663
|
return withImmediateTransaction(db, () => {
|
|
550
|
-
|
|
664
|
+
// Lenient: archiving (accept OR reject) a legacy row with malformed
|
|
665
|
+
// changes/proposedTarget metadata must still be able to flip status —
|
|
666
|
+
// see requireProposalLenient.
|
|
667
|
+
const existing = requireProposalLenient(db, stashDir, id);
|
|
551
668
|
if (existing.status !== "pending") {
|
|
552
669
|
throw new UsageError(`Proposal ${id} is not pending (current status: ${existing.status}). Only pending proposals can be ${status}.`, "INVALID_FLAG_VALUE");
|
|
553
670
|
}
|
|
@@ -561,18 +678,18 @@ export function archiveProposal(stashDir, id, status, reason, ctx) {
|
|
|
561
678
|
...(reason !== undefined ? { reason } : {}),
|
|
562
679
|
decidedAt,
|
|
563
680
|
},
|
|
681
|
+
...(gateDecision ? { gateDecision: { ...gateDecision, decidedAt: gateDecision.decidedAt ?? decidedAt } } : {}),
|
|
564
682
|
};
|
|
565
|
-
|
|
683
|
+
persistProposalUpdate(db, updated, stashDir);
|
|
566
684
|
return updated;
|
|
567
685
|
});
|
|
568
686
|
});
|
|
569
687
|
}
|
|
570
688
|
/**
|
|
571
689
|
* Record the drain/triage engine's decision onto a proposal (#577).
|
|
572
|
-
* Drain-owned audit machinery — the deterministic drain engine is the
|
|
573
|
-
* live writer since the 0.9.0 confidence-gate deletion.
|
|
690
|
+
* Drain-owned audit machinery — the deterministic drain engine is the writer.
|
|
574
691
|
*
|
|
575
|
-
* Stamps `gateDecision` (decision / reason /
|
|
692
|
+
* Stamps `gateDecision` (decision / reason / measurement / thresholds) onto the
|
|
576
693
|
* row so `akm proposal show` and `list` can explain why a proposal landed where
|
|
577
694
|
* it did. The decision is metadata about the adjudication, so this does NOT
|
|
578
695
|
* change `status` or bump `updatedAt` — a `deferred` proposal stays `pending`,
|
|
@@ -593,7 +710,7 @@ export function recordGateDecision(stashDir, id, decision, ctx) {
|
|
|
593
710
|
...existing,
|
|
594
711
|
gateDecision: { ...decision, decidedAt: decision.decidedAt ?? nowIso(ctx) },
|
|
595
712
|
};
|
|
596
|
-
|
|
713
|
+
persistProposalUpdate(db, updated, stashDir);
|
|
597
714
|
return updated;
|
|
598
715
|
});
|
|
599
716
|
});
|
|
@@ -822,7 +939,7 @@ function persistProposalTransactionState(txn, proposal, ctx) {
|
|
|
822
939
|
const current = requireProposal(db, p.stashDir, p.proposalId);
|
|
823
940
|
if (p.operation === "accept") {
|
|
824
941
|
if (current.status === "accepted") {
|
|
825
|
-
if (current.
|
|
942
|
+
if (current.acceptedTarget?.contentHash !== p.publishedHash) {
|
|
826
943
|
throw new Error(`Accepted proposal ${p.proposalId} does not match its recovery journal.`);
|
|
827
944
|
}
|
|
828
945
|
return current;
|
|
@@ -830,18 +947,34 @@ function persistProposalTransactionState(txn, proposal, ctx) {
|
|
|
830
947
|
if (current.status !== "pending") {
|
|
831
948
|
throw new Error(`Proposal ${p.proposalId} changed status during acceptance (${current.status}).`);
|
|
832
949
|
}
|
|
950
|
+
const persistedProposal = proposal.changes.length > 0 && proposal.changes.every((change) => change.path.length > 0)
|
|
951
|
+
? withProposalContent(proposal, publishedContent)
|
|
952
|
+
: {
|
|
953
|
+
...proposal,
|
|
954
|
+
payload: { ...proposal.payload, content: publishedContent },
|
|
955
|
+
changes: [
|
|
956
|
+
{
|
|
957
|
+
path: path.relative(txn.journal.root, p.assetPath),
|
|
958
|
+
op: p.originalHash === null ? "create" : "update",
|
|
959
|
+
after: publishedContent,
|
|
960
|
+
},
|
|
961
|
+
],
|
|
962
|
+
proposedTarget: { source: p.targetSource, root: txn.journal.root },
|
|
963
|
+
};
|
|
833
964
|
const accepted = {
|
|
834
|
-
...
|
|
965
|
+
...persistedProposal,
|
|
835
966
|
status: "accepted",
|
|
836
967
|
updatedAt: decidedAt,
|
|
837
968
|
review: { outcome: "accepted", decidedAt },
|
|
838
|
-
acceptedContentHash: p.publishedHash,
|
|
839
969
|
acceptedTarget: {
|
|
840
970
|
source: p.targetSource,
|
|
841
971
|
root: txn.journal.root,
|
|
842
972
|
path: p.assetPath,
|
|
843
973
|
contentHash: p.publishedHash,
|
|
844
974
|
},
|
|
975
|
+
...(p.gateDecision
|
|
976
|
+
? { gateDecision: { ...p.gateDecision, decidedAt: p.gateDecision.decidedAt ?? decidedAt } }
|
|
977
|
+
: {}),
|
|
845
978
|
...(backupContent !== undefined ? { backupContent } : {}),
|
|
846
979
|
};
|
|
847
980
|
upsertProposal(db, accepted, p.stashDir);
|
|
@@ -894,15 +1027,26 @@ async function finalizeProposalTransaction(txn, target, proposal, ctx) {
|
|
|
894
1027
|
if (txn.journal.phase === "asset-published") {
|
|
895
1028
|
const commitRoot = target.source.repoPath ?? target.source.path;
|
|
896
1029
|
const commitPath = path.relative(commitRoot, p.assetPath).replaceAll(path.sep, "/");
|
|
897
|
-
|
|
1030
|
+
publishWriteTargetTransaction(target, p.gitPublication, {
|
|
1031
|
+
transactionId: txn.journal.transactionId,
|
|
1032
|
+
message: `${p.operation === "accept" ? "Update" : "Revert"} ${p.ref}`,
|
|
898
1033
|
paths: [commitPath],
|
|
1034
|
+
snapshots: p.gitSnapshots ?? {},
|
|
1035
|
+
onCommitRecorded: (commit) => {
|
|
1036
|
+
// biome-ignore lint/style/noNonNullAssertion: publishWriteTargetTransaction throws when absent
|
|
1037
|
+
const publication = p.gitPublication;
|
|
1038
|
+
if (publication.commit !== commit) {
|
|
1039
|
+
publication.commit = commit;
|
|
1040
|
+
advanceTxn(txn, "asset-published");
|
|
1041
|
+
}
|
|
1042
|
+
},
|
|
899
1043
|
});
|
|
900
1044
|
persistProposalTransactionState(txn, proposal, ctx);
|
|
901
1045
|
advanceTxn(txn, "proposal-persisted");
|
|
902
1046
|
}
|
|
903
1047
|
let accepted = getProposal(p.stashDir, p.proposalId, ctx);
|
|
904
1048
|
if (txn.journal.phase === "proposal-persisted") {
|
|
905
|
-
if (!(await indexWrittenAssets(txn.journal.root, [p.assetPath]))) {
|
|
1049
|
+
if (!(await indexWrittenAssets(txn.journal.root, [p.assetPath], { bundleId: target.source.name }))) {
|
|
906
1050
|
throw new Error(`Proposal ${p.proposalId} index finalization failed.`);
|
|
907
1051
|
}
|
|
908
1052
|
advanceTxn(txn, "index-finalized");
|
|
@@ -919,12 +1063,15 @@ async function finalizeProposalTransaction(txn, target, proposal, ctx) {
|
|
|
919
1063
|
}
|
|
920
1064
|
/**
|
|
921
1065
|
* Kind-level safety fence for a `proposal` journal, run before any recovery
|
|
922
|
-
* action
|
|
923
|
-
* and the uniform changes[] separately).
|
|
1066
|
+
* action. The engine fences root binding and the uniform changes[] separately.
|
|
924
1067
|
*/
|
|
925
1068
|
function fenceProposalTxnJournal(journal, txnDir, root) {
|
|
926
1069
|
const p = journal.payload;
|
|
1070
|
+
const refIdentity = proposalRefIdentity(p.ref);
|
|
927
1071
|
if (!["accept", "revert"].includes(p.operation) ||
|
|
1072
|
+
!p.targetSource ||
|
|
1073
|
+
!p.targetKind ||
|
|
1074
|
+
refIdentity?.bundle === undefined ||
|
|
928
1075
|
!isWithin(p.assetPath, root) ||
|
|
929
1076
|
![p.contentPath, p.backupPath]
|
|
930
1077
|
.filter((candidate) => candidate !== null)
|
|
@@ -933,6 +1080,17 @@ function fenceProposalTxnJournal(journal, txnDir, root) {
|
|
|
933
1080
|
throw new Error(`Refusing unsafe proposal transaction journal at ${path.join(txnDir, "journal.json")}.`);
|
|
934
1081
|
}
|
|
935
1082
|
}
|
|
1083
|
+
function resolveProposalRecoveryTarget(config, journal) {
|
|
1084
|
+
let target;
|
|
1085
|
+
try {
|
|
1086
|
+
target = resolveBundleWriteTarget(config, journal.payload.targetSource);
|
|
1087
|
+
}
|
|
1088
|
+
catch {
|
|
1089
|
+
throw new UsageError(`Proposal transaction ${journal.transactionId} target is no longer configured.`, "INVALID_FLAG_VALUE");
|
|
1090
|
+
}
|
|
1091
|
+
const bundleId = canonicalBundleIdForTarget(config, target);
|
|
1092
|
+
return { ...target, source: { ...target.source, name: bundleId } };
|
|
1093
|
+
}
|
|
936
1094
|
async function recoverProposalTransactions(target, stashDir, ctx) {
|
|
937
1095
|
const completed = new Map();
|
|
938
1096
|
const nsDir = txnNamespaceDir(target.source.path);
|
|
@@ -954,13 +1112,17 @@ async function recoverProposalTransactions(target, stashDir, ctx) {
|
|
|
954
1112
|
continue;
|
|
955
1113
|
if (path.resolve(journal.payload.stashDir) !== path.resolve(stashDir))
|
|
956
1114
|
continue;
|
|
957
|
-
if (journal.version !== 1 ||
|
|
1115
|
+
if (journal.version !== 1 ||
|
|
1116
|
+
canonicalTxnRoot(journal.root) !== canonicalTxnRoot(target.source.path) ||
|
|
1117
|
+
journal.payload.targetSource !== target.source.name ||
|
|
1118
|
+
journal.payload.targetKind !== target.source.kind) {
|
|
958
1119
|
throw new Error(`Refusing unsafe proposal transaction journal at ${journalPath}.`);
|
|
959
1120
|
}
|
|
960
1121
|
fenceProposalTxnJournal(journal, transactionDir, target.source.path);
|
|
961
1122
|
const txn = { journal, journalPath, dir: transactionDir };
|
|
962
|
-
if (journal.phase === "prepared")
|
|
1123
|
+
if (journal.phase === "prepared") {
|
|
963
1124
|
rollbackPreparedProposalTransaction(txn);
|
|
1125
|
+
}
|
|
964
1126
|
else if (journal.phase !== "committed") {
|
|
965
1127
|
const proposal = getProposal(stashDir, journal.payload.proposalId, ctx);
|
|
966
1128
|
completed.set(journal.payload.proposalId, await finalizeProposalTransaction(txn, target, proposal, ctx));
|
|
@@ -984,20 +1146,17 @@ export async function recoverProposalTransactionsForStash(stashDir, config, ctx,
|
|
|
984
1146
|
}
|
|
985
1147
|
const recoveredRoots = new Set();
|
|
986
1148
|
for (const journal of matches) {
|
|
987
|
-
let target;
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
}
|
|
994
|
-
if (canonicalTxnRoot(target.source.path) !== canonicalTxnRoot(journal.root)) {
|
|
1149
|
+
let target = resolveProposalRecoveryTarget(config, journal);
|
|
1150
|
+
const requiresGitPublication = matches.some((candidate) => candidate.phase === "asset-published" && canonicalTxnRoot(candidate.root) === canonicalTxnRoot(journal.root));
|
|
1151
|
+
if (requiresGitPublication)
|
|
1152
|
+
target = prepareWriteTargetForMutation(target, { allowAhead: true });
|
|
1153
|
+
if (canonicalTxnRoot(target.source.path) !== canonicalTxnRoot(journal.root) ||
|
|
1154
|
+
journal.payload.targetKind !== target.source.kind) {
|
|
995
1155
|
throw new Error(`Proposal transaction ${journal.transactionId} is bound to a different target root.`);
|
|
996
1156
|
}
|
|
997
1157
|
const key = path.resolve(target.source.path);
|
|
998
1158
|
if (recoveredRoots.has(key))
|
|
999
1159
|
continue;
|
|
1000
|
-
await recoverTxnsForRoot(target.source.path, (journal) => journal.kind === "mv");
|
|
1001
1160
|
const recovered = await recoverProposalTransactions(target, stashDir, ctx);
|
|
1002
1161
|
for (const [id, proposal] of recovered)
|
|
1003
1162
|
completed.set(id, proposal);
|
|
@@ -1010,13 +1169,12 @@ const REJECT_TXN_PHASES = ["prepared", "state-persisted", "event-finalized", "co
|
|
|
1010
1169
|
function finalizeRejectTransaction(txn, ctx) {
|
|
1011
1170
|
const p = txn.journal.payload;
|
|
1012
1171
|
const decidedAt = txn.journal.decidedAt;
|
|
1013
|
-
|
|
1172
|
+
// Lenient: a reject transaction must run to completion even on a legacy
|
|
1173
|
+
// row whose metadata fails strict decoding — see requireProposalLenient.
|
|
1174
|
+
let proposal = getProposalLenient(p.stashDir, p.proposalId, ctx);
|
|
1014
1175
|
if (txn.journal.phase === "prepared") {
|
|
1015
1176
|
if (proposal.status === "pending") {
|
|
1016
|
-
proposal = archiveProposal(p.stashDir, p.proposalId, "rejected", p.reason, {
|
|
1017
|
-
...ctx,
|
|
1018
|
-
now: () => Date.parse(decidedAt),
|
|
1019
|
-
});
|
|
1177
|
+
proposal = archiveProposal(p.stashDir, p.proposalId, "rejected", p.reason, { ...ctx, now: () => Date.parse(decidedAt) }, p.gateDecision);
|
|
1020
1178
|
}
|
|
1021
1179
|
else if (proposal.status !== "rejected") {
|
|
1022
1180
|
throw new Error(`Proposal ${p.proposalId} changed status during rejection (${proposal.status}).`);
|
|
@@ -1025,7 +1183,7 @@ function finalizeRejectTransaction(txn, ctx) {
|
|
|
1025
1183
|
txnMutationHook("reject-state-persisted");
|
|
1026
1184
|
}
|
|
1027
1185
|
if (txn.journal.phase === "state-persisted") {
|
|
1028
|
-
proposal =
|
|
1186
|
+
proposal = getProposalLenient(p.stashDir, p.proposalId, ctx);
|
|
1029
1187
|
const eventRef = proposal.ref;
|
|
1030
1188
|
const eventMeta = {
|
|
1031
1189
|
proposalId: proposal.id,
|
|
@@ -1075,11 +1233,13 @@ function recoverRejectTransaction(stashDir, proposalId, ctx) {
|
|
|
1075
1233
|
}
|
|
1076
1234
|
return undefined;
|
|
1077
1235
|
}
|
|
1078
|
-
export function rejectProposalDurably(stashDir, proposalId, reason, ctx) {
|
|
1236
|
+
export function rejectProposalDurably(stashDir, proposalId, reason, ctx, gateDecision) {
|
|
1079
1237
|
const recovered = recoverRejectTransaction(stashDir, proposalId, ctx);
|
|
1080
1238
|
if (recovered)
|
|
1081
1239
|
return recovered;
|
|
1082
|
-
|
|
1240
|
+
// Lenient: see requireProposalLenient — the reject decision itself must not
|
|
1241
|
+
// be blocked by a legacy row's malformed changes/proposedTarget metadata.
|
|
1242
|
+
const proposal = getProposalLenient(stashDir, proposalId, ctx);
|
|
1083
1243
|
if (proposal.status !== "pending") {
|
|
1084
1244
|
throw new UsageError(`Proposal ${proposalId} is not pending (current status: ${proposal.status}). Only pending proposals can be rejected.`, "INVALID_FLAG_VALUE");
|
|
1085
1245
|
}
|
|
@@ -1087,7 +1247,12 @@ export function rejectProposalDurably(stashDir, proposalId, reason, ctx) {
|
|
|
1087
1247
|
kind: REJECT_TXN_KIND,
|
|
1088
1248
|
root: stashDir,
|
|
1089
1249
|
changes: [],
|
|
1090
|
-
payload: {
|
|
1250
|
+
payload: {
|
|
1251
|
+
proposalId,
|
|
1252
|
+
stashDir,
|
|
1253
|
+
...(reason !== undefined ? { reason } : {}),
|
|
1254
|
+
...(gateDecision ? { gateDecision } : {}),
|
|
1255
|
+
},
|
|
1091
1256
|
decidedAt: nowIso(ctx),
|
|
1092
1257
|
});
|
|
1093
1258
|
const rejected = finalizeRejectTransaction(txn, ctx);
|
|
@@ -1095,6 +1260,8 @@ export function rejectProposalDurably(stashDir, proposalId, reason, ctx) {
|
|
|
1095
1260
|
return rejected;
|
|
1096
1261
|
}
|
|
1097
1262
|
function prepareProposalTransaction(stashDir, target, proposal, ref, content, options, ctx) {
|
|
1263
|
+
if (options.operation === "accept")
|
|
1264
|
+
assertAkmAssetWrite(target.source);
|
|
1098
1265
|
const assetPath = resolveAssetFilePathSafe(target.source, ref);
|
|
1099
1266
|
if (!assetPath)
|
|
1100
1267
|
throw new Error(`Cannot resolve proposal target ${proposal.ref}.`);
|
|
@@ -1105,6 +1272,7 @@ function prepareProposalTransaction(stashDir, target, proposal, ref, content, op
|
|
|
1105
1272
|
// and the initial `prepared` journal must be written exactly ONCE with its
|
|
1106
1273
|
// final contents (crash runners intercept the first rename per phase).
|
|
1107
1274
|
const transactionId = mintTxnId();
|
|
1275
|
+
const gitPublication = captureGitPublication(target);
|
|
1108
1276
|
const transactionDir = txnDirFor(target.source.path, transactionId);
|
|
1109
1277
|
fs.mkdirSync(transactionDir, { recursive: true, mode: 0o700 });
|
|
1110
1278
|
const contentPath = path.join(transactionDir, "published-content");
|
|
@@ -1136,6 +1304,7 @@ function prepareProposalTransaction(stashDir, target, proposal, ref, content, op
|
|
|
1136
1304
|
proposalId: proposal.id,
|
|
1137
1305
|
stashDir,
|
|
1138
1306
|
targetSource: target.source.name,
|
|
1307
|
+
targetKind: target.source.kind,
|
|
1139
1308
|
assetPath,
|
|
1140
1309
|
ref: proposal.ref,
|
|
1141
1310
|
contentPath,
|
|
@@ -1144,7 +1313,9 @@ function prepareProposalTransaction(stashDir, target, proposal, ref, content, op
|
|
|
1144
1313
|
backupPath: persistedBackupPath,
|
|
1145
1314
|
originalHash: options.originalHash,
|
|
1146
1315
|
publishedHash,
|
|
1316
|
+
...(gitPublication ? { gitPublication } : {}),
|
|
1147
1317
|
...(options.eventMetadata ? { eventMetadata: options.eventMetadata } : {}),
|
|
1318
|
+
...(options.gateDecision ? { gateDecision: options.gateDecision } : {}),
|
|
1148
1319
|
},
|
|
1149
1320
|
decidedAt: nowIso(ctx),
|
|
1150
1321
|
});
|
|
@@ -1161,7 +1332,7 @@ function prepareProposalTransaction(stashDir, target, proposal, ref, content, op
|
|
|
1161
1332
|
}
|
|
1162
1333
|
return txn;
|
|
1163
1334
|
}
|
|
1164
|
-
function publishProposalAsset(txn) {
|
|
1335
|
+
function publishProposalAsset(txn, target) {
|
|
1165
1336
|
const p = txn.journal.payload;
|
|
1166
1337
|
try {
|
|
1167
1338
|
if (p.originalHash !== null) {
|
|
@@ -1173,6 +1344,9 @@ function publishProposalAsset(txn) {
|
|
|
1173
1344
|
}
|
|
1174
1345
|
fs.linkSync(p.publishPath, p.assetPath);
|
|
1175
1346
|
fsyncTxnDir(path.dirname(p.assetPath));
|
|
1347
|
+
const snapshot = captureWriteTargetPathSnapshot(target, p.assetPath);
|
|
1348
|
+
if (snapshot)
|
|
1349
|
+
p.gitSnapshots = { [snapshot.path]: snapshot.state };
|
|
1176
1350
|
advanceTxn(txn, "asset-published");
|
|
1177
1351
|
}
|
|
1178
1352
|
catch (error) {
|
|
@@ -1181,6 +1355,175 @@ function publishProposalAsset(txn) {
|
|
|
1181
1355
|
throw error;
|
|
1182
1356
|
}
|
|
1183
1357
|
}
|
|
1358
|
+
function resolveRecordedProposalTarget(config, proposalId, binding, explicitTarget) {
|
|
1359
|
+
let target;
|
|
1360
|
+
try {
|
|
1361
|
+
target = explicitTarget
|
|
1362
|
+
? resolveWriteTarget(config, explicitTarget)
|
|
1363
|
+
: resolveBundleWriteTarget(config, binding.source);
|
|
1364
|
+
}
|
|
1365
|
+
catch {
|
|
1366
|
+
throw new UsageError(`Proposal ${proposalId} is bound to target "${binding.source}" at ${binding.root}, but that writable target is no longer configured.`, "INVALID_FLAG_VALUE");
|
|
1367
|
+
}
|
|
1368
|
+
const targetBundleId = canonicalBundleIdForTarget(config, target);
|
|
1369
|
+
if (targetBundleId !== binding.source || path.resolve(target.source.path) !== path.resolve(binding.root)) {
|
|
1370
|
+
throw new UsageError(`Proposal ${proposalId} is bound to target "${binding.source}" at ${binding.root}; ` +
|
|
1371
|
+
`--target "${explicitTarget}" resolves to "${targetBundleId}" at ${target.source.path}.`, "INVALID_FLAG_VALUE");
|
|
1372
|
+
}
|
|
1373
|
+
return { ...target, source: { ...target.source, name: targetBundleId } };
|
|
1374
|
+
}
|
|
1375
|
+
function resolveProposalWriteTarget(config, proposal, explicitTarget, queueTarget) {
|
|
1376
|
+
if (!proposal.proposedTarget) {
|
|
1377
|
+
const identity = proposalRefIdentity(proposal.ref);
|
|
1378
|
+
if (!identity)
|
|
1379
|
+
throw new UsageError(`Proposal ${proposal.id} has an invalid ref.`, "INVALID_PROPOSAL");
|
|
1380
|
+
if (identity.bundle !== undefined) {
|
|
1381
|
+
const target = resolveBundleWriteTarget(config, identity.bundle);
|
|
1382
|
+
const targetBundleId = canonicalBundleIdForTarget(config, target);
|
|
1383
|
+
if (explicitTarget !== undefined) {
|
|
1384
|
+
const explicit = resolveWriteTarget(config, explicitTarget);
|
|
1385
|
+
if (canonicalBundleIdForTarget(config, explicit) !== identity.bundle) {
|
|
1386
|
+
throw new UsageError(`Proposal ${proposal.id} ref is bound to bundle "${identity.bundle}", which conflicts with --target "${explicitTarget}".`, "INVALID_FLAG_VALUE");
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
if (queueTarget && canonicalBundleIdForTarget(config, queueTarget) !== identity.bundle) {
|
|
1390
|
+
throw new UsageError(`Proposal ${proposal.id} is bound to a different queue target.`, "INVALID_FLAG_VALUE");
|
|
1391
|
+
}
|
|
1392
|
+
return { ...target, source: { ...target.source, name: targetBundleId } };
|
|
1393
|
+
}
|
|
1394
|
+
const target = explicitTarget ? resolveWriteTarget(config, explicitTarget) : queueTarget;
|
|
1395
|
+
if (!target) {
|
|
1396
|
+
throw new UsageError(`Unbound short proposal ${proposal.id} requires an explicit --target or authenticated --queue context.`, "INVALID_PROPOSAL");
|
|
1397
|
+
}
|
|
1398
|
+
const targetBundleId = canonicalBundleIdForTarget(config, target);
|
|
1399
|
+
return { ...target, source: { ...target.source, name: targetBundleId } };
|
|
1400
|
+
}
|
|
1401
|
+
if (queueTarget && explicitTarget === undefined) {
|
|
1402
|
+
const queueBundleId = canonicalBundleIdForTarget(config, queueTarget);
|
|
1403
|
+
if (queueBundleId !== proposal.proposedTarget.source ||
|
|
1404
|
+
path.resolve(queueTarget.source.path) !== path.resolve(proposal.proposedTarget.root)) {
|
|
1405
|
+
throw new UsageError(`Proposal ${proposal.id} is bound to a different queue target.`, "INVALID_FLAG_VALUE");
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
return resolveRecordedProposalTarget(config, proposal.id, proposal.proposedTarget, explicitTarget);
|
|
1409
|
+
}
|
|
1410
|
+
// ── D2 (#730) — OKF v0.2 provenance stamping on promotion ───────────────────
|
|
1411
|
+
//
|
|
1412
|
+
// The proposals system already tracks exactly what OKF v0.2 wants on disk —
|
|
1413
|
+
// `source`/`sourceRun` (PROV-DM modeled, `proposal-types.ts` §80-120),
|
|
1414
|
+
// `gateDecision` (`:200-237`), `review` (`:170-174`) — but none of it leaves
|
|
1415
|
+
// state.db. This section projects it onto the written asset's frontmatter at
|
|
1416
|
+
// promotion time, AKM-native assets only (an OKF-adapter target never reaches
|
|
1417
|
+
// this function — `assertAkmAssetWrite` rejects it earlier in
|
|
1418
|
+
// `promoteProposalWithLease`, before any of this runs).
|
|
1419
|
+
//
|
|
1420
|
+
// Two DISTINCT actors, deliberately not conflated (documented judgment call —
|
|
1421
|
+
// see the PR body for the alternative considered and rejected):
|
|
1422
|
+
// - `generated.by` answers "what produced the CONTENT" — keyed on
|
|
1423
|
+
// `isAutomatedProposalSource(proposal.source)`: an automated pipeline
|
|
1424
|
+
// (reflect/distill/consolidate/extract/improve/schema-repair) stamps
|
|
1425
|
+
// `akm/<pkgVersion>`; a human-initiated source (propose/remember/import)
|
|
1426
|
+
// or the semi-automated `feedback` source stamps `human:<actorId>`.
|
|
1427
|
+
// - `verified[0].by` answers "what accepted/reviewed THIS promotion" —
|
|
1428
|
+
// keyed on whether a `gateDecision` was supplied to THIS call: present
|
|
1429
|
+
// (the automated drain/triage path decided) stamps `akm/<pkgVersion>`;
|
|
1430
|
+
// absent (a human explicitly ran `akm proposal accept`) stamps
|
|
1431
|
+
// `human:<actorId>`. Every promotion reaches this function via exactly
|
|
1432
|
+
// one of those two paths, so `verified` is always stamped — there is no
|
|
1433
|
+
// third, unreviewed path to disk.
|
|
1434
|
+
/** Resolve the `human:<id>` actor id — the OS account, the least-surprising stand-in for "the human at this keyboard" in a system with no multi-user identity. */
|
|
1435
|
+
function resolveActorId(ctx) {
|
|
1436
|
+
if (ctx?.actorId)
|
|
1437
|
+
return ctx.actorId();
|
|
1438
|
+
try {
|
|
1439
|
+
const username = os.userInfo().username?.trim();
|
|
1440
|
+
return username ? username : "local";
|
|
1441
|
+
}
|
|
1442
|
+
catch {
|
|
1443
|
+
return "local";
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
/** `generated.by` — keyed on the SOURCE that produced the content (see file-header note above). */
|
|
1447
|
+
function generatedByActor(proposal, ctx) {
|
|
1448
|
+
return isAutomatedProposalSource(proposal.source) ? `akm/${pkgVersion}` : `human:${resolveActorId(ctx)}`;
|
|
1449
|
+
}
|
|
1450
|
+
/** `verified[0].by` — keyed on whether THIS promotion was gated (automated) or a direct human accept (see file-header note above). */
|
|
1451
|
+
function verifiedByActor(gateDecision, ctx) {
|
|
1452
|
+
return gateDecision !== undefined ? `akm/${pkgVersion}` : `human:${resolveActorId(ctx)}`;
|
|
1453
|
+
}
|
|
1454
|
+
/** True for a plain (non-null, non-array) object. */
|
|
1455
|
+
function isPlainRecord(value) {
|
|
1456
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1457
|
+
}
|
|
1458
|
+
/**
|
|
1459
|
+
* Stamp OKF v0.2 provenance onto ONE promoted asset's frontmatter (D2.1/D2.2/
|
|
1460
|
+
* D2.3), in the **hybrid** on-disk shape settled by the #730 review:
|
|
1461
|
+
*
|
|
1462
|
+
* - `generated: {by, at}` and `verified: [{by, at}]` are written **bare at the
|
|
1463
|
+
* top level**, exactly as OKF v0.2 spells them (SPEC §5.2/§5.3). Neither key
|
|
1464
|
+
* has any pre-existing AKM consumer, so spelling them the spec's way costs
|
|
1465
|
+
* nothing and makes `okf-support.md`'s "AKM Markdown is an OKF-compatible
|
|
1466
|
+
* superset" positioning actually true for trust metadata: a third-party OKF
|
|
1467
|
+
* v0.2 reader pointed at an AKM stash sees conformant provenance.
|
|
1468
|
+
* - `sources` stays namespaced under `provenance:`, because a bare top-level
|
|
1469
|
+
* `sources:` genuinely collides with the pre-existing AKM-native wiki
|
|
1470
|
+
* citation-**string** convention
|
|
1471
|
+
* (`indexer/passes/metadata.ts#applyWikiFrontmatter`, which silently drops
|
|
1472
|
+
* non-strings) on a promoted wiki page.
|
|
1473
|
+
*
|
|
1474
|
+
* The read side back into `IndexDocument.provenance` is
|
|
1475
|
+
* `metadata.ts#applyProvenanceFrontmatter` (which accepts both this shape and
|
|
1476
|
+
* the older fully-nested one), carried through `akm-adapter.ts`'s
|
|
1477
|
+
* `DOCUMENT_JSON_CARRIED_FIELDS` (D2.4).
|
|
1478
|
+
*
|
|
1479
|
+
* A no-op frontmatter mutation preserves the existing frontmatter block's raw
|
|
1480
|
+
* body bytes (mirrors `frontmatter.ts#mutateFrontmatter`'s documented
|
|
1481
|
+
* contract) rather than reshaping via `assembleAsset`, which would strip
|
|
1482
|
+
* leading body blank lines / force a trailing newline. A file with no
|
|
1483
|
+
* frontmatter block at all (non-conformant input) gains one via
|
|
1484
|
+
* `assembleAsset`, exactly as any other first-frontmatter write would.
|
|
1485
|
+
*/
|
|
1486
|
+
function stampProposalProvenance(content, proposal, gateDecision, ctx, nowIsoStr) {
|
|
1487
|
+
const parsed = parseFrontmatter(content);
|
|
1488
|
+
const fm = { ...parsed.data };
|
|
1489
|
+
const existingProvenance = isPlainRecord(fm.provenance) ? fm.provenance : {};
|
|
1490
|
+
// Bare `generated:` — OKF v0.2's replacement for `timestamp` (SPEC §13).
|
|
1491
|
+
fm.generated = { by: generatedByActor(proposal, ctx), at: nowIsoStr };
|
|
1492
|
+
// Bare `verified:` — append, so independent confirmations accumulate rather
|
|
1493
|
+
// than the newest overwriting the record. Both the bare list and the older
|
|
1494
|
+
// nested spelling are absorbed, so a re-promotion never loses history.
|
|
1495
|
+
const priorVerified = Array.isArray(fm.verified)
|
|
1496
|
+
? fm.verified
|
|
1497
|
+
: Array.isArray(existingProvenance.verified)
|
|
1498
|
+
? existingProvenance.verified
|
|
1499
|
+
: [];
|
|
1500
|
+
fm.verified = [
|
|
1501
|
+
...priorVerified,
|
|
1502
|
+
{ by: verifiedByActor(gateDecision, ctx), at: gateDecision?.decidedAt ?? nowIsoStr },
|
|
1503
|
+
];
|
|
1504
|
+
// `sources` alone stays namespaced — bare `sources:` is the wiki
|
|
1505
|
+
// citation-string convention. Drop the nested provenance block entirely when
|
|
1506
|
+
// it would otherwise be empty, so unrelated assets gain no dead key.
|
|
1507
|
+
const provenance = { ...existingProvenance };
|
|
1508
|
+
delete provenance.generatedBy;
|
|
1509
|
+
delete provenance.generatedAt;
|
|
1510
|
+
delete provenance.verified;
|
|
1511
|
+
const evidenceSources = fm.evidenceSources;
|
|
1512
|
+
if (Array.isArray(evidenceSources)) {
|
|
1513
|
+
const sources = evidenceSources
|
|
1514
|
+
.filter((s) => typeof s === "string" && s.trim().length > 0)
|
|
1515
|
+
.map((resource) => ({ resource: resource.trim() }));
|
|
1516
|
+
if (sources.length > 0)
|
|
1517
|
+
provenance.sources = sources;
|
|
1518
|
+
}
|
|
1519
|
+
if (Object.keys(provenance).length > 0)
|
|
1520
|
+
fm.provenance = provenance;
|
|
1521
|
+
else
|
|
1522
|
+
delete fm.provenance;
|
|
1523
|
+
return parsed.frontmatter !== null
|
|
1524
|
+
? `---\n${serializeFrontmatter(fm)}\n---\n${parsed.content}`
|
|
1525
|
+
: assembleAsset(fm, parsed.content);
|
|
1526
|
+
}
|
|
1184
1527
|
/**
|
|
1185
1528
|
* Validate a proposal, then promote it through the canonical
|
|
1186
1529
|
* {@link writeAssetToSource} dispatch (the single place that branches on
|
|
@@ -1196,47 +1539,171 @@ function publishProposalAsset(txn) {
|
|
|
1196
1539
|
export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
|
|
1197
1540
|
return withAssetMutationLease("proposal-accept", () => promoteProposalWithLease(stashDir, config, id, options, ctx));
|
|
1198
1541
|
}
|
|
1542
|
+
function promotionLintBlockers(raw, assetPath, targetRoot, refType, config) {
|
|
1543
|
+
let data;
|
|
1544
|
+
let body;
|
|
1545
|
+
let frontmatter;
|
|
1546
|
+
if (refType === "task") {
|
|
1547
|
+
try {
|
|
1548
|
+
const parsed = parseYaml(raw);
|
|
1549
|
+
data = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
1550
|
+
}
|
|
1551
|
+
catch {
|
|
1552
|
+
data = {};
|
|
1553
|
+
}
|
|
1554
|
+
body = raw;
|
|
1555
|
+
frontmatter = null;
|
|
1556
|
+
}
|
|
1557
|
+
else {
|
|
1558
|
+
({ data, content: body, frontmatter } = parseFrontmatter(raw));
|
|
1559
|
+
}
|
|
1560
|
+
const resolvedRoot = path.resolve(targetRoot);
|
|
1561
|
+
const extraStashRoots = resolveSourceEntries(targetRoot, config)
|
|
1562
|
+
.map((source) => source.path)
|
|
1563
|
+
.filter((sourcePath) => path.resolve(sourcePath) !== resolvedRoot);
|
|
1564
|
+
return runBaseChecks({
|
|
1565
|
+
filePath: assetPath,
|
|
1566
|
+
relPath: path.relative(targetRoot, assetPath),
|
|
1567
|
+
raw,
|
|
1568
|
+
data,
|
|
1569
|
+
body,
|
|
1570
|
+
frontmatter,
|
|
1571
|
+
fix: false,
|
|
1572
|
+
stashRoot: targetRoot,
|
|
1573
|
+
extraStashRoots,
|
|
1574
|
+
}).filter((finding) => PROMOTION_LINT_BLOCKERS.has(finding.issue));
|
|
1575
|
+
}
|
|
1576
|
+
/** Build and validate the exact stamped bytes promotion would publish, without writing. */
|
|
1577
|
+
export function preflightProposalPromotion(config, proposal, options = {}, ctx) {
|
|
1578
|
+
const repairedContent = repairProposalContent(proposalContent(proposal));
|
|
1579
|
+
const preparedProposal = repairedContent === proposalContent(proposal) ? proposal : withProposalContent(proposal, repairedContent);
|
|
1580
|
+
const report = validateProposal(preparedProposal);
|
|
1581
|
+
if (!report.ok) {
|
|
1582
|
+
const message = report.findings.map((finding) => `[${finding.kind}] ${finding.message}`).join("\n");
|
|
1583
|
+
throw new UsageError(`Proposal ${proposal.id} failed validation:\n${message}`, "MISSING_REQUIRED_ARGUMENT", "Fix the proposal payload (frontmatter / content) and try again, or reject the proposal with a reason.");
|
|
1584
|
+
}
|
|
1585
|
+
const ref = parseRefInput(preparedProposal.ref);
|
|
1586
|
+
if (!stashDirFor(ref.type)) {
|
|
1587
|
+
throw new UsageError(`Proposal ${proposal.id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
|
|
1588
|
+
}
|
|
1589
|
+
const target = resolveProposalWriteTarget(config, preparedProposal, options.target, options.queueTarget);
|
|
1590
|
+
const assetPath = resolveAssetFilePathSafe(target.source, ref);
|
|
1591
|
+
if (!assetPath)
|
|
1592
|
+
throw new UsageError(`Cannot resolve proposal target ${preparedProposal.ref}.`, "INVALID_PROPOSAL");
|
|
1593
|
+
assertAkmAssetWrite(target.source);
|
|
1594
|
+
const stampedContent = assetPath.toLowerCase().endsWith(".md")
|
|
1595
|
+
? stampProposalProvenance(repairedContent, preparedProposal, options.gateDecision, ctx, nowIso(ctx))
|
|
1596
|
+
: repairedContent;
|
|
1597
|
+
const lintBlockers = promotionLintBlockers(stampedContent, assetPath, target.source.path, ref.type, config);
|
|
1598
|
+
if (lintBlockers.length > 0) {
|
|
1599
|
+
const message = lintBlockers.map((finding) => `[${finding.issue}] ${finding.detail}`).join("\n");
|
|
1600
|
+
throw new UsageError(`Proposal ${proposal.id} failed lint:\n${message}`, "INVALID_PROPOSAL", "Fix or explicitly suppress the reported lint findings, then retry.");
|
|
1601
|
+
}
|
|
1602
|
+
return { proposal: preparedProposal, repairedContent, ref, target, assetPath, stampedContent };
|
|
1603
|
+
}
|
|
1604
|
+
/**
|
|
1605
|
+
* The change-transaction pre-commit gate — the `BundleAdapter.validate()`
|
|
1606
|
+
* interface contract's OTHER stated consumer (`core/adapter/bundle-adapter.ts`
|
|
1607
|
+
* doc comment, alongside `lint --fix`). Runs the target's OWN adapter's
|
|
1608
|
+
* `validate()` over the ONE pending write `preflight` describes, with a
|
|
1609
|
+
* {@link createValidateContext} overlay carrying the proposal's about-to-be-
|
|
1610
|
+
* written bytes — so the adapter sees the bundle AS IT WOULD LOOK the instant
|
|
1611
|
+
* after this transaction commits, without ever touching disk.
|
|
1612
|
+
*
|
|
1613
|
+
* DELIBERATELY ADVISORY, not blocking (see the report for the full
|
|
1614
|
+
* rationale): the akm adapter's `missing-ref` check resolves prose refs
|
|
1615
|
+
* through the SAME core overlay `resolveRef` that closes the OKF/llm-wiki
|
|
1616
|
+
* lint gaps — and that resolver is proven to disagree with the legacy
|
|
1617
|
+
* `commands/lint/base-linter.ts#checkMissingRefs` resolver in one specific,
|
|
1618
|
+
* real case: a fully-qualified `bundle//conceptId` prose ref (or a bare
|
|
1619
|
+
* frontmatter xref) whose leading segment does NOT name a registered AKM
|
|
1620
|
+
* placement type. The legacy resolver treats an unrecognized type prefix as
|
|
1621
|
+
* "not a locally-checkable ref, skip it, never flag missing" (whole-hearted
|
|
1622
|
+
* leniency for cross-bundle / foreign-format refs); this module's core
|
|
1623
|
+
* resolver additionally tries the ref as a literal on-disk path — the
|
|
1624
|
+
* resolution non-akm adapters (OKF, llm-wiki) actually NEED for their own
|
|
1625
|
+
* same-component conceptIds — which means it CAN report `missing-ref` for a
|
|
1626
|
+
* foreign-typed prose ref the legacy checker always let through. Promoting a
|
|
1627
|
+
* proposal is a live, user-facing write path; blocking it on a diagnostic
|
|
1628
|
+
* that can disagree with the existing (already-tested, already-run)
|
|
1629
|
+
* `promotionLintBlockers` gate a few lines above is not a change to make
|
|
1630
|
+
* without a dedicated equivalence pass first. So: this computes and surfaces
|
|
1631
|
+
* the finding (visible via `warn`, and never thrown) without changing whether
|
|
1632
|
+
* ANY promotion succeeds or fails — proving the wiring end-to-end on real
|
|
1633
|
+
* proposal data while leaving today's blocking behavior completely
|
|
1634
|
+
* untouched. Never throws: a validate() failure here must not corrupt or
|
|
1635
|
+
* half-apply the transaction that follows.
|
|
1636
|
+
*/
|
|
1637
|
+
async function runAdapterPreCommitCheck(config, preflight) {
|
|
1638
|
+
try {
|
|
1639
|
+
const adapterId = preflight.target.source.adapterId ?? "akm";
|
|
1640
|
+
const adapter = adapterForId(adapterId);
|
|
1641
|
+
if (!adapter)
|
|
1642
|
+
return;
|
|
1643
|
+
const root = preflight.target.source.path;
|
|
1644
|
+
const relPath = path.relative(root, preflight.assetPath).replace(/\\/g, "/");
|
|
1645
|
+
if (!relPath || relPath.startsWith(".."))
|
|
1646
|
+
return; // resolved outside its own bundle root — nothing to check
|
|
1647
|
+
const change = {
|
|
1648
|
+
path: relPath,
|
|
1649
|
+
after: preflight.stampedContent,
|
|
1650
|
+
op: fs.existsSync(preflight.assetPath) ? "update" : "create",
|
|
1651
|
+
};
|
|
1652
|
+
const extraRoots = resolveSourceEntries(root, config)
|
|
1653
|
+
.map((source) => source.path)
|
|
1654
|
+
.filter((sourcePath) => path.resolve(sourcePath) !== path.resolve(root));
|
|
1655
|
+
const componentCtx = createValidateContext({ root, extraRoots, changes: [change] });
|
|
1656
|
+
const diagnostics = await adapter.validate({ id: preflight.target.selector ?? adapterId, adapter: adapterId, root, writable: true }, [change], componentCtx);
|
|
1657
|
+
if (diagnostics.length > 0) {
|
|
1658
|
+
const summary = diagnostics.map((d) => `[${d.issue}] ${d.detail}`).join("; ");
|
|
1659
|
+
warn(`[proposal] pre-commit adapter check for ${preflight.proposal.id} found (non-blocking): ${summary}`);
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
catch (error) {
|
|
1663
|
+
// Advisory only — never let a validate() failure interrupt or corrupt the
|
|
1664
|
+
// promotion transaction that follows.
|
|
1665
|
+
warn(`[proposal] pre-commit adapter check for ${preflight.proposal.id} threw (ignored, non-blocking): ${error instanceof Error ? error.message : String(error)}`);
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1199
1668
|
async function promoteProposalWithLease(stashDir, config, id, options, ctx) {
|
|
1669
|
+
recoverRejectTransaction(stashDir, id, ctx);
|
|
1200
1670
|
let proposal = getProposal(stashDir, id, ctx);
|
|
1201
|
-
// Attempt bounded auto-repair of mechanically-fixable structural defects
|
|
1202
|
-
// (pseudo-frontmatter-in-body, stray `---` fences, truncated description)
|
|
1203
|
-
// BEFORE running validation. If the repair produces valid content, we
|
|
1204
|
-
// promote the repaired version; if validation still fails, the original
|
|
1205
|
-
// error path throws as before. The repair is content-preserving and
|
|
1206
|
-
// deterministic — it never invents text.
|
|
1207
1671
|
const repairedContent = repairProposalContent(proposalContent(proposal));
|
|
1208
|
-
const proposalToValidate = repairedContent
|
|
1672
|
+
const proposalToValidate = repairedContent === proposalContent(proposal) ? proposal : withProposalContent(proposal, repairedContent);
|
|
1209
1673
|
const report = validateProposal(proposalToValidate);
|
|
1210
1674
|
if (!report.ok) {
|
|
1211
|
-
const message = report.findings.map((
|
|
1675
|
+
const message = report.findings.map((finding) => `[${finding.kind}] ${finding.message}`).join("\n");
|
|
1212
1676
|
throw new UsageError(`Proposal ${id} failed validation:\n${message}`, "MISSING_REQUIRED_ARGUMENT", "Fix the proposal payload (frontmatter / content) and try again, or reject the proposal with a reason.");
|
|
1213
1677
|
}
|
|
1678
|
+
const ref = parseRefInput(proposalToValidate.ref);
|
|
1679
|
+
if (!stashDirFor(ref.type)) {
|
|
1680
|
+
throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
|
|
1681
|
+
}
|
|
1214
1682
|
// Use the (possibly repaired) payload for the promotion write. Persist the
|
|
1215
1683
|
// repaired content back onto the DB row so the audit trail reflects the
|
|
1216
1684
|
// final promoted payload (not the defective original).
|
|
1217
1685
|
if (repairedContent !== proposalContent(proposal)) {
|
|
1218
1686
|
withProposalsDb(stashDir, ctx, (db) => {
|
|
1219
|
-
|
|
1687
|
+
persistProposalUpdate(db, proposalToValidate, stashDir);
|
|
1220
1688
|
});
|
|
1221
1689
|
}
|
|
1222
|
-
const ref = parseRefInput(proposalToValidate.ref);
|
|
1223
|
-
if (!stashDirFor(ref.type)) {
|
|
1224
|
-
throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
|
|
1225
|
-
}
|
|
1226
1690
|
await recoverProposalTransactionsForStash(stashDir, config, ctx, id);
|
|
1227
1691
|
proposal = getProposal(stashDir, id, ctx);
|
|
1228
|
-
const target =
|
|
1229
|
-
|
|
1230
|
-
|
|
1692
|
+
const target = resolveProposalWriteTarget(config, proposal, options.target, options.queueTarget);
|
|
1693
|
+
if (proposal.status === "accepted") {
|
|
1694
|
+
if (!proposal.acceptedTarget) {
|
|
1695
|
+
throw new UsageError(`Accepted proposal ${id} has no recorded target.`, "INVALID_PROPOSAL");
|
|
1696
|
+
}
|
|
1231
1697
|
const assetPath = resolveAssetFilePathSafe(target.source, ref);
|
|
1232
|
-
if (proposal.acceptedTarget
|
|
1233
|
-
(proposal.acceptedTarget.
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
path.resolve(proposal.acceptedTarget.path) !== path.resolve(assetPath))) {
|
|
1698
|
+
if (proposal.acceptedTarget.source !== target.source.name ||
|
|
1699
|
+
path.resolve(proposal.acceptedTarget.root) !== path.resolve(target.source.path) ||
|
|
1700
|
+
!assetPath ||
|
|
1701
|
+
path.resolve(proposal.acceptedTarget.path) !== path.resolve(assetPath)) {
|
|
1237
1702
|
throw new UsageError(`proposal ${id} is bound to a different accepted target`, "INVALID_FLAG_VALUE");
|
|
1238
1703
|
}
|
|
1239
|
-
if (!assetPath ||
|
|
1704
|
+
if (!assetPath ||
|
|
1705
|
+
!fs.existsSync(assetPath) ||
|
|
1706
|
+
proposalFileHash(assetPath) !== proposal.acceptedTarget.contentHash) {
|
|
1240
1707
|
throw new UsageError(`Accepted proposal ${id} does not match the current asset content.`, "INVALID_FLAG_VALUE");
|
|
1241
1708
|
}
|
|
1242
1709
|
return { proposal, assetPath, ref: proposal.ref };
|
|
@@ -1244,9 +1711,13 @@ async function promoteProposalWithLease(stashDir, config, id, options, ctx) {
|
|
|
1244
1711
|
if (proposal.status !== "pending") {
|
|
1245
1712
|
throw new UsageError(`Proposal ${id} is not pending (current status: ${proposal.status}). Only pending proposals can be accepted.`, "INVALID_FLAG_VALUE");
|
|
1246
1713
|
}
|
|
1247
|
-
const
|
|
1714
|
+
const preflight = preflightProposalPromotion(config, proposal, { ...options, queueTarget: target }, ctx);
|
|
1715
|
+
await runAdapterPreCommitCheck(config, preflight);
|
|
1716
|
+
const mutationTarget = prepareWriteTargetForMutation(target);
|
|
1717
|
+
const assetPath = resolveAssetFilePathSafe(mutationTarget.source, ref);
|
|
1248
1718
|
if (!assetPath)
|
|
1249
1719
|
throw new UsageError(`Cannot resolve proposal target ${proposal.ref}.`, "INVALID_PROPOSAL");
|
|
1720
|
+
assertWriteTargetPathsClean(mutationTarget.source, [assetPath]);
|
|
1250
1721
|
let backup;
|
|
1251
1722
|
if (fs.existsSync(assetPath)) {
|
|
1252
1723
|
try {
|
|
@@ -1256,16 +1727,48 @@ async function promoteProposalWithLease(stashDir, config, id, options, ctx) {
|
|
|
1256
1727
|
throw new Error(`Proposal backup read failed for ${assetPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1257
1728
|
}
|
|
1258
1729
|
}
|
|
1259
|
-
|
|
1730
|
+
if (proposal.beforeHash !== undefined && (!backup || proposalHash(backup) !== proposal.beforeHash)) {
|
|
1731
|
+
throw new UsageError(`Proposal target changed after proposal ${id} was created; refusing to overwrite newer content.`, "INVALID_FLAG_VALUE");
|
|
1732
|
+
}
|
|
1733
|
+
if (proposal.beforeHash === undefined &&
|
|
1734
|
+
backup !== undefined &&
|
|
1735
|
+
proposal.changes.some((change) => change.op === "create")) {
|
|
1736
|
+
throw new UsageError(`Proposal target was created after proposal ${id}; refusing to overwrite newer content.`, "INVALID_FLAG_VALUE");
|
|
1737
|
+
}
|
|
1738
|
+
assertAkmAssetWrite(mutationTarget.source);
|
|
1739
|
+
// D2 (#730): stamp OKF v0.2 provenance onto the promoted content BEFORE
|
|
1740
|
+
// lint/write — reaching this point already proves the target is AKM-native
|
|
1741
|
+
// (assertAkmAssetWrite above rejects an OKF-adapter target first), so the
|
|
1742
|
+
// OKF write-rejection contract (runbook §10) is untouched: this code never
|
|
1743
|
+
// runs for it. Markdown-only: a task/env/script/other non-markdown target
|
|
1744
|
+
// has no frontmatter block to stamp into.
|
|
1745
|
+
//
|
|
1746
|
+
// workflow-format-unification removed the re-validation fallback that used
|
|
1747
|
+
// to live here: every AKM-native markdown type (workflow included) now
|
|
1748
|
+
// validates its frontmatter against a schema whose closed key set is
|
|
1749
|
+
// `envelope ∪ type-keys` (`schemas/akm-workflow.json` $ref's
|
|
1750
|
+
// `schemas/akm-asset-envelope.json`, which already admits `generated`/
|
|
1751
|
+
// `verified`/`provenance`/`status`/`stale_after`). A validator rejecting the
|
|
1752
|
+
// machine-stamped keys it is contractually required to admit is structurally
|
|
1753
|
+
// impossible now, so falling back to unstamped content on rejection would
|
|
1754
|
+
// only silently hide a real regression instead of promoting stamped content.
|
|
1755
|
+
const stampedContent = preflight.stampedContent;
|
|
1756
|
+
const proposalForPreflight = preflight.proposal;
|
|
1757
|
+
const refIdentity = proposalRefIdentity(proposalForPreflight.ref);
|
|
1758
|
+
const proposalForMutation = refIdentity?.bundle === undefined
|
|
1759
|
+
? { ...proposalForPreflight, ref: `${target.source.name}//${refIdentity?.conceptId ?? ""}` }
|
|
1760
|
+
: proposalForPreflight;
|
|
1761
|
+
const transaction = prepareProposalTransaction(stashDir, mutationTarget, proposalForMutation, ref, stampedContent, {
|
|
1260
1762
|
operation: "accept",
|
|
1261
1763
|
originalHash: backup ? proposalHash(backup) : null,
|
|
1262
1764
|
backup,
|
|
1263
1765
|
eventMetadata: options.eventMetadata,
|
|
1766
|
+
gateDecision: options.gateDecision,
|
|
1264
1767
|
}, ctx);
|
|
1265
|
-
publishProposalAsset(transaction);
|
|
1266
|
-
const accepted = await finalizeProposalTransaction(transaction,
|
|
1768
|
+
publishProposalAsset(transaction, mutationTarget);
|
|
1769
|
+
const accepted = await finalizeProposalTransaction(transaction, mutationTarget, proposalForMutation, ctx);
|
|
1267
1770
|
cleanupTxn(transaction.dir);
|
|
1268
|
-
return { proposal: accepted, assetPath: transaction.journal.payload.assetPath, ref:
|
|
1771
|
+
return { proposal: accepted, assetPath: transaction.journal.payload.assetPath, ref: accepted.ref };
|
|
1269
1772
|
}
|
|
1270
1773
|
/**
|
|
1271
1774
|
* Restore the prior content of an accepted proposal from the backup captured
|
|
@@ -1299,19 +1802,15 @@ async function revertProposalWithLease(stashDir, config, id, options, ctx) {
|
|
|
1299
1802
|
await recoverProposalTransactionsForStash(stashDir, config, ctx, id);
|
|
1300
1803
|
proposal = getProposal(stashDir, id, ctx);
|
|
1301
1804
|
if (proposal.status === "reverted") {
|
|
1302
|
-
if (
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
(proposal.acceptedTarget &&
|
|
1312
|
-
(proposal.acceptedTarget.source !== target.source.name ||
|
|
1313
|
-
path.resolve(proposal.acceptedTarget.root) !== path.resolve(target.source.path) ||
|
|
1314
|
-
path.resolve(proposal.acceptedTarget.path) !== path.resolve(requestedAssetPath)))) {
|
|
1805
|
+
if (!proposal.acceptedTarget) {
|
|
1806
|
+
throw new UsageError(`Reverted proposal ${id} has no recorded target.`, "INVALID_PROPOSAL");
|
|
1807
|
+
}
|
|
1808
|
+
const target = resolveRecordedProposalTarget(config, id, proposal.acceptedTarget, options.target);
|
|
1809
|
+
const requestedAssetPath = resolveAssetFilePathSafe(target.source, ref);
|
|
1810
|
+
if (!requestedAssetPath ||
|
|
1811
|
+
proposal.acceptedTarget.source !== target.source.name ||
|
|
1812
|
+
path.resolve(proposal.acceptedTarget.root) !== path.resolve(target.source.path) ||
|
|
1813
|
+
path.resolve(proposal.acceptedTarget.path) !== path.resolve(requestedAssetPath)) {
|
|
1315
1814
|
throw new UsageError(`proposal ${id} is bound to a different accepted target`, "INVALID_FLAG_VALUE");
|
|
1316
1815
|
}
|
|
1317
1816
|
return {
|
|
@@ -1327,96 +1826,26 @@ async function revertProposalWithLease(stashDir, config, id, options, ctx) {
|
|
|
1327
1826
|
if (backupContent === undefined) {
|
|
1328
1827
|
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.");
|
|
1329
1828
|
}
|
|
1330
|
-
const proposalBody = proposalContent(proposal);
|
|
1331
|
-
const legacyAccepted = proposalBody.endsWith("\n") ? proposalBody : `${proposalBody}\n`;
|
|
1332
|
-
let acceptedHash = proposal.acceptedTarget?.contentHash ?? proposal.acceptedContentHash ?? proposalHash(legacyAccepted);
|
|
1333
|
-
const writableTargets = resolveWritableTargets(config);
|
|
1334
|
-
let target;
|
|
1335
|
-
let assetPath;
|
|
1336
1829
|
if (!proposal.acceptedTarget) {
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
if (matching.length === 0 && candidates.length !== 1) {
|
|
1352
|
-
throw new UsageError(`legacy proposal ${id} has no accepted asset and ${candidates.length} writable targets can own the ref; ownership is ambiguous`, "INVALID_FLAG_VALUE");
|
|
1353
|
-
}
|
|
1354
|
-
const owner = matching[0] ?? candidates[0];
|
|
1355
|
-
if (!owner) {
|
|
1356
|
-
throw new UsageError(`legacy proposal ${id} has no writable target that can own ${proposal.ref}`, "INVALID_FLAG_VALUE");
|
|
1357
|
-
}
|
|
1358
|
-
const acceptedAssetWasAbsent = matching.length === 0;
|
|
1359
|
-
proposal = withProposalsDb(stashDir, ctx, (db) => withImmediateTransaction(db, () => {
|
|
1360
|
-
const current = requireProposal(db, stashDir, id);
|
|
1361
|
-
if (current.status !== "accepted" || current.acceptedTarget) {
|
|
1362
|
-
throw new Error(`Proposal ${id} changed while deriving its legacy revert target.`);
|
|
1363
|
-
}
|
|
1364
|
-
const bound = {
|
|
1365
|
-
...current,
|
|
1366
|
-
acceptedContentHash: acceptedHash,
|
|
1367
|
-
acceptedTarget: {
|
|
1368
|
-
source: owner.target.source.name,
|
|
1369
|
-
root: owner.target.source.path,
|
|
1370
|
-
path: owner.assetPath,
|
|
1371
|
-
contentHash: acceptedHash,
|
|
1372
|
-
},
|
|
1373
|
-
legacyAcceptedTargetDerived: true,
|
|
1374
|
-
...(acceptedAssetWasAbsent ? { legacyAcceptedAssetWasAbsent: true } : {}),
|
|
1375
|
-
};
|
|
1376
|
-
upsertProposal(db, bound, stashDir);
|
|
1377
|
-
return bound;
|
|
1378
|
-
}));
|
|
1379
|
-
txnMutationHook("legacy-target-derived");
|
|
1380
|
-
target = owner.target;
|
|
1381
|
-
assetPath = owner.assetPath;
|
|
1382
|
-
}
|
|
1383
|
-
else if (proposal.legacyAcceptedTargetDerived) {
|
|
1384
|
-
const acceptedTarget = proposal.acceptedTarget;
|
|
1385
|
-
if (!acceptedTarget)
|
|
1386
|
-
throw new Error(`Legacy proposal ${id} lost its derived accepted target.`);
|
|
1387
|
-
const bound = writableTargets.find((candidate) => {
|
|
1388
|
-
const candidatePath = resolveAssetFilePathSafe(candidate.source, ref);
|
|
1389
|
-
return (candidate.source.name === acceptedTarget.source &&
|
|
1390
|
-
path.resolve(candidate.source.path) === path.resolve(acceptedTarget.root) &&
|
|
1391
|
-
candidatePath !== undefined &&
|
|
1392
|
-
path.resolve(candidatePath) === path.resolve(acceptedTarget.path));
|
|
1393
|
-
});
|
|
1394
|
-
if (!bound) {
|
|
1395
|
-
throw new UsageError(`legacy proposal ${id} is bound to a writable target that is no longer configured`, "INVALID_FLAG_VALUE");
|
|
1396
|
-
}
|
|
1397
|
-
target = bound;
|
|
1398
|
-
assetPath = acceptedTarget.path;
|
|
1399
|
-
}
|
|
1400
|
-
else {
|
|
1401
|
-
target = resolveWriteTarget(config, options.target);
|
|
1402
|
-
const requestedAssetPath = resolveAssetFilePathSafe(target.source, ref);
|
|
1403
|
-
if (proposal.acceptedTarget.source !== target.source.name ||
|
|
1404
|
-
path.resolve(proposal.acceptedTarget.root) !== path.resolve(target.source.path) ||
|
|
1405
|
-
!requestedAssetPath ||
|
|
1406
|
-
path.resolve(proposal.acceptedTarget.path) !== path.resolve(requestedAssetPath)) {
|
|
1407
|
-
throw new UsageError(`proposal ${id} is bound to a different accepted target`, "INVALID_FLAG_VALUE");
|
|
1408
|
-
}
|
|
1409
|
-
assetPath = requestedAssetPath;
|
|
1410
|
-
}
|
|
1411
|
-
await recoverTxnsForRoot(target.source.path, (journal) => journal.kind === "mv");
|
|
1412
|
-
acceptedHash = proposal.acceptedTarget?.contentHash ?? proposal.acceptedContentHash ?? acceptedHash;
|
|
1413
|
-
const acceptedAssetExists = fs.existsSync(assetPath);
|
|
1414
|
-
if ((fs.existsSync(assetPath) && proposalFileHash(assetPath) !== acceptedHash) ||
|
|
1415
|
-
(!fs.existsSync(assetPath) && !proposal.legacyAcceptedAssetWasAbsent)) {
|
|
1830
|
+
throw new UsageError(`Accepted proposal ${id} has no recorded target.`, "INVALID_PROPOSAL");
|
|
1831
|
+
}
|
|
1832
|
+
let target = resolveRecordedProposalTarget(config, id, proposal.acceptedTarget, options.target);
|
|
1833
|
+
const requestedAssetPath = resolveAssetFilePathSafe(target.source, ref);
|
|
1834
|
+
if (proposal.acceptedTarget.source !== target.source.name ||
|
|
1835
|
+
path.resolve(proposal.acceptedTarget.root) !== path.resolve(target.source.path) ||
|
|
1836
|
+
!requestedAssetPath ||
|
|
1837
|
+
path.resolve(proposal.acceptedTarget.path) !== path.resolve(requestedAssetPath)) {
|
|
1838
|
+
throw new UsageError(`proposal ${id} is bound to a different accepted target`, "INVALID_FLAG_VALUE");
|
|
1839
|
+
}
|
|
1840
|
+
const assetPath = requestedAssetPath;
|
|
1841
|
+
const acceptedHash = proposal.acceptedTarget.contentHash;
|
|
1842
|
+
target = prepareWriteTargetForMutation(target);
|
|
1843
|
+
if (!fs.existsSync(assetPath) || proposalFileHash(assetPath) !== acceptedHash) {
|
|
1416
1844
|
throw new UsageError(`asset content changed after proposal ${id} was accepted; refusing to clobber the newer content`, "INVALID_FLAG_VALUE");
|
|
1417
1845
|
}
|
|
1418
|
-
|
|
1419
|
-
|
|
1846
|
+
assertWriteTargetPathsClean(target.source, [assetPath]);
|
|
1847
|
+
const transaction = prepareProposalTransaction(stashDir, target, proposal, ref, backupContent, { operation: "revert", originalHash: acceptedHash }, ctx);
|
|
1848
|
+
publishProposalAsset(transaction, target);
|
|
1420
1849
|
const reverted = await finalizeProposalTransaction(transaction, target, proposal, ctx);
|
|
1421
1850
|
cleanupTxn(transaction.dir);
|
|
1422
1851
|
return { proposal: reverted, assetPath, ref: proposal.ref };
|
|
@@ -1432,17 +1861,13 @@ export function diffProposal(stashDir, config, id, options = {}, ctx) {
|
|
|
1432
1861
|
const ref = parseRefInput(proposal.ref);
|
|
1433
1862
|
let targetPath;
|
|
1434
1863
|
let existing = null;
|
|
1435
|
-
|
|
1436
|
-
const target = resolveWriteTarget(config, options.target);
|
|
1864
|
+
const readTarget = (target) => {
|
|
1437
1865
|
targetPath = resolveAssetFilePathSafe(target.source, ref);
|
|
1438
1866
|
if (targetPath && fs.existsSync(targetPath)) {
|
|
1439
1867
|
existing = fs.readFileSync(targetPath, "utf8");
|
|
1440
1868
|
}
|
|
1441
|
-
}
|
|
1442
|
-
|
|
1443
|
-
// No writable target configured — still return a "new asset" diff so
|
|
1444
|
-
// callers can see the proposed payload without erroring out.
|
|
1445
|
-
}
|
|
1869
|
+
};
|
|
1870
|
+
readTarget(resolveProposalWriteTarget(config, proposal, options.target, options.queueTarget));
|
|
1446
1871
|
const proposed = proposalContent(proposal);
|
|
1447
1872
|
if (existing === null) {
|
|
1448
1873
|
return {
|
|
@@ -1488,14 +1913,12 @@ registerTxnKind(PROPOSAL_TXN_KIND, {
|
|
|
1488
1913
|
finalize: async (txn) => {
|
|
1489
1914
|
const p = txn.journal.payload;
|
|
1490
1915
|
const config = loadConfig();
|
|
1491
|
-
let target;
|
|
1492
|
-
|
|
1493
|
-
target =
|
|
1494
|
-
}
|
|
1495
|
-
catch {
|
|
1496
|
-
target = resolveWriteTarget(config);
|
|
1916
|
+
let target = resolveProposalRecoveryTarget(config, txn.journal);
|
|
1917
|
+
if (txn.journal.phase === "asset-published") {
|
|
1918
|
+
target = prepareWriteTargetForMutation(target, { allowAhead: true });
|
|
1497
1919
|
}
|
|
1498
|
-
if (canonicalTxnRoot(target.source.path) !== canonicalTxnRoot(txn.journal.root)
|
|
1920
|
+
if (canonicalTxnRoot(target.source.path) !== canonicalTxnRoot(txn.journal.root) ||
|
|
1921
|
+
p.targetKind !== target.source.kind) {
|
|
1499
1922
|
throw new Error(`Proposal transaction ${txn.journal.transactionId} is bound to a different target root.`);
|
|
1500
1923
|
}
|
|
1501
1924
|
const proposal = getProposal(p.stashDir, p.proposalId);
|