From 9342cc3bbc351b4a5000a87218d5fd5b317c8ced Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Tue, 31 Mar 2026 16:00:29 -0500 Subject: [PATCH 01/27] Closes changes that implemented the multi-hop migrations for 'upgrade' commands Signed-off-by: Zebulon Rouse-Frantzich --- ...e76-1774990635-750948eb2860-4418558df4a8.json | 16 ++++++++++++++++ ...9ab-1774990654-dac1daf17ec2-bd66e6912b32.json | 16 ++++++++++++++++ .../status.yaml | 8 ++++---- .../status.yaml | 8 ++++---- 4 files changed, 40 insertions(+), 8 deletions(-) create mode 100644 runecontext/assurance/receipts/changes/changes--changes-257ebe76-1774990635-750948eb2860-4418558df4a8.json create mode 100644 runecontext/assurance/receipts/changes/changes--changes-ddcbd9ab-1774990654-dac1daf17ec2-bd66e6912b32.json diff --git a/runecontext/assurance/receipts/changes/changes--changes-257ebe76-1774990635-750948eb2860-4418558df4a8.json b/runecontext/assurance/receipts/changes/changes--changes-257ebe76-1774990635-750948eb2860-4418558df4a8.json new file mode 100644 index 0000000..69cffc9 --- /dev/null +++ b/runecontext/assurance/receipts/changes/changes--changes-257ebe76-1774990635-750948eb2860-4418558df4a8.json @@ -0,0 +1,16 @@ +{ + "schema_version": 1, + "kind": "receipt", + "subject_id": "changes/CHG-2026-024-3756-plan-interval-based-real-migration-edge-discovery-for-project-upgrades", + "created_at": 1774990635, + "canonicalization": "runecontext-canonical-json-v1", + "value": { + "change_id": "CHG-2026-024-3756-plan-interval-based-real-migration-edge-discovery-for-project-upgrades", + "change_status": "closed", + "receipt_family": "changes", + "verification_status": "passed" + }, + "receipt_id": "changes-257ebe76-1774990635-750948eb2860", + "receipt_hash": "4418558df4a882f118866a1dfe21bf04bb6eecd038000bdb8f71242ae4b81436", + "provenance": "captured_verified" +} diff --git a/runecontext/assurance/receipts/changes/changes--changes-ddcbd9ab-1774990654-dac1daf17ec2-bd66e6912b32.json b/runecontext/assurance/receipts/changes/changes--changes-ddcbd9ab-1774990654-dac1daf17ec2-bd66e6912b32.json new file mode 100644 index 0000000..6930b1c --- /dev/null +++ b/runecontext/assurance/receipts/changes/changes--changes-ddcbd9ab-1774990654-dac1daf17ec2-bd66e6912b32.json @@ -0,0 +1,16 @@ +{ + "schema_version": 1, + "kind": "receipt", + "subject_id": "changes/CHG-2026-025-7854-migrate-assurance-artifacts-under-runecontext-for-alpha13-upgrades", + "created_at": 1774990654, + "canonicalization": "runecontext-canonical-json-v1", + "value": { + "change_id": "CHG-2026-025-7854-migrate-assurance-artifacts-under-runecontext-for-alpha13-upgrades", + "change_status": "closed", + "receipt_family": "changes", + "verification_status": "passed" + }, + "receipt_id": "changes-ddcbd9ab-1774990654-dac1daf17ec2", + "receipt_hash": "bd66e6912b32a46ddaefb5094d8a0d030f8260caa45571e8ae093f6e2def92a8", + "provenance": "captured_verified" +} diff --git a/runecontext/changes/CHG-2026-024-3756-plan-interval-based-real-migration-edge-discovery-for-project-upgrades/status.yaml b/runecontext/changes/CHG-2026-024-3756-plan-interval-based-real-migration-edge-discovery-for-project-upgrades/status.yaml index 5597ec8..08136d8 100644 --- a/runecontext/changes/CHG-2026-024-3756-plan-interval-based-real-migration-edge-discovery-for-project-upgrades/status.yaml +++ b/runecontext/changes/CHG-2026-024-3756-plan-interval-based-real-migration-edge-discovery-for-project-upgrades/status.yaml @@ -1,10 +1,10 @@ schema_version: 1 id: CHG-2026-024-3756-plan-interval-based-real-migration-edge-discovery-for-project-upgrades title: Plan interval-based real migration-edge discovery for project upgrades -status: implemented +status: closed type: feature size: medium -verification_status: pending +verification_status: passed context_bundles: [] related_specs: [] related_decisions: [] @@ -19,7 +19,7 @@ informed_by: [] supersedes: [] superseded_by: [] created_at: "2026-03-31" -closed_at: null +closed_at: "2026-03-31" promotion_assessment: - status: pending + status: none suggested_targets: [] diff --git a/runecontext/changes/CHG-2026-025-7854-migrate-assurance-artifacts-under-runecontext-for-alpha13-upgrades/status.yaml b/runecontext/changes/CHG-2026-025-7854-migrate-assurance-artifacts-under-runecontext-for-alpha13-upgrades/status.yaml index 537e8da..55a41e4 100644 --- a/runecontext/changes/CHG-2026-025-7854-migrate-assurance-artifacts-under-runecontext-for-alpha13-upgrades/status.yaml +++ b/runecontext/changes/CHG-2026-025-7854-migrate-assurance-artifacts-under-runecontext-for-alpha13-upgrades/status.yaml @@ -1,10 +1,10 @@ schema_version: 1 id: CHG-2026-025-7854-migrate-assurance-artifacts-under-runecontext-for-alpha13-upgrades title: Migrate assurance artifacts under runecontext for alpha.13 upgrades -status: implemented +status: closed type: feature size: medium -verification_status: pending +verification_status: passed context_bundles: [] related_specs: [] related_decisions: [] @@ -18,7 +18,7 @@ informed_by: [] supersedes: [] superseded_by: [] created_at: "2026-03-31" -closed_at: null +closed_at: "2026-03-31" promotion_assessment: - status: pending + status: none suggested_targets: [] From 6c38eae8f594746fc97cfa8589bfa2ac4849f12b Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Tue, 31 Mar 2026 18:24:19 -0500 Subject: [PATCH 02/27] Adds advisory intake and decomposition assessment commands Signed-off-by: Zebulon Rouse-Frantzich --- .../flows/change-assess-decomposition.md | 7 + .../claude-code/flows/change-assess-intake.md | 7 + .../flows/conversational-parity.md | 2 + .../flows/change-assess-decomposition.md | 7 + adapters/codex/flows/change-assess-intake.md | 7 + adapters/codex/flows/conversational-parity.md | 2 + .../flows/change-assess-decomposition.md | 7 + .../generic/flows/change-assess-intake.md | 7 + .../generic/flows/conversational-parity.md | 2 + .../flows/change-assess-decomposition.md | 21 ++ .../opencode/flows/change-assess-intake.md | 25 +++ .../opencode/flows/conversational-parity.md | 2 + .../generated/runecontext-reference.json | 18 +- fixtures/cli-completion/bash.golden | 21 +- fixtures/cli-completion/fish.golden | 19 ++ fixtures/cli-completion/zsh.golden | 21 +- .../cli-json-golden/metadata-success.json | 2 +- .../release-manifest-with-metadata.json | 20 +- internal/cli/cli.go | 6 +- internal/cli/cli_adapter_pack_test.go | 4 +- ...cli_adapter_render_host_native_markdown.go | 12 +- internal/cli/cli_adapter_render_test.go | 27 +++ internal/cli/cli_adapter_sync_host_native.go | 14 +- internal/cli/cli_adapter_test.go | 14 +- internal/cli/cli_change.go | 68 ++++++ internal/cli/cli_change_output.go | 50 +++++ internal/cli/cli_change_test.go | 102 +++++++++ internal/cli/cli_completion_suggest_test.go | 3 + internal/cli/cli_completion_test.go | 2 + internal/cli/cli_explain.go | 37 ++++ internal/cli/cli_metadata_flags.go | 11 + internal/cli/cli_metadata_registry.go | 2 + internal/cli/cli_metadata_registry_adapter.go | 2 +- internal/cli/cli_metadata_registry_test.go | 28 +++ internal/cli/cli_parse.go | 73 +++++++ internal/cli/cli_upgrade_adapter_plans.go | 2 +- internal/contracts/change_assessment.go | 203 ++++++++++++++++++ internal/contracts/change_assessment_test.go | 83 +++++++ internal/contracts/change_commands.go | 37 ++++ nix/release/metadata.nix | 2 +- 40 files changed, 951 insertions(+), 28 deletions(-) create mode 100644 adapters/claude-code/flows/change-assess-decomposition.md create mode 100644 adapters/claude-code/flows/change-assess-intake.md create mode 100644 adapters/codex/flows/change-assess-decomposition.md create mode 100644 adapters/codex/flows/change-assess-intake.md create mode 100644 adapters/generic/flows/change-assess-decomposition.md create mode 100644 adapters/generic/flows/change-assess-intake.md create mode 100644 adapters/opencode/flows/change-assess-decomposition.md create mode 100644 adapters/opencode/flows/change-assess-intake.md create mode 100644 internal/contracts/change_assessment.go create mode 100644 internal/contracts/change_assessment_test.go diff --git a/adapters/claude-code/flows/change-assess-decomposition.md b/adapters/claude-code/flows/change-assess-decomposition.md new file mode 100644 index 0000000..3adbd06 --- /dev/null +++ b/adapters/claude-code/flows/change-assess-decomposition.md @@ -0,0 +1,7 @@ +# Claude Code Flow: change assess-decomposition + +Map conversational decomposition assessment to: + +```sh +runectx change assess-decomposition [--path ] +``` diff --git a/adapters/claude-code/flows/change-assess-intake.md b/adapters/claude-code/flows/change-assess-intake.md new file mode 100644 index 0000000..7643893 --- /dev/null +++ b/adapters/claude-code/flows/change-assess-intake.md @@ -0,0 +1,7 @@ +# Claude Code Flow: change assess-intake + +Map conversational intake assessment to: + +```sh +runectx change assess-intake --title "" --type <type> [--size <size>] [--bundle <bundle-id>] [--description "<text>"] [--path <project-root>] +``` diff --git a/adapters/claude-code/flows/conversational-parity.md b/adapters/claude-code/flows/conversational-parity.md index 572cde7..2a0ff1d 100644 --- a/adapters/claude-code/flows/conversational-parity.md +++ b/adapters/claude-code/flows/conversational-parity.md @@ -10,6 +10,8 @@ CLI inputs. No adapter-only lifecycle or mutation semantics are allowed. ## Flow Mappings - `change new` conversation -> `runectx change new --title --type [--size] [--shape] [--bundle] [--description] [--path]` +- `change assess-intake` conversation -> `runectx change assess-intake --title --type [--size] [--bundle] [--description] [--path]` +- `change assess-decomposition` conversation -> `runectx change assess-decomposition CHANGE_ID [--path]` - `change shape` conversation -> `runectx change shape CHANGE_ID [--design] [--verification] [--task] [--reference] [--path]` - `standard discover` conversation -> `runectx standard discover [--path] [--change CHANGE_ID] [--scope-path PATH] [--focus TEXT] [--confirm-handoff] [--target TYPE:PATH]` - `promote` conversation -> `runectx promote CHANGE_ID [--accept|--complete] [--target TYPE:PATH] [--path]` diff --git a/adapters/codex/flows/change-assess-decomposition.md b/adapters/codex/flows/change-assess-decomposition.md new file mode 100644 index 0000000..9e1f778 --- /dev/null +++ b/adapters/codex/flows/change-assess-decomposition.md @@ -0,0 +1,7 @@ +# Codex Flow: change assess-decomposition + +Map conversational decomposition assessment to: + +```sh +runectx change assess-decomposition <CHANGE_ID> [--path <project-root>] +``` diff --git a/adapters/codex/flows/change-assess-intake.md b/adapters/codex/flows/change-assess-intake.md new file mode 100644 index 0000000..6e6a239 --- /dev/null +++ b/adapters/codex/flows/change-assess-intake.md @@ -0,0 +1,7 @@ +# Codex Flow: change assess-intake + +Map conversational intake assessment to: + +```sh +runectx change assess-intake --title "<title>" --type <type> [--size <size>] [--bundle <bundle-id>] [--description "<text>"] [--path <project-root>] +``` diff --git a/adapters/codex/flows/conversational-parity.md b/adapters/codex/flows/conversational-parity.md index eff8316..069a12e 100644 --- a/adapters/codex/flows/conversational-parity.md +++ b/adapters/codex/flows/conversational-parity.md @@ -10,6 +10,8 @@ CLI inputs. No adapter-only lifecycle or mutation semantics are allowed. ## Flow Mappings - `change new` conversation -> `runectx change new --title --type [--size] [--shape] [--bundle] [--description] [--path]` +- `change assess-intake` conversation -> `runectx change assess-intake --title --type [--size] [--bundle] [--description] [--path]` +- `change assess-decomposition` conversation -> `runectx change assess-decomposition CHANGE_ID [--path]` - `change shape` conversation -> `runectx change shape CHANGE_ID [--design] [--verification] [--task] [--reference] [--path]` - `standard discover` conversation -> `runectx standard discover [--path] [--change CHANGE_ID] [--scope-path PATH] [--focus TEXT] [--confirm-handoff] [--target TYPE:PATH]` - `promote` conversation -> `runectx promote CHANGE_ID [--accept|--complete] [--target TYPE:PATH] [--path]` diff --git a/adapters/generic/flows/change-assess-decomposition.md b/adapters/generic/flows/change-assess-decomposition.md new file mode 100644 index 0000000..3bb7eaf --- /dev/null +++ b/adapters/generic/flows/change-assess-decomposition.md @@ -0,0 +1,7 @@ +# Generic Flow: change assess-decomposition + +Use explicit CLI command proposals: + +```sh +runectx change assess-decomposition <CHANGE_ID> [--path <project-root>] +``` diff --git a/adapters/generic/flows/change-assess-intake.md b/adapters/generic/flows/change-assess-intake.md new file mode 100644 index 0000000..7549bb6 --- /dev/null +++ b/adapters/generic/flows/change-assess-intake.md @@ -0,0 +1,7 @@ +# Generic Flow: change assess-intake + +Use explicit CLI command proposals: + +```sh +runectx change assess-intake --title "<title>" --type <type> [--size <size>] [--bundle <bundle-id>] [--description "<text>"] [--path <project-root>] +``` diff --git a/adapters/generic/flows/conversational-parity.md b/adapters/generic/flows/conversational-parity.md index 835b417..157b21a 100644 --- a/adapters/generic/flows/conversational-parity.md +++ b/adapters/generic/flows/conversational-parity.md @@ -10,6 +10,8 @@ CLI inputs. No adapter-only lifecycle or mutation semantics are allowed. ## Flow Mappings - `change new` conversation -> `runectx change new --title --type [--size] [--shape] [--bundle] [--description] [--path]` +- `change assess-intake` conversation -> `runectx change assess-intake --title --type [--size] [--bundle] [--description] [--path]` +- `change assess-decomposition` conversation -> `runectx change assess-decomposition CHANGE_ID [--path]` - `change shape` conversation -> `runectx change shape CHANGE_ID [--design] [--verification] [--task] [--reference] [--path]` - `standard discover` conversation -> `runectx standard discover [--path] [--change CHANGE_ID] [--scope-path PATH] [--focus TEXT] [--confirm-handoff] [--target TYPE:PATH]` - `promote` conversation -> `runectx promote CHANGE_ID [--accept|--complete] [--target TYPE:PATH] [--path]` diff --git a/adapters/opencode/flows/change-assess-decomposition.md b/adapters/opencode/flows/change-assess-decomposition.md new file mode 100644 index 0000000..ea5327c --- /dev/null +++ b/adapters/opencode/flows/change-assess-decomposition.md @@ -0,0 +1,21 @@ +# OpenCode Flow: change assess-decomposition + +Use this conversational flow to gather read-only decomposition signals for an +existing change. + +## Inputs + +- change ID +- optional project path + +## Command Mapping + +```sh +runectx change assess-decomposition <CHANGE_ID> [--path <project-root>] +``` + +## Review Checkpoint + +- Confirm the target `CHANGE_ID` before execution. +- Keep advisory outputs (`decomposition_signal`, `eligible_sub_change_*`, and + `prerequisite_change_*`) user-visible. diff --git a/adapters/opencode/flows/change-assess-intake.md b/adapters/opencode/flows/change-assess-intake.md new file mode 100644 index 0000000..c1d37e0 --- /dev/null +++ b/adapters/opencode/flows/change-assess-intake.md @@ -0,0 +1,25 @@ +# OpenCode Flow: change assess-intake + +Use this conversational flow to gather read-only intake readiness and shaping +guidance for a proposed change. + +## Inputs + +- title +- type (`project|feature|bug|standard|chore`) +- optional size (`small|medium|large`) +- optional bundle IDs +- optional description +- optional project path + +## Command Mapping + +```sh +runectx change assess-intake --title "<title>" --type <type> [--size <size>] [--bundle <bundle-id>] [--description "<text>"] [--path <project-root>] +``` + +## Review Checkpoint + +- Keep this advisory/read-only output visible in the conversation. +- Surface `recommended_mode`, `intake_readiness`, and `decomposition_signal` + before proposing mutations. diff --git a/adapters/opencode/flows/conversational-parity.md b/adapters/opencode/flows/conversational-parity.md index 2d3b1cb..664e71e 100644 --- a/adapters/opencode/flows/conversational-parity.md +++ b/adapters/opencode/flows/conversational-parity.md @@ -10,6 +10,8 @@ CLI inputs. No adapter-only lifecycle or mutation semantics are allowed. ## Flow Mappings - `change new` conversation -> `runectx change new --title --type [--size] [--shape] [--bundle] [--description] [--path]` +- `change assess-intake` conversation -> `runectx change assess-intake --title --type [--size] [--bundle] [--description] [--path]` +- `change assess-decomposition` conversation -> `runectx change assess-decomposition CHANGE_ID [--path]` - `change shape` conversation -> `runectx change shape CHANGE_ID [--design] [--verification] [--task] [--reference] [--path]` - `standard discover` conversation -> `runectx standard discover [--path] [--change CHANGE_ID] [--scope-path PATH] [--focus TEXT] [--confirm-handoff] [--target TYPE:PATH]` - `promote` conversation -> `runectx promote CHANGE_ID [--accept|--complete] [--target TYPE:PATH] [--path]` diff --git a/docs/reference/generated/runecontext-reference.json b/docs/reference/generated/runecontext-reference.json index 56d3302..d77c470 100644 --- a/docs/reference/generated/runecontext-reference.json +++ b/docs/reference/generated/runecontext-reference.json @@ -4,8 +4,8 @@ "binary": "runectx", "release": { "package_name": "runecontext", - "version": "0.1.0-alpha.13", - "tag": "v0.1.0-alpha.13" + "version": "0.1.0-alpha.14", + "tag": "v0.1.0-alpha.14" }, "commands": { "items": [ @@ -49,6 +49,14 @@ "path": "change", "token": "change" }, + { + "path": "change assess-decomposition", + "token": "change_assess_decomposition" + }, + { + "path": "change assess-intake", + "token": "change_assess_intake" + }, { "path": "change close", "token": "change_close" @@ -163,6 +171,8 @@ "bundle", "bundle_resolve", "change", + "change_assess_decomposition", + "change_assess_intake", "change_close", "change_new", "change_reallocate", @@ -201,13 +211,13 @@ ] }, "compatibility": { - "default_project_version": "0.1.0-alpha.13", + "default_project_version": "0.1.0-alpha.14", "directly_supported_project_versions": [ "0.1.0-alpha.5", "0.1.0-alpha.6", "0.1.0-alpha.7", "0.1.0-alpha.8", - "0.1.0-alpha.13" + "0.1.0-alpha.14" ], "upgradeable_from_project_versions": [ "0.1.0-alpha.12", diff --git a/fixtures/cli-completion/bash.golden b/fixtures/cli-completion/bash.golden index a678bc6..03e1bf1 100644 --- a/fixtures/cli-completion/bash.golden +++ b/fixtures/cli-completion/bash.golden @@ -13,6 +13,8 @@ _runectx_has_command() { 'bundle') return 0 ;; 'bundle resolve') return 0 ;; 'change') return 0 ;; + 'change assess-decomposition') return 0 ;; + 'change assess-intake') return 0 ;; 'change close') return 0 ;; 'change new') return 0 ;; 'change reallocate') return 0 ;; @@ -55,7 +57,9 @@ _runectx_subcommands_for() { 'assurance enable') echo '' ;; 'bundle') echo 'resolve' ;; 'bundle resolve') echo '' ;; - 'change') echo 'close new reallocate shape update' ;; + 'change') echo 'assess-decomposition assess-intake close new reallocate shape update' ;; + 'change assess-decomposition') echo '' ;; + 'change assess-intake') echo '' ;; 'change close') echo '' ;; 'change new') echo '' ;; 'change reallocate') echo '' ;; @@ -98,6 +102,8 @@ _runectx_flags_for() { 'bundle') echo '--explain --json --non-interactive' ;; 'bundle resolve') echo '--explain --json --non-interactive --path' ;; 'change') echo '--dry-run --explain --json --non-interactive' ;; + 'change assess-decomposition') echo '--explain --json --non-interactive --path' ;; + 'change assess-intake') echo '--bundle --description --explain --json --non-interactive --path --size --title --type' ;; 'change close') echo '--closed-at --dry-run --explain --json --non-interactive --path --recursive --superseded-by --verification-status' ;; 'change new') echo '--bundle --description --dry-run --explain --json --non-interactive --path --shape --size --title --type' ;; 'change reallocate') echo '--dry-run --explain --json --non-interactive --path' ;; @@ -130,6 +136,13 @@ _runectx_flag_takes_value() { 'assurance capture|--path') return 0 ;; 'assurance enable|--path') return 0 ;; 'bundle resolve|--path') return 0 ;; + 'change assess-decomposition|--path') return 0 ;; + 'change assess-intake|--bundle') return 0 ;; + 'change assess-intake|--description') return 0 ;; + 'change assess-intake|--path') return 0 ;; + 'change assess-intake|--size') return 0 ;; + 'change assess-intake|--title') return 0 ;; + 'change assess-intake|--type') return 0 ;; 'change close|--closed-at') return 0 ;; 'change close|--path') return 0 ;; 'change close|--superseded-by') return 0 ;; @@ -180,6 +193,8 @@ _runectx_flag_takes_value() { _runectx_enum_for_flag() { case "$1" in 'adapter render-host-native|--role') echo 'discoverability_shim flow_asset' ;; + 'change assess-intake|--size') echo 'large medium small' ;; + 'change assess-intake|--type') echo 'bug chore feature project standard' ;; 'change close|--verification-status') echo 'failed passed skipped' ;; 'change new|--shape') echo 'full minimum' ;; 'change new|--size') echo 'large medium small' ;; @@ -193,6 +208,7 @@ _runectx_enum_for_flag() { _runectx_suggest_provider_for_flag() { case "$1" in + 'change assess-intake|--bundle') echo 'bundle-ids' ;; 'change close|--superseded-by') echo 'change-ids' ;; 'change new|--bundle') echo 'bundle-ids' ;; 'init|--seed-bundle') echo 'bundle-ids' ;; @@ -205,7 +221,7 @@ _runectx_suggest_provider_for_flag() { _runectx_positional_enum() { case "$1" in - 'adapter render-host-native|2') echo 'change-new change-shape index promote standard-discover' ;; + 'adapter render-host-native|2') echo 'change-assess-decomposition change-assess-intake change-new change-shape index promote standard-discover' ;; 'assurance capture|1') echo 'context-pack' ;; 'assurance enable|1') echo 'verified' ;; 'completion suggest|1') echo 'adapter-names adapter-names-shell-injection bundle-ids change-ids promotion-targets' ;; @@ -220,6 +236,7 @@ _runectx_suggest_provider_for_positional() { 'adapter sync|1') echo 'adapter-names' ;; 'assurance capture|2') echo 'bundle-ids' ;; 'bundle resolve|1') echo 'bundle-ids' ;; + 'change assess-decomposition|1') echo 'change-ids' ;; 'change close|1') echo 'change-ids' ;; 'change reallocate|1') echo 'change-ids' ;; 'change shape|1') echo 'change-ids' ;; diff --git a/fixtures/cli-completion/fish.golden b/fixtures/cli-completion/fish.golden index 1d31fe2..03c76d9 100644 --- a/fixtures/cli-completion/fish.golden +++ b/fixtures/cli-completion/fish.golden @@ -61,6 +61,8 @@ complete -c runectx -f -n '__fish_seen_subcommand_from assurance' -a 'backfill' complete -c runectx -f -n '__fish_seen_subcommand_from assurance' -a 'capture' complete -c runectx -f -n '__fish_seen_subcommand_from assurance' -a 'enable' complete -c runectx -f -n '__fish_seen_subcommand_from bundle' -a 'resolve' +complete -c runectx -f -n '__fish_seen_subcommand_from change' -a 'assess-decomposition' +complete -c runectx -f -n '__fish_seen_subcommand_from change' -a 'assess-intake' complete -c runectx -f -n '__fish_seen_subcommand_from change' -a 'close' complete -c runectx -f -n '__fish_seen_subcommand_from change' -a 'new' complete -c runectx -f -n '__fish_seen_subcommand_from change' -a 'reallocate' @@ -122,6 +124,22 @@ complete -c runectx -n '__fish_seen_subcommand_from change' -l dry-run complete -c runectx -n '__fish_seen_subcommand_from change' -l explain complete -c runectx -n '__fish_seen_subcommand_from change' -l json complete -c runectx -n '__fish_seen_subcommand_from change' -l non-interactive +complete -c runectx -n '__fish_seen_subcommand_from change assess-decomposition' -l explain +complete -c runectx -n '__fish_seen_subcommand_from change assess-decomposition' -l json +complete -c runectx -n '__fish_seen_subcommand_from change assess-decomposition' -l non-interactive +complete -c runectx -n '__fish_seen_subcommand_from change assess-decomposition' -l path -r +complete -c runectx -n '__fish_seen_subcommand_from change assess-intake' -l bundle -r +complete -c runectx -f -n '(__fish_seen_subcommand_from change assess-intake); and __runectx_prev_token_is --bundle' -a '(__runectx_dynamic_suggest bundle-ids)' +complete -c runectx -n '__fish_seen_subcommand_from change assess-intake' -l description -r +complete -c runectx -n '__fish_seen_subcommand_from change assess-intake' -l explain +complete -c runectx -n '__fish_seen_subcommand_from change assess-intake' -l json +complete -c runectx -n '__fish_seen_subcommand_from change assess-intake' -l non-interactive +complete -c runectx -n '__fish_seen_subcommand_from change assess-intake' -l path -r +complete -c runectx -n '__fish_seen_subcommand_from change assess-intake' -l size -r +complete -c runectx -f -n '(__fish_seen_subcommand_from change assess-intake); and __runectx_prev_token_is --size' -a 'large medium small' +complete -c runectx -n '__fish_seen_subcommand_from change assess-intake' -l title -r +complete -c runectx -n '__fish_seen_subcommand_from change assess-intake' -l type -r +complete -c runectx -f -n '(__fish_seen_subcommand_from change assess-intake); and __runectx_prev_token_is --type' -a 'bug chore feature project standard' complete -c runectx -n '__fish_seen_subcommand_from change close' -l closed-at -r complete -c runectx -n '__fish_seen_subcommand_from change close' -l dry-run complete -c runectx -n '__fish_seen_subcommand_from change close' -l explain @@ -368,6 +386,7 @@ complete -c runectx -f -n '__fish_seen_subcommand_from completion' -a 'bash fish complete -c runectx -f -n '__fish_seen_subcommand_from completion suggest' -a 'adapter-names adapter-names-shell-injection bundle-ids change-ids promotion-targets' complete -c runectx -f -n '__fish_seen_subcommand_from adapter render-host-native' -a '(__runectx_dynamic_suggest adapter-names-shell-injection)' complete -c runectx -f -n '__fish_seen_subcommand_from adapter sync' -a '(__runectx_dynamic_suggest adapter-names)' +complete -c runectx -f -n '__fish_seen_subcommand_from change assess-decomposition' -a '(__runectx_dynamic_suggest change-ids)' complete -c runectx -f -n '__fish_seen_subcommand_from change close' -a '(__runectx_dynamic_suggest change-ids)' complete -c runectx -f -n '__fish_seen_subcommand_from change reallocate' -a '(__runectx_dynamic_suggest change-ids)' complete -c runectx -f -n '__fish_seen_subcommand_from change shape' -a '(__runectx_dynamic_suggest change-ids)' diff --git a/fixtures/cli-completion/zsh.golden b/fixtures/cli-completion/zsh.golden index 9b9be6d..aa4a420 100644 --- a/fixtures/cli-completion/zsh.golden +++ b/fixtures/cli-completion/zsh.golden @@ -16,6 +16,8 @@ _runectx_has_command() { 'bundle') return 0 ;; 'bundle resolve') return 0 ;; 'change') return 0 ;; + 'change assess-decomposition') return 0 ;; + 'change assess-intake') return 0 ;; 'change close') return 0 ;; 'change new') return 0 ;; 'change reallocate') return 0 ;; @@ -58,7 +60,9 @@ _runectx_subcommands_for() { 'assurance enable') echo '' ;; 'bundle') echo 'resolve' ;; 'bundle resolve') echo '' ;; - 'change') echo 'close new reallocate shape update' ;; + 'change') echo 'assess-decomposition assess-intake close new reallocate shape update' ;; + 'change assess-decomposition') echo '' ;; + 'change assess-intake') echo '' ;; 'change close') echo '' ;; 'change new') echo '' ;; 'change reallocate') echo '' ;; @@ -101,6 +105,8 @@ _runectx_flags_for() { 'bundle') echo '--explain --json --non-interactive' ;; 'bundle resolve') echo '--explain --json --non-interactive --path' ;; 'change') echo '--dry-run --explain --json --non-interactive' ;; + 'change assess-decomposition') echo '--explain --json --non-interactive --path' ;; + 'change assess-intake') echo '--bundle --description --explain --json --non-interactive --path --size --title --type' ;; 'change close') echo '--closed-at --dry-run --explain --json --non-interactive --path --recursive --superseded-by --verification-status' ;; 'change new') echo '--bundle --description --dry-run --explain --json --non-interactive --path --shape --size --title --type' ;; 'change reallocate') echo '--dry-run --explain --json --non-interactive --path' ;; @@ -133,6 +139,13 @@ _runectx_flag_takes_value() { 'assurance capture|--path') return 0 ;; 'assurance enable|--path') return 0 ;; 'bundle resolve|--path') return 0 ;; + 'change assess-decomposition|--path') return 0 ;; + 'change assess-intake|--bundle') return 0 ;; + 'change assess-intake|--description') return 0 ;; + 'change assess-intake|--path') return 0 ;; + 'change assess-intake|--size') return 0 ;; + 'change assess-intake|--title') return 0 ;; + 'change assess-intake|--type') return 0 ;; 'change close|--closed-at') return 0 ;; 'change close|--path') return 0 ;; 'change close|--superseded-by') return 0 ;; @@ -183,6 +196,8 @@ _runectx_flag_takes_value() { _runectx_enum_for_flag() { case "$1" in 'adapter render-host-native|--role') echo 'discoverability_shim flow_asset' ;; + 'change assess-intake|--size') echo 'large medium small' ;; + 'change assess-intake|--type') echo 'bug chore feature project standard' ;; 'change close|--verification-status') echo 'failed passed skipped' ;; 'change new|--shape') echo 'full minimum' ;; 'change new|--size') echo 'large medium small' ;; @@ -196,6 +211,7 @@ _runectx_enum_for_flag() { _runectx_suggest_provider_for_flag() { case "$1" in + 'change assess-intake|--bundle') echo 'bundle-ids' ;; 'change close|--superseded-by') echo 'change-ids' ;; 'change new|--bundle') echo 'bundle-ids' ;; 'init|--seed-bundle') echo 'bundle-ids' ;; @@ -208,7 +224,7 @@ _runectx_suggest_provider_for_flag() { _runectx_positional_enum() { case "$1" in - 'adapter render-host-native|2') echo 'change-new change-shape index promote standard-discover' ;; + 'adapter render-host-native|2') echo 'change-assess-decomposition change-assess-intake change-new change-shape index promote standard-discover' ;; 'assurance capture|1') echo 'context-pack' ;; 'assurance enable|1') echo 'verified' ;; 'completion suggest|1') echo 'adapter-names adapter-names-shell-injection bundle-ids change-ids promotion-targets' ;; @@ -223,6 +239,7 @@ _runectx_suggest_provider_for_positional() { 'adapter sync|1') echo 'adapter-names' ;; 'assurance capture|2') echo 'bundle-ids' ;; 'bundle resolve|1') echo 'bundle-ids' ;; + 'change assess-decomposition|1') echo 'change-ids' ;; 'change close|1') echo 'change-ids' ;; 'change reallocate|1') echo 'change-ids' ;; 'change shape|1') echo 'change-ids' ;; diff --git a/fixtures/cli-json-golden/metadata-success.json b/fixtures/cli-json-golden/metadata-success.json index b3dbfe0..a3073b4 100644 --- a/fixtures/cli-json-golden/metadata-success.json +++ b/fixtures/cli-json-golden/metadata-success.json @@ -1 +1 @@ -{"assurance":{"baseline_supported":true,"receipt_families":["context-packs","changes","promotions","verifications"],"tiers":["plain","verified"]},"binary":"runectx","canonicalization":{"assurance_artifacts":{"hash_algorithm":"sha256","profile":"runecontext-canonical-json-v1"},"context_pack":{"hash_algorithm":"sha256","profile":"runecontext-canonical-json-v1"}},"capabilities":{"commands":[{"path":"adapter","token":"adapter"},{"path":"adapter render-host-native","token":"adapter_render_host_native"},{"path":"adapter sync","token":"adapter_sync"},{"path":"assurance","token":"assurance"},{"path":"assurance backfill","token":"assurance_backfill"},{"path":"assurance capture","token":"assurance_capture"},{"path":"assurance enable","token":"assurance_enable"},{"path":"bundle","token":"bundle"},{"path":"bundle resolve","token":"bundle_resolve"},{"path":"change","token":"change"},{"path":"change close","token":"change_close"},{"path":"change new","token":"change_new"},{"path":"change reallocate","token":"change_reallocate"},{"path":"change shape","token":"change_shape"},{"path":"change update","token":"change_update"},{"path":"completion","token":"completion"},{"path":"completion metadata","token":"completion_metadata"},{"path":"completion suggest","token":"completion_suggest"},{"path":"doctor","token":"doctor"},{"path":"generate","token":"generate"},{"path":"generate indexes","token":"generate_indexes"},{"path":"help","token":"help"},{"path":"init","token":"init"},{"path":"metadata","token":"metadata"},{"path":"promote","token":"promote"},{"path":"standard","token":"standard"},{"path":"standard discover","token":"standard_discover"},{"path":"status","token":"status"},{"path":"upgrade","token":"upgrade"},{"path":"upgrade apply","token":"upgrade_apply"},{"path":"upgrade cli","token":"upgrade_cli"},{"path":"upgrade cli apply","token":"upgrade_cli_apply"},{"path":"validate","token":"validate"},{"aliases":["--version","-v"],"path":"version","token":"version"}],"machine_flags":["--dry-run","--explain","--json","--non-interactive"],"value_kinds":["none","text","enum"]},"compatibility":{"default_project_version":"0.1.0-alpha.13","directly_supported_project_versions":["0.1.0-alpha.5","0.1.0-alpha.6","0.1.0-alpha.7","0.1.0-alpha.8","0.1.0-alpha.13"],"explicit_upgrade_edges":[{"from":"0.1.0-alpha.12","to":"0.1.0-alpha.13"}],"upgradeable_from_project_versions":["0.1.0-alpha.12","0.1.0-alpha.13"]},"distribution_layouts":[{"adapters_path":"adapters","profile":"repo_bundle","schema_path":"schemas"},{"adapters_path":"share/runecontext/adapters","profile":"installed_share_layout","schema_path":"share/runecontext/schemas"}],"features":["signed_tag_verification","mutable_ref_opt_in","monorepo_nearest_root_discovery","context_pack_capture","verified_assurance","completion_registry","completion_dynamic_suggestions","generated_indexes","promotion_workflow","upgrade_planning","staged_upgrade_execution","mixed_or_stale_tree_detection"],"project_profiles":[{"assurance_path":"runecontext/assurance","content_root":"runecontext","indexes_root":"runecontext/indexes","manifest_path":"runecontext/manifest.yaml","profile":"portable_project_root","root_config":"runecontext.yaml"}],"release":{"package_name":"runecontext","tag":"v0.1.0-alpha.13","version":"0.1.0-alpha.13"},"resolution":{"source_modes":["embedded","git","path"],"verification_postures":["embedded","pinned_commit","verified_signed_tag","unverified_mutable_ref","unverified_local_source"]},"schema_version":2} \ No newline at end of file +{"assurance":{"baseline_supported":true,"receipt_families":["context-packs","changes","promotions","verifications"],"tiers":["plain","verified"]},"binary":"runectx","canonicalization":{"assurance_artifacts":{"hash_algorithm":"sha256","profile":"runecontext-canonical-json-v1"},"context_pack":{"hash_algorithm":"sha256","profile":"runecontext-canonical-json-v1"}},"capabilities":{"commands":[{"path":"adapter","token":"adapter"},{"path":"adapter render-host-native","token":"adapter_render_host_native"},{"path":"adapter sync","token":"adapter_sync"},{"path":"assurance","token":"assurance"},{"path":"assurance backfill","token":"assurance_backfill"},{"path":"assurance capture","token":"assurance_capture"},{"path":"assurance enable","token":"assurance_enable"},{"path":"bundle","token":"bundle"},{"path":"bundle resolve","token":"bundle_resolve"},{"path":"change","token":"change"},{"path":"change assess-decomposition","token":"change_assess_decomposition"},{"path":"change assess-intake","token":"change_assess_intake"},{"path":"change close","token":"change_close"},{"path":"change new","token":"change_new"},{"path":"change reallocate","token":"change_reallocate"},{"path":"change shape","token":"change_shape"},{"path":"change update","token":"change_update"},{"path":"completion","token":"completion"},{"path":"completion metadata","token":"completion_metadata"},{"path":"completion suggest","token":"completion_suggest"},{"path":"doctor","token":"doctor"},{"path":"generate","token":"generate"},{"path":"generate indexes","token":"generate_indexes"},{"path":"help","token":"help"},{"path":"init","token":"init"},{"path":"metadata","token":"metadata"},{"path":"promote","token":"promote"},{"path":"standard","token":"standard"},{"path":"standard discover","token":"standard_discover"},{"path":"status","token":"status"},{"path":"upgrade","token":"upgrade"},{"path":"upgrade apply","token":"upgrade_apply"},{"path":"upgrade cli","token":"upgrade_cli"},{"path":"upgrade cli apply","token":"upgrade_cli_apply"},{"path":"validate","token":"validate"},{"aliases":["--version","-v"],"path":"version","token":"version"}],"machine_flags":["--dry-run","--explain","--json","--non-interactive"],"value_kinds":["none","text","enum"]},"compatibility":{"default_project_version":"0.1.0-alpha.14","directly_supported_project_versions":["0.1.0-alpha.5","0.1.0-alpha.6","0.1.0-alpha.7","0.1.0-alpha.8","0.1.0-alpha.14"],"explicit_upgrade_edges":[{"from":"0.1.0-alpha.12","to":"0.1.0-alpha.13"}],"upgradeable_from_project_versions":["0.1.0-alpha.12","0.1.0-alpha.13"]},"distribution_layouts":[{"adapters_path":"adapters","profile":"repo_bundle","schema_path":"schemas"},{"adapters_path":"share/runecontext/adapters","profile":"installed_share_layout","schema_path":"share/runecontext/schemas"}],"features":["signed_tag_verification","mutable_ref_opt_in","monorepo_nearest_root_discovery","context_pack_capture","verified_assurance","completion_registry","completion_dynamic_suggestions","generated_indexes","promotion_workflow","upgrade_planning","staged_upgrade_execution","mixed_or_stale_tree_detection"],"project_profiles":[{"assurance_path":"runecontext/assurance","content_root":"runecontext","indexes_root":"runecontext/indexes","manifest_path":"runecontext/manifest.yaml","profile":"portable_project_root","root_config":"runecontext.yaml"}],"release":{"package_name":"runecontext","tag":"v0.1.0-alpha.14","version":"0.1.0-alpha.14"},"resolution":{"source_modes":["embedded","git","path"],"verification_postures":["embedded","pinned_commit","verified_signed_tag","unverified_mutable_ref","unverified_local_source"]},"schema_version":2} \ No newline at end of file diff --git a/fixtures/release/release-manifest-with-metadata.json b/fixtures/release/release-manifest-with-metadata.json index 38c97d2..36fff08 100644 --- a/fixtures/release/release-manifest-with-metadata.json +++ b/fixtures/release/release-manifest-with-metadata.json @@ -67,6 +67,14 @@ "path": "change", "token": "change" }, + { + "path": "change assess-decomposition", + "token": "change_assess_decomposition" + }, + { + "path": "change assess-intake", + "token": "change_assess_intake" + }, { "path": "change close", "token": "change_close" @@ -181,13 +189,13 @@ ] }, "compatibility": { - "default_project_version": "0.1.0-alpha.13", + "default_project_version": "0.1.0-alpha.14", "directly_supported_project_versions": [ "0.1.0-alpha.5", "0.1.0-alpha.6", "0.1.0-alpha.7", "0.1.0-alpha.8", - "0.1.0-alpha.13" + "0.1.0-alpha.14" ], "explicit_upgrade_edges": [ { @@ -238,8 +246,8 @@ ], "release": { "package_name": "runecontext", - "tag": "v0.1.0-alpha.13", - "version": "0.1.0-alpha.13" + "tag": "v0.1.0-alpha.14", + "version": "0.1.0-alpha.14" }, "resolution": { "source_modes": [ @@ -258,6 +266,6 @@ "schema_version": 2 }, "package_name": "runecontext", - "tag": "v0.1.0-alpha.13", - "version": "0.1.0-alpha.13" + "tag": "v0.1.0-alpha.14", + "version": "0.1.0-alpha.14" } diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 3fcdc1c..6fa4c2c 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -15,7 +15,7 @@ const ( const ( validateUsage = "runectx validate [--json] [--non-interactive] [--explain] [--ssh-allowed-signers PATH] [--path PATH] [path]" statusUsage = "runectx status [--json] [--non-interactive] [--explain] [--path PATH] [path] (human output only: --history recent|all|none --history-limit N --verbose)" - changeUsage = "runectx change [--json] [--non-interactive] [--dry-run] [--explain] <new|shape|close|reallocate|update> ..." + changeUsage = "runectx change [--json] [--non-interactive] [--dry-run] [--explain] <new|shape|close|reallocate|update|assess-intake|assess-decomposition> ..." generateUsage = "runectx generate [--json] [--non-interactive] [--explain] <indexes>" generateIndexesUsage = "runectx generate indexes [--json] [--non-interactive] [--explain] [--path PATH] [path]" bundleUsage = "runectx bundle [--json] [--non-interactive] [--explain] <resolve>" @@ -26,6 +26,8 @@ const ( changeCloseUsage = "runectx change close [--json] [--non-interactive] [--dry-run] [--explain] CHANGE_ID [--verification-status STATUS] [--superseded-by ID] [--closed-at YYYY-MM-DD] [--recursive] [--path PATH]" changeReallocateUsage = "runectx change reallocate [--json] [--non-interactive] [--dry-run] [--explain] CHANGE_ID [--path PATH]" changeUpdateUsage = "runectx change update [--json] [--non-interactive] [--dry-run] [--explain] CHANGE_ID --status planned|implemented|verified [--verification-status passed|failed|skipped] [--recursive] [--path PATH]" + changeAssessIntakeUsage = "runectx change assess-intake [--json] [--non-interactive] [--explain] --title TITLE --type TYPE [--size SIZE] [--bundle ID] [--description TEXT] [--path PATH]" + changeAssessDecompUsage = "runectx change assess-decomposition [--json] [--non-interactive] [--explain] CHANGE_ID [--path PATH]" initUsage = "runectx init [--json] [--non-interactive] [--dry-run] [--explain] [--mode embedded|linked] [--seed-bundle NAME] [--path PATH]" promoteUsage = "runectx promote [--json] [--non-interactive] [--dry-run] [--explain] CHANGE_ID [--accept | --complete] [--target TYPE:PATH (summary auto-filled per target type)] [--path PATH]" standardUsage = "runectx standard [--json] [--non-interactive] [--explain] <discover>" @@ -160,7 +162,7 @@ func rootHelpCommandGroups() []rootHelpGroup { { title: "Change Workflow", commands: []rootHelpCommand{ - {name: "change", description: "Create, shape, update, close, and reallocate changes"}, + {name: "change", description: "Create, shape, assess, update, close, and reallocate changes"}, {name: "promote", description: "Advance promotion assessment state"}, {name: "standard", description: "Discover advisory standards candidates"}, }, diff --git a/internal/cli/cli_adapter_pack_test.go b/internal/cli/cli_adapter_pack_test.go index d7f3a2e..a5b6938 100644 --- a/internal/cli/cli_adapter_pack_test.go +++ b/internal/cli/cli_adapter_pack_test.go @@ -147,7 +147,7 @@ func assertHostCapabilitiesSection(t *testing.T, text, path string) { func assertFlowMappings(t *testing.T, text, path string) { t.Helper() - for _, flow := range []string{"change new", "change shape", "standard discover", "promote"} { + for _, flow := range []string{"change new", "change assess-intake", "change assess-decomposition", "change shape", "standard discover", "promote"} { if !strings.Contains(text, flow) { t.Fatalf("%s missing conversational flow mapping for %q", path, flow) } @@ -165,7 +165,7 @@ func assertStandardDiscoverMappings(t *testing.T, text, path string) { func checkAdapterFlowPlaybooks(t *testing.T, base, name string) { t.Helper() - required := []string{"change-new.md", "change-shape.md", "standard-discover.md", "promote.md"} + required := []string{"change-new.md", "change-assess-intake.md", "change-assess-decomposition.md", "change-shape.md", "standard-discover.md", "promote.md"} for _, file := range required { path := filepath.Join(base, "flows", file) content, err := os.ReadFile(path) diff --git a/internal/cli/cli_adapter_render_host_native_markdown.go b/internal/cli/cli_adapter_render_host_native_markdown.go index 1823052..3fae727 100644 --- a/internal/cli/cli_adapter_render_host_native_markdown.go +++ b/internal/cli/cli_adapter_render_host_native_markdown.go @@ -49,7 +49,7 @@ func renderHostNativeIndexMarkdown(request adapterRenderRequest) (string, error) "- operation_identifier: `runecontext:index`", } for _, flow := range flows { - commandPath := strings.ReplaceAll(flow.id, "-", " ") + commandPath := commandPathFromFlowID(flow.id) command, ok := commandByPath(commandPath) if !ok { continue @@ -74,7 +74,7 @@ type hostNativeRenderMeta struct { } func hostNativeRenderMetadata(request adapterRenderRequest, flow hostNativeFlow) (hostNativeRenderMeta, error) { - path := strings.ReplaceAll(flow.id, "-", " ") + path := commandPathFromFlowID(flow.id) command, ok := commandByPath(path) if !ok { return hostNativeRenderMeta{}, fmt.Errorf("adapter render-host-native metadata missing command path %q", path) @@ -94,6 +94,14 @@ func hostNativeRenderMetadata(request adapterRenderRequest, flow hostNativeFlow) }, nil } +func commandPathFromFlowID(flowID string) string { + if strings.HasPrefix(flowID, "change-assess-") { + suffix := strings.TrimPrefix(flowID, "change-assess-") + return "change assess-" + suffix + } + return strings.ReplaceAll(flowID, "-", " ") +} + func commandByPath(path string) (CommandMetadata, bool) { registry := CommandMetadataRegistry() var walk func(items []CommandMetadata) (CommandMetadata, bool) diff --git a/internal/cli/cli_adapter_render_test.go b/internal/cli/cli_adapter_render_test.go index df83cfc..52c0403 100644 --- a/internal/cli/cli_adapter_render_test.go +++ b/internal/cli/cli_adapter_render_test.go @@ -42,6 +42,27 @@ func TestRunAdapterRenderHostNativeOutputsMinimalMarkdown(t *testing.T) { } } +func TestRunAdapterRenderHostNativeSupportsAssessmentOperations(t *testing.T) { + for _, operation := range []string{"change-assess-intake", "change-assess-decomposition"} { + t.Run(operation, func(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Run([]string{"adapter", "render-host-native", "opencode", operation}, &stdout, &stderr) + if code != exitOK { + t.Fatalf("expected success exit code, got %d (%s)", code, stderr.String()) + } + text := stdout.String() + if !strings.Contains(text, "operation_identifier: `runecontext:"+operation+"`") { + t.Fatalf("expected operation identifier for %s, got %q", operation, text) + } + expectedPath := "change assess-" + strings.TrimPrefix(operation, "change-assess-") + if !strings.Contains(text, "command_path: `"+expectedPath+"`") { + t.Fatalf("expected command path for %s, got %q", operation, text) + } + }) + } +} + func TestRunAdapterRenderHostNativeIndexForClaude(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer @@ -56,6 +77,12 @@ func TestRunAdapterRenderHostNativeIndexForClaude(t *testing.T) { if !strings.Contains(text, "operation: `runecontext:change-new`") { t.Fatalf("expected indexed change-new operation, got %q", text) } + if !strings.Contains(text, "operation: `runecontext:change-assess-intake`") { + t.Fatalf("expected indexed change-assess-intake operation, got %q", text) + } + if !strings.Contains(text, "operation: `runecontext:change-assess-decomposition`") { + t.Fatalf("expected indexed change-assess-decomposition operation, got %q", text) + } } func TestRunAdapterRenderHostNativeRejectsUnsupportedTool(t *testing.T) { diff --git a/internal/cli/cli_adapter_sync_host_native.go b/internal/cli/cli_adapter_sync_host_native.go index 282ff81..57c06ea 100644 --- a/internal/cli/cli_adapter_sync_host_native.go +++ b/internal/cli/cli_adapter_sync_host_native.go @@ -124,6 +124,18 @@ func toolFlowMappings(tool string) []hostNativeFlow { description: "Create a new RuneContext change", source: "adapters/" + tool + "/flows/change-new.md", }, + { + id: "change-assess-intake", + name: "change assess-intake", + description: "Assess intake readiness and advisory shaping signals", + source: "adapters/" + tool + "/flows/change-assess-intake.md", + }, + { + id: "change-assess-decomposition", + name: "change assess-decomposition", + description: "Assess decomposition and umbrella/sub-change signals", + source: "adapters/" + tool + "/flows/change-assess-decomposition.md", + }, { id: "change-shape", name: "change shape", @@ -188,7 +200,7 @@ func buildClaudeCommandIndexShimContent(flows []hostNativeFlow) []byte { "## Commands", }...) for _, flow := range flows { - commandPath := strings.ReplaceAll(flow.id, "-", " ") + commandPath := commandPathFromFlowID(flow.id) lines = append(lines, "", "- `runecontext:"+flow.id+"`", diff --git a/internal/cli/cli_adapter_test.go b/internal/cli/cli_adapter_test.go index 996ebde..1cda3e7 100644 --- a/internal/cli/cli_adapter_test.go +++ b/internal/cli/cli_adapter_test.go @@ -119,20 +119,26 @@ func TestRunAdapterSyncWritesHostNativeArtifactsByTool(t *testing.T) { projectRoot := t.TempDir() opencode := runAdapterSyncAndParse(t, projectRoot, "opencode") - if got, want := opencode["host_native_file_count"], "8"; got != want { + if got, want := opencode["host_native_file_count"], "12"; got != want { t.Fatalf("expected opencode host_native_file_count %q, got %q", want, got) } assertShellInjectionCallPresent(t, filepath.Join(projectRoot, ".opencode", "skills", "runecontext-change-new.md"), "runectx adapter render-host-native --role flow_asset opencode change-new") + assertShellInjectionCallPresent(t, filepath.Join(projectRoot, ".opencode", "skills", "runecontext-change-assess-intake.md"), "runectx adapter render-host-native --role flow_asset opencode change-assess-intake") + assertShellInjectionCallPresent(t, filepath.Join(projectRoot, ".opencode", "commands", "runecontext-change-assess-intake.md"), "runectx adapter render-host-native --role discoverability_shim opencode change-assess-intake") + assertShellInjectionCallPresent(t, filepath.Join(projectRoot, ".opencode", "skills", "runecontext-change-assess-decomposition.md"), "runectx adapter render-host-native --role flow_asset opencode change-assess-decomposition") + assertShellInjectionCallPresent(t, filepath.Join(projectRoot, ".opencode", "commands", "runecontext-change-assess-decomposition.md"), "runectx adapter render-host-native --role discoverability_shim opencode change-assess-decomposition") assertShellInjectionCallPresent(t, filepath.Join(projectRoot, ".opencode", "commands", "runecontext-change-new.md"), "runectx adapter render-host-native --role discoverability_shim opencode change-new") assertFrontmatterContains(t, filepath.Join(projectRoot, ".opencode", "commands", "runecontext-change-new.md"), "description: Create a new RuneContext change") assertManagedArtifactMarker(t, filepath.Join(projectRoot, ".opencode", "skills", "runecontext-change-new.md")) assertManagedArtifactMarker(t, filepath.Join(projectRoot, ".opencode", "commands", "runecontext-change-new.md")) claude := runAdapterSyncAndParse(t, projectRoot, "claude-code") - if got, want := claude["host_native_file_count"], "5"; got != want { + if got, want := claude["host_native_file_count"], "7"; got != want { t.Fatalf("expected claude host_native_file_count %q, got %q", want, got) } assertShellInjectionCallPresent(t, filepath.Join(projectRoot, ".claude", "skills", "runecontext-change-new.md"), "runectx adapter render-host-native --role flow_asset claude-code change-new") + assertShellInjectionCallPresent(t, filepath.Join(projectRoot, ".claude", "skills", "runecontext-change-assess-intake.md"), "runectx adapter render-host-native --role flow_asset claude-code change-assess-intake") + assertShellInjectionCallPresent(t, filepath.Join(projectRoot, ".claude", "skills", "runecontext-change-assess-decomposition.md"), "runectx adapter render-host-native --role flow_asset claude-code change-assess-decomposition") assertShellInjectionCallPresent(t, filepath.Join(projectRoot, ".claude", "commands", "runecontext.md"), "runectx adapter render-host-native --role discoverability_shim claude-code index") assertFrontmatterContains(t, filepath.Join(projectRoot, ".claude", "skills", "runecontext-change-new.md"), "name: runecontext-change-new") assertFrontmatterContains(t, filepath.Join(projectRoot, ".claude", "skills", "runecontext-change-new.md"), "description: Create a new RuneContext change") @@ -141,10 +147,12 @@ func TestRunAdapterSyncWritesHostNativeArtifactsByTool(t *testing.T) { assertManagedArtifactMarker(t, filepath.Join(projectRoot, ".claude", "commands", "runecontext.md")) codex := runAdapterSyncAndParse(t, projectRoot, "codex") - if got, want := codex["host_native_file_count"], "4"; got != want { + if got, want := codex["host_native_file_count"], "6"; got != want { t.Fatalf("expected codex host_native_file_count %q, got %q", want, got) } assertFrontmatterContains(t, filepath.Join(projectRoot, ".agents", "skills", "runecontext-change-new.md"), "name: runecontext-change-new") + assertFrontmatterContains(t, filepath.Join(projectRoot, ".agents", "skills", "runecontext-change-assess-intake.md"), "name: runecontext-change-assess-intake") + assertFrontmatterContains(t, filepath.Join(projectRoot, ".agents", "skills", "runecontext-change-assess-decomposition.md"), "name: runecontext-change-assess-decomposition") assertFrontmatterContains(t, filepath.Join(projectRoot, ".agents", "skills", "runecontext-change-new.md"), "description: Create a new RuneContext change") assertNoShellInjectionCall(t, filepath.Join(projectRoot, ".agents", "skills", "runecontext-change-new.md")) assertManagedArtifactMarker(t, filepath.Join(projectRoot, ".agents", "skills", "runecontext-change-new.md")) diff --git a/internal/cli/cli_change.go b/internal/cli/cli_change.go index fe5d394..deb0c35 100644 --- a/internal/cli/cli_change.go +++ b/internal/cli/cli_change.go @@ -37,6 +37,10 @@ func runChange(args []string, stdout, stderr io.Writer) int { return runChangeReallocate(remaining[1:], machine, stdout, stderr) case "update": return runChangeUpdate(remaining[1:], machine, stdout, stderr) + case "assess-intake": + return runChangeAssessIntake(remaining[1:], machine, stdout, stderr) + case "assess-decomposition": + return runChangeAssessDecomposition(remaining[1:], machine, stdout, stderr) default: emitOutput(stderr, machine, appendMachineOptionLines(buildCommandUsageErrorLines("change", changeUsage, fmt.Errorf("unknown change subcommand %q", remaining[0])), machine), exitUsage, failureClassUsage) return exitUsage @@ -193,3 +197,67 @@ func runChangeUpdate(args []string, machine machineOptions, stdout, stderr io.Wr emitOutput(stdout, machine, appendMachineOptionLines(output, machine), exitOK, failureClassNone) return exitOK } + +func runChangeAssessIntake(args []string, machine machineOptions, stdout, stderr io.Writer) int { + if machine.dryRun { + emitOutput(stderr, machine, appendMachineOptionLines(buildCommandUsageErrorLines("change_assess_intake", changeAssessIntakeUsage, fmt.Errorf("--dry-run is not supported for advisory change assess-intake")), machine), exitUsage, failureClassUsage) + return exitUsage + } + request, err := parseChangeAssessIntakeArgs(args) + if err != nil { + emitOutput(stderr, machine, appendMachineOptionLines(buildCommandUsageErrorLines("change_assess_intake", changeAssessIntakeUsage, err), machine), exitUsage, failureClassUsage) + return exitUsage + } + project, code := loadProjectOrReport(request.root, request.explicitRoot, stderr, "change_assess_intake", machine) + if code != exitOK { + return code + } + defer project.close() + + result, err := contracts.AssessChangeIntake(project.validator, project.loaded, contracts.ChangeAssessIntakeOptions{ + Title: request.title, + Type: request.changeType, + Size: request.size, + Description: request.description, + ContextBundles: request.contextBundles, + }) + if err != nil { + emitOutput(stderr, machine, appendMachineOptionLines(buildCommandInvalidLines("change_assess_intake", project.absRoot, err), machine), exitInvalid, failureClassInvalid) + return exitInvalid + } + output := buildChangeAssessIntakeOutput(project.absRoot, project.loaded, result) + if machine.explain { + output = appendChangeAssessIntakeExplainLines(output, result) + } + emitOutput(stdout, machine, appendMachineOptionLines(output, machine), exitOK, failureClassNone) + return exitOK +} + +func runChangeAssessDecomposition(args []string, machine machineOptions, stdout, stderr io.Writer) int { + if machine.dryRun { + emitOutput(stderr, machine, appendMachineOptionLines(buildCommandUsageErrorLines("change_assess_decomposition", changeAssessDecompUsage, fmt.Errorf("--dry-run is not supported for advisory change assess-decomposition")), machine), exitUsage, failureClassUsage) + return exitUsage + } + request, err := parseChangeAssessDecompositionArgs(args) + if err != nil { + emitOutput(stderr, machine, appendMachineOptionLines(buildCommandUsageErrorLines("change_assess_decomposition", changeAssessDecompUsage, err), machine), exitUsage, failureClassUsage) + return exitUsage + } + project, code := loadProjectOrReport(request.root, request.explicitRoot, stderr, "change_assess_decomposition", machine) + if code != exitOK { + return code + } + defer project.close() + + result, err := contracts.AssessChangeDecomposition(project.validator, project.loaded, request.changeID) + if err != nil { + emitOutput(stderr, machine, appendMachineOptionLines(buildCommandInvalidLines("change_assess_decomposition", project.absRoot, err), machine), exitInvalid, failureClassInvalid) + return exitInvalid + } + output := buildChangeAssessDecompositionOutput(project.absRoot, project.loaded, result) + if machine.explain { + output = appendChangeAssessDecompositionExplainLines(output, result) + } + emitOutput(stdout, machine, appendMachineOptionLines(output, machine), exitOK, failureClassNone) + return exitOK +} diff --git a/internal/cli/cli_change_output.go b/internal/cli/cli_change_output.go index dd33e26..05bb9b4 100644 --- a/internal/cli/cli_change_output.go +++ b/internal/cli/cli_change_output.go @@ -102,6 +102,56 @@ func buildChangeUpdateOutput(absRoot string, loaded *contracts.LoadedProject, re return appendChangedFiles(output, result.ChangedFiles) } +func buildChangeAssessIntakeOutput(absRoot string, loaded *contracts.LoadedProject, result *contracts.ChangeAssessIntakeResult) []line { + output := []line{ + {"result", "ok"}, + {"command", "change_assess_intake"}, + {"root", absRoot}, + {"selected_config_path", selectedConfigPath(loaded)}, + {"mutation_performed", "false"}, + {"change_type", result.Type}, + {"change_size", result.Size}, + {"recommended_mode", string(result.RecommendedMode)}, + {"intake_readiness", result.IntakeReadiness}, + {"clarification_needed", boolString(result.ClarificationNeeded)}, + {"decomposition_signal", result.DecompositionSignal}, + {"context_bundle_count", fmt.Sprintf("%d", len(result.ContextBundles))}, + } + output = appendStringItems(output, "context_bundle", result.ContextBundles) + output = append(output, line{"applicable_standard_count", fmt.Sprintf("%d", len(result.ApplicableStandards))}) + output = appendStringItems(output, "applicable_standard", result.ApplicableStandards) + output = append(output, line{"clarification_prompt_count", fmt.Sprintf("%d", len(result.ClarificationPrompts))}) + output = appendStringItems(output, "clarification_prompt", result.ClarificationPrompts) + return appendReasonsAndAssumptions(output, result.Reasons, result.Assumptions) +} + +func buildChangeAssessDecompositionOutput(absRoot string, loaded *contracts.LoadedProject, result *contracts.ChangeAssessDecompositionResult) []line { + output := []line{ + {"result", "ok"}, + {"command", "change_assess_decomposition"}, + {"root", absRoot}, + {"selected_config_path", selectedConfigPath(loaded)}, + {"mutation_performed", "false"}, + {"change_id", result.ID}, + {"change_status", result.Status}, + {"change_type", result.Type}, + {"change_size", result.Size}, + {"recommended_mode", string(result.RecommendedMode)}, + {"decomposition_signal", result.DecompositionSignal}, + {"clarification_needed", boolString(result.ClarificationNeeded)}, + {"related_change_count", fmt.Sprintf("%d", len(result.RelatedChanges))}, + } + output = appendStringItems(output, "related_change", result.RelatedChanges) + output = append(output, line{"eligible_sub_change_count", fmt.Sprintf("%d", len(result.EligibleSubChangeIDs))}) + output = appendStringItems(output, "eligible_sub_change", result.EligibleSubChangeIDs) + output = append(output, line{"prerequisite_change_count", fmt.Sprintf("%d", len(result.PrerequisiteChangeIDs))}) + output = appendStringItems(output, "prerequisite_change", result.PrerequisiteChangeIDs) + output = append(output, line{"clarification_prompt_count", fmt.Sprintf("%d", len(result.ClarificationPrompts))}) + output = appendStringItems(output, "clarification_prompt", result.ClarificationPrompts) + output = append(output, line{"reason_count", fmt.Sprintf("%d", len(result.Reasons))}) + return appendStringItems(output, "reason", result.Reasons) +} + func selectedConfigPath(loaded *contracts.LoadedProject) string { if loaded == nil || loaded.Resolution == nil { return "" diff --git a/internal/cli/cli_change_test.go b/internal/cli/cli_change_test.go index 277ced2..c68c40d 100644 --- a/internal/cli/cli_change_test.go +++ b/internal/cli/cli_change_test.go @@ -221,6 +221,108 @@ func TestRunChangeUpdateUsageErrors(t *testing.T) { } } +func TestRunChangeAssessIntakeOutputsAdvisoryContract(t *testing.T) { + projectRoot := prepareCLIWorkflowProject(t) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := Run([]string{"change", "assess-intake", "--title", "Launch payments platform", "--type", "project", "--path", projectRoot}, &stdout, &stderr) + if code != 0 { + t.Fatalf("expected success exit code, got %d (%s)", code, stderr.String()) + } + fields := parseCLIKeyValueOutput(t, stdout.String()) + if got, want := fields["command"], "change_assess_intake"; got != want { + t.Fatalf("expected command %q, got %q", want, got) + } + if got, want := fields["mutation_performed"], "false"; got != want { + t.Fatalf("expected mutation_performed %q, got %q", want, got) + } + if got, want := fields["recommended_mode"], "full"; got != want { + t.Fatalf("expected recommended_mode %q, got %q", want, got) + } + if got, want := fields["intake_readiness"], "needs_clarification"; got != want { + t.Fatalf("expected intake_readiness %q, got %q", want, got) + } + if got, want := fields["decomposition_signal"], "consider_decomposition"; got != want { + t.Fatalf("expected decomposition_signal %q, got %q", want, got) + } + if got, want := fields["clarification_needed"], "true"; got != want { + t.Fatalf("expected clarification_needed %q, got %q", want, got) + } +} + +func TestRunChangeAssessIntakeUsageErrors(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Run([]string{"change", "assess-intake", "--type", "feature"}, &stdout, &stderr) + if code != 2 { + t.Fatalf("expected usage exit code for missing title, got %d", code) + } + if !strings.Contains(stderr.String(), "--title is required") { + t.Fatalf("expected missing-title usage output, got %q", stderr.String()) + } + stdout.Reset() + stderr.Reset() + code = Run([]string{"change", "assess-intake", "--title", "x", "--type", "feature", "--dry-run"}, &stdout, &stderr) + if code != 2 { + t.Fatalf("expected usage exit code for dry-run on assess-intake, got %d", code) + } + if !strings.Contains(stderr.String(), "--dry-run is not supported") { + t.Fatalf("expected dry-run usage output, got %q", stderr.String()) + } +} + +func TestRunChangeAssessDecompositionOutputsAdvisoryContract(t *testing.T) { + projectRoot := prepareCLIWorkflowProject(t) + umbrellaID := runCLIProjectChangeNewForTest(t, projectRoot, "Umbrella project") + featureID := runCLIChangeNewForTest(t, projectRoot, "Feature sub-change") + writeCLIBidirectionalRelatedLink(t, projectRoot, umbrellaID, featureID) + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Run([]string{"change", "assess-decomposition", umbrellaID, "--path", projectRoot}, &stdout, &stderr) + if code != 0 { + t.Fatalf("expected success exit code, got %d (%s)", code, stderr.String()) + } + fields := parseCLIKeyValueOutput(t, stdout.String()) + if got, want := fields["command"], "change_assess_decomposition"; got != want { + t.Fatalf("expected command %q, got %q", want, got) + } + if got, want := fields["change_id"], umbrellaID; got != want { + t.Fatalf("expected change_id %q, got %q", want, got) + } + if got, want := fields["decomposition_signal"], "umbrella_graph_detected"; got != want { + t.Fatalf("expected decomposition_signal %q, got %q", want, got) + } + if got, want := fields["eligible_sub_change_count"], "1"; got != want { + t.Fatalf("expected eligible_sub_change_count %q, got %q", want, got) + } + if got, want := fields["eligible_sub_change_1"], featureID; got != want { + t.Fatalf("expected eligible_sub_change_1 %q, got %q", want, got) + } +} + +func TestRunChangeAssessDecompositionUsageErrors(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Run([]string{"change", "assess-decomposition"}, &stdout, &stderr) + if code != 2 { + t.Fatalf("expected usage exit code for missing ID, got %d", code) + } + if !strings.Contains(stderr.String(), "requires exactly one change ID") { + t.Fatalf("expected missing-ID usage output, got %q", stderr.String()) + } + stdout.Reset() + stderr.Reset() + code = Run([]string{"change", "assess-decomposition", "CHG-2026-001-a3f2-auth-gateway", "--dry-run"}, &stdout, &stderr) + if code != 2 { + t.Fatalf("expected usage exit code for dry-run on assess-decomposition, got %d", code) + } + if !strings.Contains(stderr.String(), "--dry-run is not supported") { + t.Fatalf("expected dry-run usage output, got %q", stderr.String()) + } +} + func TestRunPromoteOutputsAcceptedAndCompletedStatus(t *testing.T) { projectRoot := prepareCLIWorkflowProject(t) changeID := runCLIStandardChangeNewForTest(t, projectRoot, "Refresh security baseline") diff --git a/internal/cli/cli_completion_suggest_test.go b/internal/cli/cli_completion_suggest_test.go index e308f03..65d93d7 100644 --- a/internal/cli/cli_completion_suggest_test.go +++ b/internal/cli/cli_completion_suggest_test.go @@ -267,6 +267,9 @@ func TestCompletionMetadataIncludesSuggestionProviders(t *testing.T) { if got := flagProviders["change new|--bundle"]; got != suggestionProviderBundleIDs { t.Fatalf("expected change new bundle suggestion provider, got %q", got) } + if got := flagProviders["change assess-intake|--bundle"]; got != suggestionProviderBundleIDs { + t.Fatalf("expected change assess-intake bundle suggestion provider, got %q", got) + } if got := flagProviders["init|--seed-bundle"]; got != suggestionProviderBundleIDs { t.Fatalf("expected init seed-bundle suggestion provider, got %q", got) } diff --git a/internal/cli/cli_completion_test.go b/internal/cli/cli_completion_test.go index 7cd0bfa..164e4fc 100644 --- a/internal/cli/cli_completion_test.go +++ b/internal/cli/cli_completion_test.go @@ -160,6 +160,8 @@ func TestCompletionMetadataSurfaceParity(t *testing.T) { assertEnumValues(t, enums, "change new|--type", []string{"bug", "chore", "feature", "project", "standard"}) assertEnumValues(t, enums, "change new|--size", []string{"large", "medium", "small"}) assertEnumValues(t, enums, "change close|--verification-status", []string{"failed", "passed", "skipped"}) + assertEnumValues(t, enums, "change assess-intake|--type", []string{"bug", "chore", "feature", "project", "standard"}) + assertEnumValues(t, enums, "change assess-intake|--size", []string{"large", "medium", "small"}) assertNoValueFlagPresent(t, "change close|--recursive") assertNoValueFlagPresent(t, "change update|--recursive") } diff --git a/internal/cli/cli_explain.go b/internal/cli/cli_explain.go index 9b82f15..e16577d 100644 --- a/internal/cli/cli_explain.go +++ b/internal/cli/cli_explain.go @@ -178,6 +178,43 @@ func appendChangeUpdateExplainLines(lines []line, result *contracts.ChangeOperat return lines } +func appendChangeAssessIntakeExplainLines(lines []line, result *contracts.ChangeAssessIntakeResult) []line { + if result == nil { + return lines + } + lines = append(lines, + line{"explain_scope", "change-intake-advisory"}, + line{"explain_advisory_only", "true"}, + line{"explain_recommended_mode", string(result.RecommendedMode)}, + line{"explain_intake_readiness", result.IntakeReadiness}, + line{"explain_decomposition_signal", result.DecompositionSignal}, + line{"explain_clarification_needed", boolString(result.ClarificationNeeded)}, + ) + for i, prompt := range result.ClarificationPrompts { + lines = append(lines, line{fmt.Sprintf("explain_clarification_prompt_%d", i+1), prompt}) + } + return lines +} + +func appendChangeAssessDecompositionExplainLines(lines []line, result *contracts.ChangeAssessDecompositionResult) []line { + if result == nil { + return lines + } + lines = append(lines, + line{"explain_scope", "change-decomposition-advisory"}, + line{"explain_advisory_only", "true"}, + line{"explain_decomposition_signal", result.DecompositionSignal}, + line{"explain_recommended_mode", string(result.RecommendedMode)}, + line{"explain_clarification_needed", boolString(result.ClarificationNeeded)}, + line{"explain_eligible_sub_change_count", fmt.Sprintf("%d", len(result.EligibleSubChangeIDs))}, + line{"explain_prerequisite_change_count", fmt.Sprintf("%d", len(result.PrerequisiteChangeIDs))}, + ) + for i, prompt := range result.ClarificationPrompts { + lines = append(lines, line{fmt.Sprintf("explain_clarification_prompt_%d", i+1), prompt}) + } + return lines +} + func appendAssuranceEnableExplainLines(lines []line, root string, plans []string) []line { lines = append(lines, line{"explain_scope", "assurance-enable"}, diff --git a/internal/cli/cli_metadata_flags.go b/internal/cli/cli_metadata_flags.go index eb61c26..1bfbf50 100644 --- a/internal/cli/cli_metadata_flags.go +++ b/internal/cli/cli_metadata_flags.go @@ -41,6 +41,17 @@ func changeUpdateFlags() []FlagMetadata { } } +func changeAssessIntakeFlags() []FlagMetadata { + return []FlagMetadata{ + {Name: "--title", Value: textValueSpec(), Required: true}, + {Name: "--type", Value: enumValueSpec("project", "feature", "bug", "standard", "chore"), Required: true}, + {Name: "--size", Value: enumValueSpec("small", "medium", "large")}, + {Name: "--bundle", Value: textValueWithSuggestionSpec(suggestionProviderBundleIDs), Repeatable: true}, + {Name: "--description", Value: textValueSpec()}, + {Name: "--path", Value: textValueSpec()}, + } +} + func validateFlags() []FlagMetadata { return []FlagMetadata{ {Name: "--ssh-allowed-signers", Value: textValueSpec()}, diff --git a/internal/cli/cli_metadata_registry.go b/internal/cli/cli_metadata_registry.go index ed8eb02..4a9b39f 100644 --- a/internal/cli/cli_metadata_registry.go +++ b/internal/cli/cli_metadata_registry.go @@ -93,6 +93,8 @@ func changeCommandMetadata() CommandMetadata { {Name: "close", Path: "change close", Usage: changeCloseUsage, Flags: writeCommandFlags(changeCloseFlags()), Positionals: []PositionalMetadata{{Name: "CHANGE_ID", Value: textValueWithSuggestionSpec(suggestionProviderChangeIDs)}}}, {Name: "reallocate", Path: "change reallocate", Usage: changeReallocateUsage, Flags: writeCommandFlags(pathOnlyFlag()), Positionals: []PositionalMetadata{{Name: "CHANGE_ID", Value: textValueWithSuggestionSpec(suggestionProviderChangeIDs)}}}, {Name: "update", Path: "change update", Usage: changeUpdateUsage, Flags: writeCommandFlags(changeUpdateFlags()), Positionals: []PositionalMetadata{{Name: "CHANGE_ID", Value: textValueWithSuggestionSpec(suggestionProviderChangeIDs)}}}, + {Name: "assess-intake", Path: "change assess-intake", Usage: changeAssessIntakeUsage, Flags: readOnlyCommandFlags(changeAssessIntakeFlags())}, + {Name: "assess-decomposition", Path: "change assess-decomposition", Usage: changeAssessDecompUsage, Flags: readOnlyCommandFlags(pathOnlyFlag()), Positionals: []PositionalMetadata{{Name: "CHANGE_ID", Value: textValueWithSuggestionSpec(suggestionProviderChangeIDs)}}}, }, } } diff --git a/internal/cli/cli_metadata_registry_adapter.go b/internal/cli/cli_metadata_registry_adapter.go index ebb1106..1ac1419 100644 --- a/internal/cli/cli_metadata_registry_adapter.go +++ b/internal/cli/cli_metadata_registry_adapter.go @@ -24,7 +24,7 @@ func adapterCommandMetadata() CommandMetadata { Flags: writeCommandFlags([]FlagMetadata{{Name: "--role", Value: enumValueSpec(hostNativeKindFlowAsset, hostNativeKindDiscoverabilityShim)}}), Positionals: []PositionalMetadata{ {Name: "tool", Value: textValueWithSuggestionSpec(suggestionProviderAdapterNamesShellInjection)}, - {Name: "operation", Value: enumValueSpec("change-new", "change-shape", "index", "promote", "standard-discover")}, + {Name: "operation", Value: enumValueSpec("change-assess-decomposition", "change-assess-intake", "change-new", "change-shape", "index", "promote", "standard-discover")}, }, }, }, diff --git a/internal/cli/cli_metadata_registry_test.go b/internal/cli/cli_metadata_registry_test.go index db514f9..c0fba02 100644 --- a/internal/cli/cli_metadata_registry_test.go +++ b/internal/cli/cli_metadata_registry_test.go @@ -73,6 +73,34 @@ func TestCommandMetadataRegistryIncludesChangeUpdate(t *testing.T) { validateChangeUpdateFlags(t, update) } +func TestCommandMetadataRegistryIncludesChangeAssessCommands(t *testing.T) { + registry := CommandMetadataRegistry() + change := commandMetadataByPath(registry.Commands, "change") + if change == nil { + t.Fatalf("expected change command in registry") + } + assessIntake := commandMetadataByPath(change.Subcommands, "change assess-intake") + if assessIntake == nil { + t.Fatalf("expected change assess-intake subcommand in registry") + } + if assessIntake.Usage != changeAssessIntakeUsage { + t.Fatalf("expected change assess-intake usage %q, got %q", changeAssessIntakeUsage, assessIntake.Usage) + } + if len(assessIntake.Positionals) != 0 { + t.Fatalf("expected no positionals for assess-intake, got %#v", assessIntake.Positionals) + } + assessDecomp := commandMetadataByPath(change.Subcommands, "change assess-decomposition") + if assessDecomp == nil { + t.Fatalf("expected change assess-decomposition subcommand in registry") + } + if assessDecomp.Usage != changeAssessDecompUsage { + t.Fatalf("expected change assess-decomposition usage %q, got %q", changeAssessDecompUsage, assessDecomp.Usage) + } + if len(assessDecomp.Positionals) != 1 || assessDecomp.Positionals[0].Name != "CHANGE_ID" { + t.Fatalf("expected one CHANGE_ID positional for assess-decomposition, got %#v", assessDecomp.Positionals) + } +} + func validateChangeUpdateFlags(t *testing.T, update *CommandMetadata) { status := flagMetadataByName(update.Flags, "--status") if status == nil { diff --git a/internal/cli/cli_parse.go b/internal/cli/cli_parse.go index 2c2472c..9aabbe8 100644 --- a/internal/cli/cli_parse.go +++ b/internal/cli/cli_parse.go @@ -57,6 +57,22 @@ type changeUpdateRequest struct { recursive bool } +type changeAssessIntakeRequest struct { + root string + explicitRoot bool + title string + changeType string + size string + description string + contextBundles []string +} + +type changeAssessDecompositionRequest struct { + root string + explicitRoot bool + changeID string +} + func parseChangeNewArgs(args []string) (changeNewRequest, error) { request := changeNewRequest{root: "."} err := consumeArgs(args, changeNewFlagHandler(args, &request), func(arg string) error { @@ -249,3 +265,60 @@ func parseChangeUpdateArgs(args []string) (changeUpdateRequest, error) { request.changeID = changeID return request, nil } + +func parseChangeAssessIntakeArgs(args []string) (changeAssessIntakeRequest, error) { + request := changeAssessIntakeRequest{root: "."} + err := consumeArgs(args, func(flag parsedFlag) (int, error) { + switch flag.name { + case "--title": + return assignStringFlag(args, flag, &request.title) + case "--type": + return assignStringFlag(args, flag, &request.changeType) + case "--size": + return assignStringFlag(args, flag, &request.size) + case "--description": + return assignStringFlag(args, flag, &request.description) + case "--bundle": + return appendStringFlag(args, flag, &request.contextBundles) + case "--path": + return assignRootFlag(args, flag, &request.root, &request.explicitRoot) + default: + return flag.next, fmt.Errorf("unknown change assess-intake flag %q", flag.raw) + } + }, func(arg string) error { + return fmt.Errorf("unexpected positional argument %q", arg) + }) + if err != nil { + return changeAssessIntakeRequest{}, err + } + if strings.TrimSpace(request.title) == "" { + return changeAssessIntakeRequest{}, fmt.Errorf("--title is required") + } + if strings.TrimSpace(request.changeType) == "" { + return changeAssessIntakeRequest{}, fmt.Errorf("--type is required") + } + return request, nil +} + +func parseChangeAssessDecompositionArgs(args []string) (changeAssessDecompositionRequest, error) { + request := changeAssessDecompositionRequest{root: "."} + positionals := make([]string, 0, 1) + err := consumeArgs(args, func(flag parsedFlag) (int, error) { + if flag.name != "--path" { + return flag.next, fmt.Errorf("unknown change assess-decomposition flag %q", flag.raw) + } + return assignRootFlag(args, flag, &request.root, &request.explicitRoot) + }, func(arg string) error { + positionals = append(positionals, arg) + return nil + }) + if err != nil { + return changeAssessDecompositionRequest{}, err + } + changeID, err := requireExactPositional(positionals, "change assess-decomposition requires exactly one change ID") + if err != nil { + return changeAssessDecompositionRequest{}, err + } + request.changeID = changeID + return request, nil +} diff --git a/internal/cli/cli_upgrade_adapter_plans.go b/internal/cli/cli_upgrade_adapter_plans.go index 07b1bc5..5edbbaf 100644 --- a/internal/cli/cli_upgrade_adapter_plans.go +++ b/internal/cli/cli_upgrade_adapter_plans.go @@ -100,7 +100,7 @@ func markManagedHostNativeFile(path, tool string, managed *bool) error { return nil } *managed = true - return filepath.SkipAll + return nil } func existingHostNativeDir(root string) (bool, error) { diff --git a/internal/contracts/change_assessment.go b/internal/contracts/change_assessment.go new file mode 100644 index 0000000..25b0536 --- /dev/null +++ b/internal/contracts/change_assessment.go @@ -0,0 +1,203 @@ +package contracts + +import ( + "fmt" + "strings" +) + +func AssessChangeIntake(v *Validator, loaded *LoadedProject, options ChangeAssessIntakeOptions) (*ChangeAssessIntakeResult, error) { + if err := validateChangeCommandInputs(v, loaded); err != nil { + return nil, err + } + changeType := strings.TrimSpace(options.Type) + if err := validateChangeTypeValue(changeType); err != nil { + return nil, err + } + if strings.TrimSpace(options.Title) == "" { + return nil, fmt.Errorf("change intake assessment requires title") + } + index, err := v.ValidateLoadedProject(loaded) + if err != nil { + return nil, err + } + defer index.Close() + + assessment := assessChangeIntake(options.Title, changeType, options.Size, options.Description) + contextBundles, contextAssumptions, err := resolveContextBundlesForChange(index, options.ContextBundles) + if err != nil { + return nil, err + } + standards, standardAssumptions, err := resolveApplicableStandards(index, contextBundles) + if err != nil { + return nil, err + } + assumptions := uniqueStringsInOrder(append([]string{}, assessment.Assumptions...)) + assumptions = uniqueStringsInOrder(append(assumptions, contextAssumptions...)) + assumptions = uniqueStringsInOrder(append(assumptions, standardAssumptions...)) + if note := verificationAssumption(loaded.Resolution.ProjectRoot); note != "" { + assumptions = append(assumptions, note) + } + + return &ChangeAssessIntakeResult{ + Type: changeType, + Size: assessment.Size, + RecommendedMode: assessment.RecommendedMode, + IntakeReadiness: intakeReadinessFromAssessment(assessment), + ClarificationNeeded: len(assessment.FollowUpPrompts) > 0, + ClarificationPrompts: append([]string(nil), assessment.FollowUpPrompts...), + DecompositionSignal: decompositionSignalForIntake(changeType, assessment.Size, options.Description), + ContextBundles: append([]string(nil), contextBundles...), + ApplicableStandards: append([]string(nil), standards...), + Reasons: append([]string(nil), assessment.Reasons...), + Assumptions: assumptions, + }, nil +} + +func AssessChangeDecomposition(v *Validator, loaded *LoadedProject, changeID string) (*ChangeAssessDecompositionResult, error) { + if err := validateChangeCommandInputs(v, loaded); err != nil { + return nil, err + } + trimmedID := strings.TrimSpace(changeID) + if trimmedID == "" { + return nil, fmt.Errorf("change decomposition assessment requires a change ID") + } + index, err := v.ValidateLoadedProject(loaded) + if err != nil { + return nil, err + } + defer index.Close() + + record := index.Changes[trimmedID] + if record == nil { + return nil, fmt.Errorf("change %q does not exist", trimmedID) + } + related := uniqueSortedStrings(record.RelatedChanges) + eligible, prerequisites := decompositionGraphSignals(index, record) + reasons := decompositionReasons(record, eligible, prerequisites) + prompts := decompositionClarificationPrompts(record) + + return &ChangeAssessDecompositionResult{ + ID: record.ID, + Status: string(record.Status), + Type: record.Type, + Size: record.Size, + RecommendedMode: recommendedModeForExisting(record), + DecompositionSignal: decompositionSignalForRecord(record, eligible, prerequisites), + ClarificationNeeded: len(prompts) > 0, + ClarificationPrompts: prompts, + RelatedChanges: related, + EligibleSubChangeIDs: eligible, + PrerequisiteChangeIDs: prerequisites, + Reasons: reasons, + }, nil +} + +func intakeReadinessFromAssessment(assessment changeIntakeAssessment) string { + if assessment.RecommendedMode == ChangeModeFull && len(assessment.FollowUpPrompts) > 0 { + return "needs_clarification" + } + if assessment.RecommendedMode == ChangeModeFull { + return "ready_with_full_mode" + } + return "ready" +} + +func decompositionSignalForIntake(changeType, size, description string) string { + if shouldFlagIntakeDecomposition(changeType, size, description) { + return "consider_decomposition" + } + return "none" +} + +func shouldFlagIntakeDecomposition(changeType, size, description string) bool { + if changeType == "project" { + return true + } + if size == "large" { + return true + } + return containsHeuristicKeyword(description, []string{"umbrella", "decompose", "split", "phased", "multi-step", "multiple teams"}) +} + +func decompositionGraphSignals(index *ProjectIndex, record *ChangeRecord) ([]string, []string) { + if index == nil || record == nil { + return nil, nil + } + eligible := make([]string, 0) + for _, relatedID := range uniqueSortedStrings(record.RelatedChanges) { + related := index.Changes[relatedID] + if related == nil { + continue + } + if related.Type == "feature" && containsString(related.RelatedChanges, record.ID) { + eligible = append(eligible, related.ID) + } + } + return uniqueSortedStrings(eligible), uniqueSortedStrings(record.DependsOn) +} + +func recommendedModeForExisting(record *ChangeRecord) ChangeMode { + if record == nil { + return ChangeModeMinimum + } + if strings.TrimSpace(record.Type) == "project" || strings.TrimSpace(record.Size) == "large" { + return ChangeModeFull + } + return ChangeModeMinimum +} + +func decompositionSignalForRecord(record *ChangeRecord, eligible, prerequisites []string) string { + if record == nil { + return "none" + } + if len(eligible) > 0 { + return "umbrella_graph_detected" + } + if strings.TrimSpace(record.Type) == "project" || strings.TrimSpace(record.Size) == "large" { + return "consider_decomposition" + } + if len(prerequisites) > 0 { + return "prerequisites_present" + } + return "none" +} + +func decompositionReasons(record *ChangeRecord, eligible, prerequisites []string) []string { + reasons := make([]string, 0, 3) + if record == nil { + return reasons + } + if strings.TrimSpace(record.Type) == "project" { + reasons = append(reasons, "Project changes usually benefit from explicit decomposition planning.") + } + if strings.TrimSpace(record.Size) == "large" { + reasons = append(reasons, "Large changes are better tracked as linked sub-change plans.") + } + if len(eligible) > 0 { + reasons = append(reasons, "Reciprocal related feature changes indicate an umbrella/sub-change graph.") + } + if len(prerequisites) > 0 { + reasons = append(reasons, "depends_on links indicate prerequisite ordering that decomposition planning should preserve.") + } + if len(reasons) == 0 { + reasons = append(reasons, "No decomposition pressure detected from current change metadata.") + } + return reasons +} + +func decompositionClarificationPrompts(record *ChangeRecord) []string { + if record == nil { + return nil + } + prompts := make([]string, 0, 3) + if strings.TrimSpace(record.Type) == "project" { + prompts = append(prompts, "Define umbrella scope boundaries and expected feature sub-changes.") + } + if strings.TrimSpace(record.Size) == "large" { + prompts = append(prompts, "Identify milestones that should become separate linked changes.") + } + if len(record.DependsOn) > 0 { + prompts = append(prompts, "Confirm dependency ordering for related change execution.") + } + return prompts +} diff --git a/internal/contracts/change_assessment_test.go b/internal/contracts/change_assessment_test.go new file mode 100644 index 0000000..7c6cd00 --- /dev/null +++ b/internal/contracts/change_assessment_test.go @@ -0,0 +1,83 @@ +package contracts + +import ( + "strings" + "testing" +) + +func TestAssessChangeIntakeReturnsAdvisorySignals(t *testing.T) { + root := copyChangeWorkflowTemplate(t) + v, loaded := mustLoadWorkflowProject(t, root) + defer loaded.Close() + + result, err := AssessChangeIntake(v, loaded, ChangeAssessIntakeOptions{ + Title: "Launch payments platform", + Type: "project", + Description: "Multi-step migration across services", + }) + if err != nil { + t.Fatalf("assess intake: %v", err) + } + if got, want := result.RecommendedMode, ChangeModeFull; got != want { + t.Fatalf("expected recommended mode %q, got %q", want, got) + } + if got, want := result.IntakeReadiness, "needs_clarification"; got != want { + t.Fatalf("expected intake_readiness %q, got %q", want, got) + } + if !result.ClarificationNeeded || len(result.ClarificationPrompts) == 0 { + t.Fatalf("expected clarification prompts, got %#v", result.ClarificationPrompts) + } + if got, want := result.DecompositionSignal, "consider_decomposition"; got != want { + t.Fatalf("expected decomposition signal %q, got %q", want, got) + } + if len(result.ApplicableStandards) == 0 { + t.Fatalf("expected standards from bundle resolution") + } + if len(result.Assumptions) == 0 { + t.Fatalf("expected assumptions to be populated") + } +} + +func TestAssessChangeIntakeRejectsMissingTitle(t *testing.T) { + root := copyChangeWorkflowTemplate(t) + v, loaded := mustLoadWorkflowProject(t, root) + defer loaded.Close() + + _, err := AssessChangeIntake(v, loaded, ChangeAssessIntakeOptions{Type: "feature"}) + if err == nil || !strings.Contains(err.Error(), "requires title") { + t.Fatalf("expected missing title error, got %v", err) + } +} + +func TestAssessChangeDecompositionDetectsUmbrellaGraph(t *testing.T) { + root := copyChangeWorkflowTemplate(t) + v, umbrellaID, _ := createUmbrellaAndFeatureSubChange(t, root) + + loaded := mustReloadWorkflowProject(t, v, root) + defer loaded.Close() + + result, err := AssessChangeDecomposition(v, loaded, umbrellaID) + if err != nil { + t.Fatalf("assess decomposition: %v", err) + } + if got, want := result.DecompositionSignal, "umbrella_graph_detected"; got != want { + t.Fatalf("expected decomposition signal %q, got %q", want, got) + } + if got := len(result.EligibleSubChangeIDs); got != 1 { + t.Fatalf("expected one eligible sub-change, got %d (%#v)", got, result.EligibleSubChangeIDs) + } + if got, want := result.RecommendedMode, ChangeModeFull; got != want { + t.Fatalf("expected recommended mode %q, got %q", want, got) + } +} + +func TestAssessChangeDecompositionRejectsUnknownChange(t *testing.T) { + root := copyChangeWorkflowTemplate(t) + v, loaded := mustLoadWorkflowProject(t, root) + defer loaded.Close() + + _, err := AssessChangeDecomposition(v, loaded, "CHG-2099-001-missing") + if err == nil || !strings.Contains(err.Error(), "does not exist") { + t.Fatalf("expected unknown change error, got %v", err) + } +} diff --git a/internal/contracts/change_commands.go b/internal/contracts/change_commands.go index df25212..06ffb9e 100644 --- a/internal/contracts/change_commands.go +++ b/internal/contracts/change_commands.go @@ -31,6 +31,14 @@ type ChangeCreateOptions struct { References []string } +type ChangeAssessIntakeOptions struct { + Title string + Type string + Size string + Description string + ContextBundles []string +} + type ChangeShapeOptions struct { Design string Verification string @@ -88,6 +96,35 @@ type ChangeOperationResult struct { RecursiveTargetIDs []string } +type ChangeAssessIntakeResult struct { + Type string + Size string + RecommendedMode ChangeMode + IntakeReadiness string + ClarificationNeeded bool + ClarificationPrompts []string + DecompositionSignal string + ContextBundles []string + ApplicableStandards []string + Reasons []string + Assumptions []string +} + +type ChangeAssessDecompositionResult struct { + ID string + Status string + Type string + Size string + RecommendedMode ChangeMode + DecompositionSignal string + ClarificationNeeded bool + ClarificationPrompts []string + RelatedChanges []string + EligibleSubChangeIDs []string + PrerequisiteChangeIDs []string + Reasons []string +} + type ChangeReallocationResult struct { OldID string ID string diff --git a/nix/release/metadata.nix b/nix/release/metadata.nix index de041c6..e845220 100644 --- a/nix/release/metadata.nix +++ b/nix/release/metadata.nix @@ -1,7 +1,7 @@ let base = rec { packageName = "runecontext"; - version = "0.1.0-alpha.13"; + version = "0.1.0-alpha.14"; topLevelFiles = [ "README.md" From f74e363f0403d4d2c616ced5b2fcd0161b488671 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich <zeb@runecode.org> Date: Tue, 31 Mar 2026 18:52:48 -0500 Subject: [PATCH 03/27] Adds structured change update command for safe status and relationship edits Signed-off-by: Zebulon Rouse-Frantzich <zeb@runecode.org> --- fixtures/cli-completion/bash.golden | 6 +- fixtures/cli-completion/fish.golden | 8 + fixtures/cli-completion/zsh.golden | 6 +- internal/cli/cli.go | 2 +- internal/cli/cli_change.go | 2 +- internal/cli/cli_change_output.go | 2 + .../cli_change_update_relationship_test.go | 77 +++++++ internal/cli/cli_explain.go | 4 + internal/cli/cli_metadata_flags.go | 2 + internal/cli/cli_metadata_registry_test.go | 16 ++ internal/cli/cli_parse.go | 55 +---- internal/cli/cli_parse_change_update.go | 68 +++++++ internal/contracts/change_commands.go | 9 +- .../contracts/change_commands_update_test.go | 80 ++++++++ internal/contracts/change_update.go | 67 +++++-- .../contracts/change_update_relationships.go | 188 ++++++++++++++++++ 16 files changed, 526 insertions(+), 66 deletions(-) create mode 100644 internal/cli/cli_change_update_relationship_test.go create mode 100644 internal/cli/cli_parse_change_update.go create mode 100644 internal/contracts/change_update_relationships.go diff --git a/fixtures/cli-completion/bash.golden b/fixtures/cli-completion/bash.golden index 03e1bf1..edba287 100644 --- a/fixtures/cli-completion/bash.golden +++ b/fixtures/cli-completion/bash.golden @@ -108,7 +108,7 @@ _runectx_flags_for() { 'change new') echo '--bundle --description --dry-run --explain --json --non-interactive --path --shape --size --title --type' ;; 'change reallocate') echo '--dry-run --explain --json --non-interactive --path' ;; 'change shape') echo '--design --dry-run --explain --json --non-interactive --path --reference --task --verification' ;; - 'change update') echo '--dry-run --explain --json --non-interactive --path --recursive --status --verification-status' ;; + 'change update') echo '--add-related-change --dry-run --explain --json --non-interactive --path --recursive --remove-related-change --status --verification-status' ;; 'completion suggest') echo '--path --prefix' ;; 'doctor') echo '--explain --json --non-interactive --path' ;; 'generate') echo '--explain --json --non-interactive' ;; @@ -160,7 +160,9 @@ _runectx_flag_takes_value() { 'change shape|--reference') return 0 ;; 'change shape|--task') return 0 ;; 'change shape|--verification') return 0 ;; + 'change update|--add-related-change') return 0 ;; 'change update|--path') return 0 ;; + 'change update|--remove-related-change') return 0 ;; 'change update|--status') return 0 ;; 'change update|--verification-status') return 0 ;; 'completion suggest|--path') return 0 ;; @@ -211,6 +213,8 @@ _runectx_suggest_provider_for_flag() { 'change assess-intake|--bundle') echo 'bundle-ids' ;; 'change close|--superseded-by') echo 'change-ids' ;; 'change new|--bundle') echo 'bundle-ids' ;; + 'change update|--add-related-change') echo 'change-ids' ;; + 'change update|--remove-related-change') echo 'change-ids' ;; 'init|--seed-bundle') echo 'bundle-ids' ;; 'promote|--target') echo 'promotion-targets' ;; 'standard discover|--change') echo 'change-ids' ;; diff --git a/fixtures/cli-completion/fish.golden b/fixtures/cli-completion/fish.golden index 03c76d9..c1c6ea1 100644 --- a/fixtures/cli-completion/fish.golden +++ b/fixtures/cli-completion/fish.golden @@ -180,12 +180,16 @@ complete -c runectx -n '__fish_seen_subcommand_from change shape' -l path -r complete -c runectx -n '__fish_seen_subcommand_from change shape' -l reference -r complete -c runectx -n '__fish_seen_subcommand_from change shape' -l task -r complete -c runectx -n '__fish_seen_subcommand_from change shape' -l verification -r +complete -c runectx -n '__fish_seen_subcommand_from change update' -l add-related-change -r +complete -c runectx -f -n '(__fish_seen_subcommand_from change update); and __runectx_prev_token_is --add-related-change' -a '(__runectx_dynamic_suggest change-ids)' complete -c runectx -n '__fish_seen_subcommand_from change update' -l dry-run complete -c runectx -n '__fish_seen_subcommand_from change update' -l explain complete -c runectx -n '__fish_seen_subcommand_from change update' -l json complete -c runectx -n '__fish_seen_subcommand_from change update' -l non-interactive complete -c runectx -n '__fish_seen_subcommand_from change update' -l path -r complete -c runectx -n '__fish_seen_subcommand_from change update' -l recursive +complete -c runectx -n '__fish_seen_subcommand_from change update' -l remove-related-change -r +complete -c runectx -f -n '(__fish_seen_subcommand_from change update); and __runectx_prev_token_is --remove-related-change' -a '(__runectx_dynamic_suggest change-ids)' complete -c runectx -n '__fish_seen_subcommand_from change update' -l status -r complete -c runectx -f -n '(__fish_seen_subcommand_from change update); and __runectx_prev_token_is --status' -a 'implemented planned verified' complete -c runectx -n '__fish_seen_subcommand_from change update' -l verification-status -r @@ -266,6 +270,8 @@ complete -c runectx -n '__fish_seen_subcommand_from version' -l json complete -c runectx -n '__fish_seen_subcommand_from version' -l non-interactive function __runectx_flag_name_takes_value switch $argv[1] + case --add-related-change + return 0 case --bundle return 0 case --change @@ -286,6 +292,8 @@ function __runectx_flag_name_takes_value return 0 case --reference return 0 + case --remove-related-change + return 0 case --role return 0 case --scope-path diff --git a/fixtures/cli-completion/zsh.golden b/fixtures/cli-completion/zsh.golden index aa4a420..01b8d57 100644 --- a/fixtures/cli-completion/zsh.golden +++ b/fixtures/cli-completion/zsh.golden @@ -111,7 +111,7 @@ _runectx_flags_for() { 'change new') echo '--bundle --description --dry-run --explain --json --non-interactive --path --shape --size --title --type' ;; 'change reallocate') echo '--dry-run --explain --json --non-interactive --path' ;; 'change shape') echo '--design --dry-run --explain --json --non-interactive --path --reference --task --verification' ;; - 'change update') echo '--dry-run --explain --json --non-interactive --path --recursive --status --verification-status' ;; + 'change update') echo '--add-related-change --dry-run --explain --json --non-interactive --path --recursive --remove-related-change --status --verification-status' ;; 'completion suggest') echo '--path --prefix' ;; 'doctor') echo '--explain --json --non-interactive --path' ;; 'generate') echo '--explain --json --non-interactive' ;; @@ -163,7 +163,9 @@ _runectx_flag_takes_value() { 'change shape|--reference') return 0 ;; 'change shape|--task') return 0 ;; 'change shape|--verification') return 0 ;; + 'change update|--add-related-change') return 0 ;; 'change update|--path') return 0 ;; + 'change update|--remove-related-change') return 0 ;; 'change update|--status') return 0 ;; 'change update|--verification-status') return 0 ;; 'completion suggest|--path') return 0 ;; @@ -214,6 +216,8 @@ _runectx_suggest_provider_for_flag() { 'change assess-intake|--bundle') echo 'bundle-ids' ;; 'change close|--superseded-by') echo 'change-ids' ;; 'change new|--bundle') echo 'bundle-ids' ;; + 'change update|--add-related-change') echo 'change-ids' ;; + 'change update|--remove-related-change') echo 'change-ids' ;; 'init|--seed-bundle') echo 'bundle-ids' ;; 'promote|--target') echo 'promotion-targets' ;; 'standard discover|--change') echo 'change-ids' ;; diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 6fa4c2c..0eca83f 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -25,7 +25,7 @@ const ( changeShapeUsage = "runectx change shape [--json] [--non-interactive] [--dry-run] [--explain] CHANGE_ID [--design TEXT] [--verification TEXT] [--task TEXT] [--reference TEXT] [--path PATH]" changeCloseUsage = "runectx change close [--json] [--non-interactive] [--dry-run] [--explain] CHANGE_ID [--verification-status STATUS] [--superseded-by ID] [--closed-at YYYY-MM-DD] [--recursive] [--path PATH]" changeReallocateUsage = "runectx change reallocate [--json] [--non-interactive] [--dry-run] [--explain] CHANGE_ID [--path PATH]" - changeUpdateUsage = "runectx change update [--json] [--non-interactive] [--dry-run] [--explain] CHANGE_ID --status planned|implemented|verified [--verification-status passed|failed|skipped] [--recursive] [--path PATH]" + changeUpdateUsage = "runectx change update [--json] [--non-interactive] [--dry-run] [--explain] CHANGE_ID --status planned|implemented|verified [--verification-status passed|failed|skipped] [--add-related-change ID] [--remove-related-change ID] [--recursive] [--path PATH]" changeAssessIntakeUsage = "runectx change assess-intake [--json] [--non-interactive] [--explain] --title TITLE --type TYPE [--size SIZE] [--bundle ID] [--description TEXT] [--path PATH]" changeAssessDecompUsage = "runectx change assess-decomposition [--json] [--non-interactive] [--explain] CHANGE_ID [--path PATH]" initUsage = "runectx init [--json] [--non-interactive] [--dry-run] [--explain] [--mode embedded|linked] [--seed-bundle NAME] [--path PATH]" diff --git a/internal/cli/cli_change.go b/internal/cli/cli_change.go index deb0c35..abbf6ed 100644 --- a/internal/cli/cli_change.go +++ b/internal/cli/cli_change.go @@ -184,7 +184,7 @@ func runChangeUpdate(args []string, machine machineOptions, stdout, stderr io.Wr } defer project.close() result, err := runChangeOperation(project, machine, func(v *contracts.Validator, loaded *contracts.LoadedProject) (*contracts.ChangeOperationResult, error) { - return contracts.UpdateChange(v, loaded, request.changeID, contracts.ChangeUpdateOptions{Status: request.status, VerificationStatus: request.verificationStatus, Recursive: request.recursive}) + return contracts.UpdateChange(v, loaded, request.changeID, contracts.ChangeUpdateOptions{Status: request.status, VerificationStatus: request.verificationStatus, AddRelatedChanges: request.addRelatedChanges, RemoveRelatedChanges: request.removeRelatedChanges, Recursive: request.recursive}) }) if err != nil { emitOutput(stderr, machine, appendMachineOptionLines(buildCommandInvalidLines("change_update", project.absRoot, err), machine), exitInvalid, failureClassInvalid) diff --git a/internal/cli/cli_change_output.go b/internal/cli/cli_change_output.go index 05bb9b4..73442a3 100644 --- a/internal/cli/cli_change_output.go +++ b/internal/cli/cli_change_output.go @@ -94,7 +94,9 @@ func buildChangeUpdateOutput(absRoot string, loaded *contracts.LoadedProject, re {"change_path", result.ChangePath}, {"change_mode", string(result.Mode)}, {"change_status", result.Status}, + {"related_change_count", fmt.Sprintf("%d", len(result.RelatedChanges))}, } + output = appendStringItems(output, "related_change", result.RelatedChanges) if result.Recursive { output = append(output, line{"recursive", "true"}, line{"recursive_target_count", fmt.Sprintf("%d", result.RecursiveTargetCount)}) output = appendStringItems(output, "recursive_target", result.RecursiveTargetIDs) diff --git a/internal/cli/cli_change_update_relationship_test.go b/internal/cli/cli_change_update_relationship_test.go new file mode 100644 index 0000000..1c74431 --- /dev/null +++ b/internal/cli/cli_change_update_relationship_test.go @@ -0,0 +1,77 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRunChangeUpdateRelationshipEditsPersistAndRemainReciprocal(t *testing.T) { + projectRoot := prepareCLIWorkflowProject(t) + firstID := runCLIChangeNewForTest(t, projectRoot, "First change") + secondID := runCLIChangeNewForTest(t, projectRoot, "Second change") + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Run([]string{"change", "update", firstID, "--status", "planned", "--add-related-change", secondID, "--path", projectRoot}, &stdout, &stderr) + if code != 0 { + t.Fatalf("expected success exit code, got %d (%s)", code, stderr.String()) + } + fields := parseCLIKeyValueOutput(t, stdout.String()) + if got, want := fields["change_status"], "planned"; got != want { + t.Fatalf("expected change_status %q, got %q", want, got) + } + if got, want := fields["related_change_count"], "1"; got != want { + t.Fatalf("expected related_change_count %q, got %q", want, got) + } + if got, want := fields["related_change_1"], secondID; got != want { + t.Fatalf("expected related_change_1 %q, got %q", want, got) + } + assertCLIReciprocalRelatedLink(t, projectRoot, firstID, secondID) +} + +func TestRunChangeUpdateRelationshipEditUsageErrors(t *testing.T) { + projectRoot := prepareCLIWorkflowProject(t) + firstID := runCLIChangeNewForTest(t, projectRoot, "First change") + secondID := runCLIChangeNewForTest(t, projectRoot, "Second change") + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Run([]string{"change", "update", firstID, "--status", "planned", "--add-related-change", secondID, "--remove-related-change", secondID, "--path", projectRoot}, &stdout, &stderr) + if code != 1 { + t.Fatalf("expected invalid exit code for conflicting related-change edits, got %d", code) + } + if !strings.Contains(stderr.String(), "relationship edit lists conflict") { + t.Fatalf("expected conflict validation output, got %q", stderr.String()) + } + + stdout.Reset() + stderr.Reset() + code = Run([]string{"change", "update", firstID, "--status", "planned", "--add-related-change", " ", "--path", projectRoot}, &stdout, &stderr) + if code != 2 { + t.Fatalf("expected usage exit code for blank related-change ID, got %d", code) + } + if !strings.Contains(stderr.String(), "--add-related-change requires a value") { + t.Fatalf("expected blank-related-change usage output, got %q", stderr.String()) + } +} + +func assertCLIReciprocalRelatedLink(t *testing.T, projectRoot, firstID, secondID string) { + t.Helper() + assertCLIStatusContainsRelated(t, filepath.Join(projectRoot, "runecontext", "changes", firstID, "status.yaml"), secondID) + assertCLIStatusContainsRelated(t, filepath.Join(projectRoot, "runecontext", "changes", secondID, "status.yaml"), firstID) +} + +func assertCLIStatusContainsRelated(t *testing.T, statusPath, relatedID string) { + t.Helper() + statusBytes, err := os.ReadFile(statusPath) + if err != nil { + t.Fatalf("read status %s: %v", statusPath, err) + } + statusText := strings.ReplaceAll(string(statusBytes), "\r\n", "\n") + if !strings.Contains(statusText, " - "+relatedID) { + t.Fatalf("expected %s to include related change %s, got:\n%s", statusPath, relatedID, statusText) + } +} diff --git a/internal/cli/cli_explain.go b/internal/cli/cli_explain.go index e16577d..6af4e33 100644 --- a/internal/cli/cli_explain.go +++ b/internal/cli/cli_explain.go @@ -165,7 +165,11 @@ func appendChangeUpdateExplainLines(lines []line, result *contracts.ChangeOperat lines = append(lines, line{"explain_scope", "lifecycle-transition"}, line{"explain_lifecycle_status", result.Status}, + line{"explain_related_change_count", fmt.Sprintf("%d", len(result.RelatedChanges))}, ) + for i, relatedID := range result.RelatedChanges { + lines = append(lines, line{fmt.Sprintf("explain_related_change_%d", i+1), relatedID}) + } if result.Recursive { lines = append(lines, line{"explain_scope_2", "recursive-lifecycle-cascade"}, diff --git a/internal/cli/cli_metadata_flags.go b/internal/cli/cli_metadata_flags.go index 1bfbf50..c0eaf51 100644 --- a/internal/cli/cli_metadata_flags.go +++ b/internal/cli/cli_metadata_flags.go @@ -36,6 +36,8 @@ func changeUpdateFlags() []FlagMetadata { return []FlagMetadata{ {Name: "--status", Value: enumValueSpec("planned", "implemented", "verified"), Required: true}, {Name: "--verification-status", Value: enumValueSpec("passed", "failed", "skipped")}, + {Name: "--add-related-change", Value: textValueWithSuggestionSpec(suggestionProviderChangeIDs), Repeatable: true}, + {Name: "--remove-related-change", Value: textValueWithSuggestionSpec(suggestionProviderChangeIDs), Repeatable: true}, {Name: "--recursive", Value: noValueSpec()}, {Name: "--path", Value: textValueSpec()}, } diff --git a/internal/cli/cli_metadata_registry_test.go b/internal/cli/cli_metadata_registry_test.go index c0fba02..3e87aaf 100644 --- a/internal/cli/cli_metadata_registry_test.go +++ b/internal/cli/cli_metadata_registry_test.go @@ -122,6 +122,8 @@ func validateChangeUpdateFlags(t *testing.T, update *CommandMetadata) { if got := verification.Value.EnumValues; !slices.Equal(got, []string{"failed", "passed", "skipped"}) { t.Fatalf("expected verification_status enums [failed passed skipped], got %#v", got) } + validateChangeUpdateRelationshipFlag(t, update, "--add-related-change") + validateChangeUpdateRelationshipFlag(t, update, "--remove-related-change") recursive := flagMetadataByName(update.Flags, "--recursive") if recursive == nil { t.Fatalf("expected --recursive flag for change update") @@ -131,6 +133,20 @@ func validateChangeUpdateFlags(t *testing.T, update *CommandMetadata) { } } +func validateChangeUpdateRelationshipFlag(t *testing.T, update *CommandMetadata, name string) { + t.Helper() + flag := flagMetadataByName(update.Flags, name) + if flag == nil { + t.Fatalf("expected %s flag for change update", name) + } + if flag.Required { + t.Fatalf("expected %s to be optional", name) + } + if !flag.Repeatable { + t.Fatalf("expected %s to be repeatable", name) + } +} + func commandMetadataByPath(commands []CommandMetadata, path string) *CommandMetadata { for i := range commands { if commands[i].Path == path { diff --git a/internal/cli/cli_parse.go b/internal/cli/cli_parse.go index 9aabbe8..9757e55 100644 --- a/internal/cli/cli_parse.go +++ b/internal/cli/cli_parse.go @@ -49,12 +49,14 @@ type changeReallocateRequest struct { } type changeUpdateRequest struct { - root string - explicitRoot bool - changeID string - status string - verificationStatus string - recursive bool + root string + explicitRoot bool + changeID string + status string + verificationStatus string + addRelatedChanges []string + removeRelatedChanges []string + recursive bool } type changeAssessIntakeRequest struct { @@ -225,47 +227,6 @@ func parseChangeReallocateArgs(args []string) (changeReallocateRequest, error) { return request, nil } -func parseChangeUpdateArgs(args []string) (changeUpdateRequest, error) { - request := changeUpdateRequest{root: "."} - positionals := make([]string, 0, 1) - err := consumeArgs(args, func(flag parsedFlag) (int, error) { - switch flag.name { - case "--status": - return assignStringFlag(args, flag, &request.status) - case "--verification-status": - return assignStringFlag(args, flag, &request.verificationStatus) - case "--recursive": - return assignNoValueBoolFlag(flag, &request.recursive) - case "--path": - return assignRootFlag(args, flag, &request.root, &request.explicitRoot) - default: - return flag.next, fmt.Errorf("unknown change update flag %q", flag.raw) - } - }, func(arg string) error { - positionals = append(positionals, arg) - return nil - }) - if err != nil { - return changeUpdateRequest{}, err - } - changeID, err := requireExactPositional(positionals, "change update requires exactly one change ID") - if err != nil { - return changeUpdateRequest{}, err - } - if strings.TrimSpace(request.status) == "" { - return changeUpdateRequest{}, fmt.Errorf("change update requires --status") - } - switch strings.TrimSpace(request.status) { - case "planned", "implemented", "verified": - request.status = strings.TrimSpace(request.status) - default: - return changeUpdateRequest{}, fmt.Errorf("change update --status must be one of planned, implemented, or verified") - } - request.verificationStatus = strings.TrimSpace(request.verificationStatus) - request.changeID = changeID - return request, nil -} - func parseChangeAssessIntakeArgs(args []string) (changeAssessIntakeRequest, error) { request := changeAssessIntakeRequest{root: "."} err := consumeArgs(args, func(flag parsedFlag) (int, error) { diff --git a/internal/cli/cli_parse_change_update.go b/internal/cli/cli_parse_change_update.go new file mode 100644 index 0000000..2b6ccf5 --- /dev/null +++ b/internal/cli/cli_parse_change_update.go @@ -0,0 +1,68 @@ +package cli + +import ( + "fmt" + "strings" +) + +func parseChangeUpdateArgs(args []string) (changeUpdateRequest, error) { + request := changeUpdateRequest{root: "."} + positionals, err := parseChangeUpdateFlagsAndPositionals(args, &request) + if err != nil { + return changeUpdateRequest{}, err + } + changeID, err := requireExactPositional(positionals, "change update requires exactly one change ID") + if err != nil { + return changeUpdateRequest{}, err + } + request.changeID = changeID + return finalizeChangeUpdateRequest(request) +} + +func parseChangeUpdateFlagsAndPositionals(args []string, request *changeUpdateRequest) ([]string, error) { + positionals := make([]string, 0, 1) + err := consumeArgs(args, changeUpdateFlagHandler(args, request), func(arg string) error { + positionals = append(positionals, arg) + return nil + }) + if err != nil { + return nil, err + } + return positionals, nil +} + +func changeUpdateFlagHandler(args []string, request *changeUpdateRequest) func(parsedFlag) (int, error) { + return func(flag parsedFlag) (int, error) { + switch flag.name { + case "--status": + return assignStringFlag(args, flag, &request.status) + case "--verification-status": + return assignStringFlag(args, flag, &request.verificationStatus) + case "--add-related-change": + return appendStringFlag(args, flag, &request.addRelatedChanges) + case "--remove-related-change": + return appendStringFlag(args, flag, &request.removeRelatedChanges) + case "--recursive": + return assignNoValueBoolFlag(flag, &request.recursive) + case "--path": + return assignRootFlag(args, flag, &request.root, &request.explicitRoot) + default: + return flag.next, fmt.Errorf("unknown change update flag %q", flag.raw) + } + } +} + +func finalizeChangeUpdateRequest(request changeUpdateRequest) (changeUpdateRequest, error) { + status := strings.TrimSpace(request.status) + if status == "" { + return changeUpdateRequest{}, fmt.Errorf("change update requires --status") + } + switch status { + case "planned", "implemented", "verified": + request.status = status + default: + return changeUpdateRequest{}, fmt.Errorf("change update --status must be one of planned, implemented, or verified") + } + request.verificationStatus = strings.TrimSpace(request.verificationStatus) + return request, nil +} diff --git a/internal/contracts/change_commands.go b/internal/contracts/change_commands.go index 06ffb9e..aae2852 100644 --- a/internal/contracts/change_commands.go +++ b/internal/contracts/change_commands.go @@ -54,9 +54,11 @@ type ChangeCloseOptions struct { } type ChangeUpdateOptions struct { - Status string - VerificationStatus string - Recursive bool + Status string + VerificationStatus string + AddRelatedChanges []string + RemoveRelatedChanges []string + Recursive bool } type ChangeReallocateOptions struct { @@ -80,6 +82,7 @@ type ChangeOperationResult struct { Mode ChangeMode RecommendedMode ChangeMode Status string + RelatedChanges []string ClosedAt string ContextBundles []string ApplicableStandards []string diff --git a/internal/contracts/change_commands_update_test.go b/internal/contracts/change_commands_update_test.go index acba2e9..3cf4960 100644 --- a/internal/contracts/change_commands_update_test.go +++ b/internal/contracts/change_commands_update_test.go @@ -109,6 +109,86 @@ func TestUpdateChangeRejectsVerificationStatusOnNonVerifiedTransition(t *testing } } +func TestUpdateChangeAddsReciprocalRelatedChanges(t *testing.T) { + root := copyChangeWorkflowTemplate(t) + v, first := mustCreateDefaultFeatureChange(t, root) + _, second := mustCreateChange(t, root, defaultFeatureChangeOptions("Second feature", []byte{0x51, 0x52})) + + loaded := mustReloadWorkflowProject(t, v, root) + defer loaded.Close() + result, err := UpdateChange(v, loaded, first.ID, ChangeUpdateOptions{Status: "planned", AddRelatedChanges: []string{second.ID}}) + if err != nil { + t.Fatalf("update change with related addition: %v", err) + } + if len(result.RelatedChanges) != 1 || result.RelatedChanges[0] != second.ID { + t.Fatalf("expected updated related_changes [%s], got %#v", second.ID, result.RelatedChanges) + } + + firstStatusPath := filepath.Join(root, "runecontext", "changes", first.ID, "status.yaml") + firstText := strings.ReplaceAll(string(mustReadBytes(t, firstStatusPath)), "\r\n", "\n") + if !strings.Contains(firstText, "related_changes:\n - "+second.ID) { + t.Fatalf("expected first status related_changes to include %s, got:\n%s", second.ID, firstText) + } + secondStatusPath := filepath.Join(root, "runecontext", "changes", second.ID, "status.yaml") + secondText := strings.ReplaceAll(string(mustReadBytes(t, secondStatusPath)), "\r\n", "\n") + if !strings.Contains(secondText, "related_changes:\n - "+first.ID) { + t.Fatalf("expected reciprocal related_changes to include %s, got:\n%s", first.ID, secondText) + } +} + +func TestUpdateChangeRemovesReciprocalRelatedChanges(t *testing.T) { + root := copyChangeWorkflowTemplate(t) + v, first := mustCreateDefaultFeatureChange(t, root) + _, second := mustCreateChange(t, root, defaultFeatureChangeOptions("Second feature", []byte{0x61, 0x62})) + wireBidirectionalRelatedChangeLink(t, root, first.ID, second.ID) + + loaded := mustReloadWorkflowProject(t, v, root) + defer loaded.Close() + result, err := UpdateChange(v, loaded, first.ID, ChangeUpdateOptions{Status: "planned", RemoveRelatedChanges: []string{second.ID}}) + if err != nil { + t.Fatalf("update change with related removal: %v", err) + } + if len(result.RelatedChanges) != 0 { + t.Fatalf("expected updated related_changes to be empty, got %#v", result.RelatedChanges) + } + + firstStatusPath := filepath.Join(root, "runecontext", "changes", first.ID, "status.yaml") + firstText := strings.ReplaceAll(string(mustReadBytes(t, firstStatusPath)), "\r\n", "\n") + if !strings.Contains(firstText, "related_changes: []") { + t.Fatalf("expected first status related_changes to be empty, got:\n%s", firstText) + } + secondStatusPath := filepath.Join(root, "runecontext", "changes", second.ID, "status.yaml") + secondText := strings.ReplaceAll(string(mustReadBytes(t, secondStatusPath)), "\r\n", "\n") + if !strings.Contains(secondText, "related_changes: []") { + t.Fatalf("expected reciprocal related_changes to be empty, got:\n%s", secondText) + } +} + +func TestUpdateChangeRejectsConflictingRelationshipEdits(t *testing.T) { + root := copyChangeWorkflowTemplate(t) + v, first := mustCreateDefaultFeatureChange(t, root) + _, second := mustCreateChange(t, root, defaultFeatureChangeOptions("Second feature", []byte{0x71, 0x72})) + + loaded := mustReloadWorkflowProject(t, v, root) + defer loaded.Close() + _, err := UpdateChange(v, loaded, first.ID, ChangeUpdateOptions{Status: "planned", AddRelatedChanges: []string{second.ID}, RemoveRelatedChanges: []string{second.ID}}) + if err == nil || !strings.Contains(err.Error(), "relationship edit lists conflict") { + t.Fatalf("expected relationship conflict rejection, got %v", err) + } +} + +func TestUpdateChangeRejectsSelfRelationshipEdit(t *testing.T) { + root := copyChangeWorkflowTemplate(t) + v, created := mustCreateDefaultFeatureChange(t, root) + + loaded := mustReloadWorkflowProject(t, v, root) + defer loaded.Close() + _, err := UpdateChange(v, loaded, created.ID, ChangeUpdateOptions{Status: "planned", AddRelatedChanges: []string{created.ID}}) + if err == nil || !strings.Contains(err.Error(), "must not relate a change to itself") { + t.Fatalf("expected self-related-change rejection, got %v", err) + } +} + func rewriteUpdatePromotionAssessmentFixture(t *testing.T, statusPath string) { t.Helper() rewriteFile(t, statusPath, func(text string) string { diff --git a/internal/contracts/change_update.go b/internal/contracts/change_update.go index 513db0b..b8237ee 100644 --- a/internal/contracts/change_update.go +++ b/internal/contracts/change_update.go @@ -24,6 +24,7 @@ type updateChangeContext struct { record *ChangeRecord status LifecycleStatus changedFiles []FileMutation + relatedChanges []string recursive bool recursiveTargetIDs []string recursiveTargetCount int @@ -40,7 +41,12 @@ func prepareUpdateChange(v *Validator, loaded *LoadedProject, changeID string, o if err != nil { return nil, nil, updateChangeContext{}, err } - updated, statusWrites, changedFiles, err := buildUpdateTargetWrites(v, index, writableRoot, targets, changeID, nextStatus, options.VerificationStatus) + relatedTargets, relatedTargetIDs, err := resolveRelatedChangeTargets(index, record, options.AddRelatedChanges, options.RemoveRelatedChanges) + if err != nil { + return nil, nil, updateChangeContext{}, err + } + relationshipEditRequested := len(options.AddRelatedChanges) > 0 || len(options.RemoveRelatedChanges) > 0 + updated, statusWrites, changedFiles, err := buildUpdateTargetWrites(v, index, writableRoot, targets, relatedTargets, changeID, nextStatus, options.VerificationStatus, relatedTargetIDs, relationshipEditRequested) if err != nil { return nil, nil, updateChangeContext{}, err } @@ -51,35 +57,71 @@ func prepareUpdateChange(v *Validator, loaded *LoadedProject, changeID string, o record: record, status: nextStatus, changedFiles: changedFiles, + relatedChanges: extractStringList(updated["related_changes"]), recursive: options.Recursive, recursiveTargetIDs: recursiveTargetIDs, recursiveTargetCount: len(recursiveTargetIDs), }, nil } -func buildUpdateTargetWrites(v *Validator, index *ProjectIndex, writableRoot string, targets []*ChangeRecord, changeID string, nextStatus LifecycleStatus, requestedVerificationStatus string) (map[string]any, []fileRewrite, []FileMutation, error) { +func buildUpdateTargetWrites(v *Validator, index *ProjectIndex, writableRoot string, targets []*ChangeRecord, relatedTargets []*ChangeRecord, changeID string, nextStatus LifecycleStatus, requestedVerificationStatus string, relatedTargetIDs []string, relationshipEditRequested bool) (map[string]any, []fileRewrite, []FileMutation, error) { verificationStatus := strings.TrimSpace(requestedVerificationStatus) + statusWrites, changedFiles, writesByPath, updated, err := buildLifecycleTargetWrites(v, index, writableRoot, targets, changeID, nextStatus, verificationStatus, relatedTargetIDs, relationshipEditRequested) + if err != nil { + return nil, nil, nil, err + } + if !relationshipEditRequested { + sortFileMutations(changedFiles) + return updated, statusWrites, changedFiles, nil + } + relatedWrites, relatedChangedFiles, err := buildReciprocalRelatedChangeWrites(v, index, writableRoot, relatedTargets, changeID, relatedTargetIDs) + if err != nil { + return nil, nil, nil, err + } + mergeRelatedWrites(&statusWrites, &changedFiles, writesByPath, relatedWrites, relatedChangedFiles) + sortFileMutations(changedFiles) + return updated, statusWrites, changedFiles, nil +} + +func buildLifecycleTargetWrites(v *Validator, index *ProjectIndex, writableRoot string, targets []*ChangeRecord, changeID string, nextStatus LifecycleStatus, verificationStatus string, relatedTargetIDs []string, relationshipEditRequested bool) ([]fileRewrite, []FileMutation, map[string]int, map[string]any, error) { statusWrites := make([]fileRewrite, 0, len(targets)) changedFiles := make([]FileMutation, 0, len(targets)) + writesByPath := map[string]int{} updated := map[string]any{} for _, target := range targets { - targetStatus := cloneMap(index.StatusFiles[target.StatusPath].Data) - targetStatus["status"] = string(nextStatus) - if verificationStatus != "" { - targetStatus["verification_status"] = verificationStatus - } + targetStatus := lifecycleTargetStatus(index, target, changeID, nextStatus, verificationStatus, relatedTargetIDs, relationshipEditRequested) statusWrite, _, err := buildPrimaryCloseStatusWrite(v, writableRoot, target, targetStatus) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } - statusWrites = append(statusWrites, statusWrite) - changedFiles = append(changedFiles, FileMutation{Path: runeContextRelativePath(writableRoot, target.StatusPath), Action: "updated"}) + insertOrReplaceStatusWrite(&statusWrites, writesByPath, statusWrite) + insertOrReplaceChangedFile(&changedFiles, writableRoot, target.StatusPath) if target.ID == changeID { updated = targetStatus } } - sortFileMutations(changedFiles) - return updated, statusWrites, changedFiles, nil + return statusWrites, changedFiles, writesByPath, updated, nil +} + +func lifecycleTargetStatus(index *ProjectIndex, target *ChangeRecord, changeID string, nextStatus LifecycleStatus, verificationStatus string, relatedTargetIDs []string, relationshipEditRequested bool) map[string]any { + targetStatus := cloneMap(index.StatusFiles[target.StatusPath].Data) + targetStatus["status"] = string(nextStatus) + if verificationStatus != "" { + targetStatus["verification_status"] = verificationStatus + } + if relationshipEditRequested && target.ID == changeID { + targetStatus["related_changes"] = stringSliceToAny(relatedTargetIDs) + } + return targetStatus +} + +func mergeRelatedWrites(statusWrites *[]fileRewrite, changedFiles *[]FileMutation, writesByPath map[string]int, relatedWrites []fileRewrite, relatedChangedFiles []FileMutation) { + for _, write := range relatedWrites { + insertOrReplaceStatusWrite(statusWrites, writesByPath, write) + } + for _, changed := range relatedChangedFiles { + insertOrReplaceChangedFileEntry(changedFiles, changed) + } } func collectRecursiveTargetIDs(targets []*ChangeRecord, rootID string) []string { @@ -172,6 +214,7 @@ func buildUpdateChangeResult(updated map[string]any, context updateChangeContext Mode: inferChangeMode(changeDir), RecommendedMode: inferChangeMode(changeDir), Status: string(context.status), + RelatedChanges: append([]string(nil), context.relatedChanges...), ContextBundles: append([]string(nil), context.record.ContextBundles...), ApplicableStandards: append([]string(nil), context.record.ApplicableStandards...), ChangedFiles: append([]FileMutation(nil), context.changedFiles...), diff --git a/internal/contracts/change_update_relationships.go b/internal/contracts/change_update_relationships.go new file mode 100644 index 0000000..a0e1d00 --- /dev/null +++ b/internal/contracts/change_update_relationships.go @@ -0,0 +1,188 @@ +package contracts + +import ( + "fmt" + "strings" +) + +func resolveRelatedChangeTargets(index *ProjectIndex, record *ChangeRecord, adds []string, removes []string) ([]*ChangeRecord, []string, error) { + addSet, err := normalizedRelationshipTargetSet(adds) + if err != nil { + return nil, nil, err + } + removeSet, err := normalizedRelationshipTargetSet(removes) + if err != nil { + return nil, nil, err + } + if err := validateRelationshipEditConflict(addSet, removeSet); err != nil { + return nil, nil, err + } + next := uniqueSortedStrings(append([]string(nil), record.RelatedChanges...)) + next, err = applyRelatedChangeAdds(index, record, next, addSet) + if err != nil { + return nil, nil, err + } + next = applyRelatedChangeRemovals(record, next, removeSet) + next = uniqueSortedStrings(next) + targets, err := collectRelatedChangeRecords(index, record.RelatedChanges, next) + if err != nil { + return nil, nil, err + } + return targets, next, nil +} + +func validateRelationshipEditConflict(addSet, removeSet map[string]struct{}) error { + for id := range addSet { + if _, ok := removeSet[id]; ok { + return fmt.Errorf("change update relationship edit lists conflict on %q", id) + } + } + return nil +} + +func applyRelatedChangeAdds(index *ProjectIndex, record *ChangeRecord, next []string, addSet map[string]struct{}) ([]string, error) { + for _, id := range SortedKeys(addSet) { + if err := validateRelatedAddTarget(index, record.ID, id); err != nil { + return nil, err + } + if !containsString(next, id) { + next = append(next, id) + } + } + return next, nil +} + +func validateRelatedAddTarget(index *ProjectIndex, changeID, relatedID string) error { + if relatedID == changeID { + return fmt.Errorf("change update must not relate a change to itself") + } + if index.Changes[relatedID] == nil { + return fmt.Errorf("change update related change %q does not exist", relatedID) + } + return nil +} + +func applyRelatedChangeRemovals(record *ChangeRecord, next []string, removeSet map[string]struct{}) []string { + for _, id := range SortedKeys(removeSet) { + if !containsString(record.RelatedChanges, id) { + continue + } + next = removeStringValue(next, id) + } + return next +} + +func collectRelatedChangeRecords(index *ProjectIndex, existing []string, next []string) ([]*ChangeRecord, error) { + targetSet := map[string]struct{}{} + for _, id := range existing { + targetSet[id] = struct{}{} + } + for _, id := range next { + targetSet[id] = struct{}{} + } + targets := make([]*ChangeRecord, 0, len(targetSet)) + for _, id := range SortedKeys(targetSet) { + target := index.Changes[id] + if target == nil { + return nil, fmt.Errorf("change update related change %q does not exist", id) + } + targets = append(targets, target) + } + return targets, nil +} + +func normalizedRelationshipTargetSet(items []string) (map[string]struct{}, error) { + set := map[string]struct{}{} + for _, item := range items { + trimmed := strings.TrimSpace(item) + if trimmed == "" { + return nil, fmt.Errorf("change update relationship edit IDs must not be blank") + } + set[trimmed] = struct{}{} + } + return set, nil +} + +func removeStringValue(items []string, target string) []string { + filtered := make([]string, 0, len(items)) + for _, item := range items { + if item == target { + continue + } + filtered = append(filtered, item) + } + return filtered +} + +func buildReciprocalRelatedChangeWrites(v *Validator, index *ProjectIndex, writableRoot string, relatedTargets []*ChangeRecord, changeID string, rootRelatedChanges []string) ([]fileRewrite, []FileMutation, error) { + rootRelatedSet := map[string]struct{}{} + for _, id := range rootRelatedChanges { + rootRelatedSet[id] = struct{}{} + } + writes := make([]fileRewrite, 0, len(relatedTargets)) + changed := make([]FileMutation, 0, len(relatedTargets)) + for _, target := range relatedTargets { + targetStatus := cloneMap(index.StatusFiles[target.StatusPath].Data) + before := uniqueSortedStrings(extractStringList(targetStatus["related_changes"])) + after := mergeRelatedChangeReciprocal(before, changeID, target.ID, rootRelatedSet) + if slicesEqualStrings(after, before) { + continue + } + targetStatus["related_changes"] = stringSliceToAny(after) + write, _, err := buildPrimaryCloseStatusWrite(v, writableRoot, target, targetStatus) + if err != nil { + return nil, nil, err + } + writes = append(writes, write) + changed = append(changed, FileMutation{Path: runeContextRelativePath(writableRoot, target.StatusPath), Action: "updated"}) + } + return writes, changed, nil +} + +func mergeRelatedChangeReciprocal(existing []string, changeID string, targetID string, rootRelatedSet map[string]struct{}) []string { + updated := append([]string(nil), existing...) + _, shouldLink := rootRelatedSet[targetID] + hasLink := containsString(updated, changeID) + if shouldLink && !hasLink { + updated = append(updated, changeID) + } + if !shouldLink && hasLink { + updated = removeStringValue(updated, changeID) + } + return uniqueSortedStrings(updated) +} + +func slicesEqualStrings(left, right []string) bool { + if len(left) != len(right) { + return false + } + for i := range left { + if left[i] != right[i] { + return false + } + } + return true +} + +func insertOrReplaceStatusWrite(writes *[]fileRewrite, byPath map[string]int, write fileRewrite) { + if index, ok := byPath[write.Path]; ok { + (*writes)[index] = write + return + } + byPath[write.Path] = len(*writes) + *writes = append(*writes, write) +} + +func insertOrReplaceChangedFile(items *[]FileMutation, writableRoot, statusPath string) { + insertOrReplaceChangedFileEntry(items, FileMutation{Path: runeContextRelativePath(writableRoot, statusPath), Action: "updated"}) +} + +func insertOrReplaceChangedFileEntry(items *[]FileMutation, next FileMutation) { + for i := range *items { + if (*items)[i].Path == next.Path { + (*items)[i] = next + return + } + } + *items = append(*items, next) +} From 64825e791c8d594790f55546fe819d0fad847731 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich <zeb@runecode.org> Date: Tue, 31 Mar 2026 19:28:11 -0500 Subject: [PATCH 04/27] Adds first-class change decomposition planning and apply operations Signed-off-by: Zebulon Rouse-Frantzich <zeb@runecode.org> --- .../flows/change-decomposition-apply.md | 7 + .../flows/change-decomposition-plan.md | 7 + .../flows/conversational-parity.md | 2 + .../codex/flows/change-decomposition-apply.md | 7 + .../codex/flows/change-decomposition-plan.md | 7 + adapters/codex/flows/conversational-parity.md | 2 + .../flows/change-decomposition-apply.md | 7 + .../flows/change-decomposition-plan.md | 7 + .../generic/flows/conversational-parity.md | 2 + .../flows/change-decomposition-apply.md | 22 ++ .../flows/change-decomposition-plan.md | 22 ++ .../opencode/flows/conversational-parity.md | 2 + .../generated/runecontext-reference.json | 10 + fixtures/cli-completion/bash.golden | 18 +- fixtures/cli-completion/fish.golden | 23 ++ fixtures/cli-completion/zsh.golden | 18 +- .../cli-json-golden/metadata-success.json | 2 +- .../release-manifest-with-metadata.json | 8 + internal/cli/cli.go | 5 +- internal/cli/cli_adapter_pack_test.go | 4 +- ...cli_adapter_render_host_native_markdown.go | 4 + internal/cli/cli_adapter_render_test.go | 10 +- internal/cli/cli_adapter_sync_host_native.go | 41 ---- .../cli/cli_adapter_sync_host_native_flows.go | 23 ++ internal/cli/cli_adapter_test.go | 178 +++------------- internal/cli/cli_adapter_test_helpers_test.go | 158 ++++++++++++++ internal/cli/cli_change.go | 71 +------ .../cli/cli_change_advisory_decomposition.go | 134 ++++++++++++ .../cli/cli_change_decomposition_flow_test.go | 160 ++++++++++++++ internal/cli/cli_change_decomposition_test.go | 52 +++++ internal/cli/cli_change_output.go | 39 ++++ internal/cli/cli_change_test.go | 92 -------- internal/cli/cli_change_test_helpers_test.go | 101 +++++++++ internal/cli/cli_completion_suggest_test.go | 58 +++--- internal/cli/cli_explain.go | 26 +++ internal/cli/cli_help_validate_flags_test.go | 3 + internal/cli/cli_metadata_flags.go | 8 + internal/cli/cli_metadata_registry.go | 2 + internal/cli/cli_metadata_registry_test.go | 34 +++ internal/cli/cli_parse.go | 72 +------ internal/cli/cli_parse_change_assess.go | 79 +++++++ .../cli/cli_parse_change_decomposition.go | 107 ++++++++++ internal/contracts/change_assessment.go | 47 +++-- internal/contracts/change_assessment_test.go | 77 +++++++ internal/contracts/change_commands.go | 23 ++ internal/contracts/change_decomposition.go | 197 ++++++++++++++++++ 46 files changed, 1509 insertions(+), 469 deletions(-) create mode 100644 adapters/claude-code/flows/change-decomposition-apply.md create mode 100644 adapters/claude-code/flows/change-decomposition-plan.md create mode 100644 adapters/codex/flows/change-decomposition-apply.md create mode 100644 adapters/codex/flows/change-decomposition-plan.md create mode 100644 adapters/generic/flows/change-decomposition-apply.md create mode 100644 adapters/generic/flows/change-decomposition-plan.md create mode 100644 adapters/opencode/flows/change-decomposition-apply.md create mode 100644 adapters/opencode/flows/change-decomposition-plan.md create mode 100644 internal/cli/cli_adapter_sync_host_native_flows.go create mode 100644 internal/cli/cli_adapter_test_helpers_test.go create mode 100644 internal/cli/cli_change_advisory_decomposition.go create mode 100644 internal/cli/cli_change_decomposition_flow_test.go create mode 100644 internal/cli/cli_change_decomposition_test.go create mode 100644 internal/cli/cli_change_test_helpers_test.go create mode 100644 internal/cli/cli_parse_change_assess.go create mode 100644 internal/cli/cli_parse_change_decomposition.go create mode 100644 internal/contracts/change_decomposition.go diff --git a/adapters/claude-code/flows/change-decomposition-apply.md b/adapters/claude-code/flows/change-decomposition-apply.md new file mode 100644 index 0000000..33c6096 --- /dev/null +++ b/adapters/claude-code/flows/change-decomposition-apply.md @@ -0,0 +1,7 @@ +# Claude Code Flow: change decomposition-apply + +Map conversational decomposition apply to: + +```sh +runectx change decomposition-apply <UMBRELLA_CHANGE_ID> --sub-change <CHANGE_ID> [--sub-change <CHANGE_ID> ...] [--depends-on <SUB_CHANGE_ID:CHANGE_ID> ...] [--path <project-root>] +``` diff --git a/adapters/claude-code/flows/change-decomposition-plan.md b/adapters/claude-code/flows/change-decomposition-plan.md new file mode 100644 index 0000000..bcbeaff --- /dev/null +++ b/adapters/claude-code/flows/change-decomposition-plan.md @@ -0,0 +1,7 @@ +# Claude Code Flow: change decomposition-plan + +Map conversational decomposition planning to: + +```sh +runectx change decomposition-plan <UMBRELLA_CHANGE_ID> --sub-change <CHANGE_ID> [--sub-change <CHANGE_ID> ...] [--depends-on <SUB_CHANGE_ID:CHANGE_ID> ...] [--path <project-root>] +``` diff --git a/adapters/claude-code/flows/conversational-parity.md b/adapters/claude-code/flows/conversational-parity.md index 2a0ff1d..b33d27d 100644 --- a/adapters/claude-code/flows/conversational-parity.md +++ b/adapters/claude-code/flows/conversational-parity.md @@ -12,6 +12,8 @@ CLI inputs. No adapter-only lifecycle or mutation semantics are allowed. - `change new` conversation -> `runectx change new --title --type [--size] [--shape] [--bundle] [--description] [--path]` - `change assess-intake` conversation -> `runectx change assess-intake --title --type [--size] [--bundle] [--description] [--path]` - `change assess-decomposition` conversation -> `runectx change assess-decomposition CHANGE_ID [--path]` +- `change decomposition-plan` conversation -> `runectx change decomposition-plan UMBRELLA_CHANGE_ID --sub-change CHANGE_ID [--sub-change CHANGE_ID ...] [--depends-on SUB_CHANGE_ID:CHANGE_ID ...] [--path]` +- `change decomposition-apply` conversation -> `runectx change decomposition-apply UMBRELLA_CHANGE_ID --sub-change CHANGE_ID [--sub-change CHANGE_ID ...] [--depends-on SUB_CHANGE_ID:CHANGE_ID ...] [--path]` - `change shape` conversation -> `runectx change shape CHANGE_ID [--design] [--verification] [--task] [--reference] [--path]` - `standard discover` conversation -> `runectx standard discover [--path] [--change CHANGE_ID] [--scope-path PATH] [--focus TEXT] [--confirm-handoff] [--target TYPE:PATH]` - `promote` conversation -> `runectx promote CHANGE_ID [--accept|--complete] [--target TYPE:PATH] [--path]` diff --git a/adapters/codex/flows/change-decomposition-apply.md b/adapters/codex/flows/change-decomposition-apply.md new file mode 100644 index 0000000..8ce642c --- /dev/null +++ b/adapters/codex/flows/change-decomposition-apply.md @@ -0,0 +1,7 @@ +# Codex Flow: change decomposition-apply + +Map conversational decomposition apply to: + +```sh +runectx change decomposition-apply <UMBRELLA_CHANGE_ID> --sub-change <CHANGE_ID> [--sub-change <CHANGE_ID> ...] [--depends-on <SUB_CHANGE_ID:CHANGE_ID> ...] [--path <project-root>] +``` diff --git a/adapters/codex/flows/change-decomposition-plan.md b/adapters/codex/flows/change-decomposition-plan.md new file mode 100644 index 0000000..207654c --- /dev/null +++ b/adapters/codex/flows/change-decomposition-plan.md @@ -0,0 +1,7 @@ +# Codex Flow: change decomposition-plan + +Map conversational decomposition planning to: + +```sh +runectx change decomposition-plan <UMBRELLA_CHANGE_ID> --sub-change <CHANGE_ID> [--sub-change <CHANGE_ID> ...] [--depends-on <SUB_CHANGE_ID:CHANGE_ID> ...] [--path <project-root>] +``` diff --git a/adapters/codex/flows/conversational-parity.md b/adapters/codex/flows/conversational-parity.md index 069a12e..9653531 100644 --- a/adapters/codex/flows/conversational-parity.md +++ b/adapters/codex/flows/conversational-parity.md @@ -12,6 +12,8 @@ CLI inputs. No adapter-only lifecycle or mutation semantics are allowed. - `change new` conversation -> `runectx change new --title --type [--size] [--shape] [--bundle] [--description] [--path]` - `change assess-intake` conversation -> `runectx change assess-intake --title --type [--size] [--bundle] [--description] [--path]` - `change assess-decomposition` conversation -> `runectx change assess-decomposition CHANGE_ID [--path]` +- `change decomposition-plan` conversation -> `runectx change decomposition-plan UMBRELLA_CHANGE_ID --sub-change CHANGE_ID [--sub-change CHANGE_ID ...] [--depends-on SUB_CHANGE_ID:CHANGE_ID ...] [--path]` +- `change decomposition-apply` conversation -> `runectx change decomposition-apply UMBRELLA_CHANGE_ID --sub-change CHANGE_ID [--sub-change CHANGE_ID ...] [--depends-on SUB_CHANGE_ID:CHANGE_ID ...] [--path]` - `change shape` conversation -> `runectx change shape CHANGE_ID [--design] [--verification] [--task] [--reference] [--path]` - `standard discover` conversation -> `runectx standard discover [--path] [--change CHANGE_ID] [--scope-path PATH] [--focus TEXT] [--confirm-handoff] [--target TYPE:PATH]` - `promote` conversation -> `runectx promote CHANGE_ID [--accept|--complete] [--target TYPE:PATH] [--path]` diff --git a/adapters/generic/flows/change-decomposition-apply.md b/adapters/generic/flows/change-decomposition-apply.md new file mode 100644 index 0000000..787a2a9 --- /dev/null +++ b/adapters/generic/flows/change-decomposition-apply.md @@ -0,0 +1,7 @@ +# Generic Flow: change decomposition-apply + +Use explicit CLI command proposals: + +```sh +runectx change decomposition-apply <UMBRELLA_CHANGE_ID> --sub-change <CHANGE_ID> [--sub-change <CHANGE_ID> ...] [--depends-on <SUB_CHANGE_ID:CHANGE_ID> ...] [--path <project-root>] +``` diff --git a/adapters/generic/flows/change-decomposition-plan.md b/adapters/generic/flows/change-decomposition-plan.md new file mode 100644 index 0000000..e9b89e2 --- /dev/null +++ b/adapters/generic/flows/change-decomposition-plan.md @@ -0,0 +1,7 @@ +# Generic Flow: change decomposition-plan + +Use explicit CLI command proposals: + +```sh +runectx change decomposition-plan <UMBRELLA_CHANGE_ID> --sub-change <CHANGE_ID> [--sub-change <CHANGE_ID> ...] [--depends-on <SUB_CHANGE_ID:CHANGE_ID> ...] [--path <project-root>] +``` diff --git a/adapters/generic/flows/conversational-parity.md b/adapters/generic/flows/conversational-parity.md index 157b21a..6d522b9 100644 --- a/adapters/generic/flows/conversational-parity.md +++ b/adapters/generic/flows/conversational-parity.md @@ -12,6 +12,8 @@ CLI inputs. No adapter-only lifecycle or mutation semantics are allowed. - `change new` conversation -> `runectx change new --title --type [--size] [--shape] [--bundle] [--description] [--path]` - `change assess-intake` conversation -> `runectx change assess-intake --title --type [--size] [--bundle] [--description] [--path]` - `change assess-decomposition` conversation -> `runectx change assess-decomposition CHANGE_ID [--path]` +- `change decomposition-plan` conversation -> `runectx change decomposition-plan UMBRELLA_CHANGE_ID --sub-change CHANGE_ID [--sub-change CHANGE_ID ...] [--depends-on SUB_CHANGE_ID:CHANGE_ID ...] [--path]` +- `change decomposition-apply` conversation -> `runectx change decomposition-apply UMBRELLA_CHANGE_ID --sub-change CHANGE_ID [--sub-change CHANGE_ID ...] [--depends-on SUB_CHANGE_ID:CHANGE_ID ...] [--path]` - `change shape` conversation -> `runectx change shape CHANGE_ID [--design] [--verification] [--task] [--reference] [--path]` - `standard discover` conversation -> `runectx standard discover [--path] [--change CHANGE_ID] [--scope-path PATH] [--focus TEXT] [--confirm-handoff] [--target TYPE:PATH]` - `promote` conversation -> `runectx promote CHANGE_ID [--accept|--complete] [--target TYPE:PATH] [--path]` diff --git a/adapters/opencode/flows/change-decomposition-apply.md b/adapters/opencode/flows/change-decomposition-apply.md new file mode 100644 index 0000000..9bad63c --- /dev/null +++ b/adapters/opencode/flows/change-decomposition-apply.md @@ -0,0 +1,22 @@ +# OpenCode Flow: change decomposition-apply + +Use this conversational flow to apply a decomposition graph rewrite across an +umbrella change and selected sub-changes. + +## Inputs + +- umbrella change ID +- one or more sub-change IDs +- zero or more dependency edges (`SUB_CHANGE_ID:CHANGE_ID`) +- optional project path + +## Command Mapping + +```sh +runectx change decomposition-apply <UMBRELLA_CHANGE_ID> --sub-change <CHANGE_ID> [--sub-change <CHANGE_ID> ...] [--depends-on <SUB_CHANGE_ID:CHANGE_ID> ...] [--path <project-root>] +``` + +## Review Checkpoint + +- Confirm umbrella and sub-change IDs before execution. +- Review changed status files and relationship rewrites before commit. diff --git a/adapters/opencode/flows/change-decomposition-plan.md b/adapters/opencode/flows/change-decomposition-plan.md new file mode 100644 index 0000000..3f8a92b --- /dev/null +++ b/adapters/opencode/flows/change-decomposition-plan.md @@ -0,0 +1,22 @@ +# OpenCode Flow: change decomposition-plan + +Use this conversational flow to compute an advisory decomposition graph for an +umbrella change and selected sub-changes. + +## Inputs + +- umbrella change ID +- one or more sub-change IDs +- zero or more dependency edges (`SUB_CHANGE_ID:CHANGE_ID`) +- optional project path + +## Command Mapping + +```sh +runectx change decomposition-plan <UMBRELLA_CHANGE_ID> --sub-change <CHANGE_ID> [--sub-change <CHANGE_ID> ...] [--depends-on <SUB_CHANGE_ID:CHANGE_ID> ...] [--path <project-root>] +``` + +## Review Checkpoint + +- Confirm umbrella and sub-change IDs before execution. +- Keep advisory graph outputs (`graph_*`) user-visible for review. diff --git a/adapters/opencode/flows/conversational-parity.md b/adapters/opencode/flows/conversational-parity.md index 664e71e..7e26a00 100644 --- a/adapters/opencode/flows/conversational-parity.md +++ b/adapters/opencode/flows/conversational-parity.md @@ -12,6 +12,8 @@ CLI inputs. No adapter-only lifecycle or mutation semantics are allowed. - `change new` conversation -> `runectx change new --title --type [--size] [--shape] [--bundle] [--description] [--path]` - `change assess-intake` conversation -> `runectx change assess-intake --title --type [--size] [--bundle] [--description] [--path]` - `change assess-decomposition` conversation -> `runectx change assess-decomposition CHANGE_ID [--path]` +- `change decomposition-plan` conversation -> `runectx change decomposition-plan UMBRELLA_CHANGE_ID --sub-change CHANGE_ID [--sub-change CHANGE_ID ...] [--depends-on SUB_CHANGE_ID:CHANGE_ID ...] [--path]` +- `change decomposition-apply` conversation -> `runectx change decomposition-apply UMBRELLA_CHANGE_ID --sub-change CHANGE_ID [--sub-change CHANGE_ID ...] [--depends-on SUB_CHANGE_ID:CHANGE_ID ...] [--path]` - `change shape` conversation -> `runectx change shape CHANGE_ID [--design] [--verification] [--task] [--reference] [--path]` - `standard discover` conversation -> `runectx standard discover [--path] [--change CHANGE_ID] [--scope-path PATH] [--focus TEXT] [--confirm-handoff] [--target TYPE:PATH]` - `promote` conversation -> `runectx promote CHANGE_ID [--accept|--complete] [--target TYPE:PATH] [--path]` diff --git a/docs/reference/generated/runecontext-reference.json b/docs/reference/generated/runecontext-reference.json index d77c470..689ed35 100644 --- a/docs/reference/generated/runecontext-reference.json +++ b/docs/reference/generated/runecontext-reference.json @@ -61,6 +61,14 @@ "path": "change close", "token": "change_close" }, + { + "path": "change decomposition-apply", + "token": "change_decomposition_apply" + }, + { + "path": "change decomposition-plan", + "token": "change_decomposition_plan" + }, { "path": "change new", "token": "change_new" @@ -174,6 +182,8 @@ "change_assess_decomposition", "change_assess_intake", "change_close", + "change_decomposition_apply", + "change_decomposition_plan", "change_new", "change_reallocate", "change_shape", diff --git a/fixtures/cli-completion/bash.golden b/fixtures/cli-completion/bash.golden index edba287..927707a 100644 --- a/fixtures/cli-completion/bash.golden +++ b/fixtures/cli-completion/bash.golden @@ -16,6 +16,8 @@ _runectx_has_command() { 'change assess-decomposition') return 0 ;; 'change assess-intake') return 0 ;; 'change close') return 0 ;; + 'change decomposition-apply') return 0 ;; + 'change decomposition-plan') return 0 ;; 'change new') return 0 ;; 'change reallocate') return 0 ;; 'change shape') return 0 ;; @@ -57,10 +59,12 @@ _runectx_subcommands_for() { 'assurance enable') echo '' ;; 'bundle') echo 'resolve' ;; 'bundle resolve') echo '' ;; - 'change') echo 'assess-decomposition assess-intake close new reallocate shape update' ;; + 'change') echo 'assess-decomposition assess-intake close decomposition-apply decomposition-plan new reallocate shape update' ;; 'change assess-decomposition') echo '' ;; 'change assess-intake') echo '' ;; 'change close') echo '' ;; + 'change decomposition-apply') echo '' ;; + 'change decomposition-plan') echo '' ;; 'change new') echo '' ;; 'change reallocate') echo '' ;; 'change shape') echo '' ;; @@ -105,6 +109,8 @@ _runectx_flags_for() { 'change assess-decomposition') echo '--explain --json --non-interactive --path' ;; 'change assess-intake') echo '--bundle --description --explain --json --non-interactive --path --size --title --type' ;; 'change close') echo '--closed-at --dry-run --explain --json --non-interactive --path --recursive --superseded-by --verification-status' ;; + 'change decomposition-apply') echo '--depends-on --dry-run --explain --json --non-interactive --path --sub-change' ;; + 'change decomposition-plan') echo '--depends-on --explain --json --non-interactive --path --sub-change' ;; 'change new') echo '--bundle --description --dry-run --explain --json --non-interactive --path --shape --size --title --type' ;; 'change reallocate') echo '--dry-run --explain --json --non-interactive --path' ;; 'change shape') echo '--design --dry-run --explain --json --non-interactive --path --reference --task --verification' ;; @@ -147,6 +153,12 @@ _runectx_flag_takes_value() { 'change close|--path') return 0 ;; 'change close|--superseded-by') return 0 ;; 'change close|--verification-status') return 0 ;; + 'change decomposition-apply|--depends-on') return 0 ;; + 'change decomposition-apply|--path') return 0 ;; + 'change decomposition-apply|--sub-change') return 0 ;; + 'change decomposition-plan|--depends-on') return 0 ;; + 'change decomposition-plan|--path') return 0 ;; + 'change decomposition-plan|--sub-change') return 0 ;; 'change new|--bundle') return 0 ;; 'change new|--description') return 0 ;; 'change new|--path') return 0 ;; @@ -212,6 +224,8 @@ _runectx_suggest_provider_for_flag() { case "$1" in 'change assess-intake|--bundle') echo 'bundle-ids' ;; 'change close|--superseded-by') echo 'change-ids' ;; + 'change decomposition-apply|--sub-change') echo 'change-ids' ;; + 'change decomposition-plan|--sub-change') echo 'change-ids' ;; 'change new|--bundle') echo 'bundle-ids' ;; 'change update|--add-related-change') echo 'change-ids' ;; 'change update|--remove-related-change') echo 'change-ids' ;; @@ -242,6 +256,8 @@ _runectx_suggest_provider_for_positional() { 'bundle resolve|1') echo 'bundle-ids' ;; 'change assess-decomposition|1') echo 'change-ids' ;; 'change close|1') echo 'change-ids' ;; + 'change decomposition-apply|1') echo 'change-ids' ;; + 'change decomposition-plan|1') echo 'change-ids' ;; 'change reallocate|1') echo 'change-ids' ;; 'change shape|1') echo 'change-ids' ;; 'change update|1') echo 'change-ids' ;; diff --git a/fixtures/cli-completion/fish.golden b/fixtures/cli-completion/fish.golden index c1c6ea1..ac6dc54 100644 --- a/fixtures/cli-completion/fish.golden +++ b/fixtures/cli-completion/fish.golden @@ -64,6 +64,8 @@ complete -c runectx -f -n '__fish_seen_subcommand_from bundle' -a 'resolve' complete -c runectx -f -n '__fish_seen_subcommand_from change' -a 'assess-decomposition' complete -c runectx -f -n '__fish_seen_subcommand_from change' -a 'assess-intake' complete -c runectx -f -n '__fish_seen_subcommand_from change' -a 'close' +complete -c runectx -f -n '__fish_seen_subcommand_from change' -a 'decomposition-apply' +complete -c runectx -f -n '__fish_seen_subcommand_from change' -a 'decomposition-plan' complete -c runectx -f -n '__fish_seen_subcommand_from change' -a 'new' complete -c runectx -f -n '__fish_seen_subcommand_from change' -a 'reallocate' complete -c runectx -f -n '__fish_seen_subcommand_from change' -a 'shape' @@ -151,6 +153,21 @@ complete -c runectx -n '__fish_seen_subcommand_from change close' -l superseded- complete -c runectx -f -n '(__fish_seen_subcommand_from change close); and __runectx_prev_token_is --superseded-by' -a '(__runectx_dynamic_suggest change-ids)' complete -c runectx -n '__fish_seen_subcommand_from change close' -l verification-status -r complete -c runectx -f -n '(__fish_seen_subcommand_from change close); and __runectx_prev_token_is --verification-status' -a 'failed passed skipped' +complete -c runectx -n '__fish_seen_subcommand_from change decomposition-apply' -l depends-on -r +complete -c runectx -n '__fish_seen_subcommand_from change decomposition-apply' -l dry-run +complete -c runectx -n '__fish_seen_subcommand_from change decomposition-apply' -l explain +complete -c runectx -n '__fish_seen_subcommand_from change decomposition-apply' -l json +complete -c runectx -n '__fish_seen_subcommand_from change decomposition-apply' -l non-interactive +complete -c runectx -n '__fish_seen_subcommand_from change decomposition-apply' -l path -r +complete -c runectx -n '__fish_seen_subcommand_from change decomposition-apply' -l sub-change -r +complete -c runectx -f -n '(__fish_seen_subcommand_from change decomposition-apply); and __runectx_prev_token_is --sub-change' -a '(__runectx_dynamic_suggest change-ids)' +complete -c runectx -n '__fish_seen_subcommand_from change decomposition-plan' -l depends-on -r +complete -c runectx -n '__fish_seen_subcommand_from change decomposition-plan' -l explain +complete -c runectx -n '__fish_seen_subcommand_from change decomposition-plan' -l json +complete -c runectx -n '__fish_seen_subcommand_from change decomposition-plan' -l non-interactive +complete -c runectx -n '__fish_seen_subcommand_from change decomposition-plan' -l path -r +complete -c runectx -n '__fish_seen_subcommand_from change decomposition-plan' -l sub-change -r +complete -c runectx -f -n '(__fish_seen_subcommand_from change decomposition-plan); and __runectx_prev_token_is --sub-change' -a '(__runectx_dynamic_suggest change-ids)' complete -c runectx -n '__fish_seen_subcommand_from change new' -l bundle -r complete -c runectx -f -n '(__fish_seen_subcommand_from change new); and __runectx_prev_token_is --bundle' -a '(__runectx_dynamic_suggest bundle-ids)' complete -c runectx -n '__fish_seen_subcommand_from change new' -l description -r @@ -278,6 +295,8 @@ function __runectx_flag_name_takes_value return 0 case --closed-at return 0 + case --depends-on + return 0 case --description return 0 case --design @@ -308,6 +327,8 @@ function __runectx_flag_name_takes_value return 0 case --status return 0 + case --sub-change + return 0 case --superseded-by return 0 case --target @@ -396,6 +417,8 @@ complete -c runectx -f -n '__fish_seen_subcommand_from adapter render-host-nativ complete -c runectx -f -n '__fish_seen_subcommand_from adapter sync' -a '(__runectx_dynamic_suggest adapter-names)' complete -c runectx -f -n '__fish_seen_subcommand_from change assess-decomposition' -a '(__runectx_dynamic_suggest change-ids)' complete -c runectx -f -n '__fish_seen_subcommand_from change close' -a '(__runectx_dynamic_suggest change-ids)' +complete -c runectx -f -n '__fish_seen_subcommand_from change decomposition-apply' -a '(__runectx_dynamic_suggest change-ids)' +complete -c runectx -f -n '__fish_seen_subcommand_from change decomposition-plan' -a '(__runectx_dynamic_suggest change-ids)' complete -c runectx -f -n '__fish_seen_subcommand_from change reallocate' -a '(__runectx_dynamic_suggest change-ids)' complete -c runectx -f -n '__fish_seen_subcommand_from change shape' -a '(__runectx_dynamic_suggest change-ids)' complete -c runectx -f -n '__fish_seen_subcommand_from change update' -a '(__runectx_dynamic_suggest change-ids)' diff --git a/fixtures/cli-completion/zsh.golden b/fixtures/cli-completion/zsh.golden index 01b8d57..e87824e 100644 --- a/fixtures/cli-completion/zsh.golden +++ b/fixtures/cli-completion/zsh.golden @@ -19,6 +19,8 @@ _runectx_has_command() { 'change assess-decomposition') return 0 ;; 'change assess-intake') return 0 ;; 'change close') return 0 ;; + 'change decomposition-apply') return 0 ;; + 'change decomposition-plan') return 0 ;; 'change new') return 0 ;; 'change reallocate') return 0 ;; 'change shape') return 0 ;; @@ -60,10 +62,12 @@ _runectx_subcommands_for() { 'assurance enable') echo '' ;; 'bundle') echo 'resolve' ;; 'bundle resolve') echo '' ;; - 'change') echo 'assess-decomposition assess-intake close new reallocate shape update' ;; + 'change') echo 'assess-decomposition assess-intake close decomposition-apply decomposition-plan new reallocate shape update' ;; 'change assess-decomposition') echo '' ;; 'change assess-intake') echo '' ;; 'change close') echo '' ;; + 'change decomposition-apply') echo '' ;; + 'change decomposition-plan') echo '' ;; 'change new') echo '' ;; 'change reallocate') echo '' ;; 'change shape') echo '' ;; @@ -108,6 +112,8 @@ _runectx_flags_for() { 'change assess-decomposition') echo '--explain --json --non-interactive --path' ;; 'change assess-intake') echo '--bundle --description --explain --json --non-interactive --path --size --title --type' ;; 'change close') echo '--closed-at --dry-run --explain --json --non-interactive --path --recursive --superseded-by --verification-status' ;; + 'change decomposition-apply') echo '--depends-on --dry-run --explain --json --non-interactive --path --sub-change' ;; + 'change decomposition-plan') echo '--depends-on --explain --json --non-interactive --path --sub-change' ;; 'change new') echo '--bundle --description --dry-run --explain --json --non-interactive --path --shape --size --title --type' ;; 'change reallocate') echo '--dry-run --explain --json --non-interactive --path' ;; 'change shape') echo '--design --dry-run --explain --json --non-interactive --path --reference --task --verification' ;; @@ -150,6 +156,12 @@ _runectx_flag_takes_value() { 'change close|--path') return 0 ;; 'change close|--superseded-by') return 0 ;; 'change close|--verification-status') return 0 ;; + 'change decomposition-apply|--depends-on') return 0 ;; + 'change decomposition-apply|--path') return 0 ;; + 'change decomposition-apply|--sub-change') return 0 ;; + 'change decomposition-plan|--depends-on') return 0 ;; + 'change decomposition-plan|--path') return 0 ;; + 'change decomposition-plan|--sub-change') return 0 ;; 'change new|--bundle') return 0 ;; 'change new|--description') return 0 ;; 'change new|--path') return 0 ;; @@ -215,6 +227,8 @@ _runectx_suggest_provider_for_flag() { case "$1" in 'change assess-intake|--bundle') echo 'bundle-ids' ;; 'change close|--superseded-by') echo 'change-ids' ;; + 'change decomposition-apply|--sub-change') echo 'change-ids' ;; + 'change decomposition-plan|--sub-change') echo 'change-ids' ;; 'change new|--bundle') echo 'bundle-ids' ;; 'change update|--add-related-change') echo 'change-ids' ;; 'change update|--remove-related-change') echo 'change-ids' ;; @@ -245,6 +259,8 @@ _runectx_suggest_provider_for_positional() { 'bundle resolve|1') echo 'bundle-ids' ;; 'change assess-decomposition|1') echo 'change-ids' ;; 'change close|1') echo 'change-ids' ;; + 'change decomposition-apply|1') echo 'change-ids' ;; + 'change decomposition-plan|1') echo 'change-ids' ;; 'change reallocate|1') echo 'change-ids' ;; 'change shape|1') echo 'change-ids' ;; 'change update|1') echo 'change-ids' ;; diff --git a/fixtures/cli-json-golden/metadata-success.json b/fixtures/cli-json-golden/metadata-success.json index a3073b4..86e073e 100644 --- a/fixtures/cli-json-golden/metadata-success.json +++ b/fixtures/cli-json-golden/metadata-success.json @@ -1 +1 @@ -{"assurance":{"baseline_supported":true,"receipt_families":["context-packs","changes","promotions","verifications"],"tiers":["plain","verified"]},"binary":"runectx","canonicalization":{"assurance_artifacts":{"hash_algorithm":"sha256","profile":"runecontext-canonical-json-v1"},"context_pack":{"hash_algorithm":"sha256","profile":"runecontext-canonical-json-v1"}},"capabilities":{"commands":[{"path":"adapter","token":"adapter"},{"path":"adapter render-host-native","token":"adapter_render_host_native"},{"path":"adapter sync","token":"adapter_sync"},{"path":"assurance","token":"assurance"},{"path":"assurance backfill","token":"assurance_backfill"},{"path":"assurance capture","token":"assurance_capture"},{"path":"assurance enable","token":"assurance_enable"},{"path":"bundle","token":"bundle"},{"path":"bundle resolve","token":"bundle_resolve"},{"path":"change","token":"change"},{"path":"change assess-decomposition","token":"change_assess_decomposition"},{"path":"change assess-intake","token":"change_assess_intake"},{"path":"change close","token":"change_close"},{"path":"change new","token":"change_new"},{"path":"change reallocate","token":"change_reallocate"},{"path":"change shape","token":"change_shape"},{"path":"change update","token":"change_update"},{"path":"completion","token":"completion"},{"path":"completion metadata","token":"completion_metadata"},{"path":"completion suggest","token":"completion_suggest"},{"path":"doctor","token":"doctor"},{"path":"generate","token":"generate"},{"path":"generate indexes","token":"generate_indexes"},{"path":"help","token":"help"},{"path":"init","token":"init"},{"path":"metadata","token":"metadata"},{"path":"promote","token":"promote"},{"path":"standard","token":"standard"},{"path":"standard discover","token":"standard_discover"},{"path":"status","token":"status"},{"path":"upgrade","token":"upgrade"},{"path":"upgrade apply","token":"upgrade_apply"},{"path":"upgrade cli","token":"upgrade_cli"},{"path":"upgrade cli apply","token":"upgrade_cli_apply"},{"path":"validate","token":"validate"},{"aliases":["--version","-v"],"path":"version","token":"version"}],"machine_flags":["--dry-run","--explain","--json","--non-interactive"],"value_kinds":["none","text","enum"]},"compatibility":{"default_project_version":"0.1.0-alpha.14","directly_supported_project_versions":["0.1.0-alpha.5","0.1.0-alpha.6","0.1.0-alpha.7","0.1.0-alpha.8","0.1.0-alpha.14"],"explicit_upgrade_edges":[{"from":"0.1.0-alpha.12","to":"0.1.0-alpha.13"}],"upgradeable_from_project_versions":["0.1.0-alpha.12","0.1.0-alpha.13"]},"distribution_layouts":[{"adapters_path":"adapters","profile":"repo_bundle","schema_path":"schemas"},{"adapters_path":"share/runecontext/adapters","profile":"installed_share_layout","schema_path":"share/runecontext/schemas"}],"features":["signed_tag_verification","mutable_ref_opt_in","monorepo_nearest_root_discovery","context_pack_capture","verified_assurance","completion_registry","completion_dynamic_suggestions","generated_indexes","promotion_workflow","upgrade_planning","staged_upgrade_execution","mixed_or_stale_tree_detection"],"project_profiles":[{"assurance_path":"runecontext/assurance","content_root":"runecontext","indexes_root":"runecontext/indexes","manifest_path":"runecontext/manifest.yaml","profile":"portable_project_root","root_config":"runecontext.yaml"}],"release":{"package_name":"runecontext","tag":"v0.1.0-alpha.14","version":"0.1.0-alpha.14"},"resolution":{"source_modes":["embedded","git","path"],"verification_postures":["embedded","pinned_commit","verified_signed_tag","unverified_mutable_ref","unverified_local_source"]},"schema_version":2} \ No newline at end of file +{"assurance":{"baseline_supported":true,"receipt_families":["context-packs","changes","promotions","verifications"],"tiers":["plain","verified"]},"binary":"runectx","canonicalization":{"assurance_artifacts":{"hash_algorithm":"sha256","profile":"runecontext-canonical-json-v1"},"context_pack":{"hash_algorithm":"sha256","profile":"runecontext-canonical-json-v1"}},"capabilities":{"commands":[{"path":"adapter","token":"adapter"},{"path":"adapter render-host-native","token":"adapter_render_host_native"},{"path":"adapter sync","token":"adapter_sync"},{"path":"assurance","token":"assurance"},{"path":"assurance backfill","token":"assurance_backfill"},{"path":"assurance capture","token":"assurance_capture"},{"path":"assurance enable","token":"assurance_enable"},{"path":"bundle","token":"bundle"},{"path":"bundle resolve","token":"bundle_resolve"},{"path":"change","token":"change"},{"path":"change assess-decomposition","token":"change_assess_decomposition"},{"path":"change assess-intake","token":"change_assess_intake"},{"path":"change close","token":"change_close"},{"path":"change decomposition-apply","token":"change_decomposition_apply"},{"path":"change decomposition-plan","token":"change_decomposition_plan"},{"path":"change new","token":"change_new"},{"path":"change reallocate","token":"change_reallocate"},{"path":"change shape","token":"change_shape"},{"path":"change update","token":"change_update"},{"path":"completion","token":"completion"},{"path":"completion metadata","token":"completion_metadata"},{"path":"completion suggest","token":"completion_suggest"},{"path":"doctor","token":"doctor"},{"path":"generate","token":"generate"},{"path":"generate indexes","token":"generate_indexes"},{"path":"help","token":"help"},{"path":"init","token":"init"},{"path":"metadata","token":"metadata"},{"path":"promote","token":"promote"},{"path":"standard","token":"standard"},{"path":"standard discover","token":"standard_discover"},{"path":"status","token":"status"},{"path":"upgrade","token":"upgrade"},{"path":"upgrade apply","token":"upgrade_apply"},{"path":"upgrade cli","token":"upgrade_cli"},{"path":"upgrade cli apply","token":"upgrade_cli_apply"},{"path":"validate","token":"validate"},{"aliases":["--version","-v"],"path":"version","token":"version"}],"machine_flags":["--dry-run","--explain","--json","--non-interactive"],"value_kinds":["none","text","enum"]},"compatibility":{"default_project_version":"0.1.0-alpha.14","directly_supported_project_versions":["0.1.0-alpha.5","0.1.0-alpha.6","0.1.0-alpha.7","0.1.0-alpha.8","0.1.0-alpha.14"],"explicit_upgrade_edges":[{"from":"0.1.0-alpha.12","to":"0.1.0-alpha.13"}],"upgradeable_from_project_versions":["0.1.0-alpha.12","0.1.0-alpha.13"]},"distribution_layouts":[{"adapters_path":"adapters","profile":"repo_bundle","schema_path":"schemas"},{"adapters_path":"share/runecontext/adapters","profile":"installed_share_layout","schema_path":"share/runecontext/schemas"}],"features":["signed_tag_verification","mutable_ref_opt_in","monorepo_nearest_root_discovery","context_pack_capture","verified_assurance","completion_registry","completion_dynamic_suggestions","generated_indexes","promotion_workflow","upgrade_planning","staged_upgrade_execution","mixed_or_stale_tree_detection"],"project_profiles":[{"assurance_path":"runecontext/assurance","content_root":"runecontext","indexes_root":"runecontext/indexes","manifest_path":"runecontext/manifest.yaml","profile":"portable_project_root","root_config":"runecontext.yaml"}],"release":{"package_name":"runecontext","tag":"v0.1.0-alpha.14","version":"0.1.0-alpha.14"},"resolution":{"source_modes":["embedded","git","path"],"verification_postures":["embedded","pinned_commit","verified_signed_tag","unverified_mutable_ref","unverified_local_source"]},"schema_version":2} \ No newline at end of file diff --git a/fixtures/release/release-manifest-with-metadata.json b/fixtures/release/release-manifest-with-metadata.json index 36fff08..96d0e08 100644 --- a/fixtures/release/release-manifest-with-metadata.json +++ b/fixtures/release/release-manifest-with-metadata.json @@ -79,6 +79,14 @@ "path": "change close", "token": "change_close" }, + { + "path": "change decomposition-apply", + "token": "change_decomposition_apply" + }, + { + "path": "change decomposition-plan", + "token": "change_decomposition_plan" + }, { "path": "change new", "token": "change_new" diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 0eca83f..db15dc8 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -15,7 +15,7 @@ const ( const ( validateUsage = "runectx validate [--json] [--non-interactive] [--explain] [--ssh-allowed-signers PATH] [--path PATH] [path]" statusUsage = "runectx status [--json] [--non-interactive] [--explain] [--path PATH] [path] (human output only: --history recent|all|none --history-limit N --verbose)" - changeUsage = "runectx change [--json] [--non-interactive] [--dry-run] [--explain] <new|shape|close|reallocate|update|assess-intake|assess-decomposition> ..." + changeUsage = "runectx change [--json] [--non-interactive] [--dry-run] [--explain] <new|shape|close|reallocate|update|assess-intake|assess-decomposition|decomposition-plan|decomposition-apply> ..." generateUsage = "runectx generate [--json] [--non-interactive] [--explain] <indexes>" generateIndexesUsage = "runectx generate indexes [--json] [--non-interactive] [--explain] [--path PATH] [path]" bundleUsage = "runectx bundle [--json] [--non-interactive] [--explain] <resolve>" @@ -28,6 +28,8 @@ const ( changeUpdateUsage = "runectx change update [--json] [--non-interactive] [--dry-run] [--explain] CHANGE_ID --status planned|implemented|verified [--verification-status passed|failed|skipped] [--add-related-change ID] [--remove-related-change ID] [--recursive] [--path PATH]" changeAssessIntakeUsage = "runectx change assess-intake [--json] [--non-interactive] [--explain] --title TITLE --type TYPE [--size SIZE] [--bundle ID] [--description TEXT] [--path PATH]" changeAssessDecompUsage = "runectx change assess-decomposition [--json] [--non-interactive] [--explain] CHANGE_ID [--path PATH]" + changeDecompPlanUsage = "runectx change decomposition-plan [--json] [--non-interactive] [--explain] UMBRELLA_CHANGE_ID --sub-change ID [--sub-change ID ...] [--depends-on SUB_CHANGE_ID:CHANGE_ID ...] [--path PATH]" + changeDecompApplyUsage = "runectx change decomposition-apply [--json] [--non-interactive] [--dry-run] [--explain] UMBRELLA_CHANGE_ID --sub-change ID [--sub-change ID ...] [--depends-on SUB_CHANGE_ID:CHANGE_ID ...] [--path PATH]" initUsage = "runectx init [--json] [--non-interactive] [--dry-run] [--explain] [--mode embedded|linked] [--seed-bundle NAME] [--path PATH]" promoteUsage = "runectx promote [--json] [--non-interactive] [--dry-run] [--explain] CHANGE_ID [--accept | --complete] [--target TYPE:PATH (summary auto-filled per target type)] [--path PATH]" standardUsage = "runectx standard [--json] [--non-interactive] [--explain] <discover>" @@ -200,6 +202,7 @@ func rootHelpExamples() []string { "runectx help", "runectx status --path /path/to/project", "runectx change update CHANGE_ID --status verified --verification-status passed", + "runectx change decomposition-apply UMBRELLA_CHANGE_ID --sub-change CHANGE_ID", "runectx change close CHANGE_ID --verification-status passed --closed-at YYYY-MM-DD", "runectx completion metadata", } diff --git a/internal/cli/cli_adapter_pack_test.go b/internal/cli/cli_adapter_pack_test.go index a5b6938..01f0daa 100644 --- a/internal/cli/cli_adapter_pack_test.go +++ b/internal/cli/cli_adapter_pack_test.go @@ -147,7 +147,7 @@ func assertHostCapabilitiesSection(t *testing.T, text, path string) { func assertFlowMappings(t *testing.T, text, path string) { t.Helper() - for _, flow := range []string{"change new", "change assess-intake", "change assess-decomposition", "change shape", "standard discover", "promote"} { + for _, flow := range []string{"change new", "change assess-intake", "change assess-decomposition", "change decomposition-plan", "change decomposition-apply", "change shape", "standard discover", "promote"} { if !strings.Contains(text, flow) { t.Fatalf("%s missing conversational flow mapping for %q", path, flow) } @@ -165,7 +165,7 @@ func assertStandardDiscoverMappings(t *testing.T, text, path string) { func checkAdapterFlowPlaybooks(t *testing.T, base, name string) { t.Helper() - required := []string{"change-new.md", "change-assess-intake.md", "change-assess-decomposition.md", "change-shape.md", "standard-discover.md", "promote.md"} + required := []string{"change-new.md", "change-assess-intake.md", "change-assess-decomposition.md", "change-decomposition-plan.md", "change-decomposition-apply.md", "change-shape.md", "standard-discover.md", "promote.md"} for _, file := range required { path := filepath.Join(base, "flows", file) content, err := os.ReadFile(path) diff --git a/internal/cli/cli_adapter_render_host_native_markdown.go b/internal/cli/cli_adapter_render_host_native_markdown.go index 3fae727..8fc70be 100644 --- a/internal/cli/cli_adapter_render_host_native_markdown.go +++ b/internal/cli/cli_adapter_render_host_native_markdown.go @@ -99,6 +99,10 @@ func commandPathFromFlowID(flowID string) string { suffix := strings.TrimPrefix(flowID, "change-assess-") return "change assess-" + suffix } + if strings.HasPrefix(flowID, "change-decomposition-") { + suffix := strings.TrimPrefix(flowID, "change-decomposition-") + return "change decomposition-" + suffix + } return strings.ReplaceAll(flowID, "-", " ") } diff --git a/internal/cli/cli_adapter_render_test.go b/internal/cli/cli_adapter_render_test.go index 52c0403..553fe61 100644 --- a/internal/cli/cli_adapter_render_test.go +++ b/internal/cli/cli_adapter_render_test.go @@ -43,7 +43,7 @@ func TestRunAdapterRenderHostNativeOutputsMinimalMarkdown(t *testing.T) { } func TestRunAdapterRenderHostNativeSupportsAssessmentOperations(t *testing.T) { - for _, operation := range []string{"change-assess-intake", "change-assess-decomposition"} { + for _, operation := range []string{"change-assess-intake", "change-assess-decomposition", "change-decomposition-plan", "change-decomposition-apply"} { t.Run(operation, func(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer @@ -55,7 +55,7 @@ func TestRunAdapterRenderHostNativeSupportsAssessmentOperations(t *testing.T) { if !strings.Contains(text, "operation_identifier: `runecontext:"+operation+"`") { t.Fatalf("expected operation identifier for %s, got %q", operation, text) } - expectedPath := "change assess-" + strings.TrimPrefix(operation, "change-assess-") + expectedPath := commandPathFromFlowID(operation) if !strings.Contains(text, "command_path: `"+expectedPath+"`") { t.Fatalf("expected command path for %s, got %q", operation, text) } @@ -83,6 +83,12 @@ func TestRunAdapterRenderHostNativeIndexForClaude(t *testing.T) { if !strings.Contains(text, "operation: `runecontext:change-assess-decomposition`") { t.Fatalf("expected indexed change-assess-decomposition operation, got %q", text) } + if !strings.Contains(text, "operation: `runecontext:change-decomposition-plan`") { + t.Fatalf("expected indexed change-decomposition-plan operation, got %q", text) + } + if !strings.Contains(text, "operation: `runecontext:change-decomposition-apply`") { + t.Fatalf("expected indexed change-decomposition-apply operation, got %q", text) + } } func TestRunAdapterRenderHostNativeRejectsUnsupportedTool(t *testing.T) { diff --git a/internal/cli/cli_adapter_sync_host_native.go b/internal/cli/cli_adapter_sync_host_native.go index 57c06ea..b5d3af1 100644 --- a/internal/cli/cli_adapter_sync_host_native.go +++ b/internal/cli/cli_adapter_sync_host_native.go @@ -116,47 +116,6 @@ func sortHostNativeArtifacts(artifacts []hostNativeArtifact) { sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].relPath < artifacts[j].relPath }) } -func toolFlowMappings(tool string) []hostNativeFlow { - return []hostNativeFlow{ - { - id: "change-new", - name: "change new", - description: "Create a new RuneContext change", - source: "adapters/" + tool + "/flows/change-new.md", - }, - { - id: "change-assess-intake", - name: "change assess-intake", - description: "Assess intake readiness and advisory shaping signals", - source: "adapters/" + tool + "/flows/change-assess-intake.md", - }, - { - id: "change-assess-decomposition", - name: "change assess-decomposition", - description: "Assess decomposition and umbrella/sub-change signals", - source: "adapters/" + tool + "/flows/change-assess-decomposition.md", - }, - { - id: "change-shape", - name: "change shape", - description: "Shape an existing RuneContext change", - source: "adapters/" + tool + "/flows/change-shape.md", - }, - { - id: "standard-discover", - name: "standard discover", - description: "Discover standards candidates for promotion", - source: "adapters/" + tool + "/flows/standard-discover.md", - }, - { - id: "promote", - name: "promote", - description: "Advance RuneContext promotion state", - source: "adapters/" + tool + "/flows/promote.md", - }, - } -} - func buildHostNativeFlowAssetContent(tool string, flow hostNativeFlow) []byte { body := buildHostNativeBody(tool, flow.id, hostNativeKindFlowAsset) lines := append(hostNativeFrontmatter(tool, flow, hostNativeKindFlowAsset), []string{ diff --git a/internal/cli/cli_adapter_sync_host_native_flows.go b/internal/cli/cli_adapter_sync_host_native_flows.go new file mode 100644 index 0000000..70c5b40 --- /dev/null +++ b/internal/cli/cli_adapter_sync_host_native_flows.go @@ -0,0 +1,23 @@ +package cli + +func toolFlowMappings(tool string) []hostNativeFlow { + return []hostNativeFlow{ + flowForTool(tool, "change-new", "change new", "Create a new RuneContext change"), + flowForTool(tool, "change-assess-intake", "change assess-intake", "Assess intake readiness and advisory shaping signals"), + flowForTool(tool, "change-assess-decomposition", "change assess-decomposition", "Assess decomposition and umbrella/sub-change signals"), + flowForTool(tool, "change-decomposition-plan", "change decomposition-plan", "Plan umbrella and sub-change graph rewrites"), + flowForTool(tool, "change-decomposition-apply", "change decomposition-apply", "Apply umbrella and sub-change graph rewrites"), + flowForTool(tool, "change-shape", "change shape", "Shape an existing RuneContext change"), + flowForTool(tool, "standard-discover", "standard discover", "Discover standards candidates for promotion"), + flowForTool(tool, "promote", "promote", "Advance RuneContext promotion state"), + } +} + +func flowForTool(tool, id, name, description string) hostNativeFlow { + return hostNativeFlow{ + id: id, + name: name, + description: description, + source: "adapters/" + tool + "/flows/" + id + ".md", + } +} diff --git a/internal/cli/cli_adapter_test.go b/internal/cli/cli_adapter_test.go index 1cda3e7..7dce128 100644 --- a/internal/cli/cli_adapter_test.go +++ b/internal/cli/cli_adapter_test.go @@ -2,7 +2,6 @@ package cli import ( "bytes" - "fmt" "os" "os/exec" "path/filepath" @@ -118,8 +117,15 @@ func TestRunAdapterSyncAppliesHostNativeFiles(t *testing.T) { func TestRunAdapterSyncWritesHostNativeArtifactsByTool(t *testing.T) { projectRoot := t.TempDir() + assertOpenCodeHostNativeArtifacts(t, projectRoot) + assertClaudeCodeHostNativeArtifacts(t, projectRoot) + assertCodexHostNativeArtifacts(t, projectRoot) +} + +func assertOpenCodeHostNativeArtifacts(t *testing.T, projectRoot string) { + t.Helper() opencode := runAdapterSyncAndParse(t, projectRoot, "opencode") - if got, want := opencode["host_native_file_count"], "12"; got != want { + if got, want := opencode["host_native_file_count"], "16"; got != want { t.Fatalf("expected opencode host_native_file_count %q, got %q", want, got) } assertShellInjectionCallPresent(t, filepath.Join(projectRoot, ".opencode", "skills", "runecontext-change-new.md"), "runectx adapter render-host-native --role flow_asset opencode change-new") @@ -127,32 +133,46 @@ func TestRunAdapterSyncWritesHostNativeArtifactsByTool(t *testing.T) { assertShellInjectionCallPresent(t, filepath.Join(projectRoot, ".opencode", "commands", "runecontext-change-assess-intake.md"), "runectx adapter render-host-native --role discoverability_shim opencode change-assess-intake") assertShellInjectionCallPresent(t, filepath.Join(projectRoot, ".opencode", "skills", "runecontext-change-assess-decomposition.md"), "runectx adapter render-host-native --role flow_asset opencode change-assess-decomposition") assertShellInjectionCallPresent(t, filepath.Join(projectRoot, ".opencode", "commands", "runecontext-change-assess-decomposition.md"), "runectx adapter render-host-native --role discoverability_shim opencode change-assess-decomposition") + assertShellInjectionCallPresent(t, filepath.Join(projectRoot, ".opencode", "skills", "runecontext-change-decomposition-plan.md"), "runectx adapter render-host-native --role flow_asset opencode change-decomposition-plan") + assertShellInjectionCallPresent(t, filepath.Join(projectRoot, ".opencode", "commands", "runecontext-change-decomposition-plan.md"), "runectx adapter render-host-native --role discoverability_shim opencode change-decomposition-plan") + assertShellInjectionCallPresent(t, filepath.Join(projectRoot, ".opencode", "skills", "runecontext-change-decomposition-apply.md"), "runectx adapter render-host-native --role flow_asset opencode change-decomposition-apply") + assertShellInjectionCallPresent(t, filepath.Join(projectRoot, ".opencode", "commands", "runecontext-change-decomposition-apply.md"), "runectx adapter render-host-native --role discoverability_shim opencode change-decomposition-apply") assertShellInjectionCallPresent(t, filepath.Join(projectRoot, ".opencode", "commands", "runecontext-change-new.md"), "runectx adapter render-host-native --role discoverability_shim opencode change-new") assertFrontmatterContains(t, filepath.Join(projectRoot, ".opencode", "commands", "runecontext-change-new.md"), "description: Create a new RuneContext change") assertManagedArtifactMarker(t, filepath.Join(projectRoot, ".opencode", "skills", "runecontext-change-new.md")) assertManagedArtifactMarker(t, filepath.Join(projectRoot, ".opencode", "commands", "runecontext-change-new.md")) +} +func assertClaudeCodeHostNativeArtifacts(t *testing.T, projectRoot string) { + t.Helper() claude := runAdapterSyncAndParse(t, projectRoot, "claude-code") - if got, want := claude["host_native_file_count"], "7"; got != want { + if got, want := claude["host_native_file_count"], "9"; got != want { t.Fatalf("expected claude host_native_file_count %q, got %q", want, got) } assertShellInjectionCallPresent(t, filepath.Join(projectRoot, ".claude", "skills", "runecontext-change-new.md"), "runectx adapter render-host-native --role flow_asset claude-code change-new") assertShellInjectionCallPresent(t, filepath.Join(projectRoot, ".claude", "skills", "runecontext-change-assess-intake.md"), "runectx adapter render-host-native --role flow_asset claude-code change-assess-intake") assertShellInjectionCallPresent(t, filepath.Join(projectRoot, ".claude", "skills", "runecontext-change-assess-decomposition.md"), "runectx adapter render-host-native --role flow_asset claude-code change-assess-decomposition") + assertShellInjectionCallPresent(t, filepath.Join(projectRoot, ".claude", "skills", "runecontext-change-decomposition-plan.md"), "runectx adapter render-host-native --role flow_asset claude-code change-decomposition-plan") + assertShellInjectionCallPresent(t, filepath.Join(projectRoot, ".claude", "skills", "runecontext-change-decomposition-apply.md"), "runectx adapter render-host-native --role flow_asset claude-code change-decomposition-apply") assertShellInjectionCallPresent(t, filepath.Join(projectRoot, ".claude", "commands", "runecontext.md"), "runectx adapter render-host-native --role discoverability_shim claude-code index") assertFrontmatterContains(t, filepath.Join(projectRoot, ".claude", "skills", "runecontext-change-new.md"), "name: runecontext-change-new") assertFrontmatterContains(t, filepath.Join(projectRoot, ".claude", "skills", "runecontext-change-new.md"), "description: Create a new RuneContext change") assertFrontmatterContains(t, filepath.Join(projectRoot, ".claude", "commands", "runecontext.md"), "name: runecontext") assertManagedArtifactMarker(t, filepath.Join(projectRoot, ".claude", "skills", "runecontext-change-new.md")) assertManagedArtifactMarker(t, filepath.Join(projectRoot, ".claude", "commands", "runecontext.md")) +} +func assertCodexHostNativeArtifacts(t *testing.T, projectRoot string) { + t.Helper() codex := runAdapterSyncAndParse(t, projectRoot, "codex") - if got, want := codex["host_native_file_count"], "6"; got != want { + if got, want := codex["host_native_file_count"], "8"; got != want { t.Fatalf("expected codex host_native_file_count %q, got %q", want, got) } assertFrontmatterContains(t, filepath.Join(projectRoot, ".agents", "skills", "runecontext-change-new.md"), "name: runecontext-change-new") assertFrontmatterContains(t, filepath.Join(projectRoot, ".agents", "skills", "runecontext-change-assess-intake.md"), "name: runecontext-change-assess-intake") assertFrontmatterContains(t, filepath.Join(projectRoot, ".agents", "skills", "runecontext-change-assess-decomposition.md"), "name: runecontext-change-assess-decomposition") + assertFrontmatterContains(t, filepath.Join(projectRoot, ".agents", "skills", "runecontext-change-decomposition-plan.md"), "name: runecontext-change-decomposition-plan") + assertFrontmatterContains(t, filepath.Join(projectRoot, ".agents", "skills", "runecontext-change-decomposition-apply.md"), "name: runecontext-change-decomposition-apply") assertFrontmatterContains(t, filepath.Join(projectRoot, ".agents", "skills", "runecontext-change-new.md"), "description: Create a new RuneContext change") assertNoShellInjectionCall(t, filepath.Join(projectRoot, ".agents", "skills", "runecontext-change-new.md")) assertManagedArtifactMarker(t, filepath.Join(projectRoot, ".agents", "skills", "runecontext-change-new.md")) @@ -276,7 +296,7 @@ func assertAdapterSyncWritesExpectedFilePermissions(t *testing.T, adaptersRoot s t.Fatalf("expected synced host-native file to contain render-host-native call") } if runtime.GOOS != "windows" && syncedMode.Perm() != 0o644 { - t.Fatalf("expected synced host-native file permissions 0644, got %s", fmt.Sprintf("%#o", syncedMode.Perm())) + t.Fatalf("expected synced host-native file permissions 0644, got %#o", syncedMode.Perm()) } } @@ -344,102 +364,6 @@ func TestRunAdapterSyncRejectsSymlinkedHostNativeAncestor(t *testing.T) { } } -func statMode(t *testing.T, path string) os.FileMode { - t.Helper() - info, err := os.Stat(path) - if err != nil { - t.Fatalf("stat %s: %v", path, err) - } - return info.Mode() -} - -func createUserOwnedConfig(t *testing.T, projectRoot string) string { - t.Helper() - userConfigPath := filepath.Join(projectRoot, ".opencode", "config.yml") - if err := os.MkdirAll(filepath.Dir(userConfigPath), 0o755); err != nil { - t.Fatalf("mkdir user config dir: %v", err) - } - if err := os.WriteFile(userConfigPath, []byte("user_owned: true\n"), 0o644); err != nil { - t.Fatalf("write user config file: %v", err) - } - return userConfigPath -} - -func assertUserOwnedConfigPreserved(t *testing.T, userConfigPath string) { - t.Helper() - userData, err := os.ReadFile(userConfigPath) - if err != nil { - t.Fatalf("read user config after sync: %v", err) - } - if string(userData) != "user_owned: true\n" { - t.Fatalf("expected user config boundary to be preserved, got %q", string(userData)) - } -} - -func assertAdapterSyncBoundaries(t *testing.T, userConfigPath, projectRoot string) { - t.Helper() - assertUserOwnedConfigPreserved(t, userConfigPath) - assertNoAdapterTrackingTree(t, projectRoot) - if _, err := os.Stat(filepath.Join(projectRoot, "adapters")); !os.IsNotExist(err) { - t.Fatalf("expected sync to avoid user-owned adapter source tree writes, got err=%v", err) - } -} - -func assertNoAdapterTrackingTree(t *testing.T, projectRoot string) { - t.Helper() - if _, err := os.Stat(filepath.Join(projectRoot, ".runecontext", "adapters")); !os.IsNotExist(err) { - t.Fatalf("expected no adapter tracking tree under .runecontext, got err=%v", err) - } -} - -func assertManagedArtifactMarker(t *testing.T, path string) { - t.Helper() - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read managed artifact %s: %v", path, err) - } - if _, ok := parseHostNativeOwnershipHeader(data); !ok { - t.Fatalf("expected ownership marker in %s", path) - } -} - -func assertShellInjectionCallPresent(t *testing.T, path, call string) { - t.Helper() - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read host-native artifact %s: %v", path, err) - } - token := "!`" + call + "`" - if !strings.Contains(string(data), token) { - t.Fatalf("expected shell-injection token %q in %s", token, path) - } -} - -func assertNoShellInjectionCall(t *testing.T, path string) { - t.Helper() - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read host-native artifact %s: %v", path, err) - } - if strings.Contains(string(data), "!`") { - t.Fatalf("expected no shell injection token in %s", path) - } -} - -func assertFrontmatterContains(t *testing.T, path, token string) { - t.Helper() - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read host-native artifact %s: %v", path, err) - } - if !strings.HasPrefix(string(data), "---\n") { - t.Fatalf("expected frontmatter prefix in %s", path) - } - if !strings.Contains(string(data), token) { - t.Fatalf("expected frontmatter token %q in %s", token, path) - } -} - func TestRunAdapterSyncUnknownToolFails(t *testing.T) { projectRoot := t.TempDir() var stdout bytes.Buffer @@ -492,55 +416,3 @@ func TestValidateAfterAuthoritativeEditScriptBoundaries(t *testing.T) { } }) } - -type validateHookBoundaryTest struct { - scriptPath string - projectRoot string - fakeBin string - calledPath string -} - -func prepareValidateHookBoundaryTest(t *testing.T) validateHookBoundaryTest { - t.Helper() - repoRoot, err := repoRootForTests() - if err != nil { - t.Fatal(err) - } - test := validateHookBoundaryTest{ - scriptPath: filepath.Join(repoRoot, "adapters", "opencode", "automation", "validate_after_authoritative_edit.sh"), - projectRoot: prepareCLIWorkflowProject(t), - fakeBin: t.TempDir(), - } - test.calledPath = filepath.Join(test.projectRoot, "validate-called") - writeFakeRunectxExecutable(t, filepath.Join(test.fakeBin, "runectx")) - return test -} - -func writeFakeRunectxExecutable(t *testing.T, path string) { - t.Helper() - stub := "#!/usr/bin/env bash\nset -euo pipefail\nprintf '%s\\n' \"$*\" > \"$RUNECTX_ARGS_OUT\"\n" - if err := os.WriteFile(path, []byte(stub), 0o755); err != nil { - t.Fatalf("write fake runectx: %v", err) - } -} - -func runValidateHookScript(test validateHookBoundaryTest, changedPath string) (string, error) { - _ = os.Remove(test.calledPath) - cmd := exec.Command("bash", test.scriptPath, changedPath) - cmd.Dir = test.projectRoot - cmd.Env = append(os.Environ(), - "PATH="+test.fakeBin+":"+os.Getenv("PATH"), - "RUNECTX_ARGS_OUT="+test.calledPath, - ) - if out, err := cmd.CombinedOutput(); err != nil { - return "", fmt.Errorf("script failed: %w\n%s", err, string(out)) - } - called, err := os.ReadFile(test.calledPath) - if err != nil { - if os.IsNotExist(err) { - return "", nil - } - return "", err - } - return string(called), nil -} diff --git a/internal/cli/cli_adapter_test_helpers_test.go b/internal/cli/cli_adapter_test_helpers_test.go new file mode 100644 index 0000000..6076d5f --- /dev/null +++ b/internal/cli/cli_adapter_test_helpers_test.go @@ -0,0 +1,158 @@ +package cli + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func statMode(t *testing.T, path string) os.FileMode { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat %s: %v", path, err) + } + return info.Mode() +} + +func createUserOwnedConfig(t *testing.T, projectRoot string) string { + t.Helper() + userConfigPath := filepath.Join(projectRoot, ".opencode", "config.yml") + if err := os.MkdirAll(filepath.Dir(userConfigPath), 0o755); err != nil { + t.Fatalf("mkdir user config dir: %v", err) + } + if err := os.WriteFile(userConfigPath, []byte("user_owned: true\n"), 0o644); err != nil { + t.Fatalf("write user config file: %v", err) + } + return userConfigPath +} + +func assertUserOwnedConfigPreserved(t *testing.T, userConfigPath string) { + t.Helper() + userData, err := os.ReadFile(userConfigPath) + if err != nil { + t.Fatalf("read user config after sync: %v", err) + } + if string(userData) != "user_owned: true\n" { + t.Fatalf("expected user config boundary to be preserved, got %q", string(userData)) + } +} + +func assertAdapterSyncBoundaries(t *testing.T, userConfigPath, projectRoot string) { + t.Helper() + assertUserOwnedConfigPreserved(t, userConfigPath) + assertNoAdapterTrackingTree(t, projectRoot) + if _, err := os.Stat(filepath.Join(projectRoot, "adapters")); !os.IsNotExist(err) { + t.Fatalf("expected sync to avoid user-owned adapter source tree writes, got err=%v", err) + } +} + +func assertNoAdapterTrackingTree(t *testing.T, projectRoot string) { + t.Helper() + if _, err := os.Stat(filepath.Join(projectRoot, ".runecontext", "adapters")); !os.IsNotExist(err) { + t.Fatalf("expected no adapter tracking tree under .runecontext, got err=%v", err) + } +} + +func assertManagedArtifactMarker(t *testing.T, path string) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read managed artifact %s: %v", path, err) + } + if _, ok := parseHostNativeOwnershipHeader(data); !ok { + t.Fatalf("expected ownership marker in %s", path) + } +} + +func assertShellInjectionCallPresent(t *testing.T, path, call string) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read host-native artifact %s: %v", path, err) + } + token := "!`" + call + "`" + if !strings.Contains(string(data), token) { + t.Fatalf("expected shell-injection token %q in %s", token, path) + } +} + +func assertNoShellInjectionCall(t *testing.T, path string) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read host-native artifact %s: %v", path, err) + } + if strings.Contains(string(data), "!`") { + t.Fatalf("expected no shell injection token in %s", path) + } +} + +func assertFrontmatterContains(t *testing.T, path, token string) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read host-native artifact %s: %v", path, err) + } + if !strings.HasPrefix(string(data), "---\n") { + t.Fatalf("expected frontmatter prefix in %s", path) + } + if !strings.Contains(string(data), token) { + t.Fatalf("expected frontmatter token %q in %s", token, path) + } +} + +type validateHookBoundaryTest struct { + scriptPath string + projectRoot string + fakeBin string + calledPath string +} + +func prepareValidateHookBoundaryTest(t *testing.T) validateHookBoundaryTest { + t.Helper() + repoRoot, err := repoRootForTests() + if err != nil { + t.Fatal(err) + } + test := validateHookBoundaryTest{ + scriptPath: filepath.Join(repoRoot, "adapters", "opencode", "automation", "validate_after_authoritative_edit.sh"), + projectRoot: prepareCLIWorkflowProject(t), + fakeBin: t.TempDir(), + } + test.calledPath = filepath.Join(test.projectRoot, "validate-called") + writeFakeRunectxExecutable(t, filepath.Join(test.fakeBin, "runectx")) + return test +} + +func writeFakeRunectxExecutable(t *testing.T, path string) { + t.Helper() + stub := "#!/usr/bin/env bash\nset -euo pipefail\nprintf '%s\\n' \"$*\" > \"$RUNECTX_ARGS_OUT\"\n" + if err := os.WriteFile(path, []byte(stub), 0o755); err != nil { + t.Fatalf("write fake runectx: %v", err) + } +} + +func runValidateHookScript(test validateHookBoundaryTest, changedPath string) (string, error) { + _ = os.Remove(test.calledPath) + cmd := exec.Command("bash", test.scriptPath, changedPath) + cmd.Dir = test.projectRoot + cmd.Env = append(os.Environ(), + "PATH="+test.fakeBin+":"+os.Getenv("PATH"), + "RUNECTX_ARGS_OUT="+test.calledPath, + ) + if out, err := cmd.CombinedOutput(); err != nil { + return "", fmt.Errorf("script failed: %w\n%s", err, string(out)) + } + called, err := os.ReadFile(test.calledPath) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + return string(called), nil +} diff --git a/internal/cli/cli_change.go b/internal/cli/cli_change.go index abbf6ed..a3df2b5 100644 --- a/internal/cli/cli_change.go +++ b/internal/cli/cli_change.go @@ -25,7 +25,10 @@ func runChange(args []string, stdout, stderr io.Writer) int { emitOutput(stdout, machine, appendMachineOptionLines([]line{{"result", "ok"}, {"command", "change"}, {"usage", changeUsage}}, machine), exitOK, failureClassNone) return exitOK } + return runChangeSubcommand(remaining, machine, stdout, stderr) +} +func runChangeSubcommand(remaining []string, machine machineOptions, stdout, stderr io.Writer) int { switch remaining[0] { case "new": return runChangeNew(remaining[1:], machine, stdout, stderr) @@ -41,6 +44,10 @@ func runChange(args []string, stdout, stderr io.Writer) int { return runChangeAssessIntake(remaining[1:], machine, stdout, stderr) case "assess-decomposition": return runChangeAssessDecomposition(remaining[1:], machine, stdout, stderr) + case "decomposition-plan": + return runChangeDecompositionPlan(remaining[1:], machine, stdout, stderr) + case "decomposition-apply": + return runChangeDecompositionApply(remaining[1:], machine, stdout, stderr) default: emitOutput(stderr, machine, appendMachineOptionLines(buildCommandUsageErrorLines("change", changeUsage, fmt.Errorf("unknown change subcommand %q", remaining[0])), machine), exitUsage, failureClassUsage) return exitUsage @@ -197,67 +204,3 @@ func runChangeUpdate(args []string, machine machineOptions, stdout, stderr io.Wr emitOutput(stdout, machine, appendMachineOptionLines(output, machine), exitOK, failureClassNone) return exitOK } - -func runChangeAssessIntake(args []string, machine machineOptions, stdout, stderr io.Writer) int { - if machine.dryRun { - emitOutput(stderr, machine, appendMachineOptionLines(buildCommandUsageErrorLines("change_assess_intake", changeAssessIntakeUsage, fmt.Errorf("--dry-run is not supported for advisory change assess-intake")), machine), exitUsage, failureClassUsage) - return exitUsage - } - request, err := parseChangeAssessIntakeArgs(args) - if err != nil { - emitOutput(stderr, machine, appendMachineOptionLines(buildCommandUsageErrorLines("change_assess_intake", changeAssessIntakeUsage, err), machine), exitUsage, failureClassUsage) - return exitUsage - } - project, code := loadProjectOrReport(request.root, request.explicitRoot, stderr, "change_assess_intake", machine) - if code != exitOK { - return code - } - defer project.close() - - result, err := contracts.AssessChangeIntake(project.validator, project.loaded, contracts.ChangeAssessIntakeOptions{ - Title: request.title, - Type: request.changeType, - Size: request.size, - Description: request.description, - ContextBundles: request.contextBundles, - }) - if err != nil { - emitOutput(stderr, machine, appendMachineOptionLines(buildCommandInvalidLines("change_assess_intake", project.absRoot, err), machine), exitInvalid, failureClassInvalid) - return exitInvalid - } - output := buildChangeAssessIntakeOutput(project.absRoot, project.loaded, result) - if machine.explain { - output = appendChangeAssessIntakeExplainLines(output, result) - } - emitOutput(stdout, machine, appendMachineOptionLines(output, machine), exitOK, failureClassNone) - return exitOK -} - -func runChangeAssessDecomposition(args []string, machine machineOptions, stdout, stderr io.Writer) int { - if machine.dryRun { - emitOutput(stderr, machine, appendMachineOptionLines(buildCommandUsageErrorLines("change_assess_decomposition", changeAssessDecompUsage, fmt.Errorf("--dry-run is not supported for advisory change assess-decomposition")), machine), exitUsage, failureClassUsage) - return exitUsage - } - request, err := parseChangeAssessDecompositionArgs(args) - if err != nil { - emitOutput(stderr, machine, appendMachineOptionLines(buildCommandUsageErrorLines("change_assess_decomposition", changeAssessDecompUsage, err), machine), exitUsage, failureClassUsage) - return exitUsage - } - project, code := loadProjectOrReport(request.root, request.explicitRoot, stderr, "change_assess_decomposition", machine) - if code != exitOK { - return code - } - defer project.close() - - result, err := contracts.AssessChangeDecomposition(project.validator, project.loaded, request.changeID) - if err != nil { - emitOutput(stderr, machine, appendMachineOptionLines(buildCommandInvalidLines("change_assess_decomposition", project.absRoot, err), machine), exitInvalid, failureClassInvalid) - return exitInvalid - } - output := buildChangeAssessDecompositionOutput(project.absRoot, project.loaded, result) - if machine.explain { - output = appendChangeAssessDecompositionExplainLines(output, result) - } - emitOutput(stdout, machine, appendMachineOptionLines(output, machine), exitOK, failureClassNone) - return exitOK -} diff --git a/internal/cli/cli_change_advisory_decomposition.go b/internal/cli/cli_change_advisory_decomposition.go new file mode 100644 index 0000000..644713c --- /dev/null +++ b/internal/cli/cli_change_advisory_decomposition.go @@ -0,0 +1,134 @@ +package cli + +import ( + "fmt" + "io" + + "github.com/runecode-systems/runecontext/internal/contracts" +) + +func runChangeAssessIntake(args []string, machine machineOptions, stdout, stderr io.Writer) int { + if machine.dryRun { + emitOutput(stderr, machine, appendMachineOptionLines(buildCommandUsageErrorLines("change_assess_intake", changeAssessIntakeUsage, fmt.Errorf("--dry-run is not supported for advisory change assess-intake")), machine), exitUsage, failureClassUsage) + return exitUsage + } + request, err := parseChangeAssessIntakeArgs(args) + if err != nil { + emitOutput(stderr, machine, appendMachineOptionLines(buildCommandUsageErrorLines("change_assess_intake", changeAssessIntakeUsage, err), machine), exitUsage, failureClassUsage) + return exitUsage + } + project, code := loadProjectOrReport(request.root, request.explicitRoot, stderr, "change_assess_intake", machine) + if code != exitOK { + return code + } + defer project.close() + + result, err := contracts.AssessChangeIntake(project.validator, project.loaded, contracts.ChangeAssessIntakeOptions{ + Title: request.title, + Type: request.changeType, + Size: request.size, + Description: request.description, + ContextBundles: request.contextBundles, + }) + if err != nil { + emitOutput(stderr, machine, appendMachineOptionLines(buildCommandInvalidLines("change_assess_intake", project.absRoot, err), machine), exitInvalid, failureClassInvalid) + return exitInvalid + } + output := buildChangeAssessIntakeOutput(project.absRoot, project.loaded, result) + if machine.explain { + output = appendChangeAssessIntakeExplainLines(output, result) + } + emitOutput(stdout, machine, appendMachineOptionLines(output, machine), exitOK, failureClassNone) + return exitOK +} + +func runChangeAssessDecomposition(args []string, machine machineOptions, stdout, stderr io.Writer) int { + if machine.dryRun { + emitOutput(stderr, machine, appendMachineOptionLines(buildCommandUsageErrorLines("change_assess_decomposition", changeAssessDecompUsage, fmt.Errorf("--dry-run is not supported for advisory change assess-decomposition")), machine), exitUsage, failureClassUsage) + return exitUsage + } + request, err := parseChangeAssessDecompositionArgs(args) + if err != nil { + emitOutput(stderr, machine, appendMachineOptionLines(buildCommandUsageErrorLines("change_assess_decomposition", changeAssessDecompUsage, err), machine), exitUsage, failureClassUsage) + return exitUsage + } + project, code := loadProjectOrReport(request.root, request.explicitRoot, stderr, "change_assess_decomposition", machine) + if code != exitOK { + return code + } + defer project.close() + + result, err := contracts.AssessChangeDecomposition(project.validator, project.loaded, request.changeID) + if err != nil { + emitOutput(stderr, machine, appendMachineOptionLines(buildCommandInvalidLines("change_assess_decomposition", project.absRoot, err), machine), exitInvalid, failureClassInvalid) + return exitInvalid + } + output := buildChangeAssessDecompositionOutput(project.absRoot, project.loaded, result) + if machine.explain { + output = appendChangeAssessDecompositionExplainLines(output, result) + } + emitOutput(stdout, machine, appendMachineOptionLines(output, machine), exitOK, failureClassNone) + return exitOK +} + +func runChangeDecompositionPlan(args []string, machine machineOptions, stdout, stderr io.Writer) int { + if machine.dryRun { + emitOutput(stderr, machine, appendMachineOptionLines(buildCommandUsageErrorLines("change_decomposition_plan", changeDecompPlanUsage, fmt.Errorf("--dry-run is not supported for advisory change decomposition-plan")), machine), exitUsage, failureClassUsage) + return exitUsage + } + request, err := parseChangeDecompositionArgs(args, "change decomposition-plan") + if err != nil { + emitOutput(stderr, machine, appendMachineOptionLines(buildCommandUsageErrorLines("change_decomposition_plan", changeDecompPlanUsage, err), machine), exitUsage, failureClassUsage) + return exitUsage + } + project, code := loadProjectOrReport(request.root, request.explicitRoot, stderr, "change_decomposition_plan", machine) + if code != exitOK { + return code + } + defer project.close() + + result, err := contracts.PlanChangeDecomposition(project.validator, project.loaded, contracts.ChangeDecompositionPlanOptions{ + UmbrellaID: request.umbrellaID, + SubChanges: request.subChanges, + }) + if err != nil { + emitOutput(stderr, machine, appendMachineOptionLines(buildCommandInvalidLines("change_decomposition_plan", project.absRoot, err), machine), exitInvalid, failureClassInvalid) + return exitInvalid + } + output := buildChangeDecompositionPlanOutput(project.absRoot, project.loaded, result) + if machine.explain { + output = appendChangeDecompositionPlanExplainLines(output, result) + } + emitOutput(stdout, machine, appendMachineOptionLines(output, machine), exitOK, failureClassNone) + return exitOK +} + +func runChangeDecompositionApply(args []string, machine machineOptions, stdout, stderr io.Writer) int { + request, err := parseChangeDecompositionArgs(args, "change decomposition-apply") + if err != nil { + emitOutput(stderr, machine, appendMachineOptionLines(buildCommandUsageErrorLines("change_decomposition_apply", changeDecompApplyUsage, err), machine), exitUsage, failureClassUsage) + return exitUsage + } + project, code := loadProjectOrReport(request.root, request.explicitRoot, stderr, "change_decomposition_apply", machine) + if code != exitOK { + return code + } + defer project.close() + + result, err := runChangeOperation(project, machine, func(v *contracts.Validator, loaded *contracts.LoadedProject) (*contracts.ChangeDecompositionApplyResult, error) { + return contracts.ApplyChangeDecomposition(v, loaded, contracts.ChangeDecompositionApplyOptions{ + UmbrellaID: request.umbrellaID, + SubChanges: request.subChanges, + }) + }) + if err != nil { + emitOutput(stderr, machine, appendMachineOptionLines(buildCommandInvalidLines("change_decomposition_apply", project.absRoot, err), machine), exitInvalid, failureClassInvalid) + return exitInvalid + } + output := buildChangeDecompositionApplyOutput(project.absRoot, project.loaded, result) + if machine.explain { + output = appendChangeDecompositionApplyExplainLines(output, result) + } + emitOutput(stdout, machine, appendMachineOptionLines(output, machine), exitOK, failureClassNone) + return exitOK +} diff --git a/internal/cli/cli_change_decomposition_flow_test.go b/internal/cli/cli_change_decomposition_flow_test.go new file mode 100644 index 0000000..1405e9c --- /dev/null +++ b/internal/cli/cli_change_decomposition_flow_test.go @@ -0,0 +1,160 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRunChangeDecompositionPlanOutputsAdvisoryGraph(t *testing.T) { + projectRoot := prepareCLIWorkflowProject(t) + umbrellaID := runCLIProjectChangeNewForTest(t, projectRoot, "Umbrella project") + featureAID := runCLIChangeNewForTest(t, projectRoot, "Feature A") + featureBID := runCLIChangeNewForTest(t, projectRoot, "Feature B") + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Run([]string{ + "change", "decomposition-plan", umbrellaID, + "--sub-change", featureAID, + "--sub-change", featureBID, + "--depends-on", featureBID + ":" + featureAID, + "--path", projectRoot, + }, &stdout, &stderr) + if code != 0 { + t.Fatalf("expected success exit code, got %d (%s)", code, stderr.String()) + } + fields := parseCLIKeyValueOutput(t, stdout.String()) + if got, want := fields["command"], "change_decomposition_plan"; got != want { + t.Fatalf("expected command %q, got %q", want, got) + } + if got, want := fields["mutation_performed"], "false"; got != want { + t.Fatalf("expected mutation_performed %q, got %q", want, got) + } + if got, want := fields["umbrella_change_id"], umbrellaID; got != want { + t.Fatalf("expected umbrella_change_id %q, got %q", want, got) + } + if got, want := fields["graph_"+umbrellaID+"_related_change_count"], "2"; got != want { + t.Fatalf("expected umbrella related count %q, got %q", want, got) + } + if got, want := fields["graph_"+featureBID+"_depends_on_1"], featureAID; got != want { + t.Fatalf("expected %q dependency %q, got %q", featureBID, want, got) + } +} + +func TestRunChangeDecompositionApplyUpdatesGraph(t *testing.T) { + projectRoot := prepareCLIWorkflowProject(t) + umbrellaID := runCLIProjectChangeNewForTest(t, projectRoot, "Umbrella project") + featureAID := runCLIChangeNewForTest(t, projectRoot, "Feature A") + featureBID := runCLIChangeNewForTest(t, projectRoot, "Feature B") + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Run([]string{ + "change", "decomposition-apply", umbrellaID, + "--sub-change", featureAID, + "--sub-change", featureBID, + "--depends-on", featureBID + ":" + featureAID, + "--path", projectRoot, + }, &stdout, &stderr) + if code != 0 { + t.Fatalf("expected success exit code, got %d (%s)", code, stderr.String()) + } + fields := parseCLIKeyValueOutput(t, stdout.String()) + if got, want := fields["command"], "change_decomposition_apply"; got != want { + t.Fatalf("expected command %q, got %q", want, got) + } + if got := fields["changed_file_count"]; got != "3" { + t.Fatalf("expected changed_file_count 3, got %q", got) + } + assertDecompositionApplyStatusLinks(t, projectRoot, umbrellaID, featureAID, featureBID) +} + +func assertDecompositionApplyStatusLinks(t *testing.T, projectRoot, umbrellaID, featureAID, featureBID string) { + t.Helper() + statusPath := filepath.Join(projectRoot, "runecontext", "changes", umbrellaID, "status.yaml") + statusData, err := os.ReadFile(statusPath) + if err != nil { + t.Fatalf("read umbrella status: %v", err) + } + text := strings.ReplaceAll(string(statusData), "\r\n", "\n") + if !strings.Contains(text, "related_changes:\n - "+featureAID+"\n - "+featureBID) { + t.Fatalf("expected umbrella related_changes to include sub-changes, got:\n%s", text) + } + featureBStatusPath := filepath.Join(projectRoot, "runecontext", "changes", featureBID, "status.yaml") + featureBData, err := os.ReadFile(featureBStatusPath) + if err != nil { + t.Fatalf("read feature status: %v", err) + } + featureBText := strings.ReplaceAll(string(featureBData), "\r\n", "\n") + if !strings.Contains(featureBText, "depends_on:\n - "+featureAID) { + t.Fatalf("expected feature B depends_on %s, got:\n%s", featureAID, featureBText) + } +} + +func TestRunChangeDecompositionApplyDryRunDoesNotPersistMutation(t *testing.T) { + projectRoot := prepareCLIWorkflowProject(t) + umbrellaID := runCLIProjectChangeNewForTest(t, projectRoot, "Umbrella project") + featureID := runCLIChangeNewForTest(t, projectRoot, "Feature sub-change") + statusPath := filepath.Join(projectRoot, "runecontext", "changes", umbrellaID, "status.yaml") + before, err := os.ReadFile(statusPath) + if err != nil { + t.Fatalf("read status before dry-run decomposition apply: %v", err) + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Run([]string{ + "change", "decomposition-apply", umbrellaID, + "--sub-change", featureID, + "--dry-run", + "--path", projectRoot, + }, &stdout, &stderr) + if code != 0 { + t.Fatalf("expected dry-run success, got %d (%s)", code, stderr.String()) + } + fields := parseCLIKeyValueOutput(t, stdout.String()) + if got, want := fields["dry_run"], "true"; got != want { + t.Fatalf("expected dry_run %q, got %q", want, got) + } + after, err := os.ReadFile(statusPath) + if err != nil { + t.Fatalf("read status after dry-run decomposition apply: %v", err) + } + if string(after) != string(before) { + t.Fatalf("expected dry-run decomposition apply to avoid status mutation") + } +} + +func TestRunChangeDecompositionUsageErrors(t *testing.T) { + for _, tc := range []struct { + name string + args []string + message string + }{ + {name: "missing umbrella", args: []string{"change", "decomposition-plan"}, message: "requires exactly one umbrella change ID"}, + {name: "plan dry-run", args: []string{"change", "decomposition-plan", "CHG-2026-001-a3f2-auth-gateway", "--dry-run"}, message: "--dry-run is not supported"}, + {name: "malformed dependency", args: []string{"change", "decomposition-plan", "CHG-2026-001-a3f2-auth-gateway", "--sub-change", "CHG-2026-002-b4c3-api", "--depends-on", "CHG-2026-002-b4c3-api"}, message: "--depends-on must use SUB_CHANGE_ID:CHANGE_ID"}, + {name: "missing sub-change", args: []string{"change", "decomposition-apply", "CHG-2026-001-a3f2-auth-gateway"}, message: "requires at least one --sub-change ID"}, + {name: "unknown apply flag", args: []string{"change", "decomposition-apply", "CHG-2026-001-a3f2-auth-gateway", "--sub-change", "CHG-2026-002-b4c3-api", "--recursive"}, message: "unknown change decomposition-apply flag"}, + } { + t.Run(tc.name, func(t *testing.T) { + assertChangeUsageError(t, tc.args, tc.message) + }) + } +} + +func assertChangeUsageError(t *testing.T, args []string, wantSubstring string) { + t.Helper() + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Run(args, &stdout, &stderr) + if code != exitUsage { + t.Fatalf("expected usage exit code, got %d", code) + } + if !strings.Contains(stderr.String(), wantSubstring) { + t.Fatalf("expected usage output containing %q, got %q", wantSubstring, stderr.String()) + } +} diff --git a/internal/cli/cli_change_decomposition_test.go b/internal/cli/cli_change_decomposition_test.go new file mode 100644 index 0000000..018031f --- /dev/null +++ b/internal/cli/cli_change_decomposition_test.go @@ -0,0 +1,52 @@ +package cli + +import ( + "bytes" + "strings" + "testing" +) + +func TestRunChangeDecompositionPlanAllowsTerminalSubChange(t *testing.T) { + projectRoot := prepareCLIWorkflowProject(t) + umbrellaID := runCLIProjectChangeNewForTest(t, projectRoot, "Umbrella project") + featureID := runCLIChangeNewForTest(t, projectRoot, "Feature sub-change") + writeCLIStatusVerificationStatus(t, projectRoot, featureID, "passed") + runCLIChangeClose(t, projectRoot, featureID, []string{"--closed-at", "2026-03-21", "--path", projectRoot}) + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Run([]string{ + "change", "decomposition-plan", umbrellaID, + "--sub-change", featureID, + "--path", projectRoot, + }, &stdout, &stderr) + if code != 0 { + t.Fatalf("expected plan to allow terminal sub-change, got %d (%s)", code, stderr.String()) + } + fields := parseCLIKeyValueOutput(t, stdout.String()) + if got, want := fields["command"], "change_decomposition_plan"; got != want { + t.Fatalf("expected command %q, got %q", want, got) + } +} + +func TestRunChangeDecompositionApplyRejectsTerminalSubChange(t *testing.T) { + projectRoot := prepareCLIWorkflowProject(t) + umbrellaID := runCLIProjectChangeNewForTest(t, projectRoot, "Umbrella project") + featureID := runCLIChangeNewForTest(t, projectRoot, "Feature sub-change") + writeCLIStatusVerificationStatus(t, projectRoot, featureID, "passed") + runCLIChangeClose(t, projectRoot, featureID, []string{"--closed-at", "2026-03-21", "--path", projectRoot}) + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Run([]string{ + "change", "decomposition-apply", umbrellaID, + "--sub-change", featureID, + "--path", projectRoot, + }, &stdout, &stderr) + if code != 1 { + t.Fatalf("expected invalid exit for terminal sub-change apply, got %d (%s)", code, stderr.String()) + } + if !strings.Contains(stderr.String(), "cannot accept decomposition apply edits") { + t.Fatalf("expected terminal apply rejection output, got %q", stderr.String()) + } +} diff --git a/internal/cli/cli_change_output.go b/internal/cli/cli_change_output.go index 73442a3..a284f66 100644 --- a/internal/cli/cli_change_output.go +++ b/internal/cli/cli_change_output.go @@ -154,6 +154,45 @@ func buildChangeAssessDecompositionOutput(absRoot string, loaded *contracts.Load return appendStringItems(output, "reason", result.Reasons) } +func buildChangeDecompositionPlanOutput(absRoot string, loaded *contracts.LoadedProject, result *contracts.ChangeDecompositionPlanResult) []line { + output := []line{ + {"result", "ok"}, + {"command", "change_decomposition_plan"}, + {"root", absRoot}, + {"selected_config_path", selectedConfigPath(loaded)}, + {"mutation_performed", "false"}, + {"umbrella_change_id", result.UmbrellaID}, + {"graph_node_count", fmt.Sprintf("%d", len(result.NodeIDs))}, + } + output = appendStringItems(output, "graph_node", result.NodeIDs) + return appendDecompositionGraphLines(output, result.Graph) +} + +func buildChangeDecompositionApplyOutput(absRoot string, loaded *contracts.LoadedProject, result *contracts.ChangeDecompositionApplyResult) []line { + output := []line{ + {"result", "ok"}, + {"command", "change_decomposition_apply"}, + {"root", absRoot}, + {"selected_config_path", selectedConfigPath(loaded)}, + {"umbrella_change_id", result.UmbrellaID}, + {"graph_node_count", fmt.Sprintf("%d", len(result.NodeIDs))}, + } + output = appendStringItems(output, "graph_node", result.NodeIDs) + output = appendDecompositionGraphLines(output, result.Graph) + return appendChangedFiles(output, result.ChangedFiles) +} + +func appendDecompositionGraphLines(output []line, graph map[string]contracts.ChangeGraphLinks) []line { + for _, nodeID := range contracts.SortedKeys(graph) { + links := graph[nodeID] + output = append(output, line{"graph_" + nodeID + "_related_change_count", fmt.Sprintf("%d", len(links.RelatedChanges))}) + output = appendStringItems(output, "graph_"+nodeID+"_related_change", links.RelatedChanges) + output = append(output, line{"graph_" + nodeID + "_depends_on_count", fmt.Sprintf("%d", len(links.DependsOn))}) + output = appendStringItems(output, "graph_"+nodeID+"_depends_on", links.DependsOn) + } + return output +} + func selectedConfigPath(loaded *contracts.LoadedProject) string { if loaded == nil || loaded.Resolution == nil { return "" diff --git a/internal/cli/cli_change_test.go b/internal/cli/cli_change_test.go index c68c40d..0b95558 100644 --- a/internal/cli/cli_change_test.go +++ b/internal/cli/cli_change_test.go @@ -471,87 +471,6 @@ func TestRunChangeNewDryRunDoesNotPersistChange(t *testing.T) { } } -func prepareCLIWorkflowProject(t *testing.T) string { - t.Helper() - repoRoot, err := repoRootForTests() - if err != nil { - t.Fatal(err) - } - t.Chdir(repoRoot) - projectRoot := t.TempDir() - copyDirForCLI(t, repoFixtureRoot(t, "change-workflow", "template-project"), projectRoot) - return projectRoot -} - -func appendCLIProposalSelfReference(t *testing.T, projectRoot, changeID string) { - t.Helper() - proposalPath := filepath.Join(projectRoot, "runecontext", "changes", changeID, "proposal.md") - data, err := os.ReadFile(proposalPath) - if err != nil { - t.Fatalf("read proposal: %v", err) - } - updated := strings.ReplaceAll(string(data), "\r\n", "\n") + "\nSee changes/" + changeID + "/proposal.md#summary for the current change summary.\n" - if err := os.WriteFile(proposalPath, []byte(updated), 0o644); err != nil { - t.Fatalf("write proposal: %v", err) - } -} - -func runCLIChangeReallocate(t *testing.T, projectRoot, changeID string) map[string]string { - t.Helper() - var stdout bytes.Buffer - var stderr bytes.Buffer - code := Run([]string{"change", "reallocate", changeID, "--path", projectRoot}, &stdout, &stderr) - if code != 0 { - t.Fatalf("expected success exit code, got %d (%s)", code, stderr.String()) - } - return parseCLIKeyValueOutput(t, stdout.String()) -} - -func assertCLIReallocateFields(t *testing.T, fields map[string]string, changeID string) string { - t.Helper() - if got, want := fields["command"], "change_reallocate"; got != want { - t.Fatalf("expected command %q, got %q", want, got) - } - if got, want := fields["old_change_id"], changeID; got != want { - t.Fatalf("expected old_change_id %q, got %q", want, got) - } - newID := fields["change_id"] - if newID == "" || newID == changeID { - t.Fatalf("expected a new change ID, got %#v", fields) - } - if got := fields["rewritten_reference_count"]; got != "1" { - t.Fatalf("expected one rewritten reference, got %q", got) - } - if got := fields["warning_count"]; got != "0" { - t.Fatalf("expected no warnings, got %q", got) - } - return newID -} - -func assertCLIReallocatedProposal(t *testing.T, projectRoot, oldID, newID string) { - t.Helper() - if _, err := os.Stat(filepath.Join(projectRoot, "runecontext", "changes", oldID)); !os.IsNotExist(err) { - t.Fatalf("expected old change directory to be removed, got err=%v", err) - } - proposalData, err := os.ReadFile(filepath.Join(projectRoot, "runecontext", "changes", newID, "proposal.md")) - if err != nil { - t.Fatalf("read reallocated proposal: %v", err) - } - if !strings.Contains(string(proposalData), "changes/"+newID+"/proposal.md#summary") { - t.Fatalf("expected CLI reallocation to rewrite local reference, got:\n%s", string(proposalData)) - } -} - -func runCLIChangeClose(t *testing.T, projectRoot, changeID string, args []string) { - t.Helper() - var stdout bytes.Buffer - var stderr bytes.Buffer - fullArgs := append([]string{"change", "close", changeID}, args...) - if code := Run(fullArgs, &stdout, &stderr); code != 0 { - t.Fatalf("change close failed: %d (%s)", code, stderr.String()) - } -} - func TestRunValidateRejectsInvalidProposal(t *testing.T) { root := fixtureRoot(t, "reject-proposal-invalid") var stdout bytes.Buffer @@ -565,14 +484,3 @@ func TestRunValidateRejectsInvalidProposal(t *testing.T) { t.Fatalf("expected proposal path in output, got %q", stderr.String()) } } - -func runCLIStandardChangeNewForTest(t *testing.T, projectRoot, title string) string { - t.Helper() - var stdout bytes.Buffer - var stderr bytes.Buffer - code := Run([]string{"change", "new", "--title", title, "--type", "standard", "--size", "small", "--bundle", "base", "--path", projectRoot}, &stdout, &stderr) - if code != 0 { - t.Fatalf("standard change new failed: %d (%s)", code, stderr.String()) - } - return parseCLIKeyValueOutput(t, stdout.String())["change_id"] -} diff --git a/internal/cli/cli_change_test_helpers_test.go b/internal/cli/cli_change_test_helpers_test.go new file mode 100644 index 0000000..487797d --- /dev/null +++ b/internal/cli/cli_change_test_helpers_test.go @@ -0,0 +1,101 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func prepareCLIWorkflowProject(t *testing.T) string { + t.Helper() + repoRoot, err := repoRootForTests() + if err != nil { + t.Fatal(err) + } + t.Chdir(repoRoot) + projectRoot := t.TempDir() + copyDirForCLI(t, repoFixtureRoot(t, "change-workflow", "template-project"), projectRoot) + return projectRoot +} + +func appendCLIProposalSelfReference(t *testing.T, projectRoot, changeID string) { + t.Helper() + proposalPath := filepath.Join(projectRoot, "runecontext", "changes", changeID, "proposal.md") + data, err := os.ReadFile(proposalPath) + if err != nil { + t.Fatalf("read proposal: %v", err) + } + updated := strings.ReplaceAll(string(data), "\r\n", "\n") + "\nSee changes/" + changeID + "/proposal.md#summary for the current change summary.\n" + if err := os.WriteFile(proposalPath, []byte(updated), 0o644); err != nil { + t.Fatalf("write proposal: %v", err) + } +} + +func runCLIChangeReallocate(t *testing.T, projectRoot, changeID string) map[string]string { + t.Helper() + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Run([]string{"change", "reallocate", changeID, "--path", projectRoot}, &stdout, &stderr) + if code != 0 { + t.Fatalf("expected success exit code, got %d (%s)", code, stderr.String()) + } + return parseCLIKeyValueOutput(t, stdout.String()) +} + +func assertCLIReallocateFields(t *testing.T, fields map[string]string, changeID string) string { + t.Helper() + if got, want := fields["command"], "change_reallocate"; got != want { + t.Fatalf("expected command %q, got %q", want, got) + } + if got, want := fields["old_change_id"], changeID; got != want { + t.Fatalf("expected old_change_id %q, got %q", want, got) + } + newID := fields["change_id"] + if newID == "" || newID == changeID { + t.Fatalf("expected a new change ID, got %#v", fields) + } + if got := fields["rewritten_reference_count"]; got != "1" { + t.Fatalf("expected one rewritten reference, got %q", got) + } + if got := fields["warning_count"]; got != "0" { + t.Fatalf("expected no warnings, got %q", got) + } + return newID +} + +func assertCLIReallocatedProposal(t *testing.T, projectRoot, oldID, newID string) { + t.Helper() + if _, err := os.Stat(filepath.Join(projectRoot, "runecontext", "changes", oldID)); !os.IsNotExist(err) { + t.Fatalf("expected old change directory to be removed, got err=%v", err) + } + proposalData, err := os.ReadFile(filepath.Join(projectRoot, "runecontext", "changes", newID, "proposal.md")) + if err != nil { + t.Fatalf("read reallocated proposal: %v", err) + } + if !strings.Contains(string(proposalData), "changes/"+newID+"/proposal.md#summary") { + t.Fatalf("expected CLI reallocation to rewrite local reference, got:\n%s", string(proposalData)) + } +} + +func runCLIChangeClose(t *testing.T, projectRoot, changeID string, args []string) { + t.Helper() + var stdout bytes.Buffer + var stderr bytes.Buffer + fullArgs := append([]string{"change", "close", changeID}, args...) + if code := Run(fullArgs, &stdout, &stderr); code != 0 { + t.Fatalf("change close failed: %d (%s)", code, stderr.String()) + } +} + +func runCLIStandardChangeNewForTest(t *testing.T, projectRoot, title string) string { + t.Helper() + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Run([]string{"change", "new", "--title", title, "--type", "standard", "--size", "small", "--bundle", "base", "--path", projectRoot}, &stdout, &stderr) + if code != 0 { + t.Fatalf("standard change new failed: %d (%s)", code, stderr.String()) + } + return parseCLIKeyValueOutput(t, stdout.String())["change_id"] +} diff --git a/internal/cli/cli_completion_suggest_test.go b/internal/cli/cli_completion_suggest_test.go index 65d93d7..281deef 100644 --- a/internal/cli/cli_completion_suggest_test.go +++ b/internal/cli/cli_completion_suggest_test.go @@ -258,36 +258,36 @@ func TestCompletionMetadataIncludesSuggestionProviders(t *testing.T) { metadata := CompletionMetadataRegistry() flagProviders := collectFlagSuggestionProviders(metadata) - if got := flagProviders["change close|--superseded-by"]; got != suggestionProviderChangeIDs { - t.Fatalf("expected change close superseded-by suggestion provider, got %q", got) - } - if got := flagProviders["promote|--target"]; got != suggestionProviderPromotionTargets { - t.Fatalf("expected promote target suggestion provider, got %q", got) - } - if got := flagProviders["change new|--bundle"]; got != suggestionProviderBundleIDs { - t.Fatalf("expected change new bundle suggestion provider, got %q", got) - } - if got := flagProviders["change assess-intake|--bundle"]; got != suggestionProviderBundleIDs { - t.Fatalf("expected change assess-intake bundle suggestion provider, got %q", got) - } - if got := flagProviders["init|--seed-bundle"]; got != suggestionProviderBundleIDs { - t.Fatalf("expected init seed-bundle suggestion provider, got %q", got) - } - if got := flagProviders["standard discover|--change"]; got != suggestionProviderChangeIDs { - t.Fatalf("expected standard discover change suggestion provider, got %q", got) - } + assertSuggestionProviders(t, flagProviders, []providerExpectation{ + {key: "change close|--superseded-by", provider: suggestionProviderChangeIDs}, + {key: "promote|--target", provider: suggestionProviderPromotionTargets}, + {key: "change new|--bundle", provider: suggestionProviderBundleIDs}, + {key: "change assess-intake|--bundle", provider: suggestionProviderBundleIDs}, + {key: "init|--seed-bundle", provider: suggestionProviderBundleIDs}, + {key: "standard discover|--change", provider: suggestionProviderChangeIDs}, + }) positionalProviders := collectPositionalSuggestionProviders(metadata) - if got := positionalProviders["promote|1"]; got != suggestionProviderChangeIDs { - t.Fatalf("expected promote positional suggestion provider, got %q", got) - } - if got := positionalProviders["change shape|1"]; got != suggestionProviderChangeIDs { - t.Fatalf("expected change shape positional suggestion provider, got %q", got) - } - if got := positionalProviders["bundle resolve|1"]; got != suggestionProviderBundleIDs { - t.Fatalf("expected bundle resolve positional suggestion provider, got %q", got) - } - if got := positionalProviders["adapter render-host-native|1"]; got != suggestionProviderAdapterNamesShellInjection { - t.Fatalf("expected render-host-native positional suggestion provider, got %q", got) + assertSuggestionProviders(t, positionalProviders, []providerExpectation{ + {key: "promote|1", provider: suggestionProviderChangeIDs}, + {key: "change shape|1", provider: suggestionProviderChangeIDs}, + {key: "change decomposition-plan|1", provider: suggestionProviderChangeIDs}, + {key: "change decomposition-apply|1", provider: suggestionProviderChangeIDs}, + {key: "bundle resolve|1", provider: suggestionProviderBundleIDs}, + {key: "adapter render-host-native|1", provider: suggestionProviderAdapterNamesShellInjection}, + }) +} + +type providerExpectation struct { + key string + provider string +} + +func assertSuggestionProviders(t *testing.T, actual map[string]string, want []providerExpectation) { + t.Helper() + for _, expected := range want { + if got := actual[expected.key]; got != expected.provider { + t.Fatalf("expected %q suggestion provider %q, got %q", expected.key, expected.provider, got) + } } } diff --git a/internal/cli/cli_explain.go b/internal/cli/cli_explain.go index 6af4e33..7934800 100644 --- a/internal/cli/cli_explain.go +++ b/internal/cli/cli_explain.go @@ -219,6 +219,32 @@ func appendChangeAssessDecompositionExplainLines(lines []line, result *contracts return lines } +func appendChangeDecompositionPlanExplainLines(lines []line, result *contracts.ChangeDecompositionPlanResult) []line { + if result == nil { + return lines + } + lines = append(lines, + line{"explain_scope", "change-decomposition-plan"}, + line{"explain_advisory_only", "true"}, + line{"explain_umbrella_change_id", result.UmbrellaID}, + line{"explain_graph_node_count", fmt.Sprintf("%d", len(result.NodeIDs))}, + ) + return lines +} + +func appendChangeDecompositionApplyExplainLines(lines []line, result *contracts.ChangeDecompositionApplyResult) []line { + if result == nil { + return lines + } + lines = append(lines, + line{"explain_scope", "change-decomposition-apply"}, + line{"explain_umbrella_change_id", result.UmbrellaID}, + line{"explain_graph_node_count", fmt.Sprintf("%d", len(result.NodeIDs))}, + line{"explain_changed_file_count", fmt.Sprintf("%d", len(result.ChangedFiles))}, + ) + return lines +} + func appendAssuranceEnableExplainLines(lines []line, root string, plans []string) []line { lines = append(lines, line{"explain_scope", "assurance-enable"}, diff --git a/internal/cli/cli_help_validate_flags_test.go b/internal/cli/cli_help_validate_flags_test.go index fef0e1a..55eb007 100644 --- a/internal/cli/cli_help_validate_flags_test.go +++ b/internal/cli/cli_help_validate_flags_test.go @@ -104,6 +104,9 @@ func TestRunNoCommand(t *testing.T) { if !strings.Contains(stdout.String(), "runectx change update CHANGE_ID --status verified --verification-status passed") { t.Fatalf("expected change update example in help output, got %q", stdout.String()) } + if !strings.Contains(stdout.String(), "runectx change decomposition-apply UMBRELLA_CHANGE_ID --sub-change CHANGE_ID") { + t.Fatalf("expected decomposition-apply example in help output, got %q", stdout.String()) + } if stderr.String() != "" { t.Fatalf("expected empty stderr, got %q", stderr.String()) } diff --git a/internal/cli/cli_metadata_flags.go b/internal/cli/cli_metadata_flags.go index c0eaf51..9b4c5d7 100644 --- a/internal/cli/cli_metadata_flags.go +++ b/internal/cli/cli_metadata_flags.go @@ -54,6 +54,14 @@ func changeAssessIntakeFlags() []FlagMetadata { } } +func changeDecompositionFlags() []FlagMetadata { + return []FlagMetadata{ + {Name: "--sub-change", Value: textValueWithSuggestionSpec(suggestionProviderChangeIDs), Repeatable: true, Required: true}, + {Name: "--depends-on", Value: textValueSpec(), Repeatable: true}, + {Name: "--path", Value: textValueSpec()}, + } +} + func validateFlags() []FlagMetadata { return []FlagMetadata{ {Name: "--ssh-allowed-signers", Value: textValueSpec()}, diff --git a/internal/cli/cli_metadata_registry.go b/internal/cli/cli_metadata_registry.go index 4a9b39f..e9c153f 100644 --- a/internal/cli/cli_metadata_registry.go +++ b/internal/cli/cli_metadata_registry.go @@ -95,6 +95,8 @@ func changeCommandMetadata() CommandMetadata { {Name: "update", Path: "change update", Usage: changeUpdateUsage, Flags: writeCommandFlags(changeUpdateFlags()), Positionals: []PositionalMetadata{{Name: "CHANGE_ID", Value: textValueWithSuggestionSpec(suggestionProviderChangeIDs)}}}, {Name: "assess-intake", Path: "change assess-intake", Usage: changeAssessIntakeUsage, Flags: readOnlyCommandFlags(changeAssessIntakeFlags())}, {Name: "assess-decomposition", Path: "change assess-decomposition", Usage: changeAssessDecompUsage, Flags: readOnlyCommandFlags(pathOnlyFlag()), Positionals: []PositionalMetadata{{Name: "CHANGE_ID", Value: textValueWithSuggestionSpec(suggestionProviderChangeIDs)}}}, + {Name: "decomposition-plan", Path: "change decomposition-plan", Usage: changeDecompPlanUsage, Flags: readOnlyCommandFlags(changeDecompositionFlags()), Positionals: []PositionalMetadata{{Name: "UMBRELLA_CHANGE_ID", Value: textValueWithSuggestionSpec(suggestionProviderChangeIDs)}}}, + {Name: "decomposition-apply", Path: "change decomposition-apply", Usage: changeDecompApplyUsage, Flags: writeCommandFlags(changeDecompositionFlags()), Positionals: []PositionalMetadata{{Name: "UMBRELLA_CHANGE_ID", Value: textValueWithSuggestionSpec(suggestionProviderChangeIDs)}}}, }, } } diff --git a/internal/cli/cli_metadata_registry_test.go b/internal/cli/cli_metadata_registry_test.go index 3e87aaf..6836023 100644 --- a/internal/cli/cli_metadata_registry_test.go +++ b/internal/cli/cli_metadata_registry_test.go @@ -101,6 +101,40 @@ func TestCommandMetadataRegistryIncludesChangeAssessCommands(t *testing.T) { } } +func TestCommandMetadataRegistryIncludesChangeDecompositionCommands(t *testing.T) { + registry := CommandMetadataRegistry() + change := commandMetadataByPath(registry.Commands, "change") + if change == nil { + t.Fatalf("expected change command in registry") + } + assertChangeDecompositionCommandMetadata(t, change, "change decomposition-plan", changeDecompPlanUsage) + assertChangeDecompositionCommandMetadata(t, change, "change decomposition-apply", changeDecompApplyUsage) +} + +func assertChangeDecompositionCommandMetadata(t *testing.T, change *CommandMetadata, path, usage string) { + t.Helper() + metadata := commandMetadataByPath(change.Subcommands, path) + if metadata == nil { + t.Fatalf("expected %s subcommand in registry", path) + } + if metadata.Usage != usage { + t.Fatalf("expected %s usage %q, got %q", path, usage, metadata.Usage) + } + if len(metadata.Positionals) != 1 || metadata.Positionals[0].Name != "UMBRELLA_CHANGE_ID" { + t.Fatalf("expected one UMBRELLA_CHANGE_ID positional for %s, got %#v", path, metadata.Positionals) + } + assertCommandHasFlags(t, metadata.Flags, path, []string{"--sub-change", "--depends-on"}) +} + +func assertCommandHasFlags(t *testing.T, flags []FlagMetadata, commandPath string, names []string) { + t.Helper() + for _, name := range names { + if flagMetadataByName(flags, name) == nil { + t.Fatalf("expected %s flag for %s", name, commandPath) + } + } +} + func validateChangeUpdateFlags(t *testing.T, update *CommandMetadata) { status := flagMetadataByName(update.Flags, "--status") if status == nil { diff --git a/internal/cli/cli_parse.go b/internal/cli/cli_parse.go index 9757e55..7ed5491 100644 --- a/internal/cli/cli_parse.go +++ b/internal/cli/cli_parse.go @@ -59,20 +59,11 @@ type changeUpdateRequest struct { recursive bool } -type changeAssessIntakeRequest struct { - root string - explicitRoot bool - title string - changeType string - size string - description string - contextBundles []string -} - -type changeAssessDecompositionRequest struct { +type changeDecompositionRequest struct { root string explicitRoot bool - changeID string + umbrellaID string + subChanges []contracts.SplitSubChange } func parseChangeNewArgs(args []string) (changeNewRequest, error) { @@ -226,60 +217,3 @@ func parseChangeReallocateArgs(args []string) (changeReallocateRequest, error) { request.changeID = changeID return request, nil } - -func parseChangeAssessIntakeArgs(args []string) (changeAssessIntakeRequest, error) { - request := changeAssessIntakeRequest{root: "."} - err := consumeArgs(args, func(flag parsedFlag) (int, error) { - switch flag.name { - case "--title": - return assignStringFlag(args, flag, &request.title) - case "--type": - return assignStringFlag(args, flag, &request.changeType) - case "--size": - return assignStringFlag(args, flag, &request.size) - case "--description": - return assignStringFlag(args, flag, &request.description) - case "--bundle": - return appendStringFlag(args, flag, &request.contextBundles) - case "--path": - return assignRootFlag(args, flag, &request.root, &request.explicitRoot) - default: - return flag.next, fmt.Errorf("unknown change assess-intake flag %q", flag.raw) - } - }, func(arg string) error { - return fmt.Errorf("unexpected positional argument %q", arg) - }) - if err != nil { - return changeAssessIntakeRequest{}, err - } - if strings.TrimSpace(request.title) == "" { - return changeAssessIntakeRequest{}, fmt.Errorf("--title is required") - } - if strings.TrimSpace(request.changeType) == "" { - return changeAssessIntakeRequest{}, fmt.Errorf("--type is required") - } - return request, nil -} - -func parseChangeAssessDecompositionArgs(args []string) (changeAssessDecompositionRequest, error) { - request := changeAssessDecompositionRequest{root: "."} - positionals := make([]string, 0, 1) - err := consumeArgs(args, func(flag parsedFlag) (int, error) { - if flag.name != "--path" { - return flag.next, fmt.Errorf("unknown change assess-decomposition flag %q", flag.raw) - } - return assignRootFlag(args, flag, &request.root, &request.explicitRoot) - }, func(arg string) error { - positionals = append(positionals, arg) - return nil - }) - if err != nil { - return changeAssessDecompositionRequest{}, err - } - changeID, err := requireExactPositional(positionals, "change assess-decomposition requires exactly one change ID") - if err != nil { - return changeAssessDecompositionRequest{}, err - } - request.changeID = changeID - return request, nil -} diff --git a/internal/cli/cli_parse_change_assess.go b/internal/cli/cli_parse_change_assess.go new file mode 100644 index 0000000..de577f5 --- /dev/null +++ b/internal/cli/cli_parse_change_assess.go @@ -0,0 +1,79 @@ +package cli + +import ( + "fmt" + "strings" +) + +type changeAssessIntakeRequest struct { + root string + explicitRoot bool + title string + changeType string + size string + description string + contextBundles []string +} + +type changeAssessDecompositionRequest struct { + root string + explicitRoot bool + changeID string +} + +func parseChangeAssessIntakeArgs(args []string) (changeAssessIntakeRequest, error) { + request := changeAssessIntakeRequest{root: "."} + err := consumeArgs(args, func(flag parsedFlag) (int, error) { + switch flag.name { + case "--title": + return assignStringFlag(args, flag, &request.title) + case "--type": + return assignStringFlag(args, flag, &request.changeType) + case "--size": + return assignStringFlag(args, flag, &request.size) + case "--description": + return assignStringFlag(args, flag, &request.description) + case "--bundle": + return appendStringFlag(args, flag, &request.contextBundles) + case "--path": + return assignRootFlag(args, flag, &request.root, &request.explicitRoot) + default: + return flag.next, fmt.Errorf("unknown change assess-intake flag %q", flag.raw) + } + }, func(arg string) error { + return fmt.Errorf("unexpected positional argument %q", arg) + }) + if err != nil { + return changeAssessIntakeRequest{}, err + } + if strings.TrimSpace(request.title) == "" { + return changeAssessIntakeRequest{}, fmt.Errorf("--title is required") + } + if strings.TrimSpace(request.changeType) == "" { + return changeAssessIntakeRequest{}, fmt.Errorf("--type is required") + } + return request, nil +} + +func parseChangeAssessDecompositionArgs(args []string) (changeAssessDecompositionRequest, error) { + request := changeAssessDecompositionRequest{root: "."} + positionals := make([]string, 0, 1) + err := consumeArgs(args, func(flag parsedFlag) (int, error) { + if flag.name != "--path" { + return flag.next, fmt.Errorf("unknown change assess-decomposition flag %q", flag.raw) + } + return assignRootFlag(args, flag, &request.root, &request.explicitRoot) + }, func(arg string) error { + positionals = append(positionals, arg) + return nil + }) + if err != nil { + return changeAssessDecompositionRequest{}, err + } + changeID, err := requireExactPositional(positionals, "change assess-decomposition requires exactly one change ID") + if err != nil { + return changeAssessDecompositionRequest{}, err + } + request.changeID = changeID + return request, nil +} diff --git a/internal/cli/cli_parse_change_decomposition.go b/internal/cli/cli_parse_change_decomposition.go new file mode 100644 index 0000000..9edadd7 --- /dev/null +++ b/internal/cli/cli_parse_change_decomposition.go @@ -0,0 +1,107 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/runecode-systems/runecontext/internal/contracts" +) + +type pendingSubChangeCLI struct { + id string + dependsOn []string +} + +func parseChangeDecompositionArgs(args []string, commandLabel string) (changeDecompositionRequest, error) { + request := changeDecompositionRequest{root: "."} + positionals := make([]string, 0, 1) + pending := map[string]*pendingSubChangeCLI{} + order := make([]string, 0) + err := consumeArgs(args, func(flag parsedFlag) (int, error) { + return handleDecompositionFlag(args, flag, commandLabel, &request, pending, &order) + }, func(arg string) error { + positionals = append(positionals, arg) + return nil + }) + if err != nil { + return changeDecompositionRequest{}, err + } + umbrellaID, err := requireExactPositional(positionals, fmt.Sprintf("%s requires exactly one umbrella change ID", commandLabel)) + if err != nil { + return changeDecompositionRequest{}, err + } + request.umbrellaID = umbrellaID + if len(order) == 0 { + return changeDecompositionRequest{}, fmt.Errorf("%s requires at least one --sub-change ID", commandLabel) + } + request.subChanges = make([]contracts.SplitSubChange, 0, len(order)) + for _, id := range order { + node := pending[id] + request.subChanges = append(request.subChanges, contracts.SplitSubChange{ID: node.id, DependsOn: node.dependsOn}) + } + return request, nil +} + +func handleDecompositionFlag(args []string, flag parsedFlag, commandLabel string, request *changeDecompositionRequest, pending map[string]*pendingSubChangeCLI, order *[]string) (int, error) { + switch flag.name { + case "--sub-change": + return handleSubChangeFlag(args, flag, commandLabel, pending, order) + case "--depends-on": + return handleDependsOnFlag(args, flag, commandLabel, pending, order) + case "--path": + return assignRootFlag(args, flag, &request.root, &request.explicitRoot) + default: + return flag.next, fmt.Errorf("unknown %s flag %q", commandLabel, flag.raw) + } +} + +func handleSubChangeFlag(args []string, flag parsedFlag, commandLabel string, pending map[string]*pendingSubChangeCLI, order *[]string) (int, error) { + value, next, err := flag.requireValue(args) + if err != nil { + return flag.next, err + } + id := strings.TrimSpace(value) + if id == "" { + return flag.next, fmt.Errorf("%s requires non-empty --sub-change value", commandLabel) + } + ensurePendingSubChange(id, pending, order) + return next, nil +} + +func handleDependsOnFlag(args []string, flag parsedFlag, commandLabel string, pending map[string]*pendingSubChangeCLI, order *[]string) (int, error) { + value, next, err := flag.requireValue(args) + if err != nil { + return flag.next, err + } + subID, depID, err := parseDependsOnEdge(value, commandLabel) + if err != nil { + return flag.next, err + } + node := ensurePendingSubChange(subID, pending, order) + node.dependsOn = append(node.dependsOn, depID) + return next, nil +} + +func parseDependsOnEdge(raw, commandLabel string) (string, string, error) { + rawSubID, rawDepID, ok := strings.Cut(raw, ":") + if !ok { + return "", "", fmt.Errorf("%s --depends-on must use SUB_CHANGE_ID:CHANGE_ID", commandLabel) + } + subID := strings.TrimSpace(rawSubID) + depID := strings.TrimSpace(rawDepID) + if subID == "" || depID == "" { + return "", "", fmt.Errorf("%s --depends-on must use SUB_CHANGE_ID:CHANGE_ID", commandLabel) + } + return subID, depID, nil +} + +func ensurePendingSubChange(id string, pending map[string]*pendingSubChangeCLI, order *[]string) *pendingSubChangeCLI { + node, ok := pending[id] + if ok { + return node + } + node = &pendingSubChangeCLI{id: id} + pending[id] = node + *order = append(*order, id) + return node +} diff --git a/internal/contracts/change_assessment.go b/internal/contracts/change_assessment.go index 25b0536..f836205 100644 --- a/internal/contracts/change_assessment.go +++ b/internal/contracts/change_assessment.go @@ -9,8 +9,8 @@ func AssessChangeIntake(v *Validator, loaded *LoadedProject, options ChangeAsses if err := validateChangeCommandInputs(v, loaded); err != nil { return nil, err } - changeType := strings.TrimSpace(options.Type) - if err := validateChangeTypeValue(changeType); err != nil { + changeType, err := normalizeAssessedChangeType(options.Type) + if err != nil { return nil, err } if strings.TrimSpace(options.Title) == "" { @@ -23,20 +23,10 @@ func AssessChangeIntake(v *Validator, loaded *LoadedProject, options ChangeAsses defer index.Close() assessment := assessChangeIntake(options.Title, changeType, options.Size, options.Description) - contextBundles, contextAssumptions, err := resolveContextBundlesForChange(index, options.ContextBundles) - if err != nil { - return nil, err - } - standards, standardAssumptions, err := resolveApplicableStandards(index, contextBundles) + contextBundles, assumptions, standards, err := resolveIntakeContextAndStandards(index, loaded, options, assessment) if err != nil { return nil, err } - assumptions := uniqueStringsInOrder(append([]string{}, assessment.Assumptions...)) - assumptions = uniqueStringsInOrder(append(assumptions, contextAssumptions...)) - assumptions = uniqueStringsInOrder(append(assumptions, standardAssumptions...)) - if note := verificationAssumption(loaded.Resolution.ProjectRoot); note != "" { - assumptions = append(assumptions, note) - } return &ChangeAssessIntakeResult{ Type: changeType, @@ -53,6 +43,37 @@ func AssessChangeIntake(v *Validator, loaded *LoadedProject, options ChangeAsses }, nil } +func normalizeAssessedChangeType(raw string) (string, error) { + changeType := strings.TrimSpace(raw) + if err := validateChangeTypeValue(changeType); err != nil { + return "", err + } + return changeType, nil +} + +func resolveIntakeContextAndStandards(index *ProjectIndex, loaded *LoadedProject, options ChangeAssessIntakeOptions, assessment changeIntakeAssessment) ([]string, []string, []string, error) { + contextBundles, contextAssumptions, err := resolveContextBundlesForChange(index, options.ContextBundles) + if err != nil { + return nil, nil, nil, err + } + standards, standardAssumptions, err := resolveApplicableStandards(index, contextBundles) + if err != nil { + return nil, nil, nil, err + } + assumptions := buildIntakeAssumptions(assessment, contextAssumptions, standardAssumptions, loaded.Resolution.ProjectRoot) + return contextBundles, assumptions, standards, nil +} + +func buildIntakeAssumptions(assessment changeIntakeAssessment, contextAssumptions, standardAssumptions []string, projectRoot string) []string { + assumptions := uniqueStringsInOrder(append([]string{}, assessment.Assumptions...)) + assumptions = uniqueStringsInOrder(append(assumptions, contextAssumptions...)) + assumptions = uniqueStringsInOrder(append(assumptions, standardAssumptions...)) + if note := verificationAssumption(projectRoot); note != "" { + assumptions = append(assumptions, note) + } + return assumptions +} + func AssessChangeDecomposition(v *Validator, loaded *LoadedProject, changeID string) (*ChangeAssessDecompositionResult, error) { if err := validateChangeCommandInputs(v, loaded); err != nil { return nil, err diff --git a/internal/contracts/change_assessment_test.go b/internal/contracts/change_assessment_test.go index 7c6cd00..3755384 100644 --- a/internal/contracts/change_assessment_test.go +++ b/internal/contracts/change_assessment_test.go @@ -1,6 +1,7 @@ package contracts import ( + "path/filepath" "strings" "testing" ) @@ -81,3 +82,79 @@ func TestAssessChangeDecompositionRejectsUnknownChange(t *testing.T) { t.Fatalf("expected unknown change error, got %v", err) } } + +func TestPlanChangeDecompositionBuildsGraph(t *testing.T) { + root := copyChangeWorkflowTemplate(t) + v, umbrellaID, _ := createUmbrellaAndFeatureSubChange(t, root) + _, secondFeature := mustCreateChange(t, root, defaultFeatureChangeOptions("Second sub-change", []byte{0x91, 0x92})) + + loaded := mustReloadWorkflowProject(t, v, root) + defer loaded.Close() + result, err := PlanChangeDecomposition(v, loaded, ChangeDecompositionPlanOptions{ + UmbrellaID: umbrellaID, + SubChanges: []SplitSubChange{ + {ID: secondFeature.ID}, + {ID: "CHG-2026-002-3344-feature-sub-change", DependsOn: []string{secondFeature.ID}}, + }, + }) + if err != nil { + t.Fatalf("plan decomposition: %v", err) + } + if got, want := result.UmbrellaID, umbrellaID; got != want { + t.Fatalf("expected umbrella %q, got %q", want, got) + } + if got := len(result.Graph); got != 3 { + t.Fatalf("expected graph with 3 nodes, got %d", got) + } + if got := result.Graph[umbrellaID].RelatedChanges; len(got) != 2 { + t.Fatalf("expected umbrella related links to both sub-changes, got %#v", got) + } +} + +func TestApplyChangeDecompositionRewritesRelationships(t *testing.T) { + root := copyChangeWorkflowTemplate(t) + v, umbrellaID, _ := createUmbrellaAndFeatureSubChange(t, root) + _, secondFeature := mustCreateChange(t, root, defaultFeatureChangeOptions("Second sub-change", []byte{0xa1, 0xa2})) + + loaded := mustReloadWorkflowProject(t, v, root) + defer loaded.Close() + result, err := ApplyChangeDecomposition(v, loaded, ChangeDecompositionApplyOptions{ + UmbrellaID: umbrellaID, + SubChanges: []SplitSubChange{ + {ID: secondFeature.ID}, + {ID: "CHG-2026-002-3344-feature-sub-change", DependsOn: []string{secondFeature.ID}}, + }, + }) + if err != nil { + t.Fatalf("apply decomposition: %v", err) + } + if got := len(result.ChangedFiles); got != 3 { + t.Fatalf("expected 3 changed status files, got %d (%#v)", got, result.ChangedFiles) + } + umbrellaStatusPath := filepath.Join(root, "runecontext", "changes", umbrellaID, "status.yaml") + umbrellaText := strings.ReplaceAll(string(mustReadBytes(t, umbrellaStatusPath)), "\r\n", "\n") + if !strings.Contains(umbrellaText, "related_changes:\n - CHG-2026-002-3344-feature-sub-change\n - "+secondFeature.ID) { + t.Fatalf("expected umbrella related_changes rewrite, got:\n%s", umbrellaText) + } + featureStatusPath := filepath.Join(root, "runecontext", "changes", "CHG-2026-002-3344-feature-sub-change", "status.yaml") + featureText := strings.ReplaceAll(string(mustReadBytes(t, featureStatusPath)), "\r\n", "\n") + if !strings.Contains(featureText, "depends_on:\n - "+secondFeature.ID) { + t.Fatalf("expected feature depends_on rewrite, got:\n%s", featureText) + } + assertValidatedWorkflowProject(t, v, root) +} + +func TestApplyChangeDecompositionRejectsNonProjectUmbrella(t *testing.T) { + root := copyChangeWorkflowTemplate(t) + v, feature := mustCreateDefaultFeatureChange(t, root) + _, secondFeature := mustCreateChange(t, root, defaultFeatureChangeOptions("Second feature", []byte{0xb1, 0xb2})) + loaded := mustReloadWorkflowProject(t, v, root) + defer loaded.Close() + _, err := ApplyChangeDecomposition(v, loaded, ChangeDecompositionApplyOptions{ + UmbrellaID: feature.ID, + SubChanges: []SplitSubChange{{ID: secondFeature.ID}}, + }) + if err == nil || !strings.Contains(err.Error(), "decomposition umbrella must be type project") { + t.Fatalf("expected non-project umbrella rejection, got %v", err) + } +} diff --git a/internal/contracts/change_commands.go b/internal/contracts/change_commands.go index aae2852..e7ebb58 100644 --- a/internal/contracts/change_commands.go +++ b/internal/contracts/change_commands.go @@ -128,6 +128,29 @@ type ChangeAssessDecompositionResult struct { Reasons []string } +type ChangeDecompositionPlanOptions struct { + UmbrellaID string + SubChanges []SplitSubChange +} + +type ChangeDecompositionPlanResult struct { + UmbrellaID string + NodeIDs []string + Graph map[string]ChangeGraphLinks +} + +type ChangeDecompositionApplyOptions struct { + UmbrellaID string + SubChanges []SplitSubChange +} + +type ChangeDecompositionApplyResult struct { + UmbrellaID string + NodeIDs []string + Graph map[string]ChangeGraphLinks + ChangedFiles []FileMutation +} + type ChangeReallocationResult struct { OldID string ID string diff --git a/internal/contracts/change_decomposition.go b/internal/contracts/change_decomposition.go new file mode 100644 index 0000000..21fd366 --- /dev/null +++ b/internal/contracts/change_decomposition.go @@ -0,0 +1,197 @@ +package contracts + +import ( + "fmt" + "strings" +) + +func PlanChangeDecomposition(v *Validator, loaded *LoadedProject, options ChangeDecompositionPlanOptions) (*ChangeDecompositionPlanResult, error) { + if err := validateChangeCommandInputs(v, loaded); err != nil { + return nil, err + } + index, err := v.ValidateLoadedProject(loaded) + if err != nil { + return nil, err + } + defer index.Close() + + plan, err := normalizeDecompositionPlan(options) + if err != nil { + return nil, err + } + if err := validateDecompositionPlanAgainstProject(index, plan, false); err != nil { + return nil, err + } + graph, err := BuildSplitChangeGraph(plan) + if err != nil { + return nil, err + } + return buildDecompositionPlanResult(plan, graph), nil +} + +func ApplyChangeDecomposition(v *Validator, loaded *LoadedProject, options ChangeDecompositionApplyOptions) (*ChangeDecompositionApplyResult, error) { + if err := validateWritableChangeCommand(v, loaded); err != nil { + return nil, err + } + index, err := v.ValidateLoadedProject(loaded) + if err != nil { + return nil, err + } + defer index.Close() + + plan, err := normalizeDecompositionPlan(ChangeDecompositionPlanOptions(options)) + if err != nil { + return nil, err + } + if err := validateDecompositionPlanAgainstProject(index, plan, true); err != nil { + return nil, err + } + graph, err := BuildSplitChangeGraph(plan) + if err != nil { + return nil, err + } + writableRoot, err := writableContentRoot(loaded) + if err != nil { + return nil, err + } + writes, changedFiles, err := buildDecompositionStatusWrites(v, index, writableRoot, graph) + if err != nil { + return nil, err + } + if err := applyFileRewritesTransaction(writes, func() error { + return validateChangeMutation(v, loaded.Resolution.ProjectRoot) + }); err != nil { + return nil, err + } + return buildDecompositionApplyResult(plan, graph, changedFiles), nil +} + +func normalizeDecompositionPlan(options ChangeDecompositionPlanOptions) (SplitChangePlan, error) { + umbrellaID := strings.TrimSpace(options.UmbrellaID) + if umbrellaID == "" { + return SplitChangePlan{}, fmt.Errorf("decomposition plan requires umbrella change ID") + } + if len(options.SubChanges) == 0 { + return SplitChangePlan{}, fmt.Errorf("decomposition plan requires at least one sub-change") + } + subChanges := make([]SplitSubChange, 0, len(options.SubChanges)) + for _, sub := range options.SubChanges { + subID := strings.TrimSpace(sub.ID) + if subID == "" { + return SplitChangePlan{}, fmt.Errorf("decomposition sub-change ID must not be blank") + } + dependsOn := make([]string, 0, len(sub.DependsOn)) + for _, dep := range sub.DependsOn { + trimmed := strings.TrimSpace(dep) + if trimmed == "" { + return SplitChangePlan{}, fmt.Errorf("decomposition depends_on entries must not be blank") + } + dependsOn = append(dependsOn, trimmed) + } + subChanges = append(subChanges, SplitSubChange{ID: subID, DependsOn: uniqueSortedStrings(dependsOn)}) + } + return SplitChangePlan{UmbrellaID: umbrellaID, SubChanges: subChanges}, nil +} + +func validateDecompositionPlanAgainstProject(index *ProjectIndex, plan SplitChangePlan, forApply bool) error { + if err := validateDecompositionUmbrella(index, plan.UmbrellaID, forApply); err != nil { + return err + } + for _, sub := range plan.SubChanges { + if err := validateDecompositionSubChange(index, sub.ID, forApply); err != nil { + return err + } + } + return nil +} + +func validateDecompositionUmbrella(index *ProjectIndex, umbrellaID string, forApply bool) error { + umbrella := index.Changes[umbrellaID] + if umbrella == nil { + return fmt.Errorf("change %q does not exist", umbrellaID) + } + if strings.TrimSpace(umbrella.Type) != "project" { + return fmt.Errorf("change %q is type %q; decomposition umbrella must be type project", umbrella.ID, umbrella.Type) + } + if forApply && isTerminalLifecycleStatus(umbrella.Status) { + return fmt.Errorf("change %q is already in terminal status %q and cannot accept decomposition apply edits", umbrella.ID, umbrella.Status) + } + return nil +} + +func validateDecompositionSubChange(index *ProjectIndex, subChangeID string, forApply bool) error { + record := index.Changes[subChangeID] + if record == nil { + return fmt.Errorf("change %q does not exist", subChangeID) + } + if strings.TrimSpace(record.Type) != "feature" { + return fmt.Errorf("change %q is type %q; decomposition sub-changes must be type feature", subChangeID, record.Type) + } + if forApply && isTerminalLifecycleStatus(record.Status) { + return fmt.Errorf("change %q is already in terminal status %q and cannot accept decomposition apply edits", subChangeID, record.Status) + } + return nil +} + +func buildDecompositionPlanResult(plan SplitChangePlan, graph map[string]ChangeGraphLinks) *ChangeDecompositionPlanResult { + return &ChangeDecompositionPlanResult{ + UmbrellaID: plan.UmbrellaID, + NodeIDs: decompositionNodeIDs(plan.UmbrellaID, plan.SubChanges), + Graph: cloneChangeGraphLinksMap(graph), + } +} + +func buildDecompositionApplyResult(plan SplitChangePlan, graph map[string]ChangeGraphLinks, changedFiles []FileMutation) *ChangeDecompositionApplyResult { + return &ChangeDecompositionApplyResult{ + UmbrellaID: plan.UmbrellaID, + NodeIDs: decompositionNodeIDs(plan.UmbrellaID, plan.SubChanges), + Graph: cloneChangeGraphLinksMap(graph), + ChangedFiles: append([]FileMutation(nil), changedFiles...), + } +} + +func decompositionNodeIDs(umbrellaID string, subChanges []SplitSubChange) []string { + ids := make([]string, 0, len(subChanges)+1) + ids = append(ids, umbrellaID) + for _, sub := range subChanges { + ids = append(ids, sub.ID) + } + return uniqueSortedStrings(ids) +} + +func cloneChangeGraphLinksMap(input map[string]ChangeGraphLinks) map[string]ChangeGraphLinks { + if input == nil { + return nil + } + cloned := make(map[string]ChangeGraphLinks, len(input)) + for id, links := range input { + cloned[id] = ChangeGraphLinks{ + RelatedChanges: append([]string(nil), links.RelatedChanges...), + DependsOn: append([]string(nil), links.DependsOn...), + } + } + return cloned +} + +func buildDecompositionStatusWrites(v *Validator, index *ProjectIndex, writableRoot string, graph map[string]ChangeGraphLinks) ([]fileRewrite, []FileMutation, error) { + writes := make([]fileRewrite, 0, len(graph)) + changedFiles := make([]FileMutation, 0, len(graph)) + for _, changeID := range SortedKeys(graph) { + record := index.Changes[changeID] + if record == nil { + return nil, nil, fmt.Errorf("change %q does not exist", changeID) + } + links := graph[changeID] + status := cloneMap(index.StatusFiles[record.StatusPath].Data) + status["related_changes"] = stringSliceToAny(uniqueSortedStrings(links.RelatedChanges)) + status["depends_on"] = stringSliceToAny(uniqueSortedStrings(links.DependsOn)) + write, _, err := buildPrimaryCloseStatusWrite(v, writableRoot, record, status) + if err != nil { + return nil, nil, err + } + writes = append(writes, write) + changedFiles = append(changedFiles, FileMutation{Path: runeContextRelativePath(writableRoot, record.StatusPath), Action: "updated"}) + } + sortFileMutations(changedFiles) + return writes, changedFiles, nil +} From c01a312a8ebd15716436141365a96c375234f1e0 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich <zeb@runecode.org> Date: Tue, 31 Mar 2026 19:33:52 -0500 Subject: [PATCH 05/27] Adds adapter context details so they can run guided clarification and decomposition flows Signed-off-by: Zebulon Rouse-Frantzich <zeb@runecode.org> --- .../flows/change-assess-decomposition.md | 7 +++++++ .../claude-code/flows/change-assess-intake.md | 11 +++++++++++ .../flows/change-decomposition-apply.md | 5 +++++ .../flows/change-decomposition-plan.md | 7 +++++++ .../codex/flows/change-assess-decomposition.md | 7 +++++++ adapters/codex/flows/change-assess-intake.md | 11 +++++++++++ .../codex/flows/change-decomposition-apply.md | 5 +++++ .../codex/flows/change-decomposition-plan.md | 7 +++++++ .../flows/change-assess-decomposition.md | 9 +++++++++ adapters/generic/flows/change-assess-intake.md | 11 +++++++++++ .../generic/flows/change-decomposition-apply.md | 5 +++++ .../generic/flows/change-decomposition-plan.md | 8 ++++++++ .../flows/change-assess-decomposition.md | 14 ++++++++++++++ adapters/opencode/flows/change-assess-intake.md | 17 +++++++++++++++++ .../flows/change-decomposition-apply.md | 7 +++++++ .../opencode/flows/change-decomposition-plan.md | 13 +++++++++++++ 16 files changed, 144 insertions(+) diff --git a/adapters/claude-code/flows/change-assess-decomposition.md b/adapters/claude-code/flows/change-assess-decomposition.md index 3adbd06..ea5d1bb 100644 --- a/adapters/claude-code/flows/change-assess-decomposition.md +++ b/adapters/claude-code/flows/change-assess-decomposition.md @@ -5,3 +5,10 @@ Map conversational decomposition assessment to: ```sh runectx change assess-decomposition <CHANGE_ID> [--path <project-root>] ``` + +Guided behavior: + +- Ask emitted `clarification_prompt_*` when `clarification_needed=true`. +- Keep decomposition advisory fields user-visible. +- Re-run assessment when clarified scope materially changes graph planning. +- Prefer `change decomposition-plan` before `change decomposition-apply`. diff --git a/adapters/claude-code/flows/change-assess-intake.md b/adapters/claude-code/flows/change-assess-intake.md index 7643893..fcbc281 100644 --- a/adapters/claude-code/flows/change-assess-intake.md +++ b/adapters/claude-code/flows/change-assess-intake.md @@ -5,3 +5,14 @@ Map conversational intake assessment to: ```sh runectx change assess-intake --title "<title>" --type <type> [--size <size>] [--bundle <bundle-id>] [--description "<text>"] [--path <project-root>] ``` + +Guided behavior: + +- Ask each emitted `clarification_prompt_*` when `intake_readiness` indicates + clarification or `clarification_needed=true`. +- Re-run intake assessment with updated inputs until guidance stabilizes. +- If `recommended_mode=full`, default to proposing `runectx change new --shape full` + unless the user explicitly overrides. +- If `decomposition_signal=consider_decomposition`, propose follow-up: + `change assess-decomposition` -> `change decomposition-plan` -> + `change decomposition-apply` after plan approval. diff --git a/adapters/claude-code/flows/change-decomposition-apply.md b/adapters/claude-code/flows/change-decomposition-apply.md index 33c6096..8b9f5c6 100644 --- a/adapters/claude-code/flows/change-decomposition-apply.md +++ b/adapters/claude-code/flows/change-decomposition-apply.md @@ -5,3 +5,8 @@ Map conversational decomposition apply to: ```sh runectx change decomposition-apply <UMBRELLA_CHANGE_ID> --sub-change <CHANGE_ID> [--sub-change <CHANGE_ID> ...] [--depends-on <SUB_CHANGE_ID:CHANGE_ID> ...] [--path <project-root>] ``` + +Guided behavior: + +- Execute only after explicit user confirmation of IDs and edges. +- If relationship intent is unclear, route back to decomposition plan first. diff --git a/adapters/claude-code/flows/change-decomposition-plan.md b/adapters/claude-code/flows/change-decomposition-plan.md index bcbeaff..c12755f 100644 --- a/adapters/claude-code/flows/change-decomposition-plan.md +++ b/adapters/claude-code/flows/change-decomposition-plan.md @@ -5,3 +5,10 @@ Map conversational decomposition planning to: ```sh runectx change decomposition-plan <UMBRELLA_CHANGE_ID> --sub-change <CHANGE_ID> [--sub-change <CHANGE_ID> ...] [--depends-on <SUB_CHANGE_ID:CHANGE_ID> ...] [--path <project-root>] ``` + +Guided behavior: + +- Keep `graph_*` output visible for review. +- If graph intent is unclear, gather clarifications and re-run with revised + `--sub-change`/`--depends-on` values. +- Use the approved plan inputs when mapping to decomposition apply. diff --git a/adapters/codex/flows/change-assess-decomposition.md b/adapters/codex/flows/change-assess-decomposition.md index 9e1f778..61d57d9 100644 --- a/adapters/codex/flows/change-assess-decomposition.md +++ b/adapters/codex/flows/change-assess-decomposition.md @@ -5,3 +5,10 @@ Map conversational decomposition assessment to: ```sh runectx change assess-decomposition <CHANGE_ID> [--path <project-root>] ``` + +Guided behavior: + +- Ask emitted `clarification_prompt_*` when `clarification_needed=true`. +- Keep decomposition advisory fields user-visible. +- Re-run assessment when clarified scope materially changes graph planning. +- Prefer `change decomposition-plan` before `change decomposition-apply`. diff --git a/adapters/codex/flows/change-assess-intake.md b/adapters/codex/flows/change-assess-intake.md index 6e6a239..4beb8e5 100644 --- a/adapters/codex/flows/change-assess-intake.md +++ b/adapters/codex/flows/change-assess-intake.md @@ -5,3 +5,14 @@ Map conversational intake assessment to: ```sh runectx change assess-intake --title "<title>" --type <type> [--size <size>] [--bundle <bundle-id>] [--description "<text>"] [--path <project-root>] ``` + +Guided behavior: + +- Ask each emitted `clarification_prompt_*` when `intake_readiness` indicates + clarification or `clarification_needed=true`. +- Re-run intake assessment with updated inputs until guidance stabilizes. +- If `recommended_mode=full`, default to proposing `runectx change new --shape full` + unless the user explicitly overrides. +- If `decomposition_signal=consider_decomposition`, propose follow-up: + `change assess-decomposition` -> `change decomposition-plan` -> + `change decomposition-apply` after plan approval. diff --git a/adapters/codex/flows/change-decomposition-apply.md b/adapters/codex/flows/change-decomposition-apply.md index 8ce642c..6a4b06d 100644 --- a/adapters/codex/flows/change-decomposition-apply.md +++ b/adapters/codex/flows/change-decomposition-apply.md @@ -5,3 +5,8 @@ Map conversational decomposition apply to: ```sh runectx change decomposition-apply <UMBRELLA_CHANGE_ID> --sub-change <CHANGE_ID> [--sub-change <CHANGE_ID> ...] [--depends-on <SUB_CHANGE_ID:CHANGE_ID> ...] [--path <project-root>] ``` + +Guided behavior: + +- Execute only after explicit user confirmation of IDs and edges. +- If relationship intent is unclear, route back to decomposition plan first. diff --git a/adapters/codex/flows/change-decomposition-plan.md b/adapters/codex/flows/change-decomposition-plan.md index 207654c..2965208 100644 --- a/adapters/codex/flows/change-decomposition-plan.md +++ b/adapters/codex/flows/change-decomposition-plan.md @@ -5,3 +5,10 @@ Map conversational decomposition planning to: ```sh runectx change decomposition-plan <UMBRELLA_CHANGE_ID> --sub-change <CHANGE_ID> [--sub-change <CHANGE_ID> ...] [--depends-on <SUB_CHANGE_ID:CHANGE_ID> ...] [--path <project-root>] ``` + +Guided behavior: + +- Keep `graph_*` output visible for review. +- If graph intent is unclear, gather clarifications and re-run with revised + `--sub-change`/`--depends-on` values. +- Use the approved plan inputs when mapping to decomposition apply. diff --git a/adapters/generic/flows/change-assess-decomposition.md b/adapters/generic/flows/change-assess-decomposition.md index 3bb7eaf..c2a6d6d 100644 --- a/adapters/generic/flows/change-assess-decomposition.md +++ b/adapters/generic/flows/change-assess-decomposition.md @@ -5,3 +5,12 @@ Use explicit CLI command proposals: ```sh runectx change assess-decomposition <CHANGE_ID> [--path <project-root>] ``` + +Use advisory fields to drive guided loops: + +- Ask emitted `clarification_prompt_*` when `clarification_needed=true`. +- Keep `decomposition_signal`, `eligible_sub_change_*`, and + `prerequisite_change_*` visible to the user. +- Re-run assessment when clarifications materially change decomposition scope. +- Propose `change decomposition-plan` before `change decomposition-apply` so the + graph is reviewed before mutation. diff --git a/adapters/generic/flows/change-assess-intake.md b/adapters/generic/flows/change-assess-intake.md index 7549bb6..cdecba7 100644 --- a/adapters/generic/flows/change-assess-intake.md +++ b/adapters/generic/flows/change-assess-intake.md @@ -5,3 +5,14 @@ Use explicit CLI command proposals: ```sh runectx change assess-intake --title "<title>" --type <type> [--size <size>] [--bundle <bundle-id>] [--description "<text>"] [--path <project-root>] ``` + +Use advisory fields to drive guided loops: + +- Ask emitted `clarification_prompt_*` when `intake_readiness=needs_clarification` + or `clarification_needed=true`. +- Re-run assessment with updated inputs until guidance stabilizes. +- If `recommended_mode=full`, prefer `runectx change new --shape full` unless + the user explicitly overrides. +- If `decomposition_signal=consider_decomposition`, propose follow-up with + `change assess-decomposition`, then `change decomposition-plan`, then + `change decomposition-apply` after plan review. diff --git a/adapters/generic/flows/change-decomposition-apply.md b/adapters/generic/flows/change-decomposition-apply.md index 787a2a9..d2cf69b 100644 --- a/adapters/generic/flows/change-decomposition-apply.md +++ b/adapters/generic/flows/change-decomposition-apply.md @@ -5,3 +5,8 @@ Use explicit CLI command proposals: ```sh runectx change decomposition-apply <UMBRELLA_CHANGE_ID> --sub-change <CHANGE_ID> [--sub-change <CHANGE_ID> ...] [--depends-on <SUB_CHANGE_ID:CHANGE_ID> ...] [--path <project-root>] ``` + +Apply only after explicit review: + +- Confirm umbrella/sub-change IDs and dependency edges before execution. +- If relationships are uncertain, return to `change decomposition-plan` first. diff --git a/adapters/generic/flows/change-decomposition-plan.md b/adapters/generic/flows/change-decomposition-plan.md index e9b89e2..05a7767 100644 --- a/adapters/generic/flows/change-decomposition-plan.md +++ b/adapters/generic/flows/change-decomposition-plan.md @@ -5,3 +5,11 @@ Use explicit CLI command proposals: ```sh runectx change decomposition-plan <UMBRELLA_CHANGE_ID> --sub-change <CHANGE_ID> [--sub-change <CHANGE_ID> ...] [--depends-on <SUB_CHANGE_ID:CHANGE_ID> ...] [--path <project-root>] ``` + +Keep plan output reviewable before mutation: + +- Keep `graph_*` output user-visible. +- If clarification is needed, revise `--sub-change` and `--depends-on` inputs + and re-run planning. +- After explicit user approval of the plan, map the same graph inputs to + `runectx change decomposition-apply`. diff --git a/adapters/opencode/flows/change-assess-decomposition.md b/adapters/opencode/flows/change-assess-decomposition.md index ea5327c..58cb71e 100644 --- a/adapters/opencode/flows/change-assess-decomposition.md +++ b/adapters/opencode/flows/change-assess-decomposition.md @@ -19,3 +19,17 @@ runectx change assess-decomposition <CHANGE_ID> [--path <project-root>] - Confirm the target `CHANGE_ID` before execution. - Keep advisory outputs (`decomposition_signal`, `eligible_sub_change_*`, and `prerequisite_change_*`) user-visible. + +## Guided Clarification Loop + +- If `clarification_needed=true`, ask each `clarification_prompt_*` and confirm + any ambiguous scope before proposing write operations. +- Re-run `change assess-decomposition` after major clarifications when the + target graph assumptions change. + +## Guided Decomposition Progression + +- If decomposition is indicated, propose `change decomposition-plan` first and + keep `graph_*` output user-visible for review. +- Only propose `change decomposition-apply` after the reviewed plan and edge + list are explicitly accepted. diff --git a/adapters/opencode/flows/change-assess-intake.md b/adapters/opencode/flows/change-assess-intake.md index c1d37e0..b3db97b 100644 --- a/adapters/opencode/flows/change-assess-intake.md +++ b/adapters/opencode/flows/change-assess-intake.md @@ -23,3 +23,20 @@ runectx change assess-intake --title "<title>" --type <type> [--size <size>] [-- - Keep this advisory/read-only output visible in the conversation. - Surface `recommended_mode`, `intake_readiness`, and `decomposition_signal` before proposing mutations. + +## Guided Clarification Loop + +- If `intake_readiness=needs_clarification` or `clarification_needed=true`, ask + each emitted `clarification_prompt_*` explicitly and wait for user answers. +- Re-run `change assess-intake` with the updated title/type/size/bundle/ + description inputs until readiness no longer requires clarification or the + user asks to proceed with explicit tradeoffs. +- Keep each advisory result user-visible and avoid hidden adapter-only state. + +## Guided Decomposition Handoff + +- If `recommended_mode=full`, default to `--shape full` when proposing + `runectx change new`, unless the user explicitly overrides. +- If `decomposition_signal=consider_decomposition`, propose a decomposition + sequence after change creation: `change assess-decomposition`, then + `change decomposition-plan`, then `change decomposition-apply` once reviewed. diff --git a/adapters/opencode/flows/change-decomposition-apply.md b/adapters/opencode/flows/change-decomposition-apply.md index 9bad63c..fbf292c 100644 --- a/adapters/opencode/flows/change-decomposition-apply.md +++ b/adapters/opencode/flows/change-decomposition-apply.md @@ -20,3 +20,10 @@ runectx change decomposition-apply <UMBRELLA_CHANGE_ID> --sub-change <CHANGE_ID> - Confirm umbrella and sub-change IDs before execution. - Review changed status files and relationship rewrites before commit. + +## Guided Clarification Guardrails + +- If the user is unsure about relationships or missing prerequisites, pause and + return to `change decomposition-plan` for another advisory pass. +- Keep command proposals explicit and user-confirmed; do not apply hidden + relationship edits. diff --git a/adapters/opencode/flows/change-decomposition-plan.md b/adapters/opencode/flows/change-decomposition-plan.md index 3f8a92b..af5d5e5 100644 --- a/adapters/opencode/flows/change-decomposition-plan.md +++ b/adapters/opencode/flows/change-decomposition-plan.md @@ -20,3 +20,16 @@ runectx change decomposition-plan <UMBRELLA_CHANGE_ID> --sub-change <CHANGE_ID> - Confirm umbrella and sub-change IDs before execution. - Keep advisory graph outputs (`graph_*`) user-visible for review. + +## Guided Clarification And Iteration + +- If the planned graph conflicts with user intent, gather clarifications on + missing sub-changes or dependency edges and re-run planning. +- Preserve reviewability by proposing exact revised `--sub-change` and + `--depends-on` arguments before each re-run. + +## Apply Handoff + +- When the user accepts the advisory graph, hand off to + `runectx change decomposition-apply` with the same umbrella/sub-change/edge + inputs used for the approved plan. From f1fbda9f2898435d1aeb43bb8e5647d8a3e1a6b6 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich <zeb@runecode.org> Date: Tue, 31 Mar 2026 20:01:59 -0500 Subject: [PATCH 06/27] Adds first-class standard authoring commands Signed-off-by: Zebulon Rouse-Frantzich <zeb@runecode.org> --- .../generated/runecontext-reference.json | 15 ++ fixtures/cli-completion/bash.golden | 38 +++- fixtures/cli-completion/fish.golden | 55 +++++ fixtures/cli-completion/zsh.golden | 38 +++- .../cli-json-golden/metadata-success.json | 2 +- .../release-manifest-with-metadata.json | 12 ++ internal/cli/cli.go | 7 +- internal/cli/cli_metadata_flags.go | 38 ++++ internal/cli/cli_metadata_registry.go | 5 +- internal/cli/cli_standard.go | 8 +- internal/cli/cli_standard_authoring_shared.go | 84 ++++++++ internal/cli/cli_standard_authoring_test.go | 101 ++++++++++ internal/cli/cli_standard_create.go | 105 ++++++++++ internal/cli/cli_standard_list.go | 116 +++++++++++ internal/cli/cli_standard_update.go | 108 ++++++++++ .../contracts/standard_commands_create.go | 127 ++++++++++++ .../contracts/standard_commands_helpers.go | 189 ++++++++++++++++++ internal/contracts/standard_commands_list.go | 108 ++++++++++ internal/contracts/standard_commands_test.go | 121 +++++++++++ internal/contracts/standard_commands_types.go | 62 ++++++ .../contracts/standard_commands_update.go | 172 ++++++++++++++++ 21 files changed, 1502 insertions(+), 9 deletions(-) create mode 100644 internal/cli/cli_standard_authoring_shared.go create mode 100644 internal/cli/cli_standard_authoring_test.go create mode 100644 internal/cli/cli_standard_create.go create mode 100644 internal/cli/cli_standard_list.go create mode 100644 internal/cli/cli_standard_update.go create mode 100644 internal/contracts/standard_commands_create.go create mode 100644 internal/contracts/standard_commands_helpers.go create mode 100644 internal/contracts/standard_commands_list.go create mode 100644 internal/contracts/standard_commands_test.go create mode 100644 internal/contracts/standard_commands_types.go create mode 100644 internal/contracts/standard_commands_update.go diff --git a/docs/reference/generated/runecontext-reference.json b/docs/reference/generated/runecontext-reference.json index 689ed35..bf18b39 100644 --- a/docs/reference/generated/runecontext-reference.json +++ b/docs/reference/generated/runecontext-reference.json @@ -129,10 +129,22 @@ "path": "standard", "token": "standard" }, + { + "path": "standard create", + "token": "standard_create" + }, { "path": "standard discover", "token": "standard_discover" }, + { + "path": "standard list", + "token": "standard_list" + }, + { + "path": "standard update", + "token": "standard_update" + }, { "path": "status", "token": "status" @@ -199,7 +211,10 @@ "metadata", "promote", "standard", + "standard_create", "standard_discover", + "standard_list", + "standard_update", "status", "upgrade", "upgrade_apply", diff --git a/fixtures/cli-completion/bash.golden b/fixtures/cli-completion/bash.golden index 927707a..a076eef 100644 --- a/fixtures/cli-completion/bash.golden +++ b/fixtures/cli-completion/bash.golden @@ -33,7 +33,10 @@ _runectx_has_command() { 'metadata') return 0 ;; 'promote') return 0 ;; 'standard') return 0 ;; + 'standard create') return 0 ;; 'standard discover') return 0 ;; + 'standard list') return 0 ;; + 'standard update') return 0 ;; 'status') return 0 ;; 'upgrade') return 0 ;; 'upgrade apply') return 0 ;; @@ -79,8 +82,11 @@ _runectx_subcommands_for() { 'init') echo '' ;; 'metadata') echo '' ;; 'promote') echo '' ;; - 'standard') echo 'discover' ;; + 'standard') echo 'create discover list update' ;; + 'standard create') echo '' ;; 'standard discover') echo '' ;; + 'standard list') echo '' ;; + 'standard update') echo '' ;; 'status') echo '' ;; 'upgrade') echo 'apply cli' ;; 'upgrade apply') echo '' ;; @@ -121,8 +127,11 @@ _runectx_flags_for() { 'generate indexes') echo '--explain --json --non-interactive --path' ;; 'init') echo '--dry-run --explain --json --mode --non-interactive --path --seed-bundle' ;; 'promote') echo '--accept --complete --dry-run --explain --json --non-interactive --path --target' ;; - 'standard') echo '--explain --json --non-interactive' ;; + 'standard') echo '--dry-run --explain --json --non-interactive' ;; + 'standard create') echo '--alias --body --dry-run --explain --id --json --non-interactive --path --project-path --replaced-by --status --suggested-context-bundle --title' ;; 'standard discover') echo '--change --confirm-handoff --explain --focus --json --non-interactive --path --scope-path --target' ;; + 'standard list') echo '--explain --focus --json --non-interactive --path --scope-path --status' ;; + 'standard update') echo '--alias --clear-replaced-by --dry-run --explain --json --non-interactive --path --project-path --replace-aliases --replace-suggested-context-bundles --replaced-by --status --suggested-context-bundle --title' ;; 'status') echo '--explain --json --non-interactive --path' ;; 'upgrade') echo '--explain --json --non-interactive --path --target-version' ;; 'upgrade apply') echo '--explain --json --non-interactive --path --target-version' ;; @@ -186,11 +195,31 @@ _runectx_flag_takes_value() { 'init|--seed-bundle') return 0 ;; 'promote|--path') return 0 ;; 'promote|--target') return 0 ;; + 'standard create|--alias') return 0 ;; + 'standard create|--body') return 0 ;; + 'standard create|--id') return 0 ;; + 'standard create|--path') return 0 ;; + 'standard create|--project-path') return 0 ;; + 'standard create|--replaced-by') return 0 ;; + 'standard create|--status') return 0 ;; + 'standard create|--suggested-context-bundle') return 0 ;; + 'standard create|--title') return 0 ;; 'standard discover|--change') return 0 ;; 'standard discover|--focus') return 0 ;; 'standard discover|--path') return 0 ;; 'standard discover|--scope-path') return 0 ;; 'standard discover|--target') return 0 ;; + 'standard list|--focus') return 0 ;; + 'standard list|--path') return 0 ;; + 'standard list|--scope-path') return 0 ;; + 'standard list|--status') return 0 ;; + 'standard update|--alias') return 0 ;; + 'standard update|--path') return 0 ;; + 'standard update|--project-path') return 0 ;; + 'standard update|--replaced-by') return 0 ;; + 'standard update|--status') return 0 ;; + 'standard update|--suggested-context-bundle') return 0 ;; + 'standard update|--title') return 0 ;; 'status|--path') return 0 ;; 'upgrade apply|--path') return 0 ;; 'upgrade apply|--target-version') return 0 ;; @@ -216,6 +245,9 @@ _runectx_enum_for_flag() { 'change update|--status') echo 'implemented planned verified' ;; 'change update|--verification-status') echo 'failed passed skipped' ;; 'init|--mode') echo 'embedded linked' ;; + 'standard create|--status') echo 'active deprecated draft' ;; + 'standard list|--status') echo 'active deprecated draft' ;; + 'standard update|--status') echo 'active deprecated draft' ;; *) echo '' ;; esac } @@ -231,8 +263,10 @@ _runectx_suggest_provider_for_flag() { 'change update|--remove-related-change') echo 'change-ids' ;; 'init|--seed-bundle') echo 'bundle-ids' ;; 'promote|--target') echo 'promotion-targets' ;; + 'standard create|--suggested-context-bundle') echo 'bundle-ids' ;; 'standard discover|--change') echo 'change-ids' ;; 'standard discover|--target') echo 'promotion-targets' ;; + 'standard update|--suggested-context-bundle') echo 'bundle-ids' ;; *) echo '' ;; esac } diff --git a/fixtures/cli-completion/fish.golden b/fixtures/cli-completion/fish.golden index ac6dc54..f9a5881 100644 --- a/fixtures/cli-completion/fish.golden +++ b/fixtures/cli-completion/fish.golden @@ -73,7 +73,10 @@ complete -c runectx -f -n '__fish_seen_subcommand_from change' -a 'update' complete -c runectx -f -n '__fish_seen_subcommand_from completion' -a 'metadata' complete -c runectx -f -n '__fish_seen_subcommand_from completion' -a 'suggest' complete -c runectx -f -n '__fish_seen_subcommand_from generate' -a 'indexes' +complete -c runectx -f -n '__fish_seen_subcommand_from standard' -a 'create' complete -c runectx -f -n '__fish_seen_subcommand_from standard' -a 'discover' +complete -c runectx -f -n '__fish_seen_subcommand_from standard' -a 'list' +complete -c runectx -f -n '__fish_seen_subcommand_from standard' -a 'update' complete -c runectx -f -n '__fish_seen_subcommand_from upgrade' -a 'apply' complete -c runectx -f -n '__fish_seen_subcommand_from upgrade' -a 'cli' complete -c runectx -f -n '__fish_seen_subcommand_from upgrade cli' -a 'apply' @@ -242,9 +245,25 @@ complete -c runectx -n '__fish_seen_subcommand_from promote' -l non-interactive complete -c runectx -n '__fish_seen_subcommand_from promote' -l path -r complete -c runectx -n '__fish_seen_subcommand_from promote' -l target -r complete -c runectx -f -n '(__fish_seen_subcommand_from promote); and __runectx_prev_token_is --target' -a '(__runectx_dynamic_suggest promotion-targets)' +complete -c runectx -n '__fish_seen_subcommand_from standard' -l dry-run complete -c runectx -n '__fish_seen_subcommand_from standard' -l explain complete -c runectx -n '__fish_seen_subcommand_from standard' -l json complete -c runectx -n '__fish_seen_subcommand_from standard' -l non-interactive +complete -c runectx -n '__fish_seen_subcommand_from standard create' -l alias -r +complete -c runectx -n '__fish_seen_subcommand_from standard create' -l body -r +complete -c runectx -n '__fish_seen_subcommand_from standard create' -l dry-run +complete -c runectx -n '__fish_seen_subcommand_from standard create' -l explain +complete -c runectx -n '__fish_seen_subcommand_from standard create' -l id -r +complete -c runectx -n '__fish_seen_subcommand_from standard create' -l json +complete -c runectx -n '__fish_seen_subcommand_from standard create' -l non-interactive +complete -c runectx -n '__fish_seen_subcommand_from standard create' -l path -r +complete -c runectx -n '__fish_seen_subcommand_from standard create' -l project-path -r +complete -c runectx -n '__fish_seen_subcommand_from standard create' -l replaced-by -r +complete -c runectx -n '__fish_seen_subcommand_from standard create' -l status -r +complete -c runectx -f -n '(__fish_seen_subcommand_from standard create); and __runectx_prev_token_is --status' -a 'active deprecated draft' +complete -c runectx -n '__fish_seen_subcommand_from standard create' -l suggested-context-bundle -r +complete -c runectx -f -n '(__fish_seen_subcommand_from standard create); and __runectx_prev_token_is --suggested-context-bundle' -a '(__runectx_dynamic_suggest bundle-ids)' +complete -c runectx -n '__fish_seen_subcommand_from standard create' -l title -r complete -c runectx -n '__fish_seen_subcommand_from standard discover' -l change -r complete -c runectx -f -n '(__fish_seen_subcommand_from standard discover); and __runectx_prev_token_is --change' -a '(__runectx_dynamic_suggest change-ids)' complete -c runectx -n '__fish_seen_subcommand_from standard discover' -l confirm-handoff @@ -256,6 +275,30 @@ complete -c runectx -n '__fish_seen_subcommand_from standard discover' -l path - complete -c runectx -n '__fish_seen_subcommand_from standard discover' -l scope-path -r complete -c runectx -n '__fish_seen_subcommand_from standard discover' -l target -r complete -c runectx -f -n '(__fish_seen_subcommand_from standard discover); and __runectx_prev_token_is --target' -a '(__runectx_dynamic_suggest promotion-targets)' +complete -c runectx -n '__fish_seen_subcommand_from standard list' -l explain +complete -c runectx -n '__fish_seen_subcommand_from standard list' -l focus -r +complete -c runectx -n '__fish_seen_subcommand_from standard list' -l json +complete -c runectx -n '__fish_seen_subcommand_from standard list' -l non-interactive +complete -c runectx -n '__fish_seen_subcommand_from standard list' -l path -r +complete -c runectx -n '__fish_seen_subcommand_from standard list' -l scope-path -r +complete -c runectx -n '__fish_seen_subcommand_from standard list' -l status -r +complete -c runectx -f -n '(__fish_seen_subcommand_from standard list); and __runectx_prev_token_is --status' -a 'active deprecated draft' +complete -c runectx -n '__fish_seen_subcommand_from standard update' -l alias -r +complete -c runectx -n '__fish_seen_subcommand_from standard update' -l clear-replaced-by +complete -c runectx -n '__fish_seen_subcommand_from standard update' -l dry-run +complete -c runectx -n '__fish_seen_subcommand_from standard update' -l explain +complete -c runectx -n '__fish_seen_subcommand_from standard update' -l json +complete -c runectx -n '__fish_seen_subcommand_from standard update' -l non-interactive +complete -c runectx -n '__fish_seen_subcommand_from standard update' -l path -r +complete -c runectx -n '__fish_seen_subcommand_from standard update' -l project-path -r +complete -c runectx -n '__fish_seen_subcommand_from standard update' -l replace-aliases +complete -c runectx -n '__fish_seen_subcommand_from standard update' -l replace-suggested-context-bundles +complete -c runectx -n '__fish_seen_subcommand_from standard update' -l replaced-by -r +complete -c runectx -n '__fish_seen_subcommand_from standard update' -l status -r +complete -c runectx -f -n '(__fish_seen_subcommand_from standard update); and __runectx_prev_token_is --status' -a 'active deprecated draft' +complete -c runectx -n '__fish_seen_subcommand_from standard update' -l suggested-context-bundle -r +complete -c runectx -f -n '(__fish_seen_subcommand_from standard update); and __runectx_prev_token_is --suggested-context-bundle' -a '(__runectx_dynamic_suggest bundle-ids)' +complete -c runectx -n '__fish_seen_subcommand_from standard update' -l title -r complete -c runectx -n '__fish_seen_subcommand_from status' -l explain complete -c runectx -n '__fish_seen_subcommand_from status' -l json complete -c runectx -n '__fish_seen_subcommand_from status' -l non-interactive @@ -289,6 +332,10 @@ function __runectx_flag_name_takes_value switch $argv[1] case --add-related-change return 0 + case --alias + return 0 + case --body + return 0 case --bundle return 0 case --change @@ -303,16 +350,22 @@ function __runectx_flag_name_takes_value return 0 case --focus return 0 + case --id + return 0 case --mode return 0 case --path return 0 case --prefix return 0 + case --project-path + return 0 case --reference return 0 case --remove-related-change return 0 + case --replaced-by + return 0 case --role return 0 case --scope-path @@ -329,6 +382,8 @@ function __runectx_flag_name_takes_value return 0 case --sub-change return 0 + case --suggested-context-bundle + return 0 case --superseded-by return 0 case --target diff --git a/fixtures/cli-completion/zsh.golden b/fixtures/cli-completion/zsh.golden index e87824e..6492142 100644 --- a/fixtures/cli-completion/zsh.golden +++ b/fixtures/cli-completion/zsh.golden @@ -36,7 +36,10 @@ _runectx_has_command() { 'metadata') return 0 ;; 'promote') return 0 ;; 'standard') return 0 ;; + 'standard create') return 0 ;; 'standard discover') return 0 ;; + 'standard list') return 0 ;; + 'standard update') return 0 ;; 'status') return 0 ;; 'upgrade') return 0 ;; 'upgrade apply') return 0 ;; @@ -82,8 +85,11 @@ _runectx_subcommands_for() { 'init') echo '' ;; 'metadata') echo '' ;; 'promote') echo '' ;; - 'standard') echo 'discover' ;; + 'standard') echo 'create discover list update' ;; + 'standard create') echo '' ;; 'standard discover') echo '' ;; + 'standard list') echo '' ;; + 'standard update') echo '' ;; 'status') echo '' ;; 'upgrade') echo 'apply cli' ;; 'upgrade apply') echo '' ;; @@ -124,8 +130,11 @@ _runectx_flags_for() { 'generate indexes') echo '--explain --json --non-interactive --path' ;; 'init') echo '--dry-run --explain --json --mode --non-interactive --path --seed-bundle' ;; 'promote') echo '--accept --complete --dry-run --explain --json --non-interactive --path --target' ;; - 'standard') echo '--explain --json --non-interactive' ;; + 'standard') echo '--dry-run --explain --json --non-interactive' ;; + 'standard create') echo '--alias --body --dry-run --explain --id --json --non-interactive --path --project-path --replaced-by --status --suggested-context-bundle --title' ;; 'standard discover') echo '--change --confirm-handoff --explain --focus --json --non-interactive --path --scope-path --target' ;; + 'standard list') echo '--explain --focus --json --non-interactive --path --scope-path --status' ;; + 'standard update') echo '--alias --clear-replaced-by --dry-run --explain --json --non-interactive --path --project-path --replace-aliases --replace-suggested-context-bundles --replaced-by --status --suggested-context-bundle --title' ;; 'status') echo '--explain --json --non-interactive --path' ;; 'upgrade') echo '--explain --json --non-interactive --path --target-version' ;; 'upgrade apply') echo '--explain --json --non-interactive --path --target-version' ;; @@ -189,11 +198,31 @@ _runectx_flag_takes_value() { 'init|--seed-bundle') return 0 ;; 'promote|--path') return 0 ;; 'promote|--target') return 0 ;; + 'standard create|--alias') return 0 ;; + 'standard create|--body') return 0 ;; + 'standard create|--id') return 0 ;; + 'standard create|--path') return 0 ;; + 'standard create|--project-path') return 0 ;; + 'standard create|--replaced-by') return 0 ;; + 'standard create|--status') return 0 ;; + 'standard create|--suggested-context-bundle') return 0 ;; + 'standard create|--title') return 0 ;; 'standard discover|--change') return 0 ;; 'standard discover|--focus') return 0 ;; 'standard discover|--path') return 0 ;; 'standard discover|--scope-path') return 0 ;; 'standard discover|--target') return 0 ;; + 'standard list|--focus') return 0 ;; + 'standard list|--path') return 0 ;; + 'standard list|--scope-path') return 0 ;; + 'standard list|--status') return 0 ;; + 'standard update|--alias') return 0 ;; + 'standard update|--path') return 0 ;; + 'standard update|--project-path') return 0 ;; + 'standard update|--replaced-by') return 0 ;; + 'standard update|--status') return 0 ;; + 'standard update|--suggested-context-bundle') return 0 ;; + 'standard update|--title') return 0 ;; 'status|--path') return 0 ;; 'upgrade apply|--path') return 0 ;; 'upgrade apply|--target-version') return 0 ;; @@ -219,6 +248,9 @@ _runectx_enum_for_flag() { 'change update|--status') echo 'implemented planned verified' ;; 'change update|--verification-status') echo 'failed passed skipped' ;; 'init|--mode') echo 'embedded linked' ;; + 'standard create|--status') echo 'active deprecated draft' ;; + 'standard list|--status') echo 'active deprecated draft' ;; + 'standard update|--status') echo 'active deprecated draft' ;; *) echo '' ;; esac } @@ -234,8 +266,10 @@ _runectx_suggest_provider_for_flag() { 'change update|--remove-related-change') echo 'change-ids' ;; 'init|--seed-bundle') echo 'bundle-ids' ;; 'promote|--target') echo 'promotion-targets' ;; + 'standard create|--suggested-context-bundle') echo 'bundle-ids' ;; 'standard discover|--change') echo 'change-ids' ;; 'standard discover|--target') echo 'promotion-targets' ;; + 'standard update|--suggested-context-bundle') echo 'bundle-ids' ;; *) echo '' ;; esac } diff --git a/fixtures/cli-json-golden/metadata-success.json b/fixtures/cli-json-golden/metadata-success.json index 86e073e..77a7a89 100644 --- a/fixtures/cli-json-golden/metadata-success.json +++ b/fixtures/cli-json-golden/metadata-success.json @@ -1 +1 @@ -{"assurance":{"baseline_supported":true,"receipt_families":["context-packs","changes","promotions","verifications"],"tiers":["plain","verified"]},"binary":"runectx","canonicalization":{"assurance_artifacts":{"hash_algorithm":"sha256","profile":"runecontext-canonical-json-v1"},"context_pack":{"hash_algorithm":"sha256","profile":"runecontext-canonical-json-v1"}},"capabilities":{"commands":[{"path":"adapter","token":"adapter"},{"path":"adapter render-host-native","token":"adapter_render_host_native"},{"path":"adapter sync","token":"adapter_sync"},{"path":"assurance","token":"assurance"},{"path":"assurance backfill","token":"assurance_backfill"},{"path":"assurance capture","token":"assurance_capture"},{"path":"assurance enable","token":"assurance_enable"},{"path":"bundle","token":"bundle"},{"path":"bundle resolve","token":"bundle_resolve"},{"path":"change","token":"change"},{"path":"change assess-decomposition","token":"change_assess_decomposition"},{"path":"change assess-intake","token":"change_assess_intake"},{"path":"change close","token":"change_close"},{"path":"change decomposition-apply","token":"change_decomposition_apply"},{"path":"change decomposition-plan","token":"change_decomposition_plan"},{"path":"change new","token":"change_new"},{"path":"change reallocate","token":"change_reallocate"},{"path":"change shape","token":"change_shape"},{"path":"change update","token":"change_update"},{"path":"completion","token":"completion"},{"path":"completion metadata","token":"completion_metadata"},{"path":"completion suggest","token":"completion_suggest"},{"path":"doctor","token":"doctor"},{"path":"generate","token":"generate"},{"path":"generate indexes","token":"generate_indexes"},{"path":"help","token":"help"},{"path":"init","token":"init"},{"path":"metadata","token":"metadata"},{"path":"promote","token":"promote"},{"path":"standard","token":"standard"},{"path":"standard discover","token":"standard_discover"},{"path":"status","token":"status"},{"path":"upgrade","token":"upgrade"},{"path":"upgrade apply","token":"upgrade_apply"},{"path":"upgrade cli","token":"upgrade_cli"},{"path":"upgrade cli apply","token":"upgrade_cli_apply"},{"path":"validate","token":"validate"},{"aliases":["--version","-v"],"path":"version","token":"version"}],"machine_flags":["--dry-run","--explain","--json","--non-interactive"],"value_kinds":["none","text","enum"]},"compatibility":{"default_project_version":"0.1.0-alpha.14","directly_supported_project_versions":["0.1.0-alpha.5","0.1.0-alpha.6","0.1.0-alpha.7","0.1.0-alpha.8","0.1.0-alpha.14"],"explicit_upgrade_edges":[{"from":"0.1.0-alpha.12","to":"0.1.0-alpha.13"}],"upgradeable_from_project_versions":["0.1.0-alpha.12","0.1.0-alpha.13"]},"distribution_layouts":[{"adapters_path":"adapters","profile":"repo_bundle","schema_path":"schemas"},{"adapters_path":"share/runecontext/adapters","profile":"installed_share_layout","schema_path":"share/runecontext/schemas"}],"features":["signed_tag_verification","mutable_ref_opt_in","monorepo_nearest_root_discovery","context_pack_capture","verified_assurance","completion_registry","completion_dynamic_suggestions","generated_indexes","promotion_workflow","upgrade_planning","staged_upgrade_execution","mixed_or_stale_tree_detection"],"project_profiles":[{"assurance_path":"runecontext/assurance","content_root":"runecontext","indexes_root":"runecontext/indexes","manifest_path":"runecontext/manifest.yaml","profile":"portable_project_root","root_config":"runecontext.yaml"}],"release":{"package_name":"runecontext","tag":"v0.1.0-alpha.14","version":"0.1.0-alpha.14"},"resolution":{"source_modes":["embedded","git","path"],"verification_postures":["embedded","pinned_commit","verified_signed_tag","unverified_mutable_ref","unverified_local_source"]},"schema_version":2} \ No newline at end of file +{"assurance":{"baseline_supported":true,"receipt_families":["context-packs","changes","promotions","verifications"],"tiers":["plain","verified"]},"binary":"runectx","canonicalization":{"assurance_artifacts":{"hash_algorithm":"sha256","profile":"runecontext-canonical-json-v1"},"context_pack":{"hash_algorithm":"sha256","profile":"runecontext-canonical-json-v1"}},"capabilities":{"commands":[{"path":"adapter","token":"adapter"},{"path":"adapter render-host-native","token":"adapter_render_host_native"},{"path":"adapter sync","token":"adapter_sync"},{"path":"assurance","token":"assurance"},{"path":"assurance backfill","token":"assurance_backfill"},{"path":"assurance capture","token":"assurance_capture"},{"path":"assurance enable","token":"assurance_enable"},{"path":"bundle","token":"bundle"},{"path":"bundle resolve","token":"bundle_resolve"},{"path":"change","token":"change"},{"path":"change assess-decomposition","token":"change_assess_decomposition"},{"path":"change assess-intake","token":"change_assess_intake"},{"path":"change close","token":"change_close"},{"path":"change decomposition-apply","token":"change_decomposition_apply"},{"path":"change decomposition-plan","token":"change_decomposition_plan"},{"path":"change new","token":"change_new"},{"path":"change reallocate","token":"change_reallocate"},{"path":"change shape","token":"change_shape"},{"path":"change update","token":"change_update"},{"path":"completion","token":"completion"},{"path":"completion metadata","token":"completion_metadata"},{"path":"completion suggest","token":"completion_suggest"},{"path":"doctor","token":"doctor"},{"path":"generate","token":"generate"},{"path":"generate indexes","token":"generate_indexes"},{"path":"help","token":"help"},{"path":"init","token":"init"},{"path":"metadata","token":"metadata"},{"path":"promote","token":"promote"},{"path":"standard","token":"standard"},{"path":"standard create","token":"standard_create"},{"path":"standard discover","token":"standard_discover"},{"path":"standard list","token":"standard_list"},{"path":"standard update","token":"standard_update"},{"path":"status","token":"status"},{"path":"upgrade","token":"upgrade"},{"path":"upgrade apply","token":"upgrade_apply"},{"path":"upgrade cli","token":"upgrade_cli"},{"path":"upgrade cli apply","token":"upgrade_cli_apply"},{"path":"validate","token":"validate"},{"aliases":["--version","-v"],"path":"version","token":"version"}],"machine_flags":["--dry-run","--explain","--json","--non-interactive"],"value_kinds":["none","text","enum"]},"compatibility":{"default_project_version":"0.1.0-alpha.14","directly_supported_project_versions":["0.1.0-alpha.5","0.1.0-alpha.6","0.1.0-alpha.7","0.1.0-alpha.8","0.1.0-alpha.14"],"explicit_upgrade_edges":[{"from":"0.1.0-alpha.12","to":"0.1.0-alpha.13"}],"upgradeable_from_project_versions":["0.1.0-alpha.12","0.1.0-alpha.13"]},"distribution_layouts":[{"adapters_path":"adapters","profile":"repo_bundle","schema_path":"schemas"},{"adapters_path":"share/runecontext/adapters","profile":"installed_share_layout","schema_path":"share/runecontext/schemas"}],"features":["signed_tag_verification","mutable_ref_opt_in","monorepo_nearest_root_discovery","context_pack_capture","verified_assurance","completion_registry","completion_dynamic_suggestions","generated_indexes","promotion_workflow","upgrade_planning","staged_upgrade_execution","mixed_or_stale_tree_detection"],"project_profiles":[{"assurance_path":"runecontext/assurance","content_root":"runecontext","indexes_root":"runecontext/indexes","manifest_path":"runecontext/manifest.yaml","profile":"portable_project_root","root_config":"runecontext.yaml"}],"release":{"package_name":"runecontext","tag":"v0.1.0-alpha.14","version":"0.1.0-alpha.14"},"resolution":{"source_modes":["embedded","git","path"],"verification_postures":["embedded","pinned_commit","verified_signed_tag","unverified_mutable_ref","unverified_local_source"]},"schema_version":2} \ No newline at end of file diff --git a/fixtures/release/release-manifest-with-metadata.json b/fixtures/release/release-manifest-with-metadata.json index 96d0e08..0d7fd5a 100644 --- a/fixtures/release/release-manifest-with-metadata.json +++ b/fixtures/release/release-manifest-with-metadata.json @@ -147,10 +147,22 @@ "path": "standard", "token": "standard" }, + { + "path": "standard create", + "token": "standard_create" + }, { "path": "standard discover", "token": "standard_discover" }, + { + "path": "standard list", + "token": "standard_list" + }, + { + "path": "standard update", + "token": "standard_update" + }, { "path": "status", "token": "status" diff --git a/internal/cli/cli.go b/internal/cli/cli.go index db15dc8..6c42cfa 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -32,8 +32,11 @@ const ( changeDecompApplyUsage = "runectx change decomposition-apply [--json] [--non-interactive] [--dry-run] [--explain] UMBRELLA_CHANGE_ID --sub-change ID [--sub-change ID ...] [--depends-on SUB_CHANGE_ID:CHANGE_ID ...] [--path PATH]" initUsage = "runectx init [--json] [--non-interactive] [--dry-run] [--explain] [--mode embedded|linked] [--seed-bundle NAME] [--path PATH]" promoteUsage = "runectx promote [--json] [--non-interactive] [--dry-run] [--explain] CHANGE_ID [--accept | --complete] [--target TYPE:PATH (summary auto-filled per target type)] [--path PATH]" - standardUsage = "runectx standard [--json] [--non-interactive] [--explain] <discover>" + standardUsage = "runectx standard [--json] [--non-interactive] [--dry-run] [--explain] <discover|list|create|update>" standardDiscoverUsage = "runectx standard discover [--json] [--non-interactive] [--explain] [--path PATH] [--change CHANGE_ID] [--scope-path PATH] [--focus TEXT] [--confirm-handoff] [--target TYPE:PATH]" + standardListUsage = "runectx standard list [--json] [--non-interactive] [--explain] [--path PATH] [--scope-path PATH] [--focus TEXT] [--status draft|active|deprecated]" + standardCreateUsage = "runectx standard create [--json] [--non-interactive] [--dry-run] [--explain] --path STANDARD_PATH [--id ID] --title TITLE [--status draft|active|deprecated] [--replaced-by STANDARD_PATH] [--alias ID] [--suggested-context-bundle BUNDLE_ID] [--body TEXT] [--project-path PATH]" + standardUpdateUsage = "runectx standard update [--json] [--non-interactive] [--dry-run] [--explain] --path STANDARD_PATH [--title TITLE] [--status draft|active|deprecated] [--replaced-by STANDARD_PATH | --clear-replaced-by] [--replace-aliases --alias ID] [--replace-suggested-context-bundles --suggested-context-bundle BUNDLE_ID] [--project-path PATH]" assuranceUsage = "runectx assurance [--json] [--non-interactive] [--dry-run] [--explain] <enable|backfill|capture> ..." adapterUsage = "runectx adapter [--json] [--non-interactive] [--dry-run] [--explain] <sync|render-host-native> ..." adapterSyncUsage = "runectx adapter sync [--json] [--non-interactive] [--dry-run] [--explain] [--path PATH] <tool>" @@ -166,7 +169,7 @@ func rootHelpCommandGroups() []rootHelpGroup { commands: []rootHelpCommand{ {name: "change", description: "Create, shape, assess, update, close, and reallocate changes"}, {name: "promote", description: "Advance promotion assessment state"}, - {name: "standard", description: "Discover advisory standards candidates"}, + {name: "standard", description: "Discover, list, and author standards"}, }, }, { diff --git a/internal/cli/cli_metadata_flags.go b/internal/cli/cli_metadata_flags.go index 9b4c5d7..6bd4aac 100644 --- a/internal/cli/cli_metadata_flags.go +++ b/internal/cli/cli_metadata_flags.go @@ -97,6 +97,44 @@ func standardDiscoverFlags() []FlagMetadata { } } +func standardListFlags() []FlagMetadata { + return []FlagMetadata{ + {Name: "--path", Value: textValueSpec()}, + {Name: "--scope-path", Value: textValueSpec(), Repeatable: true}, + {Name: "--focus", Value: textValueSpec()}, + {Name: "--status", Value: enumValueSpec("draft", "active", "deprecated"), Repeatable: true}, + } +} + +func standardCreateFlags() []FlagMetadata { + return []FlagMetadata{ + {Name: "--path", Value: textValueSpec(), Required: true}, + {Name: "--id", Value: textValueSpec()}, + {Name: "--title", Value: textValueSpec(), Required: true}, + {Name: "--status", Value: enumValueSpec("draft", "active", "deprecated")}, + {Name: "--replaced-by", Value: textValueSpec()}, + {Name: "--alias", Value: textValueSpec(), Repeatable: true}, + {Name: "--suggested-context-bundle", Value: textValueWithSuggestionSpec(suggestionProviderBundleIDs), Repeatable: true}, + {Name: "--body", Value: textValueSpec()}, + {Name: "--project-path", Value: textValueSpec()}, + } +} + +func standardUpdateFlags() []FlagMetadata { + return []FlagMetadata{ + {Name: "--path", Value: textValueSpec(), Required: true}, + {Name: "--title", Value: textValueSpec()}, + {Name: "--status", Value: enumValueSpec("draft", "active", "deprecated")}, + {Name: "--replaced-by", Value: textValueSpec()}, + {Name: "--clear-replaced-by", Value: noValueSpec()}, + {Name: "--replace-aliases", Value: noValueSpec()}, + {Name: "--alias", Value: textValueSpec(), Repeatable: true}, + {Name: "--replace-suggested-context-bundles", Value: noValueSpec()}, + {Name: "--suggested-context-bundle", Value: textValueWithSuggestionSpec(suggestionProviderBundleIDs), Repeatable: true}, + {Name: "--project-path", Value: textValueSpec()}, + } +} + func pathOnlyFlag() []FlagMetadata { return []FlagMetadata{{Name: "--path", Value: textValueSpec()}} } diff --git a/internal/cli/cli_metadata_registry.go b/internal/cli/cli_metadata_registry.go index e9c153f..1112903 100644 --- a/internal/cli/cli_metadata_registry.go +++ b/internal/cli/cli_metadata_registry.go @@ -130,9 +130,12 @@ func standardCommandMetadata() CommandMetadata { Name: "standard", Path: "standard", Usage: standardUsage, - Flags: readMachineFlags(), + Flags: writeMachineFlags(), Subcommands: []CommandMetadata{ {Name: "discover", Path: "standard discover", Usage: standardDiscoverUsage, Flags: readOnlyCommandFlags(standardDiscoverFlags())}, + {Name: "list", Path: "standard list", Usage: standardListUsage, Flags: readOnlyCommandFlags(standardListFlags())}, + {Name: "create", Path: "standard create", Usage: standardCreateUsage, Flags: writeCommandFlags(standardCreateFlags())}, + {Name: "update", Path: "standard update", Usage: standardUpdateUsage, Flags: writeCommandFlags(standardUpdateFlags())}, }, } } diff --git a/internal/cli/cli_standard.go b/internal/cli/cli_standard.go index 4969b1b..6e0ba6f 100644 --- a/internal/cli/cli_standard.go +++ b/internal/cli/cli_standard.go @@ -21,7 +21,7 @@ type standardDiscoverRequest struct { } func runStandard(args []string, stdout, stderr io.Writer) int { - machine, remaining, err := parseMachineFlags(args, machineFlagConfig{allowExplain: true}) + machine, remaining, err := parseMachineFlags(args, machineFlagConfig{allowDryRun: true, allowExplain: true}) if err != nil { emitOutput(stderr, machine, appendMachineOptionLines(buildCommandUsageErrorLines("standard", standardUsage, err), machine), exitUsage, failureClassUsage) return exitUsage @@ -41,6 +41,12 @@ func runStandard(args []string, stdout, stderr io.Writer) int { switch remaining[0] { case "discover": return runStandardDiscover(remaining[1:], machine, stdout, stderr) + case "list": + return runStandardList(remaining[1:], machine, stdout, stderr) + case "create": + return runStandardCreate(remaining[1:], machine, stdout, stderr) + case "update": + return runStandardUpdate(remaining[1:], machine, stdout, stderr) default: emitOutput(stderr, machine, appendMachineOptionLines(buildCommandUsageErrorLines("standard", standardUsage, fmt.Errorf("unknown standard subcommand %q", remaining[0])), machine), exitUsage, failureClassUsage) return exitUsage diff --git a/internal/cli/cli_standard_authoring_shared.go b/internal/cli/cli_standard_authoring_shared.go new file mode 100644 index 0000000..c48414e --- /dev/null +++ b/internal/cli/cli_standard_authoring_shared.go @@ -0,0 +1,84 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/runecode-systems/runecontext/internal/contracts" +) + +const ( + standardListCommand = "standard_list" + standardCreateCommand = "standard_create" + standardUpdateCommand = "standard_update" +) + +type standardListRequest struct { + root string + explicitRoot bool + scopePaths []string + focus string + statuses []string +} + +type standardCreateRequest struct { + root string + explicitRoot bool + path string + id string + title string + status string + replacedBy string + aliases []string + suggestedContextBundles []string + body string +} + +type standardUpdateRequest struct { + root string + explicitRoot bool + path string + title string + status string + replacedBy string + clearReplacedBy bool + replaceAliases bool + aliases []string + replaceSuggestedContextBundles bool + suggestedContextBundles []string +} + +func parseStandardStatuses(items []string) []contracts.StandardStatus { + out := make([]contracts.StandardStatus, 0, len(items)) + for _, item := range items { + trimmed := strings.TrimSpace(item) + if trimmed == "" { + continue + } + out = append(out, contracts.StandardStatus(trimmed)) + } + return out +} + +func buildStandardMutationOutput(absRoot string, loaded *contracts.LoadedProject, command string, result *contracts.StandardMutationResult) []line { + output := []line{ + {"result", "ok"}, + {"command", command}, + {"root", absRoot}, + {"selected_config_path", selectedConfigPath(loaded)}, + {"standard_path", result.Path}, + {"mutation_performed", boolString(len(result.ChangedFiles) > 0)}, + } + return appendChangedFiles(output, result.ChangedFiles) +} + +func appendStandardMutationExplainLines(lines []line, command string, result *contracts.StandardMutationResult) []line { + if result == nil { + return lines + } + return append(lines, + line{"explain_scope", "standards-authoring"}, + line{"explain_operation", command}, + line{"explain_mutation_count_reason", fmt.Sprintf("%d standard artifact files changed through validated transactional writes", len(result.ChangedFiles))}, + ) +} diff --git a/internal/cli/cli_standard_authoring_test.go b/internal/cli/cli_standard_authoring_test.go new file mode 100644 index 0000000..5b49122 --- /dev/null +++ b/internal/cli/cli_standard_authoring_test.go @@ -0,0 +1,101 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRunStandardListEmitsStructuredResults(t *testing.T) { + projectRoot := prepareCLIWorkflowProject(t) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := Run([]string{"standard", "list", "--path", projectRoot, "--scope-path", "security", "--focus", "review", "--status", "active"}, &stdout, &stderr) + if code != exitOK { + t.Fatalf("expected success exit code, got %d (%s)", code, stderr.String()) + } + fields := parseCLIKeyValueOutput(t, stdout.String()) + if got, want := fields["command"], standardListCommand; got != want { + t.Fatalf("expected command %q, got %q", want, got) + } + if got, want := fields["mutation_performed"], "false"; got != want { + t.Fatalf("expected mutation_performed %q, got %q", want, got) + } + if got, want := fields["standard_count"], "1"; got != want { + t.Fatalf("expected standard_count %q, got %q", want, got) + } + if got, want := fields["standard_1_path"], "standards/security/review.md"; got != want { + t.Fatalf("expected first standard path %q, got %q", want, got) + } +} + +func TestRunStandardCreateAndUpdateMutateSafely(t *testing.T) { + projectRoot := prepareCLIWorkflowProject(t) + var createOut bytes.Buffer + var createErr bytes.Buffer + + createCode := Run([]string{"standard", "create", "--project-path", projectRoot, "--path", "custom/authoring", "--title", "Authoring Standard", "--status", "draft"}, &createOut, &createErr) + if createCode != exitOK { + t.Fatalf("expected create success, got %d (%s)", createCode, createErr.String()) + } + createFields := parseCLIKeyValueOutput(t, createOut.String()) + if got, want := createFields["command"], standardCreateCommand; got != want { + t.Fatalf("expected command %q, got %q", want, got) + } + if got, want := createFields["mutation_performed"], "true"; got != want { + t.Fatalf("expected mutation_performed %q, got %q", want, got) + } + createdPath := filepath.Join(projectRoot, "runecontext", "standards", "custom", "authoring.md") + if _, err := os.Stat(createdPath); err != nil { + t.Fatalf("expected created standard file, got %v", err) + } + + var updateOut bytes.Buffer + var updateErr bytes.Buffer + updateCode := Run([]string{"standard", "update", "--project-path", projectRoot, "--path", "standards/custom/authoring.md", "--title", "Authoring Standard v2", "--status", "active", "--replace-aliases", "--alias", "custom/authoring-v1"}, &updateOut, &updateErr) + if updateCode != exitOK { + t.Fatalf("expected update success, got %d (%s)", updateCode, updateErr.String()) + } + updateFields := parseCLIKeyValueOutput(t, updateOut.String()) + if got, want := updateFields["command"], standardUpdateCommand; got != want { + t.Fatalf("expected command %q, got %q", want, got) + } + text := strings.ReplaceAll(string(mustReadBytesForCLI(t, createdPath)), "\r\n", "\n") + if !strings.Contains(text, "title: Authoring Standard v2") { + t.Fatalf("expected updated title in standard file, got:\n%s", text) + } + if !strings.Contains(text, "- custom/authoring-v1") { + t.Fatalf("expected updated alias in standard file, got:\n%s", text) + } +} + +func TestRunStandardCreateDryRunDoesNotWrite(t *testing.T) { + projectRoot := prepareCLIWorkflowProject(t) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := Run([]string{"standard", "--dry-run", "create", "--project-path", projectRoot, "--path", "custom/dry-run", "--title", "Dry Run Standard"}, &stdout, &stderr) + if code != exitOK { + t.Fatalf("expected dry-run success, got %d (%s)", code, stderr.String()) + } + fields := parseCLIKeyValueOutput(t, stdout.String()) + if got, want := fields["dry_run"], "true"; got != want { + t.Fatalf("expected dry_run %q, got %q", want, got) + } + dryRunPath := filepath.Join(projectRoot, "runecontext", "standards", "custom", "dry-run.md") + if _, err := os.Stat(dryRunPath); !os.IsNotExist(err) { + t.Fatalf("expected dry-run standard file to be absent, got err=%v", err) + } +} + +func mustReadBytesForCLI(t *testing.T, path string) []byte { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return data +} diff --git a/internal/cli/cli_standard_create.go b/internal/cli/cli_standard_create.go new file mode 100644 index 0000000..2fbc05a --- /dev/null +++ b/internal/cli/cli_standard_create.go @@ -0,0 +1,105 @@ +package cli + +import ( + "fmt" + "io" + "strings" + + "github.com/runecode-systems/runecontext/internal/contracts" +) + +func runStandardCreate(args []string, machine machineOptions, stdout, stderr io.Writer) int { + request, err := parseStandardCreateArgs(args) + if err != nil { + emitOutput(stderr, machine, appendMachineOptionLines(buildCommandUsageErrorLines(standardCreateCommand, standardCreateUsage, err), machine), exitUsage, failureClassUsage) + return exitUsage + } + project, code := loadProjectOrReport(request.root, request.explicitRoot, stderr, standardCreateCommand, machine) + if code != exitOK { + return code + } + defer project.close() + + result, err := runStandardCreateMutation(project, machine, request) + if err != nil { + emitOutput(stderr, machine, appendMachineOptionLines(buildCommandInvalidLines(standardCreateCommand, project.absRoot, err), machine), exitInvalid, failureClassInvalid) + return exitInvalid + } + output := buildStandardMutationOutput(project.absRoot, project.loaded, standardCreateCommand, result) + if machine.explain { + output = appendStandardMutationExplainLines(output, standardCreateCommand, result) + } + emitOutput(stdout, machine, appendMachineOptionLines(output, machine), exitOK, failureClassNone) + return exitOK +} + +func runStandardCreateMutation(project *cliProject, machine machineOptions, request standardCreateRequest) (*contracts.StandardMutationResult, error) { + return runChangeOperation(project, machine, func(v *contracts.Validator, loaded *contracts.LoadedProject) (*contracts.StandardMutationResult, error) { + return contracts.CreateStandard(v, loaded, contracts.StandardCreateOptions{ + Path: request.path, + ID: request.id, + Title: request.title, + Status: contracts.StandardStatus(strings.TrimSpace(request.status)), + ReplacedBy: request.replacedBy, + Aliases: request.aliases, + SuggestedContextBundles: request.suggestedContextBundles, + Body: request.body, + }) + }) +} + +func parseStandardCreateArgs(args []string) (standardCreateRequest, error) { + request := standardCreateRequest{root: "."} + positionals := make([]string, 0, len(args)) + err := consumeArgs(args, func(flag parsedFlag) (int, error) { + return parseStandardCreateFlag(args, flag, &request) + }, func(arg string) error { + positionals = append(positionals, arg) + return nil + }) + if err != nil { + return standardCreateRequest{}, err + } + if len(positionals) > 0 { + return standardCreateRequest{}, fmt.Errorf("standard create does not accept positional arguments") + } + if err := validateStandardCreateRequest(request); err != nil { + return standardCreateRequest{}, err + } + return request, nil +} + +func parseStandardCreateFlag(args []string, flag parsedFlag, request *standardCreateRequest) (int, error) { + switch flag.name { + case "--path": + return assignStringFlag(args, flag, &request.path) + case "--id": + return assignStringFlag(args, flag, &request.id) + case "--title": + return assignStringFlag(args, flag, &request.title) + case "--status": + return assignStringFlag(args, flag, &request.status) + case "--replaced-by": + return assignStringFlag(args, flag, &request.replacedBy) + case "--alias": + return appendStringFlag(args, flag, &request.aliases) + case "--suggested-context-bundle": + return appendStringFlag(args, flag, &request.suggestedContextBundles) + case "--body": + return assignStringFlag(args, flag, &request.body) + case "--project-path": + return assignRootFlag(args, flag, &request.root, &request.explicitRoot) + default: + return flag.next, fmt.Errorf("unknown standard create flag %q", flag.raw) + } +} + +func validateStandardCreateRequest(request standardCreateRequest) error { + if strings.TrimSpace(request.path) == "" { + return fmt.Errorf("standard create requires --path") + } + if strings.TrimSpace(request.title) == "" { + return fmt.Errorf("standard create requires --title") + } + return nil +} diff --git a/internal/cli/cli_standard_list.go b/internal/cli/cli_standard_list.go new file mode 100644 index 0000000..1078d36 --- /dev/null +++ b/internal/cli/cli_standard_list.go @@ -0,0 +1,116 @@ +package cli + +import ( + "fmt" + "io" + + "github.com/runecode-systems/runecontext/internal/contracts" +) + +func runStandardList(args []string, machine machineOptions, stdout, stderr io.Writer) int { + request, err := parseStandardListArgs(args) + if err != nil { + emitOutput(stderr, machine, appendMachineOptionLines(buildCommandUsageErrorLines(standardListCommand, standardListUsage, err), machine), exitUsage, failureClassUsage) + return exitUsage + } + project, code := loadProjectOrReport(request.root, request.explicitRoot, stderr, standardListCommand, machine) + if code != exitOK { + return code + } + defer project.close() + + result, err := contracts.ListStandards(project.validator, project.loaded, contracts.StandardListOptions{ + ScopePaths: request.scopePaths, + Focus: request.focus, + Statuses: parseStandardStatuses(request.statuses), + }) + if err != nil { + emitOutput(stderr, machine, appendMachineOptionLines(buildCommandInvalidLines(standardListCommand, project.absRoot, err), machine), exitInvalid, failureClassInvalid) + return exitInvalid + } + output := buildStandardListOutput(project.absRoot, project.loaded, result) + if machine.explain { + output = appendStandardListExplainLines(output, result) + } + emitOutput(stdout, machine, appendMachineOptionLines(output, machine), exitOK, failureClassNone) + return exitOK +} + +func parseStandardListArgs(args []string) (standardListRequest, error) { + request := standardListRequest{root: "."} + positionals := make([]string, 0, len(args)) + err := consumeArgs(args, func(flag parsedFlag) (int, error) { + switch flag.name { + case "--path": + return assignRootFlag(args, flag, &request.root, &request.explicitRoot) + case "--scope-path": + return appendStringFlag(args, flag, &request.scopePaths) + case "--focus": + return assignStringFlag(args, flag, &request.focus) + case "--status": + return appendStringFlag(args, flag, &request.statuses) + default: + return flag.next, fmt.Errorf("unknown standard list flag %q", flag.raw) + } + }, func(arg string) error { + positionals = append(positionals, arg) + return nil + }) + if err != nil { + return standardListRequest{}, err + } + if len(positionals) > 0 { + return standardListRequest{}, fmt.Errorf("standard list does not accept positional arguments") + } + return request, nil +} + +func buildStandardListOutput(absRoot string, loaded *contracts.LoadedProject, result *contracts.StandardListResult) []line { + output := []line{ + {"result", "ok"}, + {"command", standardListCommand}, + {"root", absRoot}, + {"selected_config_path", selectedConfigPath(loaded)}, + {"mutation_performed", "false"}, + {"standard_scope_count", fmt.Sprintf("%d", len(result.ScopePaths))}, + {"standard_focus", result.Focus}, + {"standard_status_filter_count", fmt.Sprintf("%d", len(result.Statuses))}, + {"standard_count", fmt.Sprintf("%d", len(result.Standards))}, + } + for i, scope := range result.ScopePaths { + output = append(output, line{fmt.Sprintf("standard_scope_%d", i+1), scope}) + } + for i, status := range result.Statuses { + output = append(output, line{fmt.Sprintf("standard_status_filter_%d", i+1), string(status)}) + } + for i, standard := range result.Standards { + prefix := fmt.Sprintf("standard_%d", i+1) + output = append(output, + line{prefix + "_path", standard.Path}, + line{prefix + "_id", standard.ID}, + line{prefix + "_title", standard.Title}, + line{prefix + "_status", string(standard.Status)}, + line{prefix + "_replaced_by", standard.ReplacedBy}, + line{prefix + "_alias_count", fmt.Sprintf("%d", len(standard.Aliases))}, + line{prefix + "_suggested_context_bundle_count", fmt.Sprintf("%d", len(standard.SuggestedContextBundles))}, + ) + for j, alias := range standard.Aliases { + output = append(output, line{fmt.Sprintf("%s_alias_%d", prefix, j+1), alias}) + } + for j, bundle := range standard.SuggestedContextBundles { + output = append(output, line{fmt.Sprintf("%s_suggested_context_bundle_%d", prefix, j+1), bundle}) + } + } + return output +} + +func appendStandardListExplainLines(lines []line, result *contracts.StandardListResult) []line { + if result == nil { + return lines + } + return append(lines, + line{"explain_scope", "standards-list"}, + line{"explain_advisory_only", "true"}, + line{"explain_standard_count_reason", fmt.Sprintf("%d standards matched scope, focus, and lifecycle filters", len(result.Standards))}, + ) +} diff --git a/internal/cli/cli_standard_update.go b/internal/cli/cli_standard_update.go new file mode 100644 index 0000000..2889f58 --- /dev/null +++ b/internal/cli/cli_standard_update.go @@ -0,0 +1,108 @@ +package cli + +import ( + "fmt" + "io" + "strings" + + "github.com/runecode-systems/runecontext/internal/contracts" +) + +func runStandardUpdate(args []string, machine machineOptions, stdout, stderr io.Writer) int { + request, err := parseStandardUpdateArgs(args) + if err != nil { + emitOutput(stderr, machine, appendMachineOptionLines(buildCommandUsageErrorLines(standardUpdateCommand, standardUpdateUsage, err), machine), exitUsage, failureClassUsage) + return exitUsage + } + project, code := loadProjectOrReport(request.root, request.explicitRoot, stderr, standardUpdateCommand, machine) + if code != exitOK { + return code + } + defer project.close() + + result, err := runStandardUpdateMutation(project, machine, request) + if err != nil { + emitOutput(stderr, machine, appendMachineOptionLines(buildCommandInvalidLines(standardUpdateCommand, project.absRoot, err), machine), exitInvalid, failureClassInvalid) + return exitInvalid + } + output := buildStandardMutationOutput(project.absRoot, project.loaded, standardUpdateCommand, result) + if machine.explain { + output = appendStandardMutationExplainLines(output, standardUpdateCommand, result) + } + emitOutput(stdout, machine, appendMachineOptionLines(output, machine), exitOK, failureClassNone) + return exitOK +} + +func runStandardUpdateMutation(project *cliProject, machine machineOptions, request standardUpdateRequest) (*contracts.StandardMutationResult, error) { + return runChangeOperation(project, machine, func(v *contracts.Validator, loaded *contracts.LoadedProject) (*contracts.StandardMutationResult, error) { + return contracts.UpdateStandard(v, loaded, contracts.StandardUpdateOptions{ + Path: request.path, + Title: request.title, + Status: request.status, + ReplacedBy: request.replacedBy, + ClearReplacedBy: request.clearReplacedBy, + Aliases: request.aliases, + ReplaceAliases: request.replaceAliases, + SuggestedContextBundles: request.suggestedContextBundles, + ReplaceSuggestedContextBundles: request.replaceSuggestedContextBundles, + }) + }) +} + +func parseStandardUpdateArgs(args []string) (standardUpdateRequest, error) { + request := standardUpdateRequest{root: "."} + positionals := make([]string, 0, len(args)) + err := consumeArgs(args, func(flag parsedFlag) (int, error) { + return parseStandardUpdateFlag(args, flag, &request) + }, func(arg string) error { + positionals = append(positionals, arg) + return nil + }) + if err != nil { + return standardUpdateRequest{}, err + } + if len(positionals) > 0 { + return standardUpdateRequest{}, fmt.Errorf("standard update does not accept positional arguments") + } + if err := validateStandardUpdateRequest(request); err != nil { + return standardUpdateRequest{}, err + } + return request, nil +} + +func parseStandardUpdateFlag(args []string, flag parsedFlag, request *standardUpdateRequest) (int, error) { + switch flag.name { + case "--path": + return assignStringFlag(args, flag, &request.path) + case "--title": + return assignStringFlag(args, flag, &request.title) + case "--status": + return assignStringFlag(args, flag, &request.status) + case "--replaced-by": + return assignStringFlag(args, flag, &request.replacedBy) + case "--clear-replaced-by": + return assignNoValueBoolFlag(flag, &request.clearReplacedBy) + case "--replace-aliases": + return assignNoValueBoolFlag(flag, &request.replaceAliases) + case "--alias": + return appendStringFlag(args, flag, &request.aliases) + case "--replace-suggested-context-bundles": + return assignNoValueBoolFlag(flag, &request.replaceSuggestedContextBundles) + case "--suggested-context-bundle": + return appendStringFlag(args, flag, &request.suggestedContextBundles) + case "--project-path": + return assignRootFlag(args, flag, &request.root, &request.explicitRoot) + default: + return flag.next, fmt.Errorf("unknown standard update flag %q", flag.raw) + } +} + +func validateStandardUpdateRequest(request standardUpdateRequest) error { + if strings.TrimSpace(request.path) == "" { + return fmt.Errorf("standard update requires --path") + } + if request.replacedBy != "" && request.clearReplacedBy { + return fmt.Errorf("--replaced-by and --clear-replaced-by cannot be used together") + } + return nil +} diff --git a/internal/contracts/standard_commands_create.go b/internal/contracts/standard_commands_create.go new file mode 100644 index 0000000..561de35 --- /dev/null +++ b/internal/contracts/standard_commands_create.go @@ -0,0 +1,127 @@ +package contracts + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +func CreateStandard(v *Validator, loaded *LoadedProject, options StandardCreateOptions) (*StandardMutationResult, error) { + if err := validateWritableStandardCommand(v, loaded); err != nil { + return nil, err + } + prepared, err := prepareCreateStandardOptions(options) + if err != nil { + return nil, err + } + writableRoot, err := writableContentRoot(loaded) + if err != nil { + return nil, err + } + absPath, err := createStandardAbsolutePath(writableRoot, prepared.path) + if err != nil { + return nil, err + } + data, err := buildCreateStandardData(v, absPath, prepared, options.Body) + if err != nil { + return nil, err + } + if err := applyStandardWrite(v, loaded, absPath, data); err != nil { + return nil, err + } + return &StandardMutationResult{ + Path: prepared.path, + ChangedFiles: []FileMutation{{Path: runeContextRelativePath(writableRoot, absPath), Action: "created"}}, + }, nil +} + +func prepareCreateStandardOptions(options StandardCreateOptions) (preparedCreateStandardOptions, error) { + title := strings.TrimSpace(options.Title) + if title == "" { + return preparedCreateStandardOptions{}, fmt.Errorf("standard create requires --title") + } + path, err := normalizeStandardArtifactPath(options.Path) + if err != nil { + return preparedCreateStandardOptions{}, err + } + status, err := resolvedCreateStandardStatus(options.Status) + if err != nil { + return preparedCreateStandardOptions{}, err + } + id := resolvedCreateStandardID(options.ID, path) + if id == "" { + return preparedCreateStandardOptions{}, fmt.Errorf("standard create could not infer id from %q", path) + } + replacedBy := strings.TrimSpace(options.ReplacedBy) + if err := validateStandardReplacementFields(status, replacedBy); err != nil { + return preparedCreateStandardOptions{}, err + } + if replacedBy == path { + return preparedCreateStandardOptions{}, fmt.Errorf("--replaced-by must not reference the standard itself") + } + return preparedCreateStandardOptions{ + path: path, + id: id, + title: title, + status: status, + replacedBy: replacedBy, + aliases: dedupeStringsInOrder(options.Aliases), + suggestedContextBundles: dedupeStringsInOrder(options.SuggestedContextBundles), + }, nil +} + +func resolvedCreateStandardStatus(status StandardStatus) (StandardStatus, error) { + if status == "" { + status = StandardStatusDraft + } + if err := validateStandardStatus(status); err != nil { + return "", err + } + return status, nil +} + +func resolvedCreateStandardID(id, path string) string { + trimmed := strings.TrimSpace(id) + if trimmed != "" { + return trimmed + } + return standardIDFromPath(path) +} + +func createStandardAbsolutePath(writableRoot, standardPath string) (string, error) { + absPath := filepath.Join(writableRoot, filepath.FromSlash(standardPath)) + if _, err := os.Stat(absPath); err == nil { + return "", fmt.Errorf("standard %q already exists", standardPath) + } else if !os.IsNotExist(err) { + return "", err + } + return absPath, nil +} + +func buildCreateStandardData(v *Validator, absPath string, prepared preparedCreateStandardOptions, body string) ([]byte, error) { + frontmatter := standardFrontmatter{ + SchemaVersion: 1, + ID: prepared.id, + Title: prepared.title, + Status: prepared.status, + ReplacedBy: prepared.replacedBy, + Aliases: prepared.aliases, + SuggestedContextBundles: prepared.suggestedContextBundles, + } + data, err := renderStandardDocument(frontmatter, body) + if err != nil { + return nil, err + } + if _, err := v.ParseStandard(absPath, data); err != nil { + return nil, err + } + return data, nil +} + +func applyStandardWrite(v *Validator, loaded *LoadedProject, absPath string, data []byte) error { + rewrite := fileRewrite{Path: absPath, Data: data, Perm: 0o644} + return applyFileRewritesTransaction([]fileRewrite{rewrite}, func() error { + return validateChangeMutation(v, loaded.Resolution.ProjectRoot) + }) +} diff --git a/internal/contracts/standard_commands_helpers.go b/internal/contracts/standard_commands_helpers.go new file mode 100644 index 0000000..40cbb44 --- /dev/null +++ b/internal/contracts/standard_commands_helpers.go @@ -0,0 +1,189 @@ +package contracts + +import ( + "fmt" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +func validateWritableStandardCommand(v *Validator, loaded *LoadedProject) error { + if err := validateChangeCommandInputs(v, loaded); err != nil { + return err + } + if loaded.Resolution == nil { + return fmt.Errorf("loaded project resolution is required") + } + switch loaded.Resolution.SourceMode { + case SourceModeEmbedded, SourceModePath: + return nil + default: + return fmt.Errorf("standard write operations are only supported for embedded and local path sources") + } +} + +func normalizeStandardArtifactPath(path string) (string, error) { + trimmed := strings.Trim(strings.TrimSpace(path), "/") + if trimmed == "" { + return "", fmt.Errorf("standard path is required") + } + if hasParentTraversalSegment(trimmed) { + return "", fmt.Errorf("standard path %q must not traverse outside standards/", path) + } + normalized := canonicalStandardPath(trimmed) + if strings.HasPrefix(normalized, "standards/") { + return validateCanonicalStandardPath(path, normalized) + } + normalized = filepath.ToSlash(filepath.Join("standards", normalized)) + if !strings.HasSuffix(normalized, ".md") { + normalized += ".md" + } + return validateCanonicalStandardPath(path, normalized) +} + +func canonicalStandardPath(path string) string { + normalized := filepath.ToSlash(filepath.Clean(path)) + normalized = strings.TrimPrefix(normalized, "./") + normalized = strings.TrimPrefix(normalized, "runecontext/") + return normalized +} + +func hasParentTraversalSegment(path string) bool { + for _, segment := range strings.Split(filepath.ToSlash(path), "/") { + if segment == ".." { + return true + } + } + return false +} + +func validateCanonicalStandardPath(inputPath, normalized string) (string, error) { + if !strings.HasSuffix(normalized, ".md") { + return "", fmt.Errorf("standard path %q must end with .md", inputPath) + } + if !isCanonicalStandardPathRef(normalized) { + return "", fmt.Errorf("standard path %q must use canonical standards/<path>.md form", inputPath) + } + return normalized, nil +} + +func normalizeStandardScopePaths(items []string) ([]string, error) { + seen := map[string]struct{}{} + out := make([]string, 0, len(items)) + for _, item := range items { + normalized, err := normalizeStandardScopePath(item) + if err != nil { + return nil, err + } + if normalized == "" { + continue + } + if _, ok := seen[normalized]; ok { + continue + } + seen[normalized] = struct{}{} + out = append(out, normalized) + } + return out, nil +} + +func normalizeStandardScopePath(value string) (string, error) { + trimmed := strings.Trim(strings.TrimSpace(value), "/") + if trimmed == "" { + return "", nil + } + if hasParentTraversalSegment(trimmed) { + return "", fmt.Errorf("invalid --scope-path %q: path traversal outside standards is not allowed", value) + } + normalized := canonicalStandardPath(trimmed) + if normalized == "." || normalized == "" || normalized == "runecontext" { + return "", nil + } + if normalized != "standards" && !strings.HasPrefix(normalized, "standards/") { + normalized = filepath.ToSlash(filepath.Join("standards", normalized)) + } + if normalized != "standards" && !strings.HasPrefix(normalized, "standards/") { + return "", fmt.Errorf("invalid --scope-path %q: normalized path must stay under standards/", value) + } + return normalized, nil +} + +func normalizeStandardStatuses(items []StandardStatus) ([]StandardStatus, error) { + seen := map[StandardStatus]struct{}{} + out := make([]StandardStatus, 0, len(items)) + for _, status := range items { + trimmed := StandardStatus(strings.TrimSpace(string(status))) + if trimmed == "" { + continue + } + if err := validateStandardStatus(trimmed); err != nil { + return nil, err + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + out = append(out, trimmed) + } + return out, nil +} + +func validateStandardStatus(status StandardStatus) error { + switch status { + case StandardStatusDraft, StandardStatusActive, StandardStatusDeprecated: + return nil + default: + return fmt.Errorf("standard status must be one of draft, active, or deprecated") + } +} + +func validateStandardReplacementFields(status StandardStatus, replacedBy string) error { + if replacedBy == "" { + return nil + } + if status != StandardStatusDeprecated { + return fmt.Errorf("--replaced-by requires --status deprecated") + } + if !isCanonicalStandardPathRef(replacedBy) { + return fmt.Errorf("--replaced-by must use canonical standards/<path>.md form") + } + return nil +} + +func standardIDFromPath(path string) string { + if !strings.HasPrefix(path, "standards/") || !strings.HasSuffix(path, ".md") { + return "" + } + return strings.TrimSuffix(strings.TrimPrefix(path, "standards/"), ".md") +} + +func renderStandardDocument(frontmatter standardFrontmatter, body string) ([]byte, error) { + frontmatterData, err := yaml.Marshal(frontmatter) + if err != nil { + return nil, err + } + body = strings.TrimSpace(body) + if body == "" { + body = fmt.Sprintf("# %s\n\nDescribe the standard intent and requirements.", frontmatter.Title) + } + text := strings.Join([]string{"---", strings.TrimSuffix(string(frontmatterData), "\n"), "---", "", body, ""}, "\n") + return []byte(text), nil +} + +func dedupeStringsInOrder(items []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(items)) + for _, item := range items { + trimmed := strings.TrimSpace(item) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + out = append(out, trimmed) + } + return out +} diff --git a/internal/contracts/standard_commands_list.go b/internal/contracts/standard_commands_list.go new file mode 100644 index 0000000..83e48d1 --- /dev/null +++ b/internal/contracts/standard_commands_list.go @@ -0,0 +1,108 @@ +package contracts + +import "strings" + +func ListStandards(v *Validator, loaded *LoadedProject, options StandardListOptions) (*StandardListResult, error) { + if err := validateChangeCommandInputs(v, loaded); err != nil { + return nil, err + } + index, err := v.ValidateLoadedProject(loaded) + if err != nil { + return nil, err + } + normalizedScopePaths, err := normalizeStandardScopePaths(options.ScopePaths) + if err != nil { + return nil, err + } + normalizedStatuses, err := normalizeStandardStatuses(options.Statuses) + if err != nil { + return nil, err + } + focus := strings.ToLower(strings.TrimSpace(options.Focus)) + standards := listFilteredStandards(index, normalizedScopePaths, focus, normalizedStatuses) + return &StandardListResult{ + ScopePaths: normalizedScopePaths, + Focus: focus, + Statuses: normalizedStatuses, + Standards: standards, + }, nil +} + +func listFilteredStandards(index *ProjectIndex, scopePaths []string, focus string, statuses []StandardStatus) []StandardRecord { + if index == nil { + return nil + } + allowedStatuses := makeAllowedStatusSet(statuses) + paths := SortedKeys(index.Standards) + out := make([]StandardRecord, 0, len(paths)) + for _, path := range paths { + record := index.Standards[path] + if shouldSkipStandardListRecord(record, allowedStatuses, path, scopePaths, focus) { + continue + } + out = append(out, cloneStandardListRecord(record)) + } + return out +} + +func makeAllowedStatusSet(statuses []StandardStatus) map[StandardStatus]struct{} { + allowed := map[StandardStatus]struct{}{} + for _, status := range statuses { + allowed[status] = struct{}{} + } + return allowed +} + +func shouldSkipStandardListRecord(record *StandardRecord, allowedStatuses map[StandardStatus]struct{}, path string, scopePaths []string, focus string) bool { + if record == nil { + return true + } + if len(allowedStatuses) > 0 { + if _, ok := allowedStatuses[record.Status]; !ok { + return true + } + } + if !standardPathMatchesScope(path, scopePaths) { + return true + } + return !standardMatchesFocus(path, record, focus) +} + +func standardPathMatchesScope(path string, scopes []string) bool { + if len(scopes) == 0 { + return true + } + for _, scope := range scopes { + if path == scope || strings.HasPrefix(path, scope+"/") { + return true + } + } + return false +} + +func standardMatchesFocus(path string, record *StandardRecord, focus string) bool { + if focus == "" { + return true + } + for _, candidate := range []string{path, record.ID, record.Title} { + if strings.Contains(strings.ToLower(candidate), focus) { + return true + } + } + return false +} + +func cloneStandardListRecord(record *StandardRecord) StandardRecord { + if record == nil { + return StandardRecord{} + } + return StandardRecord{ + Path: record.Path, + ID: record.ID, + Title: record.Title, + Status: record.Status, + ReplacedBy: record.ReplacedBy, + Aliases: append([]string(nil), record.Aliases...), + SuggestedContextBundles: append([]string(nil), record.SuggestedContextBundles...), + } +} diff --git a/internal/contracts/standard_commands_test.go b/internal/contracts/standard_commands_test.go new file mode 100644 index 0000000..f18aa0f --- /dev/null +++ b/internal/contracts/standard_commands_test.go @@ -0,0 +1,121 @@ +package contracts + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestListStandardsFiltersByScopeFocusAndStatus(t *testing.T) { + root := copyChangeWorkflowTemplate(t) + v, loaded := mustLoadWorkflowProject(t, root) + defer loaded.Close() + + result, err := ListStandards(v, loaded, StandardListOptions{ + ScopePaths: []string{"security"}, + Focus: "review", + Statuses: []StandardStatus{StandardStatusActive}, + }) + if err != nil { + t.Fatalf("list standards: %v", err) + } + if got, want := len(result.Standards), 1; got != want { + t.Fatalf("expected %d standards, got %d", want, got) + } + if got, want := result.Standards[0].Path, "standards/security/review.md"; got != want { + t.Fatalf("expected standard path %q, got %q", want, got) + } + + empty, err := ListStandards(v, loaded, StandardListOptions{Statuses: []StandardStatus{StandardStatusDeprecated}}) + if err != nil { + t.Fatalf("list deprecated standards: %v", err) + } + if got := len(empty.Standards); got != 0 { + t.Fatalf("expected zero deprecated standards, got %d", got) + } +} + +func TestCreateStandardWritesFileAndReturnsMutation(t *testing.T) { + root := copyChangeWorkflowTemplate(t) + v, loaded := mustLoadWorkflowProject(t, root) + defer loaded.Close() + + result, err := CreateStandard(v, loaded, StandardCreateOptions{ + Path: "custom/authoring", + Title: "Authoring Standard", + Status: StandardStatusDraft, + Body: "# Authoring Standard\n\nKeep standard authoring workflows explicit.", + }) + if err != nil { + t.Fatalf("create standard: %v", err) + } + if got, want := result.Path, "standards/custom/authoring.md"; got != want { + t.Fatalf("expected standard path %q, got %q", want, got) + } + if len(result.ChangedFiles) != 1 || result.ChangedFiles[0].Action != "created" { + t.Fatalf("expected one created file mutation, got %#v", result.ChangedFiles) + } + + assertValidatedWorkflowProject(t, v, root) + path := filepath.Join(root, "runecontext", filepath.FromSlash(result.Path)) + text := strings.ReplaceAll(string(mustReadBytes(t, path)), "\r\n", "\n") + if !strings.Contains(text, "id: custom/authoring") { + t.Fatalf("expected inferred id in created standard, got:\n%s", text) + } + if !strings.Contains(text, "status: draft") { + t.Fatalf("expected draft status in created standard, got:\n%s", text) + } +} + +func TestUpdateStandardRewritesFrontmatterFields(t *testing.T) { + root := copyChangeWorkflowTemplate(t) + v, loaded := mustLoadWorkflowProject(t, root) + defer loaded.Close() + + if _, err := CreateStandard(v, loaded, StandardCreateOptions{Path: "custom/authoring", Title: "Authoring Standard", Status: StandardStatusDraft}); err != nil { + t.Fatalf("create standard: %v", err) + } + + updated, err := UpdateStandard(v, loaded, StandardUpdateOptions{ + Path: "standards/custom/authoring.md", + Title: "Authoring Standard v2", + Status: string(StandardStatusActive), + ReplaceAliases: true, + Aliases: []string{"custom/authoring-v1"}, + ReplaceSuggestedContextBundles: true, + SuggestedContextBundles: []string{"base"}, + }) + if err != nil { + t.Fatalf("update standard: %v", err) + } + if len(updated.ChangedFiles) != 1 || updated.ChangedFiles[0].Action != "updated" { + t.Fatalf("expected one updated file mutation, got %#v", updated.ChangedFiles) + } + + assertValidatedWorkflowProject(t, v, root) + path := filepath.Join(root, "runecontext", filepath.FromSlash(updated.Path)) + text := strings.ReplaceAll(string(mustReadBytes(t, path)), "\r\n", "\n") + for _, expected := range []string{"title: Authoring Standard v2", "status: active", "aliases:", "- custom/authoring-v1", "suggested_context_bundles:", "- base"} { + if !strings.Contains(text, expected) { + t.Fatalf("expected %q in updated standard, got:\n%s", expected, text) + } + } +} + +func TestUpdateStandardRejectsReplacedByWithoutDeprecatedStatus(t *testing.T) { + root := copyChangeWorkflowTemplate(t) + v, loaded := mustLoadWorkflowProject(t, root) + defer loaded.Close() + + if _, err := CreateStandard(v, loaded, StandardCreateOptions{Path: "custom/authoring", Title: "Authoring Standard", Status: StandardStatusActive}); err != nil { + t.Fatalf("create standard: %v", err) + } + + _, err := UpdateStandard(v, loaded, StandardUpdateOptions{ + Path: "standards/custom/authoring.md", + ReplacedBy: "standards/global/base.md", + }) + if err == nil || !strings.Contains(err.Error(), "--replaced-by requires --status deprecated") { + t.Fatalf("expected replaced_by status rejection, got %v", err) + } +} diff --git a/internal/contracts/standard_commands_types.go b/internal/contracts/standard_commands_types.go new file mode 100644 index 0000000..f294393 --- /dev/null +++ b/internal/contracts/standard_commands_types.go @@ -0,0 +1,62 @@ +package contracts + +type StandardListOptions struct { + ScopePaths []string + Focus string + Statuses []StandardStatus +} + +type StandardListResult struct { + ScopePaths []string + Focus string + Statuses []StandardStatus + Standards []StandardRecord +} + +type StandardCreateOptions struct { + Path string + ID string + Title string + Status StandardStatus + ReplacedBy string + Aliases []string + SuggestedContextBundles []string + Body string +} + +type StandardUpdateOptions struct { + Path string + Title string + Status string + ReplacedBy string + ClearReplacedBy bool + Aliases []string + ReplaceAliases bool + SuggestedContextBundles []string + ReplaceSuggestedContextBundles bool +} + +type StandardMutationResult struct { + Path string + ChangedFiles []FileMutation +} + +type preparedCreateStandardOptions struct { + path string + id string + title string + status StandardStatus + replacedBy string + aliases []string + suggestedContextBundles []string +} + +type standardFrontmatter struct { + SchemaVersion int `yaml:"schema_version"` + ID string `yaml:"id"` + Title string `yaml:"title"` + Status StandardStatus `yaml:"status,omitempty"` + SuggestedContextBundles []string `yaml:"suggested_context_bundles,omitempty"` + ReplacedBy string `yaml:"replaced_by,omitempty"` + Aliases []string `yaml:"aliases,omitempty"` +} diff --git a/internal/contracts/standard_commands_update.go b/internal/contracts/standard_commands_update.go new file mode 100644 index 0000000..5309ad0 --- /dev/null +++ b/internal/contracts/standard_commands_update.go @@ -0,0 +1,172 @@ +package contracts + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +func UpdateStandard(v *Validator, loaded *LoadedProject, options StandardUpdateOptions) (*StandardMutationResult, error) { + if err := validateWritableStandardCommand(v, loaded); err != nil { + return nil, err + } + standardPath, writableRoot, absPath, err := prepareStandardUpdateTarget(loaded, options) + if err != nil { + return nil, err + } + previous, doc, err := loadExistingStandard(v, absPath, standardPath) + if err != nil { + return nil, err + } + updated, err := buildUpdatedStandardFrontmatter(absPath, standardPath, doc, options) + if err != nil { + return nil, err + } + newData, err := renderStandardDocument(updated, doc.Body) + if err != nil { + return nil, err + } + if standardDocumentUnchanged(previous, newData) { + return &StandardMutationResult{Path: standardPath}, nil + } + if _, err := v.ParseStandard(absPath, newData); err != nil { + return nil, err + } + if err := applyStandardWrite(v, loaded, absPath, newData); err != nil { + return nil, err + } + return &StandardMutationResult{ + Path: standardPath, + ChangedFiles: []FileMutation{{Path: runeContextRelativePath(writableRoot, absPath), Action: "updated"}}, + }, nil +} + +func prepareStandardUpdateTarget(loaded *LoadedProject, options StandardUpdateOptions) (string, string, string, error) { + standardPath, err := normalizeStandardArtifactPath(options.Path) + if err != nil { + return "", "", "", err + } + if err := validateStandardUpdateFlags(options); err != nil { + return "", "", "", err + } + writableRoot, err := writableContentRoot(loaded) + if err != nil { + return "", "", "", err + } + absPath := filepath.Join(writableRoot, filepath.FromSlash(standardPath)) + return standardPath, writableRoot, absPath, nil +} + +func loadExistingStandard(v *Validator, absPath, standardPath string) ([]byte, *FrontmatterDocument, error) { + data, err := os.ReadFile(absPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil, fmt.Errorf("standard %q does not exist", standardPath) + } + return nil, nil, err + } + doc, err := v.ParseStandard(absPath, data) + if err != nil { + return nil, nil, err + } + return data, doc, nil +} + +func standardDocumentUnchanged(previous, next []byte) bool { + left := strings.ReplaceAll(string(previous), "\r\n", "\n") + right := strings.ReplaceAll(string(next), "\r\n", "\n") + return left == right +} + +func validateStandardUpdateFlags(options StandardUpdateOptions) error { + if options.ReplacedBy != "" && options.ClearReplacedBy { + return fmt.Errorf("--replaced-by and --clear-replaced-by cannot be used together") + } + if hasAnyStandardUpdateMutation(options) { + return nil + } + return fmt.Errorf("standard update requires at least one mutation flag") +} + +func hasAnyStandardUpdateMutation(options StandardUpdateOptions) bool { + if options.ReplaceAliases || options.ReplaceSuggestedContextBundles || options.ClearReplacedBy { + return true + } + for _, value := range []string{options.Title, options.Status, options.ReplacedBy} { + if strings.TrimSpace(value) != "" { + return true + } + } + return false +} + +func buildUpdatedStandardFrontmatter(path, standardPath string, doc *FrontmatterDocument, options StandardUpdateOptions) (standardFrontmatter, error) { + current, err := buildStandardRecord(path, doc) + if err != nil { + return standardFrontmatter{}, err + } + updated := standardFrontmatterFromRecord(current) + if err := applyStandardUpdateTextFields(&updated, options); err != nil { + return standardFrontmatter{}, err + } + applyStandardUpdateListFields(&updated, options) + if err := applyStandardUpdateReplacement(&updated, standardPath, options); err != nil { + return standardFrontmatter{}, err + } + return updated, nil +} + +func standardFrontmatterFromRecord(record *StandardRecord) standardFrontmatter { + return standardFrontmatter{ + SchemaVersion: 1, + ID: record.ID, + Title: record.Title, + Status: record.Status, + ReplacedBy: record.ReplacedBy, + Aliases: append([]string(nil), record.Aliases...), + SuggestedContextBundles: append([]string(nil), record.SuggestedContextBundles...), + } +} + +func applyStandardUpdateTextFields(updated *standardFrontmatter, options StandardUpdateOptions) error { + if title := strings.TrimSpace(options.Title); title != "" { + updated.Title = title + } + if rawStatus := strings.TrimSpace(options.Status); rawStatus != "" { + updated.Status = StandardStatus(rawStatus) + if err := validateStandardStatus(updated.Status); err != nil { + return err + } + } + return nil +} + +func applyStandardUpdateListFields(updated *standardFrontmatter, options StandardUpdateOptions) { + if options.ReplaceAliases { + updated.Aliases = dedupeStringsInOrder(options.Aliases) + } + if options.ReplaceSuggestedContextBundles { + updated.SuggestedContextBundles = dedupeStringsInOrder(options.SuggestedContextBundles) + } +} + +func applyStandardUpdateReplacement(updated *standardFrontmatter, standardPath string, options StandardUpdateOptions) error { + if options.ClearReplacedBy { + updated.ReplacedBy = "" + } + if replacedBy := strings.TrimSpace(options.ReplacedBy); replacedBy != "" { + normalized, err := normalizeStandardArtifactPath(replacedBy) + if err != nil { + return err + } + updated.ReplacedBy = normalized + } + if err := validateStandardReplacementFields(updated.Status, updated.ReplacedBy); err != nil { + return err + } + if updated.ReplacedBy == standardPath { + return fmt.Errorf("--replaced-by must not reference the standard itself") + } + return nil +} From 0504a837bc915f28eb4e3ba0799b2ac38b05d7a1 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich <zeb@runecode.org> Date: Tue, 31 Mar 2026 20:07:01 -0500 Subject: [PATCH 07/27] Updates status for changes that implement the guided adapter-native change workflows Signed-off-by: Zebulon Rouse-Frantzich <zeb@runecode.org> --- .../status.yaml | 2 +- .../status.yaml | 2 +- .../status.yaml | 2 +- .../status.yaml | 2 +- .../status.yaml | 2 +- .../status.yaml | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/runecontext/changes/CHG-2026-001-fdc1-add-advisory-intake-and-decomposition-assessment-commands/status.yaml b/runecontext/changes/CHG-2026-001-fdc1-add-advisory-intake-and-decomposition-assessment-commands/status.yaml index 2410b18..076c2c3 100644 --- a/runecontext/changes/CHG-2026-001-fdc1-add-advisory-intake-and-decomposition-assessment-commands/status.yaml +++ b/runecontext/changes/CHG-2026-001-fdc1-add-advisory-intake-and-decomposition-assessment-commands/status.yaml @@ -1,7 +1,7 @@ schema_version: 1 id: CHG-2026-001-fdc1-add-advisory-intake-and-decomposition-assessment-commands title: Add advisory intake and decomposition assessment commands -status: proposed +status: implemented type: feature size: large verification_status: pending diff --git a/runecontext/changes/CHG-2026-002-5739-add-guided-adapter-native-change-workflows/status.yaml b/runecontext/changes/CHG-2026-002-5739-add-guided-adapter-native-change-workflows/status.yaml index 9ee8c78..ba84e0a 100644 --- a/runecontext/changes/CHG-2026-002-5739-add-guided-adapter-native-change-workflows/status.yaml +++ b/runecontext/changes/CHG-2026-002-5739-add-guided-adapter-native-change-workflows/status.yaml @@ -1,7 +1,7 @@ schema_version: 1 id: CHG-2026-002-5739-add-guided-adapter-native-change-workflows title: Add guided adapter-native change workflows -status: proposed +status: implemented type: project size: large verification_status: pending diff --git a/runecontext/changes/CHG-2026-003-5f38-teach-adapters-to-run-guided-clarification-and-decomposition-flows/status.yaml b/runecontext/changes/CHG-2026-003-5f38-teach-adapters-to-run-guided-clarification-and-decomposition-flows/status.yaml index 79d326d..16d015e 100644 --- a/runecontext/changes/CHG-2026-003-5f38-teach-adapters-to-run-guided-clarification-and-decomposition-flows/status.yaml +++ b/runecontext/changes/CHG-2026-003-5f38-teach-adapters-to-run-guided-clarification-and-decomposition-flows/status.yaml @@ -1,7 +1,7 @@ schema_version: 1 id: CHG-2026-003-5f38-teach-adapters-to-run-guided-clarification-and-decomposition-flows title: Teach adapters to run guided clarification and decomposition flows -status: proposed +status: implemented type: feature size: large verification_status: pending diff --git a/runecontext/changes/CHG-2026-004-5b03-add-first-class-change-decomposition-planning-and-apply-operations/status.yaml b/runecontext/changes/CHG-2026-004-5b03-add-first-class-change-decomposition-planning-and-apply-operations/status.yaml index b047c36..7655f28 100644 --- a/runecontext/changes/CHG-2026-004-5b03-add-first-class-change-decomposition-planning-and-apply-operations/status.yaml +++ b/runecontext/changes/CHG-2026-004-5b03-add-first-class-change-decomposition-planning-and-apply-operations/status.yaml @@ -1,7 +1,7 @@ schema_version: 1 id: CHG-2026-004-5b03-add-first-class-change-decomposition-planning-and-apply-operations title: Add first-class change decomposition planning and apply operations -status: proposed +status: implemented type: feature size: large verification_status: pending diff --git a/runecontext/changes/CHG-2026-005-d9ca-add-structured-change-update-command-for-safe-status-and-relationship-edits/status.yaml b/runecontext/changes/CHG-2026-005-d9ca-add-structured-change-update-command-for-safe-status-and-relationship-edits/status.yaml index dad4481..4b60bbf 100644 --- a/runecontext/changes/CHG-2026-005-d9ca-add-structured-change-update-command-for-safe-status-and-relationship-edits/status.yaml +++ b/runecontext/changes/CHG-2026-005-d9ca-add-structured-change-update-command-for-safe-status-and-relationship-edits/status.yaml @@ -1,7 +1,7 @@ schema_version: 1 id: CHG-2026-005-d9ca-add-structured-change-update-command-for-safe-status-and-relationship-edits title: Add structured change update command for safe status and relationship edits -status: proposed +status: implemented type: feature size: large verification_status: pending diff --git a/runecontext/changes/CHG-2026-006-5aaf-add-first-class-standard-authoring-commands/status.yaml b/runecontext/changes/CHG-2026-006-5aaf-add-first-class-standard-authoring-commands/status.yaml index 5f1baa1..031f950 100644 --- a/runecontext/changes/CHG-2026-006-5aaf-add-first-class-standard-authoring-commands/status.yaml +++ b/runecontext/changes/CHG-2026-006-5aaf-add-first-class-standard-authoring-commands/status.yaml @@ -1,7 +1,7 @@ schema_version: 1 id: CHG-2026-006-5aaf-add-first-class-standard-authoring-commands title: Add first-class standard authoring commands -status: proposed +status: implemented type: feature size: large verification_status: pending From edbd27ea7d8da8d330842245e7d64a5002a90109 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich <zeb@runecode.org> Date: Wed, 1 Apr 2026 09:06:37 -0500 Subject: [PATCH 08/27] Adds changes to reduce git change noise for generated adapter files Signed-off-by: Zebulon Rouse-Frantzich <zeb@runecode.org> --- .../status.yaml | 3 ++ .../design.md | 7 +++++ .../proposal.md | 21 ++++++++++++++ .../references.md | 6 ++++ .../standards.md | 29 +++++++++++++++++++ .../status.yaml | 21 ++++++++++++++ .../tasks.md | 6 ++++ .../verification.md | 7 +++++ .../design.md | 7 +++++ .../proposal.md | 21 ++++++++++++++ .../references.md | 7 +++++ .../standards.md | 29 +++++++++++++++++++ .../status.yaml | 22 ++++++++++++++ .../tasks.md | 6 ++++ .../verification.md | 7 +++++ .../design.md | 7 +++++ .../proposal.md | 21 ++++++++++++++ .../references.md | 6 ++++ .../standards.md | 29 +++++++++++++++++++ .../status.yaml | 23 +++++++++++++++ .../tasks.md | 5 ++++ .../verification.md | 7 +++++ 22 files changed, 297 insertions(+) create mode 100644 runecontext/changes/CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline/design.md create mode 100644 runecontext/changes/CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline/proposal.md create mode 100644 runecontext/changes/CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline/references.md create mode 100644 runecontext/changes/CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline/standards.md create mode 100644 runecontext/changes/CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline/status.yaml create mode 100644 runecontext/changes/CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline/tasks.md create mode 100644 runecontext/changes/CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline/verification.md create mode 100644 runecontext/changes/CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds/design.md create mode 100644 runecontext/changes/CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds/proposal.md create mode 100644 runecontext/changes/CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds/references.md create mode 100644 runecontext/changes/CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds/standards.md create mode 100644 runecontext/changes/CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds/status.yaml create mode 100644 runecontext/changes/CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds/tasks.md create mode 100644 runecontext/changes/CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds/verification.md create mode 100644 runecontext/changes/CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs/design.md create mode 100644 runecontext/changes/CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs/proposal.md create mode 100644 runecontext/changes/CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs/references.md create mode 100644 runecontext/changes/CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs/standards.md create mode 100644 runecontext/changes/CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs/status.yaml create mode 100644 runecontext/changes/CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs/tasks.md create mode 100644 runecontext/changes/CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs/verification.md diff --git a/runecontext/changes/CHG-2026-002-5739-add-guided-adapter-native-change-workflows/status.yaml b/runecontext/changes/CHG-2026-002-5739-add-guided-adapter-native-change-workflows/status.yaml index ba84e0a..c361f54 100644 --- a/runecontext/changes/CHG-2026-002-5739-add-guided-adapter-native-change-workflows/status.yaml +++ b/runecontext/changes/CHG-2026-002-5739-add-guided-adapter-native-change-workflows/status.yaml @@ -14,6 +14,9 @@ related_changes: - CHG-2026-004-5b03-add-first-class-change-decomposition-planning-and-apply-operations - CHG-2026-005-d9ca-add-structured-change-update-command-for-safe-status-and-relationship-edits - CHG-2026-006-5aaf-add-first-class-standard-authoring-commands + - CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline + - CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds + - CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs depends_on: [] informed_by: [] supersedes: [] diff --git a/runecontext/changes/CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline/design.md b/runecontext/changes/CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline/design.md new file mode 100644 index 0000000..05db282 --- /dev/null +++ b/runecontext/changes/CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline/design.md @@ -0,0 +1,7 @@ +# Design + +## Overview +Shape Add canonical adapter source generation and rendering pipeline before implementation so scope, standards linkage, and verification stay reviewable. + +## Shape Rationale +- Large, ambiguous, or high-risk feature work should move to full mode early. diff --git a/runecontext/changes/CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline/proposal.md b/runecontext/changes/CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline/proposal.md new file mode 100644 index 0000000..a1822ca --- /dev/null +++ b/runecontext/changes/CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline/proposal.md @@ -0,0 +1,21 @@ +## Summary +Add canonical adapter source generation and rendering pipeline + +## Problem +Introduce a smaller canonical adapter source model and a deterministic generator that renders adapter packs into build/generated/adapters for local and staged use. + +## Proposed Change +Track and deliver Add canonical adapter source generation and rendering pipeline while keeping the intent and standards linkage reviewable. + +## Why Now +The work needs stable intent, standards linkage, and verification planning before it moves further. + +## Assumptions +- Used all non-draft standards as a conservative fallback because no standards were selected through context bundles. +- Inferred `just test` from the repository's justfile test target. + +## Out of Scope +Work outside the scoped change tracked here. + +## Impact +The change keeps intent, assumptions, and standards linkage reviewable. diff --git a/runecontext/changes/CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline/references.md b/runecontext/changes/CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline/references.md new file mode 100644 index 0000000..74fae55 --- /dev/null +++ b/runecontext/changes/CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline/references.md @@ -0,0 +1,6 @@ +# References + +- nix/packages/release-artifacts.nix +- nix/scripts/build-release-artifacts.sh +- internal/cli/cli_adapter_sync_host_native.go +- internal/cli/cli_adapter_sync_host_native_flows.go diff --git a/runecontext/changes/CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline/standards.md b/runecontext/changes/CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline/standards.md new file mode 100644 index 0000000..0c97ce5 --- /dev/null +++ b/runecontext/changes/CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline/standards.md @@ -0,0 +1,29 @@ +## Applicable Standards +- `standards/architecture/closed-schema-enforcement.md`: Selected from the current context bundles. +- `standards/architecture/derived-artifact-non-authority.md`: Selected from the current context bundles. +- `standards/assurance/additive-backfill-evidence.md`: Selected from the current context bundles. +- `standards/assurance/fail-closed-verified-boundary.md`: Selected from the current context bundles. +- `standards/assurance/portable-receipt-linkage.md`: Selected from the current context bundles. +- `standards/bundles/deterministic-bundle-linearization.md`: Selected from the current context bundles. +- `standards/change/explicit-umbrella-recursion-boundaries.md`: Selected from the current context bundles. +- `standards/change/immutable-terminal-change-records.md`: Selected from the current context bundles. +- `standards/change/verification-status-consistency.md`: Selected from the current context bundles. +- `standards/cli/completion-and-metadata-from-canonical-registry.md`: Selected from the current context bundles. +- `standards/cli/network-isolated-core-operations.md`: Selected from the current context bundles. +- `standards/context-packs/explicit-generated-at-and-stable-hashing.md`: Selected from the current context bundles. +- `standards/context-packs/portable-source-ref-requirements.md`: Selected from the current context bundles. +- `standards/global/adapter-thin-ux-over-cli.md`: Selected from the current context bundles. +- `standards/global/change-relationship-consistency.md`: Selected from the current context bundles. +- `standards/global/cli-mutation-safety.md`: Selected from the current context bundles. +- `standards/global/compatibility-is-not-upgradeability.md`: Selected from the current context bundles. +- `standards/global/structured-cli-contracts.md`: Selected from the current context bundles. +- `standards/go/thin-cli-fat-contracts.md`: Selected from the current context bundles. +- `standards/quality/source-quality-gates-are-policy.md`: Selected from the current context bundles. +- `standards/release/repo-first-canonical-distribution.md`: Selected from the current context bundles. +- `standards/release/self-update-runtime-asset-discovery.md`: Selected from the current context bundles. +- `standards/release/tag-driven-signed-release-process.md`: Selected from the current context bundles. +- `standards/source/deterministic-source-resolution.md`: Selected from the current context bundles. +- `standards/source/portable-path-canonicalization.md`: Selected from the current context bundles. + +## Resolution Notes +Generated from the current context bundle selection; review any automatic refresh before committing. diff --git a/runecontext/changes/CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline/status.yaml b/runecontext/changes/CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline/status.yaml new file mode 100644 index 0000000..89721d8 --- /dev/null +++ b/runecontext/changes/CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline/status.yaml @@ -0,0 +1,21 @@ +schema_version: 1 +id: CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline +title: Add canonical adapter source generation and rendering pipeline +status: planned +type: feature +size: large +verification_status: pending +context_bundles: [] +related_specs: [] +related_decisions: [] +related_changes: + - CHG-2026-002-5739-add-guided-adapter-native-change-workflows +depends_on: [] +informed_by: [] +supersedes: [] +superseded_by: [] +created_at: "2026-04-01" +closed_at: null +promotion_assessment: + status: pending + suggested_targets: [] diff --git a/runecontext/changes/CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline/tasks.md b/runecontext/changes/CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline/tasks.md new file mode 100644 index 0000000..e71252c --- /dev/null +++ b/runecontext/changes/CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline/tasks.md @@ -0,0 +1,6 @@ +# Tasks + +- Define canonical adapter source layout with shared flow definitions and per-tool overrides +- Implement deterministic adapter pack generator with explicit output root support +- Add just sync-adapters as the normal local entrypoint +- Wire the same generator into Nix-backed checks and release builds diff --git a/runecontext/changes/CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline/verification.md b/runecontext/changes/CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline/verification.md new file mode 100644 index 0000000..7ca9a11 --- /dev/null +++ b/runecontext/changes/CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline/verification.md @@ -0,0 +1,7 @@ +# Verification + +## Planned Checks +- `just test` + +## Close Gate +Use the repository's standard verification flow before closing this change. diff --git a/runecontext/changes/CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds/design.md b/runecontext/changes/CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds/design.md new file mode 100644 index 0000000..7550016 --- /dev/null +++ b/runecontext/changes/CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds/design.md @@ -0,0 +1,7 @@ +# Design + +## Overview +Shape Stage generated adapter packs in tests and release builds before implementation so scope, standards linkage, and verification stay reviewable. + +## Shape Rationale +- Large, ambiguous, or high-risk feature work should move to full mode early. diff --git a/runecontext/changes/CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds/proposal.md b/runecontext/changes/CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds/proposal.md new file mode 100644 index 0000000..bc4ba40 --- /dev/null +++ b/runecontext/changes/CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds/proposal.md @@ -0,0 +1,21 @@ +## Summary +Stage generated adapter packs in tests and release builds + +## Problem +Update test, build, release, and install flows to generate adapter packs into staging layouts instead of reading committed rendered adapters from the repo tree. + +## Proposed Change +Track and deliver Stage generated adapter packs in tests and release builds while keeping the intent and standards linkage reviewable. + +## Why Now +The work needs stable intent, standards linkage, and verification planning before it moves further. + +## Assumptions +- Used all non-draft standards as a conservative fallback because no standards were selected through context bundles. +- Inferred `just test` from the repository's justfile test target. + +## Out of Scope +Work outside the scoped change tracked here. + +## Impact +The change keeps intent, assumptions, and standards linkage reviewable. diff --git a/runecontext/changes/CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds/references.md b/runecontext/changes/CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds/references.md new file mode 100644 index 0000000..960904b --- /dev/null +++ b/runecontext/changes/CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds/references.md @@ -0,0 +1,7 @@ +# References + +- internal/cli/cli_adapter_pack_test.go +- internal/cli/cli_release_layout_test.go +- internal/cli/cli_completion_suggest.go +- docs/release-process.md +- docs/install-verify.md diff --git a/runecontext/changes/CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds/standards.md b/runecontext/changes/CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds/standards.md new file mode 100644 index 0000000..0c97ce5 --- /dev/null +++ b/runecontext/changes/CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds/standards.md @@ -0,0 +1,29 @@ +## Applicable Standards +- `standards/architecture/closed-schema-enforcement.md`: Selected from the current context bundles. +- `standards/architecture/derived-artifact-non-authority.md`: Selected from the current context bundles. +- `standards/assurance/additive-backfill-evidence.md`: Selected from the current context bundles. +- `standards/assurance/fail-closed-verified-boundary.md`: Selected from the current context bundles. +- `standards/assurance/portable-receipt-linkage.md`: Selected from the current context bundles. +- `standards/bundles/deterministic-bundle-linearization.md`: Selected from the current context bundles. +- `standards/change/explicit-umbrella-recursion-boundaries.md`: Selected from the current context bundles. +- `standards/change/immutable-terminal-change-records.md`: Selected from the current context bundles. +- `standards/change/verification-status-consistency.md`: Selected from the current context bundles. +- `standards/cli/completion-and-metadata-from-canonical-registry.md`: Selected from the current context bundles. +- `standards/cli/network-isolated-core-operations.md`: Selected from the current context bundles. +- `standards/context-packs/explicit-generated-at-and-stable-hashing.md`: Selected from the current context bundles. +- `standards/context-packs/portable-source-ref-requirements.md`: Selected from the current context bundles. +- `standards/global/adapter-thin-ux-over-cli.md`: Selected from the current context bundles. +- `standards/global/change-relationship-consistency.md`: Selected from the current context bundles. +- `standards/global/cli-mutation-safety.md`: Selected from the current context bundles. +- `standards/global/compatibility-is-not-upgradeability.md`: Selected from the current context bundles. +- `standards/global/structured-cli-contracts.md`: Selected from the current context bundles. +- `standards/go/thin-cli-fat-contracts.md`: Selected from the current context bundles. +- `standards/quality/source-quality-gates-are-policy.md`: Selected from the current context bundles. +- `standards/release/repo-first-canonical-distribution.md`: Selected from the current context bundles. +- `standards/release/self-update-runtime-asset-discovery.md`: Selected from the current context bundles. +- `standards/release/tag-driven-signed-release-process.md`: Selected from the current context bundles. +- `standards/source/deterministic-source-resolution.md`: Selected from the current context bundles. +- `standards/source/portable-path-canonicalization.md`: Selected from the current context bundles. + +## Resolution Notes +Generated from the current context bundle selection; review any automatic refresh before committing. diff --git a/runecontext/changes/CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds/status.yaml b/runecontext/changes/CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds/status.yaml new file mode 100644 index 0000000..22a7c18 --- /dev/null +++ b/runecontext/changes/CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds/status.yaml @@ -0,0 +1,22 @@ +schema_version: 1 +id: CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds +title: Stage generated adapter packs in tests and release builds +status: planned +type: feature +size: large +verification_status: pending +context_bundles: [] +related_specs: [] +related_decisions: [] +related_changes: + - CHG-2026-002-5739-add-guided-adapter-native-change-workflows +depends_on: + - CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline +informed_by: [] +supersedes: [] +superseded_by: [] +created_at: "2026-04-01" +closed_at: null +promotion_assessment: + status: pending + suggested_targets: [] diff --git a/runecontext/changes/CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds/tasks.md b/runecontext/changes/CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds/tasks.md new file mode 100644 index 0000000..fdc6133 --- /dev/null +++ b/runecontext/changes/CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds/tasks.md @@ -0,0 +1,6 @@ +# Tasks + +- Update adapter pack and completion tests to use generated staged adapter roots +- Update release builder to package staged generated adapters +- Preserve installed share/runecontext/adapters runtime layout +- Document staged generation assumptions in release and install docs diff --git a/runecontext/changes/CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds/verification.md b/runecontext/changes/CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds/verification.md new file mode 100644 index 0000000..7ca9a11 --- /dev/null +++ b/runecontext/changes/CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds/verification.md @@ -0,0 +1,7 @@ +# Verification + +## Planned Checks +- `just test` + +## Close Gate +Use the repository's standard verification flow before closing this change. diff --git a/runecontext/changes/CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs/design.md b/runecontext/changes/CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs/design.md new file mode 100644 index 0000000..2fa91b5 --- /dev/null +++ b/runecontext/changes/CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs/design.md @@ -0,0 +1,7 @@ +# Design + +## Overview +Shape Stop committing rendered adapter packs and adopt build generated outputs before implementation so scope, standards linkage, and verification stay reviewable. + +## Shape Rationale +- Minimum mode is sufficient for the current size and risk signal. diff --git a/runecontext/changes/CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs/proposal.md b/runecontext/changes/CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs/proposal.md new file mode 100644 index 0000000..665cdbc --- /dev/null +++ b/runecontext/changes/CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs/proposal.md @@ -0,0 +1,21 @@ +## Summary +Stop committing rendered adapter packs and adopt build generated outputs + +## Problem +Remove rendered adapter packs from git, keep generated outputs under build/generated, and document which generated surfaces remain committed versus ephemeral. + +## Proposed Change +Track and deliver Stop committing rendered adapter packs and adopt build generated outputs while keeping the intent and standards linkage reviewable. + +## Why Now +The work needs stable intent, standards linkage, and verification planning before it moves further. + +## Assumptions +- Used all non-draft standards as a conservative fallback because no standards were selected through context bundles. +- Inferred `just test` from the repository's justfile test target. + +## Out of Scope +Work outside the scoped change tracked here. + +## Impact +The change keeps intent, assumptions, and standards linkage reviewable. diff --git a/runecontext/changes/CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs/references.md b/runecontext/changes/CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs/references.md new file mode 100644 index 0000000..03a69b6 --- /dev/null +++ b/runecontext/changes/CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs/references.md @@ -0,0 +1,6 @@ +# References + +- justfile +- .gitignore +- docs/release-process.md +- internal/cli/cli_docs_reference_test.go diff --git a/runecontext/changes/CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs/standards.md b/runecontext/changes/CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs/standards.md new file mode 100644 index 0000000..0c97ce5 --- /dev/null +++ b/runecontext/changes/CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs/standards.md @@ -0,0 +1,29 @@ +## Applicable Standards +- `standards/architecture/closed-schema-enforcement.md`: Selected from the current context bundles. +- `standards/architecture/derived-artifact-non-authority.md`: Selected from the current context bundles. +- `standards/assurance/additive-backfill-evidence.md`: Selected from the current context bundles. +- `standards/assurance/fail-closed-verified-boundary.md`: Selected from the current context bundles. +- `standards/assurance/portable-receipt-linkage.md`: Selected from the current context bundles. +- `standards/bundles/deterministic-bundle-linearization.md`: Selected from the current context bundles. +- `standards/change/explicit-umbrella-recursion-boundaries.md`: Selected from the current context bundles. +- `standards/change/immutable-terminal-change-records.md`: Selected from the current context bundles. +- `standards/change/verification-status-consistency.md`: Selected from the current context bundles. +- `standards/cli/completion-and-metadata-from-canonical-registry.md`: Selected from the current context bundles. +- `standards/cli/network-isolated-core-operations.md`: Selected from the current context bundles. +- `standards/context-packs/explicit-generated-at-and-stable-hashing.md`: Selected from the current context bundles. +- `standards/context-packs/portable-source-ref-requirements.md`: Selected from the current context bundles. +- `standards/global/adapter-thin-ux-over-cli.md`: Selected from the current context bundles. +- `standards/global/change-relationship-consistency.md`: Selected from the current context bundles. +- `standards/global/cli-mutation-safety.md`: Selected from the current context bundles. +- `standards/global/compatibility-is-not-upgradeability.md`: Selected from the current context bundles. +- `standards/global/structured-cli-contracts.md`: Selected from the current context bundles. +- `standards/go/thin-cli-fat-contracts.md`: Selected from the current context bundles. +- `standards/quality/source-quality-gates-are-policy.md`: Selected from the current context bundles. +- `standards/release/repo-first-canonical-distribution.md`: Selected from the current context bundles. +- `standards/release/self-update-runtime-asset-discovery.md`: Selected from the current context bundles. +- `standards/release/tag-driven-signed-release-process.md`: Selected from the current context bundles. +- `standards/source/deterministic-source-resolution.md`: Selected from the current context bundles. +- `standards/source/portable-path-canonicalization.md`: Selected from the current context bundles. + +## Resolution Notes +Generated from the current context bundle selection; review any automatic refresh before committing. diff --git a/runecontext/changes/CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs/status.yaml b/runecontext/changes/CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs/status.yaml new file mode 100644 index 0000000..2021399 --- /dev/null +++ b/runecontext/changes/CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs/status.yaml @@ -0,0 +1,23 @@ +schema_version: 1 +id: CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs +title: Stop committing rendered adapter packs and adopt build generated outputs +status: planned +type: chore +size: medium +verification_status: pending +context_bundles: [] +related_specs: [] +related_decisions: [] +related_changes: + - CHG-2026-002-5739-add-guided-adapter-native-change-workflows +depends_on: + - CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline + - CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds +informed_by: [] +supersedes: [] +superseded_by: [] +created_at: "2026-04-01" +closed_at: null +promotion_assessment: + status: pending + suggested_targets: [] diff --git a/runecontext/changes/CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs/tasks.md b/runecontext/changes/CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs/tasks.md new file mode 100644 index 0000000..59debb0 --- /dev/null +++ b/runecontext/changes/CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs/tasks.md @@ -0,0 +1,5 @@ +# Tasks + +- Ignore build/generated adapter outputs in git +- Remove tracked rendered adapter pack files after staged generation is live +- Document the repo policy for committed versus ephemeral generated artifacts diff --git a/runecontext/changes/CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs/verification.md b/runecontext/changes/CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs/verification.md new file mode 100644 index 0000000..7ca9a11 --- /dev/null +++ b/runecontext/changes/CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs/verification.md @@ -0,0 +1,7 @@ +# Verification + +## Planned Checks +- `just test` + +## Close Gate +Use the repository's standard verification flow before closing this change. From 42ce947449e510e18651c574c1b419ef2081c136 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich <zeb@runecode.org> Date: Wed, 1 Apr 2026 10:40:31 -0500 Subject: [PATCH 09/27] Adds canonical adapter source generation and rendering pipeline Signed-off-by: Zebulon Rouse-Frantzich <zeb@runecode.org> --- adapters/source/shared/flows.json | 50 ++++++ adapters/source/tools/claude-code.json | 11 ++ adapters/source/tools/codex.json | 11 ++ adapters/source/tools/generic.json | 11 ++ adapters/source/tools/opencode.json | 11 ++ internal/cli/cli_adapter_pack_test.go | 3 + internal/cli/cli_adapter_sync_discovery.go | 4 +- internal/cli/cli_release_artifacts_test.go | 2 +- justfile | 3 + nix/release/metadata.nix | 8 +- nix/scripts/build-release-artifacts.sh | 4 +- tools/syncadapters/main.go | 23 +++ tools/syncadapters/model.go | 140 +++++++++++++++++ tools/syncadapters/render.go | 174 +++++++++++++++++++++ 14 files changed, 448 insertions(+), 7 deletions(-) create mode 100644 adapters/source/shared/flows.json create mode 100644 adapters/source/tools/claude-code.json create mode 100644 adapters/source/tools/codex.json create mode 100644 adapters/source/tools/generic.json create mode 100644 adapters/source/tools/opencode.json create mode 100644 tools/syncadapters/main.go create mode 100644 tools/syncadapters/model.go create mode 100644 tools/syncadapters/render.go diff --git a/adapters/source/shared/flows.json b/adapters/source/shared/flows.json new file mode 100644 index 0000000..978c99a --- /dev/null +++ b/adapters/source/shared/flows.json @@ -0,0 +1,50 @@ +[ + { + "id": "change-new", + "command_path": "change new", + "description": "Create a new RuneContext change", + "usage": "runectx change new --title \"<title>\" --type <type> [--size <size>] [--shape <minimum|full>] [--bundle <bundle-id>] [--description \"<text>\"] [--path <project-root>]" + }, + { + "id": "change-assess-intake", + "command_path": "change assess-intake", + "description": "Assess intake readiness and advisory shaping signals", + "usage": "runectx change assess-intake --id <change-id> [--path <project-root>]" + }, + { + "id": "change-assess-decomposition", + "command_path": "change assess-decomposition", + "description": "Assess decomposition and umbrella/sub-change signals", + "usage": "runectx change assess-decomposition --id <change-id> [--path <project-root>]" + }, + { + "id": "change-decomposition-plan", + "command_path": "change decomposition-plan", + "description": "Plan umbrella and sub-change graph rewrites", + "usage": "runectx change decomposition-plan --id <change-id> [--path <project-root>]" + }, + { + "id": "change-decomposition-apply", + "command_path": "change decomposition-apply", + "description": "Apply umbrella and sub-change graph rewrites", + "usage": "runectx change decomposition-apply --id <change-id> [--path <project-root>]" + }, + { + "id": "change-shape", + "command_path": "change shape", + "description": "Shape an existing RuneContext change", + "usage": "runectx change shape --id <change-id> [--path <project-root>]" + }, + { + "id": "standard-discover", + "command_path": "standard discover", + "description": "Discover standards candidates for promotion", + "usage": "runectx standard discover --scope-path <path> [--focus <topic>] [--path <project-root>]" + }, + { + "id": "promote", + "command_path": "promote", + "description": "Advance RuneContext promotion state", + "usage": "runectx promote [--target spec:<path>|standard:<path>|decision:<path>] [--path <project-root>]" + } +] diff --git a/adapters/source/tools/claude-code.json b/adapters/source/tools/claude-code.json new file mode 100644 index 0000000..ca8d179 --- /dev/null +++ b/adapters/source/tools/claude-code.json @@ -0,0 +1,11 @@ +{ + "display_name": "Claude Code", + "flow_intro": "Map conversational intake to explicit RuneContext CLI operations.", + "capabilities": { + "prompts": "supported", + "shell_access": "supported", + "hooks": "supported", + "dynamic_suggestions": "supported", + "structured_output": "supported" + } +} diff --git a/adapters/source/tools/codex.json b/adapters/source/tools/codex.json new file mode 100644 index 0000000..488d4cb --- /dev/null +++ b/adapters/source/tools/codex.json @@ -0,0 +1,11 @@ +{ + "display_name": "Codex", + "flow_intro": "Map conversational intake to explicit RuneContext CLI operations.", + "capabilities": { + "prompts": "supported", + "shell_access": "supported", + "hooks": "supported", + "dynamic_suggestions": "supported", + "structured_output": "supported" + } +} diff --git a/adapters/source/tools/generic.json b/adapters/source/tools/generic.json new file mode 100644 index 0000000..0d8edd0 --- /dev/null +++ b/adapters/source/tools/generic.json @@ -0,0 +1,11 @@ +{ + "display_name": "Generic", + "flow_intro": "Use explicit CLI command proposals for host-agnostic, reviewable operation mapping.", + "capabilities": { + "prompts": "optional", + "shell_access": "optional", + "hooks": "optional", + "dynamic_suggestions": "optional", + "structured_output": "optional" + } +} diff --git a/adapters/source/tools/opencode.json b/adapters/source/tools/opencode.json new file mode 100644 index 0000000..79076da --- /dev/null +++ b/adapters/source/tools/opencode.json @@ -0,0 +1,11 @@ +{ + "display_name": "OpenCode", + "flow_intro": "Use this conversational flow to map host-native guidance to explicit RuneContext CLI operations.", + "capabilities": { + "prompts": "supported", + "shell_access": "supported", + "hooks": "supported", + "dynamic_suggestions": "supported", + "structured_output": "supported" + } +} diff --git a/internal/cli/cli_adapter_pack_test.go b/internal/cli/cli_adapter_pack_test.go index 01f0daa..61cc90a 100644 --- a/internal/cli/cli_adapter_pack_test.go +++ b/internal/cli/cli_adapter_pack_test.go @@ -32,6 +32,9 @@ func TestAdapterPackDocsExist(t *testing.T) { continue } name := entry.Name() + if name == "source" { + continue + } t.Run(name, func(t *testing.T) { runAdapterDocChecks(t, adaptersRoot, name) }) diff --git a/internal/cli/cli_adapter_sync_discovery.go b/internal/cli/cli_adapter_sync_discovery.go index fa3fdc9..0e46e1e 100644 --- a/internal/cli/cli_adapter_sync_discovery.go +++ b/internal/cli/cli_adapter_sync_discovery.go @@ -11,9 +11,11 @@ func locateAdaptersRoot() (string, error) { if err != nil { return "", err } + projectRoot := filepath.Dir(schemaRoot) candidates := []string{ + filepath.Join(projectRoot, "build", "generated", "adapters"), filepath.Join(schemaRoot, "adapters"), - filepath.Join(filepath.Dir(schemaRoot), "adapters"), + filepath.Join(projectRoot, "adapters"), } for _, candidate := range candidates { if isDirectory(candidate) { diff --git a/internal/cli/cli_release_artifacts_test.go b/internal/cli/cli_release_artifacts_test.go index f183021..9daefcd 100644 --- a/internal/cli/cli_release_artifacts_test.go +++ b/internal/cli/cli_release_artifacts_test.go @@ -46,7 +46,7 @@ func TestReleaseArtifactBuilderRecordsManifestAndChecksumCoverage(t *testing.T) `process_pack_archives "schema_bundle"`, `process_pack_archives "adapter_pack"`, `"${coreutils}/cp" -R schemas "${share_dir}/schemas"`, - `"${coreutils}/cp" -R adapters "${share_dir}/adapters"`, + `"${coreutils}/cp" -R build/generated/adapters "${share_dir}/adapters"`, `"${host_metadata_binary}" metadata > "release/metadata-descriptor.json"`, `if ! @jq@/bin/jq -e '`, `invalid metadata descriptor payload: release/metadata-descriptor.json`, diff --git a/justfile b/justfile index 361cdeb..fe17910 100644 --- a/justfile +++ b/justfile @@ -23,6 +23,9 @@ test: sync-metadata: go run ./tools/syncmetadataartifacts --root . +sync-adapters: + go run ./tools/syncadapters --root . --output build/generated/adapters + release-check: nix build --no-link .#release-artifacts diff --git a/nix/release/metadata.nix b/nix/release/metadata.nix index e845220..ddebe57 100644 --- a/nix/release/metadata.nix +++ b/nix/release/metadata.nix @@ -55,25 +55,25 @@ let { name = "adapter-generic"; entries = [ - "adapters/generic" + "build/generated/adapters/generic" ]; } { name = "adapter-codex"; entries = [ - "adapters/codex" + "build/generated/adapters/codex" ]; } { name = "adapter-claude-code"; entries = [ - "adapters/claude-code" + "build/generated/adapters/claude-code" ]; } { name = "adapter-opencode"; entries = [ - "adapters/opencode" + "build/generated/adapters/opencode" ]; } ]; diff --git a/nix/scripts/build-release-artifacts.sh b/nix/scripts/build-release-artifacts.sh index 127b208..c0df9bd 100644 --- a/nix/scripts/build-release-artifacts.sh +++ b/nix/scripts/build-release-artifacts.sh @@ -14,6 +14,8 @@ findutils='@findutils@/bin' "${coreutils}/mkdir" -p release/payload release/dist +go run ./tools/syncadapters --root . --output build/generated/adapters + archive_records="release/archive-records.ndjson" : > "${archive_records}" @@ -199,7 +201,7 @@ for target in "${targets[@]}"; do "${coreutils}/cp" LICENSE NOTICE README.md "${package_dir}/" "${coreutils}/cp" -R schemas "${share_dir}/schemas" - "${coreutils}/cp" -R adapters "${share_dir}/adapters" + "${coreutils}/cp" -R build/generated/adapters "${share_dir}/adapters" "${coreutils}/chmod" -R u=rwX,go=rX "${package_dir}" "${findutils}/find" "${package_dir}" -exec "${coreutils}/touch" -h -d '1980-01-01T00:00:00Z' {} + diff --git a/tools/syncadapters/main.go b/tools/syncadapters/main.go new file mode 100644 index 0000000..30ff53f --- /dev/null +++ b/tools/syncadapters/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" +) + +func main() { + rootFlag := flag.String("root", ".", "repository root path") + outputFlag := flag.String("output", filepath.Join("build", "generated", "adapters"), "rendered adapter output root") + flag.Parse() + + if err := run(*rootFlag, *outputFlag); err != nil { + fatalf("sync adapters: %v", err) + } +} + +func fatalf(pattern string, args ...any) { + _, _ = fmt.Fprintf(os.Stderr, pattern+"\n", args...) + os.Exit(1) +} diff --git a/tools/syncadapters/model.go b/tools/syncadapters/model.go new file mode 100644 index 0000000..4360787 --- /dev/null +++ b/tools/syncadapters/model.go @@ -0,0 +1,140 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +type flowDefinition struct { + ID string `json:"id"` + CommandPath string `json:"command_path"` + Description string `json:"description"` + Usage string `json:"usage"` +} + +type toolDefinition struct { + DisplayName string `json:"display_name"` + FlowIntro string `json:"flow_intro"` + Capabilities map[string]string `json:"capabilities"` +} + +func loadFlowDefinitions(root string) ([]flowDefinition, error) { + path := filepath.Join(root, "adapters", "source", "shared", "flows.json") + var defs []flowDefinition + if err := loadJSON(path, &defs); err != nil { + return nil, err + } + for i := range defs { + if err := validateFlowDefinition(defs[i]); err != nil { + return nil, fmt.Errorf("invalid flow definition %q: %w", defs[i].ID, err) + } + } + sort.Slice(defs, func(i, j int) bool { return defs[i].ID < defs[j].ID }) + return defs, nil +} + +func validateFlowDefinition(def flowDefinition) error { + if strings.TrimSpace(def.ID) == "" { + return errors.New("id is required") + } + if strings.TrimSpace(def.CommandPath) == "" { + return errors.New("command_path is required") + } + if strings.TrimSpace(def.Description) == "" { + return errors.New("description is required") + } + if strings.TrimSpace(def.Usage) == "" { + return errors.New("usage is required") + } + return nil +} + +func loadToolDefinitions(root string) (map[string]toolDefinition, error) { + pattern := filepath.Join(root, "adapters", "source", "tools", "*.json") + files, err := filepath.Glob(pattern) + if err != nil { + return nil, fmt.Errorf("list tool definitions: %w", err) + } + if len(files) == 0 { + return nil, errors.New("no adapter source tool definitions found") + } + tools := make(map[string]toolDefinition, len(files)) + for _, path := range files { + toolID := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + var def toolDefinition + if err := loadJSON(path, &def); err != nil { + return nil, err + } + if err := validateToolDefinition(toolID, def); err != nil { + return nil, err + } + tools[toolID] = def + } + return tools, nil +} + +func validateToolDefinition(toolID string, def toolDefinition) error { + if strings.TrimSpace(toolID) == "" { + return errors.New("tool id must be non-empty") + } + if strings.TrimSpace(def.DisplayName) == "" { + return fmt.Errorf("tool %q display_name is required", toolID) + } + if strings.TrimSpace(def.FlowIntro) == "" { + return fmt.Errorf("tool %q flow_intro is required", toolID) + } + for _, key := range capabilityOrder() { + value := strings.TrimSpace(def.Capabilities[key]) + if value != "supported" && value != "optional" { + return fmt.Errorf("tool %q capability %q must be supported or optional", toolID, key) + } + } + return nil +} + +func loadJSON(path string, target any) error { + raw, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read %q: %w", path, err) + } + if err := json.Unmarshal(raw, target); err != nil { + return fmt.Errorf("decode %q: %w", path, err) + } + return nil +} + +func sortedToolIDs(items map[string]toolDefinition) []string { + ids := make([]string, 0, len(items)) + for id := range items { + ids = append(ids, id) + } + sort.Strings(ids) + return ids +} + +func capabilityOrder() []string { + return []string{"prompts", "shell_access", "hooks", "dynamic_suggestions", "structured_output"} +} + +func capabilityFallback(name, value string) string { + if value == "supported" { + switch name { + case "prompts": + return "Render static guidance and explicit runectx command proposals." + case "shell_access": + return "Show command steps and candidate data without execution." + case "hooks": + return "Run explicit runectx validate at review checkpoints." + case "dynamic_suggestions": + return "Use runectx completion suggest output as numbered choices." + case "structured_output": + return "Emit plain text summary with equivalent CLI flags/values." + } + } + return "Use explicit runectx command proposals and manual review checkpoints." +} diff --git a/tools/syncadapters/render.go b/tools/syncadapters/render.go new file mode 100644 index 0000000..9b92df6 --- /dev/null +++ b/tools/syncadapters/render.go @@ -0,0 +1,174 @@ +package main + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +const generatedFlowsDir = "flows" + +func run(root, output string) error { + absRoot, err := filepath.Abs(root) + if err != nil { + return fmt.Errorf("resolve root: %w", err) + } + absOutput, err := resolveOutput(absRoot, output) + if err != nil { + return err + } + flows, err := loadFlowDefinitions(absRoot) + if err != nil { + return err + } + tools, err := loadToolDefinitions(absRoot) + if err != nil { + return err + } + if err := os.RemoveAll(absOutput); err != nil { + return fmt.Errorf("reset output root %q: %w", absOutput, err) + } + if err := os.MkdirAll(absOutput, 0o755); err != nil { + return fmt.Errorf("create output root %q: %w", absOutput, err) + } + for _, toolID := range sortedToolIDs(tools) { + if err := renderTool(absRoot, absOutput, toolID, flows, tools[toolID]); err != nil { + return err + } + } + return nil +} + +func resolveOutput(absRoot, output string) (string, error) { + if strings.TrimSpace(output) == "" { + return "", fmt.Errorf("output root must be non-empty") + } + if filepath.IsAbs(output) { + return filepath.Clean(output), nil + } + return filepath.Clean(filepath.Join(absRoot, output)), nil +} + +func renderTool(root, output, toolID string, flows []flowDefinition, tool toolDefinition) error { + sourceDir := filepath.Join(root, "adapters", toolID) + if info, err := os.Stat(sourceDir); err != nil || !info.IsDir() { + return fmt.Errorf("adapter source directory missing for %q", toolID) + } + targetDir := filepath.Join(output, toolID) + if err := os.MkdirAll(targetDir, 0o755); err != nil { + return fmt.Errorf("create adapter output %q: %w", targetDir, err) + } + excludes := generatedExcludes(flows) + if err := copyPassthroughTree(sourceDir, targetDir, excludes); err != nil { + return fmt.Errorf("copy passthrough tree for %q: %w", toolID, err) + } + if err := writeCapabilities(targetDir, toolID, tool); err != nil { + return err + } + return writeFlows(targetDir, tool, flows) +} + +func generatedExcludes(flows []flowDefinition) map[string]struct{} { + excludes := map[string]struct{}{ + "capabilities.yaml": {}, + } + for _, flow := range flows { + excludes[filepath.ToSlash(filepath.Join(generatedFlowsDir, flow.ID+".md"))] = struct{}{} + } + return excludes +} + +func copyPassthroughTree(sourceRoot, targetRoot string, excludes map[string]struct{}) error { + return filepath.WalkDir(sourceRoot, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + rel, err := filepath.Rel(sourceRoot, path) + if err != nil { + return err + } + if rel == "." { + return nil + } + rel = filepath.ToSlash(rel) + if _, excluded := excludes[rel]; excluded { + return nil + } + targetPath := filepath.Join(targetRoot, filepath.FromSlash(rel)) + if d.IsDir() { + return os.MkdirAll(targetPath, 0o755) + } + return copyFileWithMode(path, targetPath) + }) +} + +func copyFileWithMode(source, target string) error { + data, err := os.ReadFile(source) + if err != nil { + return err + } + info, err := os.Stat(source) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + return os.WriteFile(target, data, info.Mode().Perm()) +} + +func writeCapabilities(targetDir, toolID string, tool toolDefinition) error { + path := filepath.Join(targetDir, "capabilities.yaml") + var lines []string + lines = append(lines, + "schema_version: 1", + "adapter: "+toolID, + "capabilities:", + ) + for _, key := range capabilityOrder() { + lines = append(lines, " "+key+": "+tool.Capabilities[key]) + } + lines = append(lines, "fallbacks:") + for _, key := range capabilityOrder() { + lines = append(lines, " "+key+": \""+capabilityFallback(key, tool.Capabilities[key])+"\"") + } + return os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644) +} + +func writeFlows(targetDir string, tool toolDefinition, flows []flowDefinition) error { + flowsDir := filepath.Join(targetDir, generatedFlowsDir) + if err := os.MkdirAll(flowsDir, 0o755); err != nil { + return fmt.Errorf("create flows dir: %w", err) + } + for _, flow := range flows { + if err := writeFlowFile(flowsDir, tool, flow); err != nil { + return err + } + } + return nil +} + +func writeFlowFile(flowsDir string, tool toolDefinition, flow flowDefinition) error { + path := filepath.Join(flowsDir, flow.ID+".md") + lines := []string{ + "# " + tool.DisplayName + " Flow: " + flow.CommandPath, + "", + tool.FlowIntro, + "", + "## Intent", + "", + "- " + flow.Description, + "", + "## Command Mapping", + "", + "```sh", + flow.Usage, + "```", + } + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil { + return fmt.Errorf("write flow %q: %w", flow.ID, err) + } + return nil +} From fbdca695385781a1b5c1f0ff10138ff21284157d Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich <zeb@runecode.org> Date: Wed, 1 Apr 2026 10:57:48 -0500 Subject: [PATCH 10/27] Adds generated adapter packs in tests and release builds Signed-off-by: Zebulon Rouse-Frantzich <zeb@runecode.org> --- docs/install-verify.md | 5 +++++ docs/release-process.md | 1 + internal/cli/cli_adapter_pack_test.go | 7 +------ internal/cli/cli_completion_suggest_test.go | 14 ++++---------- internal/cli/cli_release_artifacts_test.go | 2 ++ internal/cli/cli_release_layout_test.go | 10 +++++++--- internal/cli/testhelpers_test.go | 20 ++++++++++++++++++++ nix/scripts/build-release-artifacts.sh | 3 +++ 8 files changed, 43 insertions(+), 19 deletions(-) diff --git a/docs/install-verify.md b/docs/install-verify.md index c216fae..32e11d0 100644 --- a/docs/install-verify.md +++ b/docs/install-verify.md @@ -30,6 +30,11 @@ the release manifest: nix build --no-link .#release-artifacts ``` +During that build, adapter packs are regenerated from canonical adapter source +definitions into `build/generated/adapters` and staged into release archives. +Installed runtime layouts still place adapters at +`share/runecontext/adapters`. + The release workflow generates the SBOM afterward, then signs and attests it separately. The canonical `SHA256SUMS` file covers the unsigned archives, schema bundle, adapter packs, and release manifest. diff --git a/docs/release-process.md b/docs/release-process.md index 23c0068..9bd81b2 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -16,6 +16,7 @@ For each pushed release tag, the workflow: - reruns `just ci` - builds the canonical unsigned artifacts with `nix build .#release-artifacts` +- regenerates adapter packs into `build/generated/adapters` during the build and stages that generated tree into published archives - emits the final versioned repo bundles, Linux/macOS `runectx` binary archives, `schema-bundle.tar.gz`, adapter-pack archives (`adapter-*.tar.gz`), `SHA256SUMS`, and `runecontext_<tag>_release-manifest.json` from the Nix diff --git a/internal/cli/cli_adapter_pack_test.go b/internal/cli/cli_adapter_pack_test.go index 61cc90a..5a3231d 100644 --- a/internal/cli/cli_adapter_pack_test.go +++ b/internal/cli/cli_adapter_pack_test.go @@ -10,12 +10,7 @@ import ( ) func TestAdapterPackDocsExist(t *testing.T) { - repoRoot, err := repoRootForTests() - if err != nil { - t.Fatalf("repo root: %v", err) - } - - adaptersRoot := filepath.Join(repoRoot, "adapters") + _, adaptersRoot := stageGeneratedAdapterWorkspaceForTests(t) if info, err := os.Stat(adaptersRoot); err != nil { t.Fatalf("adapters root: %v", err) } else if !info.IsDir() { diff --git a/internal/cli/cli_completion_suggest_test.go b/internal/cli/cli_completion_suggest_test.go index 281deef..5ad4d20 100644 --- a/internal/cli/cli_completion_suggest_test.go +++ b/internal/cli/cli_completion_suggest_test.go @@ -53,11 +53,8 @@ func TestRunCompletionSuggestSoftFailsOutsideRuneContextProject(t *testing.T) { } func TestRunCompletionSuggestAdapterNamesShellInjection(t *testing.T) { - root, err := repoRootForTests() - if err != nil { - t.Fatal(err) - } - t.Chdir(root) + workspaceRoot, _ := stageGeneratedAdapterWorkspaceForTests(t) + t.Chdir(workspaceRoot) var stdout bytes.Buffer var stderr bytes.Buffer @@ -123,11 +120,8 @@ func assertCompletionSuggestInvalid(t *testing.T, args []string, wantSubstring s } func TestRunCompletionSuggestAdapterNames(t *testing.T) { - root, err := repoRootForTests() - if err != nil { - t.Fatal(err) - } - t.Chdir(root) + workspaceRoot, _ := stageGeneratedAdapterWorkspaceForTests(t) + t.Chdir(workspaceRoot) var stdout bytes.Buffer var stderr bytes.Buffer diff --git a/internal/cli/cli_release_artifacts_test.go b/internal/cli/cli_release_artifacts_test.go index 9daefcd..0c1400d 100644 --- a/internal/cli/cli_release_artifacts_test.go +++ b/internal/cli/cli_release_artifacts_test.go @@ -45,6 +45,8 @@ func TestReleaseArtifactBuilderRecordsManifestAndChecksumCoverage(t *testing.T) requireSubstrings(t, script, `process_pack_archives "schema_bundle"`, `process_pack_archives "adapter_pack"`, + `"${coreutils}/rm" -rf "${bundle_root}/adapters"`, + `"${coreutils}/cp" -R build/generated/adapters "${bundle_root}/adapters"`, `"${coreutils}/cp" -R schemas "${share_dir}/schemas"`, `"${coreutils}/cp" -R build/generated/adapters "${share_dir}/adapters"`, `"${host_metadata_binary}" metadata > "release/metadata-descriptor.json"`, diff --git a/internal/cli/cli_release_layout_test.go b/internal/cli/cli_release_layout_test.go index 30c0c74..e832c86 100644 --- a/internal/cli/cli_release_layout_test.go +++ b/internal/cli/cli_release_layout_test.go @@ -25,8 +25,12 @@ func TestReleaseMetadataIncludesAdaptersDirectory(t *testing.T) { func TestLocateAdaptersRootFromReleaseStyleLayout(t *testing.T) { root := t.TempDir() schemaDir := filepath.Join(root, "schemas") - adaptersDir := filepath.Join(root, "adapters") - seedReleaseStyleLayout(t, schemaDir, adaptersDir) + repoAdaptersDir := filepath.Join(root, "adapters") + stagedAdaptersDir := filepath.Join(root, "build", "generated", "adapters") + seedReleaseStyleLayout(t, schemaDir, repoAdaptersDir) + if err := os.MkdirAll(stagedAdaptersDir, 0o755); err != nil { + t.Fatalf("mkdir staged adapters dir: %v", err) + } originalWD, err := os.Getwd() if err != nil { @@ -45,7 +49,7 @@ func TestLocateAdaptersRootFromReleaseStyleLayout(t *testing.T) { if err != nil { t.Fatalf("resolve located adapters root symlinks: %v", err) } - expectedCanonical, err := filepath.EvalSymlinks(adaptersDir) + expectedCanonical, err := filepath.EvalSymlinks(stagedAdaptersDir) if err != nil { t.Fatalf("resolve expected adapters root symlinks: %v", err) } diff --git a/internal/cli/testhelpers_test.go b/internal/cli/testhelpers_test.go index 0d8c0fd..0bc3d2f 100644 --- a/internal/cli/testhelpers_test.go +++ b/internal/cli/testhelpers_test.go @@ -91,6 +91,26 @@ func repoFixtureRoot(t *testing.T, elems ...string) string { return filepath.Join(parts...) } +func stageGeneratedAdapterWorkspaceForTests(t *testing.T) (string, string) { + t.Helper() + repoRoot, err := repoRootForTests() + if err != nil { + t.Fatalf("locate repo root: %v", err) + } + workspace := t.TempDir() + schemaSource := filepath.Join(repoRoot, "schemas") + schemaTarget := filepath.Join(workspace, "schemas") + copyDirForCLI(t, schemaSource, schemaTarget) + adaptersRoot := filepath.Join(workspace, "build", "generated", "adapters") + cmd := exec.Command("go", "run", "./tools/syncadapters", "--root", repoRoot, "--output", adaptersRoot) + cmd.Dir = repoRoot + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("generate staged adapter packs: %v\n%s", err, string(output)) + } + return workspace, adaptersRoot +} + func copyDirForCLI(t *testing.T, srcRoot, dstRoot string) { t.Helper() if err := filepath.Walk(srcRoot, func(path string, info os.FileInfo, err error) error { diff --git a/nix/scripts/build-release-artifacts.sh b/nix/scripts/build-release-artifacts.sh index c0df9bd..83e0e93 100644 --- a/nix/scripts/build-release-artifacts.sh +++ b/nix/scripts/build-release-artifacts.sh @@ -57,6 +57,9 @@ while IFS= read -r entry; do "${coreutils}/cp" -R --parents "${entry}" "${bundle_root}" done < "@layoutEntriesFile@" +"${coreutils}/rm" -rf "${bundle_root}/adapters" +"${coreutils}/cp" -R build/generated/adapters "${bundle_root}/adapters" + "${coreutils}/chmod" -R u=rwX,go=rX "${bundle_root}" "${findutils}/find" "${bundle_root}" -exec "${coreutils}/touch" -h -d '1980-01-01T00:00:00Z' {} + From e97f640fee287ba079092101b0ce9f7291a82a3a Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich <zeb@runecode.org> Date: Wed, 1 Apr 2026 11:30:53 -0500 Subject: [PATCH 11/27] Stops committing rendered adapter packs and adopt build generated outputs Signed-off-by: Zebulon Rouse-Frantzich <zeb@runecode.org> --- .gitignore | 1 + adapters/README.md | 9 +++- adapters/claude-code/capabilities.yaml | 14 ------- .../flows/change-assess-decomposition.md | 14 ------- .../claude-code/flows/change-assess-intake.md | 18 -------- .../flows/change-decomposition-apply.md | 12 ------ .../flows/change-decomposition-plan.md | 14 ------- adapters/claude-code/flows/change-new.md | 7 ---- adapters/claude-code/flows/change-shape.md | 7 ---- adapters/claude-code/flows/promote.md | 7 ---- .../claude-code/flows/standard-discover.md | 7 ---- adapters/codex/capabilities.yaml | 14 ------- .../flows/change-assess-decomposition.md | 14 ------- adapters/codex/flows/change-assess-intake.md | 18 -------- .../codex/flows/change-decomposition-apply.md | 12 ------ .../codex/flows/change-decomposition-plan.md | 14 ------- adapters/codex/flows/change-new.md | 7 ---- adapters/codex/flows/change-shape.md | 7 ---- adapters/codex/flows/promote.md | 7 ---- adapters/codex/flows/standard-discover.md | 7 ---- adapters/generic/capabilities.yaml | 14 ------- .../flows/change-assess-decomposition.md | 16 ------- .../generic/flows/change-assess-intake.md | 18 -------- .../flows/change-decomposition-apply.md | 12 ------ .../flows/change-decomposition-plan.md | 15 ------- adapters/generic/flows/change-new.md | 7 ---- adapters/generic/flows/change-shape.md | 7 ---- adapters/generic/flows/promote.md | 7 ---- adapters/generic/flows/standard-discover.md | 7 ---- adapters/opencode/capabilities.yaml | 14 ------- .../flows/change-assess-decomposition.md | 35 ---------------- .../opencode/flows/change-assess-intake.md | 42 ------------------- .../flows/change-decomposition-apply.md | 29 ------------- .../flows/change-decomposition-plan.md | 35 ---------------- adapters/opencode/flows/change-new.md | 25 ----------- adapters/opencode/flows/change-shape.md | 24 ----------- adapters/opencode/flows/promote.md | 21 ---------- adapters/opencode/flows/standard-discover.md | 23 ---------- .../{ => source/packs}/claude-code/README.md | 0 .../flows/conversational-parity.md | 0 .../{ => source/packs}/claude-code/setup.md | 0 adapters/{ => source/packs}/codex/README.md | 0 .../codex/flows/conversational-parity.md | 0 adapters/{ => source/packs}/codex/setup.md | 0 adapters/{ => source/packs}/generic/README.md | 0 .../generic/examples/cli-assisted-flow.md | 0 .../packs}/generic/examples/manual-flow.md | 0 .../packs}/generic/examples/non-agent-flow.md | 0 .../generic/flows/conversational-parity.md | 0 .../{ => source/packs}/opencode/README.md | 0 .../validate_after_authoritative_edit.sh | 0 .../opencode/flows/conversational-parity.md | 0 adapters/{ => source/packs}/opencode/setup.md | 0 .../generated/runecontext-reference.json | 2 +- .../cli-json-golden/metadata-success.json | 2 +- .../.agents/skills/runecontext-change-new.md | 2 +- .../skills/runecontext-change-shape.md | 2 +- .../.agents/skills/runecontext-promote.md | 2 +- .../skills/runecontext-standard-discover.md | 2 +- .../embedded/.claude/commands/runecontext.md | 8 ++-- .../release-manifest-with-metadata.json | 2 +- ...cli_adapter_render_host_native_markdown.go | 2 +- .../cli/cli_adapter_sync_host_native_flows.go | 2 +- internal/cli/cli_adapter_test.go | 2 +- internal/cli/cli_adapter_test_helpers_test.go | 2 +- .../cli/cli_metadata_descriptor_values.go | 2 +- internal/cli/cli_release_layout_test.go | 4 +- nix/release/metadata.nix | 2 +- tools/syncadapters/render.go | 4 +- 69 files changed, 30 insertions(+), 573 deletions(-) delete mode 100644 adapters/claude-code/capabilities.yaml delete mode 100644 adapters/claude-code/flows/change-assess-decomposition.md delete mode 100644 adapters/claude-code/flows/change-assess-intake.md delete mode 100644 adapters/claude-code/flows/change-decomposition-apply.md delete mode 100644 adapters/claude-code/flows/change-decomposition-plan.md delete mode 100644 adapters/claude-code/flows/change-new.md delete mode 100644 adapters/claude-code/flows/change-shape.md delete mode 100644 adapters/claude-code/flows/promote.md delete mode 100644 adapters/claude-code/flows/standard-discover.md delete mode 100644 adapters/codex/capabilities.yaml delete mode 100644 adapters/codex/flows/change-assess-decomposition.md delete mode 100644 adapters/codex/flows/change-assess-intake.md delete mode 100644 adapters/codex/flows/change-decomposition-apply.md delete mode 100644 adapters/codex/flows/change-decomposition-plan.md delete mode 100644 adapters/codex/flows/change-new.md delete mode 100644 adapters/codex/flows/change-shape.md delete mode 100644 adapters/codex/flows/promote.md delete mode 100644 adapters/codex/flows/standard-discover.md delete mode 100644 adapters/generic/capabilities.yaml delete mode 100644 adapters/generic/flows/change-assess-decomposition.md delete mode 100644 adapters/generic/flows/change-assess-intake.md delete mode 100644 adapters/generic/flows/change-decomposition-apply.md delete mode 100644 adapters/generic/flows/change-decomposition-plan.md delete mode 100644 adapters/generic/flows/change-new.md delete mode 100644 adapters/generic/flows/change-shape.md delete mode 100644 adapters/generic/flows/promote.md delete mode 100644 adapters/generic/flows/standard-discover.md delete mode 100644 adapters/opencode/capabilities.yaml delete mode 100644 adapters/opencode/flows/change-assess-decomposition.md delete mode 100644 adapters/opencode/flows/change-assess-intake.md delete mode 100644 adapters/opencode/flows/change-decomposition-apply.md delete mode 100644 adapters/opencode/flows/change-decomposition-plan.md delete mode 100644 adapters/opencode/flows/change-new.md delete mode 100644 adapters/opencode/flows/change-shape.md delete mode 100644 adapters/opencode/flows/promote.md delete mode 100644 adapters/opencode/flows/standard-discover.md rename adapters/{ => source/packs}/claude-code/README.md (100%) rename adapters/{ => source/packs}/claude-code/flows/conversational-parity.md (100%) rename adapters/{ => source/packs}/claude-code/setup.md (100%) rename adapters/{ => source/packs}/codex/README.md (100%) rename adapters/{ => source/packs}/codex/flows/conversational-parity.md (100%) rename adapters/{ => source/packs}/codex/setup.md (100%) rename adapters/{ => source/packs}/generic/README.md (100%) rename adapters/{ => source/packs}/generic/examples/cli-assisted-flow.md (100%) rename adapters/{ => source/packs}/generic/examples/manual-flow.md (100%) rename adapters/{ => source/packs}/generic/examples/non-agent-flow.md (100%) rename adapters/{ => source/packs}/generic/flows/conversational-parity.md (100%) rename adapters/{ => source/packs}/opencode/README.md (100%) rename adapters/{ => source/packs}/opencode/automation/validate_after_authoritative_edit.sh (100%) rename adapters/{ => source/packs}/opencode/flows/conversational-parity.md (100%) rename adapters/{ => source/packs}/opencode/setup.md (100%) diff --git a/.gitignore b/.gitignore index 702a039..e646c85 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ Thumbs.db runectx bin/runectx build/ +build/generated/adapters/ vendor/ result result-* diff --git a/adapters/README.md b/adapters/README.md index 664f63d..314dac5 100644 --- a/adapters/README.md +++ b/adapters/README.md @@ -1,3 +1,10 @@ # Adapters -Tool adapters live here as thin, tool-specific packs that map each tool's UX onto the shared RuneContext core model. +Canonical adapter definitions live under `adapters/source/`. + +- `adapters/source/shared/` defines shared flow metadata. +- `adapters/source/tools/` defines per-tool capabilities and generation inputs. +- `adapters/source/packs/` contains passthrough adapter content consumed by generation. + +Rendered adapter packs are build-generated at `build/generated/adapters/` via `just sync-adapters`. +Those generated outputs are ephemeral and must not be committed. diff --git a/adapters/claude-code/capabilities.yaml b/adapters/claude-code/capabilities.yaml deleted file mode 100644 index eea67a8..0000000 --- a/adapters/claude-code/capabilities.yaml +++ /dev/null @@ -1,14 +0,0 @@ -schema_version: 1 -adapter: claude-code -capabilities: - prompts: supported - shell_access: supported - hooks: supported - dynamic_suggestions: supported - structured_output: supported -fallbacks: - prompts: "Render static guidance and explicit runectx command proposals." - shell_access: "Show command steps and candidate data without execution." - hooks: "Run explicit runectx validate at review checkpoints." - dynamic_suggestions: "Use runectx completion suggest output as numbered choices." - structured_output: "Emit plain text summary with equivalent CLI flags/values." diff --git a/adapters/claude-code/flows/change-assess-decomposition.md b/adapters/claude-code/flows/change-assess-decomposition.md deleted file mode 100644 index ea5d1bb..0000000 --- a/adapters/claude-code/flows/change-assess-decomposition.md +++ /dev/null @@ -1,14 +0,0 @@ -# Claude Code Flow: change assess-decomposition - -Map conversational decomposition assessment to: - -```sh -runectx change assess-decomposition <CHANGE_ID> [--path <project-root>] -``` - -Guided behavior: - -- Ask emitted `clarification_prompt_*` when `clarification_needed=true`. -- Keep decomposition advisory fields user-visible. -- Re-run assessment when clarified scope materially changes graph planning. -- Prefer `change decomposition-plan` before `change decomposition-apply`. diff --git a/adapters/claude-code/flows/change-assess-intake.md b/adapters/claude-code/flows/change-assess-intake.md deleted file mode 100644 index fcbc281..0000000 --- a/adapters/claude-code/flows/change-assess-intake.md +++ /dev/null @@ -1,18 +0,0 @@ -# Claude Code Flow: change assess-intake - -Map conversational intake assessment to: - -```sh -runectx change assess-intake --title "<title>" --type <type> [--size <size>] [--bundle <bundle-id>] [--description "<text>"] [--path <project-root>] -``` - -Guided behavior: - -- Ask each emitted `clarification_prompt_*` when `intake_readiness` indicates - clarification or `clarification_needed=true`. -- Re-run intake assessment with updated inputs until guidance stabilizes. -- If `recommended_mode=full`, default to proposing `runectx change new --shape full` - unless the user explicitly overrides. -- If `decomposition_signal=consider_decomposition`, propose follow-up: - `change assess-decomposition` -> `change decomposition-plan` -> - `change decomposition-apply` after plan approval. diff --git a/adapters/claude-code/flows/change-decomposition-apply.md b/adapters/claude-code/flows/change-decomposition-apply.md deleted file mode 100644 index 8b9f5c6..0000000 --- a/adapters/claude-code/flows/change-decomposition-apply.md +++ /dev/null @@ -1,12 +0,0 @@ -# Claude Code Flow: change decomposition-apply - -Map conversational decomposition apply to: - -```sh -runectx change decomposition-apply <UMBRELLA_CHANGE_ID> --sub-change <CHANGE_ID> [--sub-change <CHANGE_ID> ...] [--depends-on <SUB_CHANGE_ID:CHANGE_ID> ...] [--path <project-root>] -``` - -Guided behavior: - -- Execute only after explicit user confirmation of IDs and edges. -- If relationship intent is unclear, route back to decomposition plan first. diff --git a/adapters/claude-code/flows/change-decomposition-plan.md b/adapters/claude-code/flows/change-decomposition-plan.md deleted file mode 100644 index c12755f..0000000 --- a/adapters/claude-code/flows/change-decomposition-plan.md +++ /dev/null @@ -1,14 +0,0 @@ -# Claude Code Flow: change decomposition-plan - -Map conversational decomposition planning to: - -```sh -runectx change decomposition-plan <UMBRELLA_CHANGE_ID> --sub-change <CHANGE_ID> [--sub-change <CHANGE_ID> ...] [--depends-on <SUB_CHANGE_ID:CHANGE_ID> ...] [--path <project-root>] -``` - -Guided behavior: - -- Keep `graph_*` output visible for review. -- If graph intent is unclear, gather clarifications and re-run with revised - `--sub-change`/`--depends-on` values. -- Use the approved plan inputs when mapping to decomposition apply. diff --git a/adapters/claude-code/flows/change-new.md b/adapters/claude-code/flows/change-new.md deleted file mode 100644 index 45da417..0000000 --- a/adapters/claude-code/flows/change-new.md +++ /dev/null @@ -1,7 +0,0 @@ -# Claude Code Flow: change new - -Map conversational intake to: - -```sh -runectx change new --title "<title>" --type <type> [--size <size>] [--shape <minimum|full>] [--bundle <bundle-id>] [--description "<text>"] [--path <project-root>] -``` diff --git a/adapters/claude-code/flows/change-shape.md b/adapters/claude-code/flows/change-shape.md deleted file mode 100644 index df8f21a..0000000 --- a/adapters/claude-code/flows/change-shape.md +++ /dev/null @@ -1,7 +0,0 @@ -# Claude Code Flow: change shape - -Map conversational shaping to: - -```sh -runectx change shape <CHANGE_ID> [--design "<text>"] [--verification "<text>"] [--task "<text>"] [--reference "<text>"] [--path <project-root>] -``` diff --git a/adapters/claude-code/flows/promote.md b/adapters/claude-code/flows/promote.md deleted file mode 100644 index bf62263..0000000 --- a/adapters/claude-code/flows/promote.md +++ /dev/null @@ -1,7 +0,0 @@ -# Claude Code Flow: promote - -Map conversational promotion updates to: - -```sh -runectx promote <CHANGE_ID> [--accept|--complete] [--target <TYPE:PATH>] [--path <project-root>] -``` diff --git a/adapters/claude-code/flows/standard-discover.md b/adapters/claude-code/flows/standard-discover.md deleted file mode 100644 index 31cdc92..0000000 --- a/adapters/claude-code/flows/standard-discover.md +++ /dev/null @@ -1,7 +0,0 @@ -# Claude Code Flow: standard discover - -Map conversational discovery to: - -```sh -runectx standard discover [--path <project-root>] [--change <CHANGE_ID>] [--scope-path <path>] [--focus "<text>"] [--confirm-handoff] [--target <TYPE:PATH>] -``` diff --git a/adapters/codex/capabilities.yaml b/adapters/codex/capabilities.yaml deleted file mode 100644 index 150c120..0000000 --- a/adapters/codex/capabilities.yaml +++ /dev/null @@ -1,14 +0,0 @@ -schema_version: 1 -adapter: codex -capabilities: - prompts: supported - shell_access: supported - hooks: supported - dynamic_suggestions: supported - structured_output: supported -fallbacks: - prompts: "Render static guidance and explicit runectx command proposals." - shell_access: "Show command steps and candidate data without execution." - hooks: "Run explicit runectx validate at review checkpoints." - dynamic_suggestions: "Use runectx completion suggest output as numbered choices." - structured_output: "Emit plain text summary with equivalent CLI flags/values." diff --git a/adapters/codex/flows/change-assess-decomposition.md b/adapters/codex/flows/change-assess-decomposition.md deleted file mode 100644 index 61d57d9..0000000 --- a/adapters/codex/flows/change-assess-decomposition.md +++ /dev/null @@ -1,14 +0,0 @@ -# Codex Flow: change assess-decomposition - -Map conversational decomposition assessment to: - -```sh -runectx change assess-decomposition <CHANGE_ID> [--path <project-root>] -``` - -Guided behavior: - -- Ask emitted `clarification_prompt_*` when `clarification_needed=true`. -- Keep decomposition advisory fields user-visible. -- Re-run assessment when clarified scope materially changes graph planning. -- Prefer `change decomposition-plan` before `change decomposition-apply`. diff --git a/adapters/codex/flows/change-assess-intake.md b/adapters/codex/flows/change-assess-intake.md deleted file mode 100644 index 4beb8e5..0000000 --- a/adapters/codex/flows/change-assess-intake.md +++ /dev/null @@ -1,18 +0,0 @@ -# Codex Flow: change assess-intake - -Map conversational intake assessment to: - -```sh -runectx change assess-intake --title "<title>" --type <type> [--size <size>] [--bundle <bundle-id>] [--description "<text>"] [--path <project-root>] -``` - -Guided behavior: - -- Ask each emitted `clarification_prompt_*` when `intake_readiness` indicates - clarification or `clarification_needed=true`. -- Re-run intake assessment with updated inputs until guidance stabilizes. -- If `recommended_mode=full`, default to proposing `runectx change new --shape full` - unless the user explicitly overrides. -- If `decomposition_signal=consider_decomposition`, propose follow-up: - `change assess-decomposition` -> `change decomposition-plan` -> - `change decomposition-apply` after plan approval. diff --git a/adapters/codex/flows/change-decomposition-apply.md b/adapters/codex/flows/change-decomposition-apply.md deleted file mode 100644 index 6a4b06d..0000000 --- a/adapters/codex/flows/change-decomposition-apply.md +++ /dev/null @@ -1,12 +0,0 @@ -# Codex Flow: change decomposition-apply - -Map conversational decomposition apply to: - -```sh -runectx change decomposition-apply <UMBRELLA_CHANGE_ID> --sub-change <CHANGE_ID> [--sub-change <CHANGE_ID> ...] [--depends-on <SUB_CHANGE_ID:CHANGE_ID> ...] [--path <project-root>] -``` - -Guided behavior: - -- Execute only after explicit user confirmation of IDs and edges. -- If relationship intent is unclear, route back to decomposition plan first. diff --git a/adapters/codex/flows/change-decomposition-plan.md b/adapters/codex/flows/change-decomposition-plan.md deleted file mode 100644 index 2965208..0000000 --- a/adapters/codex/flows/change-decomposition-plan.md +++ /dev/null @@ -1,14 +0,0 @@ -# Codex Flow: change decomposition-plan - -Map conversational decomposition planning to: - -```sh -runectx change decomposition-plan <UMBRELLA_CHANGE_ID> --sub-change <CHANGE_ID> [--sub-change <CHANGE_ID> ...] [--depends-on <SUB_CHANGE_ID:CHANGE_ID> ...] [--path <project-root>] -``` - -Guided behavior: - -- Keep `graph_*` output visible for review. -- If graph intent is unclear, gather clarifications and re-run with revised - `--sub-change`/`--depends-on` values. -- Use the approved plan inputs when mapping to decomposition apply. diff --git a/adapters/codex/flows/change-new.md b/adapters/codex/flows/change-new.md deleted file mode 100644 index 38fe93b..0000000 --- a/adapters/codex/flows/change-new.md +++ /dev/null @@ -1,7 +0,0 @@ -# Codex Flow: change new - -Map conversational intake to: - -```sh -runectx change new --title "<title>" --type <type> [--size <size>] [--shape <minimum|full>] [--bundle <bundle-id>] [--description "<text>"] [--path <project-root>] -``` diff --git a/adapters/codex/flows/change-shape.md b/adapters/codex/flows/change-shape.md deleted file mode 100644 index 1aa4a87..0000000 --- a/adapters/codex/flows/change-shape.md +++ /dev/null @@ -1,7 +0,0 @@ -# Codex Flow: change shape - -Map conversational shaping to: - -```sh -runectx change shape <CHANGE_ID> [--design "<text>"] [--verification "<text>"] [--task "<text>"] [--reference "<text>"] [--path <project-root>] -``` diff --git a/adapters/codex/flows/promote.md b/adapters/codex/flows/promote.md deleted file mode 100644 index 67dc103..0000000 --- a/adapters/codex/flows/promote.md +++ /dev/null @@ -1,7 +0,0 @@ -# Codex Flow: promote - -Map conversational promotion updates to: - -```sh -runectx promote <CHANGE_ID> [--accept|--complete] [--target <TYPE:PATH>] [--path <project-root>] -``` diff --git a/adapters/codex/flows/standard-discover.md b/adapters/codex/flows/standard-discover.md deleted file mode 100644 index c9a8390..0000000 --- a/adapters/codex/flows/standard-discover.md +++ /dev/null @@ -1,7 +0,0 @@ -# Codex Flow: standard discover - -Map conversational discovery to: - -```sh -runectx standard discover [--path <project-root>] [--change <CHANGE_ID>] [--scope-path <path>] [--focus "<text>"] [--confirm-handoff] [--target <TYPE:PATH>] -``` diff --git a/adapters/generic/capabilities.yaml b/adapters/generic/capabilities.yaml deleted file mode 100644 index a0db66d..0000000 --- a/adapters/generic/capabilities.yaml +++ /dev/null @@ -1,14 +0,0 @@ -schema_version: 1 -adapter: generic -capabilities: - prompts: optional - shell_access: optional - hooks: optional - dynamic_suggestions: optional - structured_output: optional -fallbacks: - prompts: "Use static guidance plus explicit runectx command proposals." - shell_access: "Provide user-run CLI steps and candidate values." - hooks: "Document explicit runectx validate checkpoints." - dynamic_suggestions: "Use runectx completion suggest providers manually." - structured_output: "Use plain text summaries and equivalent CLI arguments." diff --git a/adapters/generic/flows/change-assess-decomposition.md b/adapters/generic/flows/change-assess-decomposition.md deleted file mode 100644 index c2a6d6d..0000000 --- a/adapters/generic/flows/change-assess-decomposition.md +++ /dev/null @@ -1,16 +0,0 @@ -# Generic Flow: change assess-decomposition - -Use explicit CLI command proposals: - -```sh -runectx change assess-decomposition <CHANGE_ID> [--path <project-root>] -``` - -Use advisory fields to drive guided loops: - -- Ask emitted `clarification_prompt_*` when `clarification_needed=true`. -- Keep `decomposition_signal`, `eligible_sub_change_*`, and - `prerequisite_change_*` visible to the user. -- Re-run assessment when clarifications materially change decomposition scope. -- Propose `change decomposition-plan` before `change decomposition-apply` so the - graph is reviewed before mutation. diff --git a/adapters/generic/flows/change-assess-intake.md b/adapters/generic/flows/change-assess-intake.md deleted file mode 100644 index cdecba7..0000000 --- a/adapters/generic/flows/change-assess-intake.md +++ /dev/null @@ -1,18 +0,0 @@ -# Generic Flow: change assess-intake - -Use explicit CLI command proposals: - -```sh -runectx change assess-intake --title "<title>" --type <type> [--size <size>] [--bundle <bundle-id>] [--description "<text>"] [--path <project-root>] -``` - -Use advisory fields to drive guided loops: - -- Ask emitted `clarification_prompt_*` when `intake_readiness=needs_clarification` - or `clarification_needed=true`. -- Re-run assessment with updated inputs until guidance stabilizes. -- If `recommended_mode=full`, prefer `runectx change new --shape full` unless - the user explicitly overrides. -- If `decomposition_signal=consider_decomposition`, propose follow-up with - `change assess-decomposition`, then `change decomposition-plan`, then - `change decomposition-apply` after plan review. diff --git a/adapters/generic/flows/change-decomposition-apply.md b/adapters/generic/flows/change-decomposition-apply.md deleted file mode 100644 index d2cf69b..0000000 --- a/adapters/generic/flows/change-decomposition-apply.md +++ /dev/null @@ -1,12 +0,0 @@ -# Generic Flow: change decomposition-apply - -Use explicit CLI command proposals: - -```sh -runectx change decomposition-apply <UMBRELLA_CHANGE_ID> --sub-change <CHANGE_ID> [--sub-change <CHANGE_ID> ...] [--depends-on <SUB_CHANGE_ID:CHANGE_ID> ...] [--path <project-root>] -``` - -Apply only after explicit review: - -- Confirm umbrella/sub-change IDs and dependency edges before execution. -- If relationships are uncertain, return to `change decomposition-plan` first. diff --git a/adapters/generic/flows/change-decomposition-plan.md b/adapters/generic/flows/change-decomposition-plan.md deleted file mode 100644 index 05a7767..0000000 --- a/adapters/generic/flows/change-decomposition-plan.md +++ /dev/null @@ -1,15 +0,0 @@ -# Generic Flow: change decomposition-plan - -Use explicit CLI command proposals: - -```sh -runectx change decomposition-plan <UMBRELLA_CHANGE_ID> --sub-change <CHANGE_ID> [--sub-change <CHANGE_ID> ...] [--depends-on <SUB_CHANGE_ID:CHANGE_ID> ...] [--path <project-root>] -``` - -Keep plan output reviewable before mutation: - -- Keep `graph_*` output user-visible. -- If clarification is needed, revise `--sub-change` and `--depends-on` inputs - and re-run planning. -- After explicit user approval of the plan, map the same graph inputs to - `runectx change decomposition-apply`. diff --git a/adapters/generic/flows/change-new.md b/adapters/generic/flows/change-new.md deleted file mode 100644 index d2cb164..0000000 --- a/adapters/generic/flows/change-new.md +++ /dev/null @@ -1,7 +0,0 @@ -# Generic Flow: change new - -Use explicit CLI command proposals: - -```sh -runectx change new --title "<title>" --type <type> [--size <size>] [--shape <minimum|full>] [--bundle <bundle-id>] [--description "<text>"] [--path <project-root>] -``` diff --git a/adapters/generic/flows/change-shape.md b/adapters/generic/flows/change-shape.md deleted file mode 100644 index a7b4a6f..0000000 --- a/adapters/generic/flows/change-shape.md +++ /dev/null @@ -1,7 +0,0 @@ -# Generic Flow: change shape - -Use explicit CLI command proposals: - -```sh -runectx change shape <CHANGE_ID> [--design "<text>"] [--verification "<text>"] [--task "<text>"] [--reference "<text>"] [--path <project-root>] -``` diff --git a/adapters/generic/flows/promote.md b/adapters/generic/flows/promote.md deleted file mode 100644 index cbdcab7..0000000 --- a/adapters/generic/flows/promote.md +++ /dev/null @@ -1,7 +0,0 @@ -# Generic Flow: promote - -Use explicit CLI command proposals: - -```sh -runectx promote <CHANGE_ID> [--accept|--complete] [--target <TYPE:PATH>] [--path <project-root>] -``` diff --git a/adapters/generic/flows/standard-discover.md b/adapters/generic/flows/standard-discover.md deleted file mode 100644 index 506f6b1..0000000 --- a/adapters/generic/flows/standard-discover.md +++ /dev/null @@ -1,7 +0,0 @@ -# Generic Flow: standard discover - -Use explicit CLI command proposals: - -```sh -runectx standard discover [--path <project-root>] [--change <CHANGE_ID>] [--scope-path <path>] [--focus "<text>"] [--confirm-handoff] [--target <TYPE:PATH>] -``` diff --git a/adapters/opencode/capabilities.yaml b/adapters/opencode/capabilities.yaml deleted file mode 100644 index 8b1d59f..0000000 --- a/adapters/opencode/capabilities.yaml +++ /dev/null @@ -1,14 +0,0 @@ -schema_version: 1 -adapter: opencode -capabilities: - prompts: supported - shell_access: supported - hooks: supported - dynamic_suggestions: supported - structured_output: supported -fallbacks: - prompts: "Render static guidance and explicit runectx command proposals." - shell_access: "Show command steps and candidate data without execution." - hooks: "Run explicit runectx validate at review checkpoints." - dynamic_suggestions: "Use runectx completion suggest output as numbered choices." - structured_output: "Emit plain text summary with equivalent CLI flags/values." diff --git a/adapters/opencode/flows/change-assess-decomposition.md b/adapters/opencode/flows/change-assess-decomposition.md deleted file mode 100644 index 58cb71e..0000000 --- a/adapters/opencode/flows/change-assess-decomposition.md +++ /dev/null @@ -1,35 +0,0 @@ -# OpenCode Flow: change assess-decomposition - -Use this conversational flow to gather read-only decomposition signals for an -existing change. - -## Inputs - -- change ID -- optional project path - -## Command Mapping - -```sh -runectx change assess-decomposition <CHANGE_ID> [--path <project-root>] -``` - -## Review Checkpoint - -- Confirm the target `CHANGE_ID` before execution. -- Keep advisory outputs (`decomposition_signal`, `eligible_sub_change_*`, and - `prerequisite_change_*`) user-visible. - -## Guided Clarification Loop - -- If `clarification_needed=true`, ask each `clarification_prompt_*` and confirm - any ambiguous scope before proposing write operations. -- Re-run `change assess-decomposition` after major clarifications when the - target graph assumptions change. - -## Guided Decomposition Progression - -- If decomposition is indicated, propose `change decomposition-plan` first and - keep `graph_*` output user-visible for review. -- Only propose `change decomposition-apply` after the reviewed plan and edge - list are explicitly accepted. diff --git a/adapters/opencode/flows/change-assess-intake.md b/adapters/opencode/flows/change-assess-intake.md deleted file mode 100644 index b3db97b..0000000 --- a/adapters/opencode/flows/change-assess-intake.md +++ /dev/null @@ -1,42 +0,0 @@ -# OpenCode Flow: change assess-intake - -Use this conversational flow to gather read-only intake readiness and shaping -guidance for a proposed change. - -## Inputs - -- title -- type (`project|feature|bug|standard|chore`) -- optional size (`small|medium|large`) -- optional bundle IDs -- optional description -- optional project path - -## Command Mapping - -```sh -runectx change assess-intake --title "<title>" --type <type> [--size <size>] [--bundle <bundle-id>] [--description "<text>"] [--path <project-root>] -``` - -## Review Checkpoint - -- Keep this advisory/read-only output visible in the conversation. -- Surface `recommended_mode`, `intake_readiness`, and `decomposition_signal` - before proposing mutations. - -## Guided Clarification Loop - -- If `intake_readiness=needs_clarification` or `clarification_needed=true`, ask - each emitted `clarification_prompt_*` explicitly and wait for user answers. -- Re-run `change assess-intake` with the updated title/type/size/bundle/ - description inputs until readiness no longer requires clarification or the - user asks to proceed with explicit tradeoffs. -- Keep each advisory result user-visible and avoid hidden adapter-only state. - -## Guided Decomposition Handoff - -- If `recommended_mode=full`, default to `--shape full` when proposing - `runectx change new`, unless the user explicitly overrides. -- If `decomposition_signal=consider_decomposition`, propose a decomposition - sequence after change creation: `change assess-decomposition`, then - `change decomposition-plan`, then `change decomposition-apply` once reviewed. diff --git a/adapters/opencode/flows/change-decomposition-apply.md b/adapters/opencode/flows/change-decomposition-apply.md deleted file mode 100644 index fbf292c..0000000 --- a/adapters/opencode/flows/change-decomposition-apply.md +++ /dev/null @@ -1,29 +0,0 @@ -# OpenCode Flow: change decomposition-apply - -Use this conversational flow to apply a decomposition graph rewrite across an -umbrella change and selected sub-changes. - -## Inputs - -- umbrella change ID -- one or more sub-change IDs -- zero or more dependency edges (`SUB_CHANGE_ID:CHANGE_ID`) -- optional project path - -## Command Mapping - -```sh -runectx change decomposition-apply <UMBRELLA_CHANGE_ID> --sub-change <CHANGE_ID> [--sub-change <CHANGE_ID> ...] [--depends-on <SUB_CHANGE_ID:CHANGE_ID> ...] [--path <project-root>] -``` - -## Review Checkpoint - -- Confirm umbrella and sub-change IDs before execution. -- Review changed status files and relationship rewrites before commit. - -## Guided Clarification Guardrails - -- If the user is unsure about relationships or missing prerequisites, pause and - return to `change decomposition-plan` for another advisory pass. -- Keep command proposals explicit and user-confirmed; do not apply hidden - relationship edits. diff --git a/adapters/opencode/flows/change-decomposition-plan.md b/adapters/opencode/flows/change-decomposition-plan.md deleted file mode 100644 index af5d5e5..0000000 --- a/adapters/opencode/flows/change-decomposition-plan.md +++ /dev/null @@ -1,35 +0,0 @@ -# OpenCode Flow: change decomposition-plan - -Use this conversational flow to compute an advisory decomposition graph for an -umbrella change and selected sub-changes. - -## Inputs - -- umbrella change ID -- one or more sub-change IDs -- zero or more dependency edges (`SUB_CHANGE_ID:CHANGE_ID`) -- optional project path - -## Command Mapping - -```sh -runectx change decomposition-plan <UMBRELLA_CHANGE_ID> --sub-change <CHANGE_ID> [--sub-change <CHANGE_ID> ...] [--depends-on <SUB_CHANGE_ID:CHANGE_ID> ...] [--path <project-root>] -``` - -## Review Checkpoint - -- Confirm umbrella and sub-change IDs before execution. -- Keep advisory graph outputs (`graph_*`) user-visible for review. - -## Guided Clarification And Iteration - -- If the planned graph conflicts with user intent, gather clarifications on - missing sub-changes or dependency edges and re-run planning. -- Preserve reviewability by proposing exact revised `--sub-change` and - `--depends-on` arguments before each re-run. - -## Apply Handoff - -- When the user accepts the advisory graph, hand off to - `runectx change decomposition-apply` with the same umbrella/sub-change/edge - inputs used for the approved plan. diff --git a/adapters/opencode/flows/change-new.md b/adapters/opencode/flows/change-new.md deleted file mode 100644 index 82e2b74..0000000 --- a/adapters/opencode/flows/change-new.md +++ /dev/null @@ -1,25 +0,0 @@ -# OpenCode Flow: change new - -Use this conversational flow to create a new change while keeping all mutation -semantics in `runectx`. - -## Inputs - -- title -- type (`project|feature|bug|standard|chore`) -- optional size (`small|medium|large`) -- optional shape (`minimum|full`) -- optional bundle IDs -- optional description -- optional project path - -## Command Mapping - -```sh -runectx change new --title "<title>" --type <type> [--size <size>] [--shape <minimum|full>] [--bundle <bundle-id>] [--description "<text>"] [--path <project-root>] -``` - -## Review Checkpoint - -- Show the full command before execution. -- Capture resulting change ID from command output. diff --git a/adapters/opencode/flows/change-shape.md b/adapters/opencode/flows/change-shape.md deleted file mode 100644 index d80453d..0000000 --- a/adapters/opencode/flows/change-shape.md +++ /dev/null @@ -1,24 +0,0 @@ -# OpenCode Flow: change shape - -Use this conversational flow to shape an existing change with explicit -`runectx` flags. - -## Inputs - -- change ID -- optional design summary -- optional verification summary -- optional tasks (repeatable) -- optional references (repeatable) -- optional project path - -## Command Mapping - -```sh -runectx change shape <CHANGE_ID> [--design "<text>"] [--verification "<text>"] [--task "<text>"] [--reference "<text>"] [--path <project-root>] -``` - -## Review Checkpoint - -- Keep tasks/references user-visible in the conversation. -- Confirm the target `CHANGE_ID` before execution. diff --git a/adapters/opencode/flows/promote.md b/adapters/opencode/flows/promote.md deleted file mode 100644 index c7ddcf3..0000000 --- a/adapters/opencode/flows/promote.md +++ /dev/null @@ -1,21 +0,0 @@ -# OpenCode Flow: promote - -Use this conversational flow to advance promotion state explicitly. - -## Inputs - -- change ID -- action (`--accept` or `--complete`) -- optional targets (repeatable) -- optional project path - -## Command Mapping - -```sh -runectx promote <CHANGE_ID> [--accept|--complete] [--target <TYPE:PATH>] [--path <project-root>] -``` - -## Review Checkpoint - -- Validate that targets come from discover output or explicit user input. -- Never mutate promotion state without explicit command execution. diff --git a/adapters/opencode/flows/standard-discover.md b/adapters/opencode/flows/standard-discover.md deleted file mode 100644 index 3d2ebcf..0000000 --- a/adapters/opencode/flows/standard-discover.md +++ /dev/null @@ -1,23 +0,0 @@ -# OpenCode Flow: standard discover - -Use this conversational flow to gather advisory standards candidates and optional -promotion handoff metadata. - -## Inputs - -- optional project path -- optional change ID -- optional scope paths (repeatable) -- optional focus text -- optional handoff confirmation and target - -## Command Mapping - -```sh -runectx standard discover [--path <project-root>] [--change <CHANGE_ID>] [--scope-path <path>] [--focus "<text>"] [--confirm-handoff] [--target <TYPE:PATH>] -``` - -## Candidate Data Rule - -- Candidate promotion targets come from `candidate_promotion_target_*` output. -- Reuse emitted target values directly; do not invent hidden targets. diff --git a/adapters/claude-code/README.md b/adapters/source/packs/claude-code/README.md similarity index 100% rename from adapters/claude-code/README.md rename to adapters/source/packs/claude-code/README.md diff --git a/adapters/claude-code/flows/conversational-parity.md b/adapters/source/packs/claude-code/flows/conversational-parity.md similarity index 100% rename from adapters/claude-code/flows/conversational-parity.md rename to adapters/source/packs/claude-code/flows/conversational-parity.md diff --git a/adapters/claude-code/setup.md b/adapters/source/packs/claude-code/setup.md similarity index 100% rename from adapters/claude-code/setup.md rename to adapters/source/packs/claude-code/setup.md diff --git a/adapters/codex/README.md b/adapters/source/packs/codex/README.md similarity index 100% rename from adapters/codex/README.md rename to adapters/source/packs/codex/README.md diff --git a/adapters/codex/flows/conversational-parity.md b/adapters/source/packs/codex/flows/conversational-parity.md similarity index 100% rename from adapters/codex/flows/conversational-parity.md rename to adapters/source/packs/codex/flows/conversational-parity.md diff --git a/adapters/codex/setup.md b/adapters/source/packs/codex/setup.md similarity index 100% rename from adapters/codex/setup.md rename to adapters/source/packs/codex/setup.md diff --git a/adapters/generic/README.md b/adapters/source/packs/generic/README.md similarity index 100% rename from adapters/generic/README.md rename to adapters/source/packs/generic/README.md diff --git a/adapters/generic/examples/cli-assisted-flow.md b/adapters/source/packs/generic/examples/cli-assisted-flow.md similarity index 100% rename from adapters/generic/examples/cli-assisted-flow.md rename to adapters/source/packs/generic/examples/cli-assisted-flow.md diff --git a/adapters/generic/examples/manual-flow.md b/adapters/source/packs/generic/examples/manual-flow.md similarity index 100% rename from adapters/generic/examples/manual-flow.md rename to adapters/source/packs/generic/examples/manual-flow.md diff --git a/adapters/generic/examples/non-agent-flow.md b/adapters/source/packs/generic/examples/non-agent-flow.md similarity index 100% rename from adapters/generic/examples/non-agent-flow.md rename to adapters/source/packs/generic/examples/non-agent-flow.md diff --git a/adapters/generic/flows/conversational-parity.md b/adapters/source/packs/generic/flows/conversational-parity.md similarity index 100% rename from adapters/generic/flows/conversational-parity.md rename to adapters/source/packs/generic/flows/conversational-parity.md diff --git a/adapters/opencode/README.md b/adapters/source/packs/opencode/README.md similarity index 100% rename from adapters/opencode/README.md rename to adapters/source/packs/opencode/README.md diff --git a/adapters/opencode/automation/validate_after_authoritative_edit.sh b/adapters/source/packs/opencode/automation/validate_after_authoritative_edit.sh similarity index 100% rename from adapters/opencode/automation/validate_after_authoritative_edit.sh rename to adapters/source/packs/opencode/automation/validate_after_authoritative_edit.sh diff --git a/adapters/opencode/flows/conversational-parity.md b/adapters/source/packs/opencode/flows/conversational-parity.md similarity index 100% rename from adapters/opencode/flows/conversational-parity.md rename to adapters/source/packs/opencode/flows/conversational-parity.md diff --git a/adapters/opencode/setup.md b/adapters/source/packs/opencode/setup.md similarity index 100% rename from adapters/opencode/setup.md rename to adapters/source/packs/opencode/setup.md diff --git a/docs/reference/generated/runecontext-reference.json b/docs/reference/generated/runecontext-reference.json index bf18b39..4ff612b 100644 --- a/docs/reference/generated/runecontext-reference.json +++ b/docs/reference/generated/runecontext-reference.json @@ -259,7 +259,7 @@ { "profile": "repo_bundle", "schema_path": "schemas", - "adapters_path": "adapters" + "adapters_path": "build/generated/adapters" }, { "profile": "installed_share_layout", diff --git a/fixtures/cli-json-golden/metadata-success.json b/fixtures/cli-json-golden/metadata-success.json index 77a7a89..d315c61 100644 --- a/fixtures/cli-json-golden/metadata-success.json +++ b/fixtures/cli-json-golden/metadata-success.json @@ -1 +1 @@ -{"assurance":{"baseline_supported":true,"receipt_families":["context-packs","changes","promotions","verifications"],"tiers":["plain","verified"]},"binary":"runectx","canonicalization":{"assurance_artifacts":{"hash_algorithm":"sha256","profile":"runecontext-canonical-json-v1"},"context_pack":{"hash_algorithm":"sha256","profile":"runecontext-canonical-json-v1"}},"capabilities":{"commands":[{"path":"adapter","token":"adapter"},{"path":"adapter render-host-native","token":"adapter_render_host_native"},{"path":"adapter sync","token":"adapter_sync"},{"path":"assurance","token":"assurance"},{"path":"assurance backfill","token":"assurance_backfill"},{"path":"assurance capture","token":"assurance_capture"},{"path":"assurance enable","token":"assurance_enable"},{"path":"bundle","token":"bundle"},{"path":"bundle resolve","token":"bundle_resolve"},{"path":"change","token":"change"},{"path":"change assess-decomposition","token":"change_assess_decomposition"},{"path":"change assess-intake","token":"change_assess_intake"},{"path":"change close","token":"change_close"},{"path":"change decomposition-apply","token":"change_decomposition_apply"},{"path":"change decomposition-plan","token":"change_decomposition_plan"},{"path":"change new","token":"change_new"},{"path":"change reallocate","token":"change_reallocate"},{"path":"change shape","token":"change_shape"},{"path":"change update","token":"change_update"},{"path":"completion","token":"completion"},{"path":"completion metadata","token":"completion_metadata"},{"path":"completion suggest","token":"completion_suggest"},{"path":"doctor","token":"doctor"},{"path":"generate","token":"generate"},{"path":"generate indexes","token":"generate_indexes"},{"path":"help","token":"help"},{"path":"init","token":"init"},{"path":"metadata","token":"metadata"},{"path":"promote","token":"promote"},{"path":"standard","token":"standard"},{"path":"standard create","token":"standard_create"},{"path":"standard discover","token":"standard_discover"},{"path":"standard list","token":"standard_list"},{"path":"standard update","token":"standard_update"},{"path":"status","token":"status"},{"path":"upgrade","token":"upgrade"},{"path":"upgrade apply","token":"upgrade_apply"},{"path":"upgrade cli","token":"upgrade_cli"},{"path":"upgrade cli apply","token":"upgrade_cli_apply"},{"path":"validate","token":"validate"},{"aliases":["--version","-v"],"path":"version","token":"version"}],"machine_flags":["--dry-run","--explain","--json","--non-interactive"],"value_kinds":["none","text","enum"]},"compatibility":{"default_project_version":"0.1.0-alpha.14","directly_supported_project_versions":["0.1.0-alpha.5","0.1.0-alpha.6","0.1.0-alpha.7","0.1.0-alpha.8","0.1.0-alpha.14"],"explicit_upgrade_edges":[{"from":"0.1.0-alpha.12","to":"0.1.0-alpha.13"}],"upgradeable_from_project_versions":["0.1.0-alpha.12","0.1.0-alpha.13"]},"distribution_layouts":[{"adapters_path":"adapters","profile":"repo_bundle","schema_path":"schemas"},{"adapters_path":"share/runecontext/adapters","profile":"installed_share_layout","schema_path":"share/runecontext/schemas"}],"features":["signed_tag_verification","mutable_ref_opt_in","monorepo_nearest_root_discovery","context_pack_capture","verified_assurance","completion_registry","completion_dynamic_suggestions","generated_indexes","promotion_workflow","upgrade_planning","staged_upgrade_execution","mixed_or_stale_tree_detection"],"project_profiles":[{"assurance_path":"runecontext/assurance","content_root":"runecontext","indexes_root":"runecontext/indexes","manifest_path":"runecontext/manifest.yaml","profile":"portable_project_root","root_config":"runecontext.yaml"}],"release":{"package_name":"runecontext","tag":"v0.1.0-alpha.14","version":"0.1.0-alpha.14"},"resolution":{"source_modes":["embedded","git","path"],"verification_postures":["embedded","pinned_commit","verified_signed_tag","unverified_mutable_ref","unverified_local_source"]},"schema_version":2} \ No newline at end of file +{"assurance":{"baseline_supported":true,"receipt_families":["context-packs","changes","promotions","verifications"],"tiers":["plain","verified"]},"binary":"runectx","canonicalization":{"assurance_artifacts":{"hash_algorithm":"sha256","profile":"runecontext-canonical-json-v1"},"context_pack":{"hash_algorithm":"sha256","profile":"runecontext-canonical-json-v1"}},"capabilities":{"commands":[{"path":"adapter","token":"adapter"},{"path":"adapter render-host-native","token":"adapter_render_host_native"},{"path":"adapter sync","token":"adapter_sync"},{"path":"assurance","token":"assurance"},{"path":"assurance backfill","token":"assurance_backfill"},{"path":"assurance capture","token":"assurance_capture"},{"path":"assurance enable","token":"assurance_enable"},{"path":"bundle","token":"bundle"},{"path":"bundle resolve","token":"bundle_resolve"},{"path":"change","token":"change"},{"path":"change assess-decomposition","token":"change_assess_decomposition"},{"path":"change assess-intake","token":"change_assess_intake"},{"path":"change close","token":"change_close"},{"path":"change decomposition-apply","token":"change_decomposition_apply"},{"path":"change decomposition-plan","token":"change_decomposition_plan"},{"path":"change new","token":"change_new"},{"path":"change reallocate","token":"change_reallocate"},{"path":"change shape","token":"change_shape"},{"path":"change update","token":"change_update"},{"path":"completion","token":"completion"},{"path":"completion metadata","token":"completion_metadata"},{"path":"completion suggest","token":"completion_suggest"},{"path":"doctor","token":"doctor"},{"path":"generate","token":"generate"},{"path":"generate indexes","token":"generate_indexes"},{"path":"help","token":"help"},{"path":"init","token":"init"},{"path":"metadata","token":"metadata"},{"path":"promote","token":"promote"},{"path":"standard","token":"standard"},{"path":"standard create","token":"standard_create"},{"path":"standard discover","token":"standard_discover"},{"path":"standard list","token":"standard_list"},{"path":"standard update","token":"standard_update"},{"path":"status","token":"status"},{"path":"upgrade","token":"upgrade"},{"path":"upgrade apply","token":"upgrade_apply"},{"path":"upgrade cli","token":"upgrade_cli"},{"path":"upgrade cli apply","token":"upgrade_cli_apply"},{"path":"validate","token":"validate"},{"aliases":["--version","-v"],"path":"version","token":"version"}],"machine_flags":["--dry-run","--explain","--json","--non-interactive"],"value_kinds":["none","text","enum"]},"compatibility":{"default_project_version":"0.1.0-alpha.14","directly_supported_project_versions":["0.1.0-alpha.5","0.1.0-alpha.6","0.1.0-alpha.7","0.1.0-alpha.8","0.1.0-alpha.14"],"explicit_upgrade_edges":[{"from":"0.1.0-alpha.12","to":"0.1.0-alpha.13"}],"upgradeable_from_project_versions":["0.1.0-alpha.12","0.1.0-alpha.13"]},"distribution_layouts":[{"adapters_path":"build/generated/adapters","profile":"repo_bundle","schema_path":"schemas"},{"adapters_path":"share/runecontext/adapters","profile":"installed_share_layout","schema_path":"share/runecontext/schemas"}],"features":["signed_tag_verification","mutable_ref_opt_in","monorepo_nearest_root_discovery","context_pack_capture","verified_assurance","completion_registry","completion_dynamic_suggestions","generated_indexes","promotion_workflow","upgrade_planning","staged_upgrade_execution","mixed_or_stale_tree_detection"],"project_profiles":[{"assurance_path":"runecontext/assurance","content_root":"runecontext","indexes_root":"runecontext/indexes","manifest_path":"runecontext/manifest.yaml","profile":"portable_project_root","root_config":"runecontext.yaml"}],"release":{"package_name":"runecontext","tag":"v0.1.0-alpha.14","version":"0.1.0-alpha.14"},"resolution":{"source_modes":["embedded","git","path"],"verification_postures":["embedded","pinned_commit","verified_signed_tag","unverified_mutable_ref","unverified_local_source"]},"schema_version":2} \ No newline at end of file diff --git a/fixtures/reference-projects/embedded/.agents/skills/runecontext-change-new.md b/fixtures/reference-projects/embedded/.agents/skills/runecontext-change-new.md index 92ad3f8..a01da9c 100644 --- a/fixtures/reference-projects/embedded/.agents/skills/runecontext-change-new.md +++ b/fixtures/reference-projects/embedded/.agents/skills/runecontext-change-new.md @@ -8,7 +8,7 @@ description: Create a new RuneContext change <!-- runecontext-id: runecontext:change-new --> # RuneContext Skill: change new -- canonical_flow_source: `adapters/codex/flows/change-new.md` +- canonical_flow_source: `build/generated/adapters/codex/flows/change-new.md` - adapter_role: `flow_asset` - operation_identifier: `runecontext:change-new` - command_path: `change new` diff --git a/fixtures/reference-projects/embedded/.agents/skills/runecontext-change-shape.md b/fixtures/reference-projects/embedded/.agents/skills/runecontext-change-shape.md index f592bfa..a9f53e4 100644 --- a/fixtures/reference-projects/embedded/.agents/skills/runecontext-change-shape.md +++ b/fixtures/reference-projects/embedded/.agents/skills/runecontext-change-shape.md @@ -8,7 +8,7 @@ description: Shape an existing RuneContext change <!-- runecontext-id: runecontext:change-shape --> # RuneContext Skill: change shape -- canonical_flow_source: `adapters/codex/flows/change-shape.md` +- canonical_flow_source: `build/generated/adapters/codex/flows/change-shape.md` - adapter_role: `flow_asset` - operation_identifier: `runecontext:change-shape` - command_path: `change shape` diff --git a/fixtures/reference-projects/embedded/.agents/skills/runecontext-promote.md b/fixtures/reference-projects/embedded/.agents/skills/runecontext-promote.md index 90361f0..7aa768e 100644 --- a/fixtures/reference-projects/embedded/.agents/skills/runecontext-promote.md +++ b/fixtures/reference-projects/embedded/.agents/skills/runecontext-promote.md @@ -8,7 +8,7 @@ description: Advance RuneContext promotion state <!-- runecontext-id: runecontext:promote --> # RuneContext Skill: promote -- canonical_flow_source: `adapters/codex/flows/promote.md` +- canonical_flow_source: `build/generated/adapters/codex/flows/promote.md` - adapter_role: `flow_asset` - operation_identifier: `runecontext:promote` - command_path: `promote` diff --git a/fixtures/reference-projects/embedded/.agents/skills/runecontext-standard-discover.md b/fixtures/reference-projects/embedded/.agents/skills/runecontext-standard-discover.md index 1f7bda8..65b6f42 100644 --- a/fixtures/reference-projects/embedded/.agents/skills/runecontext-standard-discover.md +++ b/fixtures/reference-projects/embedded/.agents/skills/runecontext-standard-discover.md @@ -8,7 +8,7 @@ description: Discover standards candidates for promotion <!-- runecontext-id: runecontext:standard-discover --> # RuneContext Skill: standard discover -- canonical_flow_source: `adapters/codex/flows/standard-discover.md` +- canonical_flow_source: `build/generated/adapters/codex/flows/standard-discover.md` - adapter_role: `flow_asset` - operation_identifier: `runecontext:standard-discover` - command_path: `standard discover` diff --git a/fixtures/reference-projects/embedded/.claude/commands/runecontext.md b/fixtures/reference-projects/embedded/.claude/commands/runecontext.md index abb993e..b0156b5 100644 --- a/fixtures/reference-projects/embedded/.claude/commands/runecontext.md +++ b/fixtures/reference-projects/embedded/.claude/commands/runecontext.md @@ -15,25 +15,25 @@ This file is a discoverability shim. Canonical flow assets live in `.claude/skil ## Commands - `runecontext:change-new` - - Canonical flow source: `adapters/claude-code/flows/change-new.md` + - Canonical flow source: `build/generated/adapters/claude-code/flows/change-new.md` - Skill file: `.claude/skills/runecontext-change-new.md` - command_path: `change new` - `runecontext:change-shape` - - Canonical flow source: `adapters/claude-code/flows/change-shape.md` + - Canonical flow source: `build/generated/adapters/claude-code/flows/change-shape.md` - Skill file: `.claude/skills/runecontext-change-shape.md` - command_path: `change shape` - `runecontext:standard-discover` - - Canonical flow source: `adapters/claude-code/flows/standard-discover.md` + - Canonical flow source: `build/generated/adapters/claude-code/flows/standard-discover.md` - Skill file: `.claude/skills/runecontext-standard-discover.md` - command_path: `standard discover` - `runecontext:promote` - - Canonical flow source: `adapters/claude-code/flows/promote.md` + - Canonical flow source: `build/generated/adapters/claude-code/flows/promote.md` - Skill file: `.claude/skills/runecontext-promote.md` - command_path: `promote` diff --git a/fixtures/release/release-manifest-with-metadata.json b/fixtures/release/release-manifest-with-metadata.json index 0d7fd5a..77d079a 100644 --- a/fixtures/release/release-manifest-with-metadata.json +++ b/fixtures/release/release-manifest-with-metadata.json @@ -230,7 +230,7 @@ }, "distribution_layouts": [ { - "adapters_path": "adapters", + "adapters_path": "build/generated/adapters", "profile": "repo_bundle", "schema_path": "schemas" }, diff --git a/internal/cli/cli_adapter_render_host_native_markdown.go b/internal/cli/cli_adapter_render_host_native_markdown.go index 8fc70be..760354c 100644 --- a/internal/cli/cli_adapter_render_host_native_markdown.go +++ b/internal/cli/cli_adapter_render_host_native_markdown.go @@ -44,7 +44,7 @@ func renderHostNativeIndexMarkdown(request adapterRenderRequest) (string, error) return "", fmt.Errorf("adapter render-host-native index is not defined for tool %q", request.tool) } lines := []string{ - "- canonical_flow_source: `adapters/" + request.tool + "/flows/*.md`", + "- canonical_flow_source: `build/generated/adapters/" + request.tool + "/flows/*.md`", "- adapter_role: `" + hostNativeKindDiscoverabilityShim + "`", "- operation_identifier: `runecontext:index`", } diff --git a/internal/cli/cli_adapter_sync_host_native_flows.go b/internal/cli/cli_adapter_sync_host_native_flows.go index 70c5b40..d36e426 100644 --- a/internal/cli/cli_adapter_sync_host_native_flows.go +++ b/internal/cli/cli_adapter_sync_host_native_flows.go @@ -18,6 +18,6 @@ func flowForTool(tool, id, name, description string) hostNativeFlow { id: id, name: name, description: description, - source: "adapters/" + tool + "/flows/" + id + ".md", + source: "build/generated/adapters/" + tool + "/flows/" + id + ".md", } } diff --git a/internal/cli/cli_adapter_test.go b/internal/cli/cli_adapter_test.go index 7dce128..a5a12d8 100644 --- a/internal/cli/cli_adapter_test.go +++ b/internal/cli/cli_adapter_test.go @@ -260,7 +260,7 @@ func TestRunAdapterSyncWritesExpectedHostNativeFilePermissions(t *testing.T) { if err != nil { t.Fatal(err) } - adaptersRoot := filepath.Join(root, "adapters") + adaptersRoot := filepath.Join(root, "adapters", "source", "packs") t.Chdir(root) assertAdapterSyncWritesExpectedFilePermissions(t, adaptersRoot) } diff --git a/internal/cli/cli_adapter_test_helpers_test.go b/internal/cli/cli_adapter_test_helpers_test.go index 6076d5f..d7038f7 100644 --- a/internal/cli/cli_adapter_test_helpers_test.go +++ b/internal/cli/cli_adapter_test_helpers_test.go @@ -119,7 +119,7 @@ func prepareValidateHookBoundaryTest(t *testing.T) validateHookBoundaryTest { t.Fatal(err) } test := validateHookBoundaryTest{ - scriptPath: filepath.Join(repoRoot, "adapters", "opencode", "automation", "validate_after_authoritative_edit.sh"), + scriptPath: filepath.Join(repoRoot, "adapters", "source", "packs", "opencode", "automation", "validate_after_authoritative_edit.sh"), projectRoot: prepareCLIWorkflowProject(t), fakeBin: t.TempDir(), } diff --git a/internal/cli/cli_metadata_descriptor_values.go b/internal/cli/cli_metadata_descriptor_values.go index 1a8c8c4..4354a47 100644 --- a/internal/cli/cli_metadata_descriptor_values.go +++ b/internal/cli/cli_metadata_descriptor_values.go @@ -4,7 +4,7 @@ import "github.com/runecode-systems/runecontext/internal/contracts" func descriptorDistributionLayouts() []descriptorLayout { return []descriptorLayout{ - {Profile: "repo_bundle", SchemaPath: "schemas", AdaptersPath: "adapters"}, + {Profile: "repo_bundle", SchemaPath: "schemas", AdaptersPath: "build/generated/adapters"}, {Profile: "installed_share_layout", SchemaPath: "share/runecontext/schemas", AdaptersPath: "share/runecontext/adapters"}, } } diff --git a/internal/cli/cli_release_layout_test.go b/internal/cli/cli_release_layout_test.go index e832c86..d8b544c 100644 --- a/internal/cli/cli_release_layout_test.go +++ b/internal/cli/cli_release_layout_test.go @@ -17,8 +17,8 @@ func TestReleaseMetadataIncludesAdaptersDirectory(t *testing.T) { if err != nil { t.Fatalf("read release metadata: %v", err) } - if !strings.Contains(string(data), "\"adapters\"") { - t.Fatalf("expected release metadata to include adapters top-level directory") + if strings.Contains(string(data), "\"adapters\"") { + t.Fatalf("expected release metadata to stop including adapters top-level directory") } } diff --git a/nix/release/metadata.nix b/nix/release/metadata.nix index ddebe57..6e35a28 100644 --- a/nix/release/metadata.nix +++ b/nix/release/metadata.nix @@ -21,7 +21,7 @@ let topLevelDirectories = [ "docs" "core" - "adapters" + "adapters/source" "schemas" "fixtures" "scripts" diff --git a/tools/syncadapters/render.go b/tools/syncadapters/render.go index 9b92df6..13014d6 100644 --- a/tools/syncadapters/render.go +++ b/tools/syncadapters/render.go @@ -52,9 +52,9 @@ func resolveOutput(absRoot, output string) (string, error) { } func renderTool(root, output, toolID string, flows []flowDefinition, tool toolDefinition) error { - sourceDir := filepath.Join(root, "adapters", toolID) + sourceDir := filepath.Join(root, "adapters", "source", "packs", toolID) if info, err := os.Stat(sourceDir); err != nil || !info.IsDir() { - return fmt.Errorf("adapter source directory missing for %q", toolID) + return fmt.Errorf("adapter passthrough source directory missing for %q", toolID) } targetDir := filepath.Join(output, toolID) if err := os.MkdirAll(targetDir, 0o755); err != nil { From ef5a5d80e6bd9ea8350d350a263769481a50c7be Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich <zeb@runecode.org> Date: Wed, 1 Apr 2026 12:02:16 -0500 Subject: [PATCH 12/27] Updates based on PR comments. Signed-off-by: Zebulon Rouse-Frantzich <zeb@runecode.org> --- fixtures/cli-completion/bash.golden | 2 +- fixtures/cli-completion/zsh.golden | 2 +- internal/cli/cli_metadata_registry_adapter.go | 2 +- internal/cli/cli_metadata_registry_test.go | 21 +++++++++++++++++++ internal/cli/cli_upgrade_adapter_plans.go | 3 +++ .../cli/cli_upgrade_adapter_security_test.go | 21 +++++++++++++++++++ internal/contracts/standard_commands_list.go | 1 + 7 files changed, 49 insertions(+), 3 deletions(-) diff --git a/fixtures/cli-completion/bash.golden b/fixtures/cli-completion/bash.golden index a076eef..5182808 100644 --- a/fixtures/cli-completion/bash.golden +++ b/fixtures/cli-completion/bash.golden @@ -273,7 +273,7 @@ _runectx_suggest_provider_for_flag() { _runectx_positional_enum() { case "$1" in - 'adapter render-host-native|2') echo 'change-assess-decomposition change-assess-intake change-new change-shape index promote standard-discover' ;; + 'adapter render-host-native|2') echo 'change-assess-decomposition change-assess-intake change-decomposition-apply change-decomposition-plan change-new change-shape index promote standard-discover' ;; 'assurance capture|1') echo 'context-pack' ;; 'assurance enable|1') echo 'verified' ;; 'completion suggest|1') echo 'adapter-names adapter-names-shell-injection bundle-ids change-ids promotion-targets' ;; diff --git a/fixtures/cli-completion/zsh.golden b/fixtures/cli-completion/zsh.golden index 6492142..d2c6603 100644 --- a/fixtures/cli-completion/zsh.golden +++ b/fixtures/cli-completion/zsh.golden @@ -276,7 +276,7 @@ _runectx_suggest_provider_for_flag() { _runectx_positional_enum() { case "$1" in - 'adapter render-host-native|2') echo 'change-assess-decomposition change-assess-intake change-new change-shape index promote standard-discover' ;; + 'adapter render-host-native|2') echo 'change-assess-decomposition change-assess-intake change-decomposition-apply change-decomposition-plan change-new change-shape index promote standard-discover' ;; 'assurance capture|1') echo 'context-pack' ;; 'assurance enable|1') echo 'verified' ;; 'completion suggest|1') echo 'adapter-names adapter-names-shell-injection bundle-ids change-ids promotion-targets' ;; diff --git a/internal/cli/cli_metadata_registry_adapter.go b/internal/cli/cli_metadata_registry_adapter.go index 1ac1419..1ad282a 100644 --- a/internal/cli/cli_metadata_registry_adapter.go +++ b/internal/cli/cli_metadata_registry_adapter.go @@ -24,7 +24,7 @@ func adapterCommandMetadata() CommandMetadata { Flags: writeCommandFlags([]FlagMetadata{{Name: "--role", Value: enumValueSpec(hostNativeKindFlowAsset, hostNativeKindDiscoverabilityShim)}}), Positionals: []PositionalMetadata{ {Name: "tool", Value: textValueWithSuggestionSpec(suggestionProviderAdapterNamesShellInjection)}, - {Name: "operation", Value: enumValueSpec("change-assess-decomposition", "change-assess-intake", "change-new", "change-shape", "index", "promote", "standard-discover")}, + {Name: "operation", Value: enumValueSpec("change-assess-decomposition", "change-assess-intake", "change-decomposition-apply", "change-decomposition-plan", "change-new", "change-shape", "index", "promote", "standard-discover")}, }, }, }, diff --git a/internal/cli/cli_metadata_registry_test.go b/internal/cli/cli_metadata_registry_test.go index 6836023..2ff78c4 100644 --- a/internal/cli/cli_metadata_registry_test.go +++ b/internal/cli/cli_metadata_registry_test.go @@ -111,6 +111,27 @@ func TestCommandMetadataRegistryIncludesChangeDecompositionCommands(t *testing.T assertChangeDecompositionCommandMetadata(t, change, "change decomposition-apply", changeDecompApplyUsage) } +func TestCommandMetadataRegistryIncludesAdapterRenderDecompositionOperations(t *testing.T) { + registry := CommandMetadataRegistry() + adapter := commandMetadataByPath(registry.Commands, "adapter") + if adapter == nil { + t.Fatalf("expected adapter command in registry") + } + render := commandMetadataByPath(adapter.Subcommands, "adapter render-host-native") + if render == nil { + t.Fatalf("expected adapter render-host-native subcommand in registry") + } + if len(render.Positionals) < 2 { + t.Fatalf("expected tool and operation positionals for adapter render-host-native, got %#v", render.Positionals) + } + operations := render.Positionals[1].Value.EnumValues + for _, op := range []string{"change-decomposition-plan", "change-decomposition-apply"} { + if !slices.Contains(operations, op) { + t.Fatalf("expected adapter render-host-native operation enum to include %q, got %#v", op, operations) + } + } +} + func assertChangeDecompositionCommandMetadata(t *testing.T, change *CommandMetadata, path, usage string) { t.Helper() metadata := commandMetadataByPath(change.Subcommands, path) diff --git a/internal/cli/cli_upgrade_adapter_plans.go b/internal/cli/cli_upgrade_adapter_plans.go index 5edbbaf..9cca675 100644 --- a/internal/cli/cli_upgrade_adapter_plans.go +++ b/internal/cli/cli_upgrade_adapter_plans.go @@ -92,6 +92,9 @@ func skipManagedHostNativeWalkEntry(root, path string, entry os.DirEntry, walkEr } func markManagedHostNativeFile(path, tool string, managed *bool) error { + if *managed { + return nil + } owned, err := isManagedHostNativeFileForTool(path, tool) if err != nil { return err diff --git a/internal/cli/cli_upgrade_adapter_security_test.go b/internal/cli/cli_upgrade_adapter_security_test.go index 6c0e6f0..a3e39a8 100644 --- a/internal/cli/cli_upgrade_adapter_security_test.go +++ b/internal/cli/cli_upgrade_adapter_security_test.go @@ -40,3 +40,24 @@ func TestRunUpgradePreviewRejectsSymlinkedManagedScanTarget(t *testing.T) { t.Fatalf("expected managed scan symlink rejection, got %q", stderr.String()) } } + +func TestScanManagedHostNativeArtifactsInDirShortCircuitsFileReadsAfterMatch(t *testing.T) { + root := t.TempDir() + managed := filepath.Join(root, "000-managed.md") + managedContent := "<!-- runecontext-managed-artifact: host-native-v1 -->\n<!-- runecontext-tool: opencode -->\n<!-- runecontext-kind: flow_asset -->\n<!-- runecontext-id: runecontext:test -->\n" + if err := os.WriteFile(managed, []byte(managedContent), 0o644); err != nil { + t.Fatalf("write managed host-native file: %v", err) + } + oversized := filepath.Join(root, "zzz-oversized.md") + if err := os.WriteFile(oversized, make([]byte, maxManagedHostNativeArtifactScanBytes+1), 0o644); err != nil { + t.Fatalf("write oversized trap file: %v", err) + } + + found, err := scanManagedHostNativeArtifactsInDir(root, "opencode") + if err != nil { + t.Fatalf("expected short-circuit scan success, got %v", err) + } + if !found { + t.Fatalf("expected managed host-native artifact detection") + } +} diff --git a/internal/contracts/standard_commands_list.go b/internal/contracts/standard_commands_list.go index 83e48d1..1c32e39 100644 --- a/internal/contracts/standard_commands_list.go +++ b/internal/contracts/standard_commands_list.go @@ -10,6 +10,7 @@ func ListStandards(v *Validator, loaded *LoadedProject, options StandardListOpti if err != nil { return nil, err } + defer index.Close() normalizedScopePaths, err := normalizeStandardScopePaths(options.ScopePaths) if err != nil { return nil, err From 196acfa3a236904d517b0d9fd788ed9f3fd6d031 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich <zeb@runecode.org> Date: Wed, 1 Apr 2026 14:33:01 -0500 Subject: [PATCH 13/27] Updates based on PR comments and code review. Signed-off-by: Zebulon Rouse-Frantzich <zeb@runecode.org> --- adapters/README.md | 6 +++ adapters/source/shared/flows.json | 10 ++-- internal/cli/cli_release_artifacts_test.go | 4 ++ internal/cli/cli_upgrade_adapter_plans.go | 21 ++++---- nix/release/metadata.nix | 1 - tools/syncadapters/render.go | 59 +++++++++++++++++++++- tools/syncadapters/render_test.go | 53 +++++++++++++++++++ 7 files changed, 137 insertions(+), 17 deletions(-) create mode 100644 tools/syncadapters/render_test.go diff --git a/adapters/README.md b/adapters/README.md index 314dac5..a98f41f 100644 --- a/adapters/README.md +++ b/adapters/README.md @@ -8,3 +8,9 @@ Canonical adapter definitions live under `adapters/source/`. Rendered adapter packs are build-generated at `build/generated/adapters/` via `just sync-adapters`. Those generated outputs are ephemeral and must not be committed. + +Contributor workflow: + +1. Edit canonical sources under `adapters/source/`. +2. Run `just sync-adapters` to regenerate `build/generated/adapters/`. +3. Review generated changes locally as build output; commit only source inputs. diff --git a/adapters/source/shared/flows.json b/adapters/source/shared/flows.json index 978c99a..6cb721f 100644 --- a/adapters/source/shared/flows.json +++ b/adapters/source/shared/flows.json @@ -9,31 +9,31 @@ "id": "change-assess-intake", "command_path": "change assess-intake", "description": "Assess intake readiness and advisory shaping signals", - "usage": "runectx change assess-intake --id <change-id> [--path <project-root>]" + "usage": "runectx change assess-intake --title <title> --type <type> [--size <size>] [--bundle <bundle-id>] [--description \"<text>\"] [--path <project-root>]" }, { "id": "change-assess-decomposition", "command_path": "change assess-decomposition", "description": "Assess decomposition and umbrella/sub-change signals", - "usage": "runectx change assess-decomposition --id <change-id> [--path <project-root>]" + "usage": "runectx change assess-decomposition <change-id> [--path <project-root>]" }, { "id": "change-decomposition-plan", "command_path": "change decomposition-plan", "description": "Plan umbrella and sub-change graph rewrites", - "usage": "runectx change decomposition-plan --id <change-id> [--path <project-root>]" + "usage": "runectx change decomposition-plan <umbrella-change-id> --sub-change <id> [--sub-change <id> ...] [--depends-on <sub-change-id>:<change-id> ...] [--path <project-root>]" }, { "id": "change-decomposition-apply", "command_path": "change decomposition-apply", "description": "Apply umbrella and sub-change graph rewrites", - "usage": "runectx change decomposition-apply --id <change-id> [--path <project-root>]" + "usage": "runectx change decomposition-apply <umbrella-change-id> --sub-change <id> [--sub-change <id> ...] [--depends-on <sub-change-id>:<change-id> ...] [--path <project-root>]" }, { "id": "change-shape", "command_path": "change shape", "description": "Shape an existing RuneContext change", - "usage": "runectx change shape --id <change-id> [--path <project-root>]" + "usage": "runectx change shape <change-id> [--design \"<text>\"] [--verification \"<text>\"] [--task \"<text>\"] [--reference \"<text>\"] [--path <project-root>]" }, { "id": "standard-discover", diff --git a/internal/cli/cli_release_artifacts_test.go b/internal/cli/cli_release_artifacts_test.go index 0c1400d..b47bee7 100644 --- a/internal/cli/cli_release_artifacts_test.go +++ b/internal/cli/cli_release_artifacts_test.go @@ -12,6 +12,9 @@ import ( func TestReleaseMetadataDeclaresSchemaBundleAndAdapterPacks(t *testing.T) { metadata := readReleaseFileForTests(t, filepath.Join("nix", "release", "metadata.nix")) + if strings.Contains(metadata, `"adapters/source"`) { + t.Fatal("expected release metadata to exclude adapters/source from shipped top-level directories") + } requireSubstrings(t, metadata, `name = "schema-bundle";`, @@ -45,6 +48,7 @@ func TestReleaseArtifactBuilderRecordsManifestAndChecksumCoverage(t *testing.T) requireSubstrings(t, script, `process_pack_archives "schema_bundle"`, `process_pack_archives "adapter_pack"`, + `go run ./tools/syncadapters --root . --output build/generated/adapters`, `"${coreutils}/rm" -rf "${bundle_root}/adapters"`, `"${coreutils}/cp" -R build/generated/adapters "${bundle_root}/adapters"`, `"${coreutils}/cp" -R schemas "${share_dir}/schemas"`, diff --git a/internal/cli/cli_upgrade_adapter_plans.go b/internal/cli/cli_upgrade_adapter_plans.go index 9cca675..5878287 100644 --- a/internal/cli/cli_upgrade_adapter_plans.go +++ b/internal/cli/cli_upgrade_adapter_plans.go @@ -10,6 +10,8 @@ import ( const maxManagedHostNativeArtifactScanBytes = 1 << 20 +var errManagedHostNativeArtifactFound = fmt.Errorf("managed host-native artifact found") + func collectUpgradeAdapterPlans(absRoot string) (map[string]adapterSyncState, []string, []string, error) { states := map[string]adapterSyncState{} conflicts := make([]string, 0) @@ -64,14 +66,19 @@ func hasManagedHostNativeArtifactsInDir(root, tool string) (bool, error) { } func scanManagedHostNativeArtifactsInDir(root, tool string) (bool, error) { - var managed bool err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { if skip, err := skipManagedHostNativeWalkEntry(root, path, entry, walkErr); skip || err != nil { return err } - return markManagedHostNativeFile(path, tool, &managed) + return markManagedHostNativeFile(path, tool) }) - return managed, err + if err == nil { + return false, nil + } + if err == errManagedHostNativeArtifactFound { + return true, nil + } + return false, err } func skipManagedHostNativeWalkEntry(root, path string, entry os.DirEntry, walkErr error) (bool, error) { @@ -91,10 +98,7 @@ func skipManagedHostNativeWalkEntry(root, path string, entry os.DirEntry, walkEr return false, nil } -func markManagedHostNativeFile(path, tool string, managed *bool) error { - if *managed { - return nil - } +func markManagedHostNativeFile(path, tool string) error { owned, err := isManagedHostNativeFileForTool(path, tool) if err != nil { return err @@ -102,8 +106,7 @@ func markManagedHostNativeFile(path, tool string, managed *bool) error { if !owned { return nil } - *managed = true - return nil + return errManagedHostNativeArtifactFound } func existingHostNativeDir(root string) (bool, error) { diff --git a/nix/release/metadata.nix b/nix/release/metadata.nix index 6e35a28..61b9894 100644 --- a/nix/release/metadata.nix +++ b/nix/release/metadata.nix @@ -21,7 +21,6 @@ let topLevelDirectories = [ "docs" "core" - "adapters/source" "schemas" "fixtures" "scripts" diff --git a/tools/syncadapters/render.go b/tools/syncadapters/render.go index 13014d6..d37e2df 100644 --- a/tools/syncadapters/render.go +++ b/tools/syncadapters/render.go @@ -45,10 +45,65 @@ func resolveOutput(absRoot, output string) (string, error) { if strings.TrimSpace(output) == "" { return "", fmt.Errorf("output root must be non-empty") } + cleanRoot := filepath.Clean(absRoot) + if !filepath.IsAbs(cleanRoot) { + return "", fmt.Errorf("root %q must be absolute", absRoot) + } + canonicalRoot, err := filepath.EvalSymlinks(cleanRoot) + if err != nil { + return "", fmt.Errorf("resolve root symlinks %q: %w", cleanRoot, err) + } + var absOutput string if filepath.IsAbs(output) { - return filepath.Clean(output), nil + absOutput = filepath.Clean(output) + } else { + absOutput = filepath.Clean(filepath.Join(canonicalRoot, output)) } - return filepath.Clean(filepath.Join(absRoot, output)), nil + if err := validateOutputRoot(canonicalRoot, absOutput); err != nil { + return "", err + } + return absOutput, nil +} + +func validateOutputRoot(absRoot, absOutput string) error { + if absOutput == string(filepath.Separator) { + return fmt.Errorf("output root %q is not allowed", absOutput) + } + rel, err := filepath.Rel(absRoot, absOutput) + if err != nil { + return fmt.Errorf("resolve output root %q relative to %q: %w", absOutput, absRoot, err) + } + if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return fmt.Errorf("output root %q must stay under repository root %q", absOutput, absRoot) + } + if err := rejectSymlinkedOutputAncestors(absRoot, rel); err != nil { + return err + } + return nil +} + +func rejectSymlinkedOutputAncestors(absRoot, relOutput string) error { + if relOutput == "." { + return nil + } + current := absRoot + for _, segment := range strings.Split(relOutput, string(filepath.Separator)) { + if segment == "" || segment == "." { + continue + } + current = filepath.Join(current, segment) + info, err := os.Lstat(current) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("stat output component %q: %w", current, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("output root component %q must not be a symlink", current) + } + } + return nil } func renderTool(root, output, toolID string, flows []flowDefinition, tool toolDefinition) error { diff --git a/tools/syncadapters/render_test.go b/tools/syncadapters/render_test.go new file mode 100644 index 0000000..5d33cd1 --- /dev/null +++ b/tools/syncadapters/render_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolveOutputRejectsUnsafeTargets(t *testing.T) { + root := t.TempDir() + for _, output := range []string{string(filepath.Separator), root, filepath.Join(root, "..", "outside")} { + output := output + t.Run(output, func(t *testing.T) { + if _, err := resolveOutput(root, output); err == nil { + t.Fatalf("expected resolveOutput to reject %q", output) + } + }) + } +} + +func TestResolveOutputAllowsRepositoryDescendants(t *testing.T) { + root := t.TempDir() + for _, output := range []string{"build/generated/adapters", filepath.Join(root, "build", "generated", "adapters")} { + output := output + t.Run(output, func(t *testing.T) { + got, err := resolveOutput(root, output) + if err != nil { + t.Fatalf("resolveOutput returned error: %v", err) + } + rel, err := filepath.Rel(root, got) + if err != nil { + t.Fatalf("compute relative path: %v", err) + } + if rel == "." || rel == ".." || rel == "" { + t.Fatalf("expected repository descendant output, got %q", got) + } + }) + } +} + +func TestResolveOutputRejectsSymlinkedOutputAncestor(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + if err := os.Symlink(outside, filepath.Join(root, "build")); err != nil { + if os.IsPermission(err) { + t.Skipf("symlink creation not permitted: %v", err) + } + t.Fatalf("create symlinked output ancestor: %v", err) + } + if _, err := resolveOutput(root, "build/generated/adapters"); err == nil { + t.Fatal("expected resolveOutput to reject symlinked output ancestor") + } +} From d4d2002ada52e84cde27f48431b8103a11424761 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich <zeb@runecode.org> Date: Wed, 1 Apr 2026 14:48:40 -0500 Subject: [PATCH 14/27] Fixes CI issue and updates implementation status of changes. Signed-off-by: Zebulon Rouse-Frantzich <zeb@runecode.org> --- internal/cli/cli_upgrade_adapter_plans.go | 29 ++++++++++---- internal/cli/testhelpers_test.go | 5 ++- .../status.yaml | 2 +- .../status.yaml | 2 +- .../status.yaml | 2 +- tools/syncadapters/render.go | 40 ++++++++++++++----- tools/syncadapters/render_test.go | 35 ++++++++-------- 7 files changed, 78 insertions(+), 37 deletions(-) diff --git a/internal/cli/cli_upgrade_adapter_plans.go b/internal/cli/cli_upgrade_adapter_plans.go index 5878287..b0b0ae0 100644 --- a/internal/cli/cli_upgrade_adapter_plans.go +++ b/internal/cli/cli_upgrade_adapter_plans.go @@ -66,8 +66,11 @@ func hasManagedHostNativeArtifactsInDir(root, tool string) (bool, error) { } func scanManagedHostNativeArtifactsInDir(root, tool string) (bool, error) { + if err := validateManagedHostNativeWalkSafety(root); err != nil { + return false, err + } err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { - if skip, err := skipManagedHostNativeWalkEntry(root, path, entry, walkErr); skip || err != nil { + if skip, err := skipManagedHostNativeFileEntry(path, entry, walkErr); skip || err != nil { return err } return markManagedHostNativeFile(path, tool) @@ -81,7 +84,23 @@ func scanManagedHostNativeArtifactsInDir(root, tool string) (bool, error) { return false, err } -func skipManagedHostNativeWalkEntry(root, path string, entry os.DirEntry, walkErr error) (bool, error) { +func validateManagedHostNativeWalkSafety(root string) error { + return filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.Type()&os.ModeSymlink == 0 { + return nil + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + return fmt.Errorf("managed host-native scan rejects symlinked path %s", filepath.ToSlash(rel)) + }) +} + +func skipManagedHostNativeFileEntry(path string, entry os.DirEntry, walkErr error) (bool, error) { if walkErr != nil { return true, walkErr } @@ -89,11 +108,7 @@ func skipManagedHostNativeWalkEntry(root, path string, entry os.DirEntry, walkEr return true, nil } if entry.Type()&os.ModeSymlink != 0 { - rel, err := filepath.Rel(root, path) - if err != nil { - return true, err - } - return true, fmt.Errorf("managed host-native scan rejects symlinked path %s", filepath.ToSlash(rel)) + return true, fmt.Errorf("managed host-native scan rejects symlinked path %s", filepath.ToSlash(path)) } return false, nil } diff --git a/internal/cli/testhelpers_test.go b/internal/cli/testhelpers_test.go index 0bc3d2f..a94e56e 100644 --- a/internal/cli/testhelpers_test.go +++ b/internal/cli/testhelpers_test.go @@ -101,8 +101,11 @@ func stageGeneratedAdapterWorkspaceForTests(t *testing.T) (string, string) { schemaSource := filepath.Join(repoRoot, "schemas") schemaTarget := filepath.Join(workspace, "schemas") copyDirForCLI(t, schemaSource, schemaTarget) + adapterSource := filepath.Join(repoRoot, "adapters", "source") + adapterTarget := filepath.Join(workspace, "adapters", "source") + copyDirForCLI(t, adapterSource, adapterTarget) adaptersRoot := filepath.Join(workspace, "build", "generated", "adapters") - cmd := exec.Command("go", "run", "./tools/syncadapters", "--root", repoRoot, "--output", adaptersRoot) + cmd := exec.Command("go", "run", "./tools/syncadapters", "--root", workspace, "--output", adaptersRoot) cmd.Dir = repoRoot output, err := cmd.CombinedOutput() if err != nil { diff --git a/runecontext/changes/CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline/status.yaml b/runecontext/changes/CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline/status.yaml index 89721d8..8fba2f2 100644 --- a/runecontext/changes/CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline/status.yaml +++ b/runecontext/changes/CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline/status.yaml @@ -1,7 +1,7 @@ schema_version: 1 id: CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline title: Add canonical adapter source generation and rendering pipeline -status: planned +status: implemented type: feature size: large verification_status: pending diff --git a/runecontext/changes/CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds/status.yaml b/runecontext/changes/CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds/status.yaml index 22a7c18..3996aa7 100644 --- a/runecontext/changes/CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds/status.yaml +++ b/runecontext/changes/CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds/status.yaml @@ -1,7 +1,7 @@ schema_version: 1 id: CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds title: Stage generated adapter packs in tests and release builds -status: planned +status: implemented type: feature size: large verification_status: pending diff --git a/runecontext/changes/CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs/status.yaml b/runecontext/changes/CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs/status.yaml index 2021399..b77cef9 100644 --- a/runecontext/changes/CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs/status.yaml +++ b/runecontext/changes/CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs/status.yaml @@ -1,7 +1,7 @@ schema_version: 1 id: CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs title: Stop committing rendered adapter packs and adopt build generated outputs -status: planned +status: implemented type: chore size: medium verification_status: pending diff --git a/tools/syncadapters/render.go b/tools/syncadapters/render.go index d37e2df..7353d71 100644 --- a/tools/syncadapters/render.go +++ b/tools/syncadapters/render.go @@ -87,25 +87,45 @@ func rejectSymlinkedOutputAncestors(absRoot, relOutput string) error { return nil } current := absRoot - for _, segment := range strings.Split(relOutput, string(filepath.Separator)) { - if segment == "" || segment == "." { - continue - } + for _, segment := range outputSegments(relOutput) { current = filepath.Join(current, segment) - info, err := os.Lstat(current) + exists, symlinked, err := outputComponentState(current) if err != nil { - if os.IsNotExist(err) { - return nil - } - return fmt.Errorf("stat output component %q: %w", current, err) + return err + } + if !exists { + return nil } - if info.Mode()&os.ModeSymlink != 0 { + if symlinked { return fmt.Errorf("output root component %q must not be a symlink", current) } } return nil } +func outputSegments(relOutput string) []string { + parts := strings.Split(relOutput, string(filepath.Separator)) + segments := make([]string, 0, len(parts)) + for _, part := range parts { + if part == "" || part == "." { + continue + } + segments = append(segments, part) + } + return segments +} + +func outputComponentState(path string) (exists bool, symlinked bool, err error) { + info, err := os.Lstat(path) + if err != nil { + if os.IsNotExist(err) { + return false, false, nil + } + return false, false, fmt.Errorf("stat output component %q: %w", path, err) + } + return true, info.Mode()&os.ModeSymlink != 0, nil +} + func renderTool(root, output, toolID string, flows []flowDefinition, tool toolDefinition) error { sourceDir := filepath.Join(root, "adapters", "source", "packs", toolID) if info, err := os.Stat(sourceDir); err != nil || !info.IsDir() { diff --git a/tools/syncadapters/render_test.go b/tools/syncadapters/render_test.go index 5d33cd1..667831d 100644 --- a/tools/syncadapters/render_test.go +++ b/tools/syncadapters/render_test.go @@ -20,22 +20,8 @@ func TestResolveOutputRejectsUnsafeTargets(t *testing.T) { func TestResolveOutputAllowsRepositoryDescendants(t *testing.T) { root := t.TempDir() - for _, output := range []string{"build/generated/adapters", filepath.Join(root, "build", "generated", "adapters")} { - output := output - t.Run(output, func(t *testing.T) { - got, err := resolveOutput(root, output) - if err != nil { - t.Fatalf("resolveOutput returned error: %v", err) - } - rel, err := filepath.Rel(root, got) - if err != nil { - t.Fatalf("compute relative path: %v", err) - } - if rel == "." || rel == ".." || rel == "" { - t.Fatalf("expected repository descendant output, got %q", got) - } - }) - } + assertRepositoryDescendantOutput(t, root, "build/generated/adapters") + assertRepositoryDescendantOutput(t, root, filepath.Join(root, "build", "generated", "adapters")) } func TestResolveOutputRejectsSymlinkedOutputAncestor(t *testing.T) { @@ -51,3 +37,20 @@ func TestResolveOutputRejectsSymlinkedOutputAncestor(t *testing.T) { t.Fatal("expected resolveOutput to reject symlinked output ancestor") } } + +func assertRepositoryDescendantOutput(t *testing.T, root, output string) { + t.Helper() + t.Run(output, func(t *testing.T) { + got, err := resolveOutput(root, output) + if err != nil { + t.Fatalf("resolveOutput returned error: %v", err) + } + rel, err := filepath.Rel(root, got) + if err != nil { + t.Fatalf("compute relative path: %v", err) + } + if rel == "." || rel == ".." || rel == "" { + t.Fatalf("expected repository descendant output, got %q", got) + } + }) +} From 92215c5353c245733f5e5782b7b9b7e9608701d4 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich <zeb@runecode.org> Date: Wed, 1 Apr 2026 15:22:38 -0500 Subject: [PATCH 15/27] Adds a 'no question tool' rule to the adapter sources Signed-off-by: Zebulon Rouse-Frantzich <zeb@runecode.org> --- .../cli/cli_adapter_render_host_native_markdown.go | 2 ++ internal/cli/cli_adapter_render_test.go | 5 +++++ internal/cli/cli_adapter_sync_host_native.go | 7 +++++++ internal/cli/cli_adapter_test.go | 5 +++++ internal/cli/cli_adapter_test_helpers_test.go | 11 +++++++++++ 5 files changed, 30 insertions(+) diff --git a/internal/cli/cli_adapter_render_host_native_markdown.go b/internal/cli/cli_adapter_render_host_native_markdown.go index 760354c..53b9c58 100644 --- a/internal/cli/cli_adapter_render_host_native_markdown.go +++ b/internal/cli/cli_adapter_render_host_native_markdown.go @@ -25,6 +25,7 @@ func renderHostNativeOperationMarkdown(request adapterRenderRequest) (string, er "- operation_identifier: `" + meta.operationID + "`", "- command_path: `" + meta.commandPath + "`", "- usage: `" + meta.usage + "`", + "- interaction_rule: " + hostNativeNoQuestionRule, } if len(meta.requiredFlags) > 0 { lines = append(lines, "- required_flags: `"+strings.Join(meta.requiredFlags, " ")+"`") @@ -47,6 +48,7 @@ func renderHostNativeIndexMarkdown(request adapterRenderRequest) (string, error) "- canonical_flow_source: `build/generated/adapters/" + request.tool + "/flows/*.md`", "- adapter_role: `" + hostNativeKindDiscoverabilityShim + "`", "- operation_identifier: `runecontext:index`", + "- interaction_rule: " + hostNativeNoQuestionRule, } for _, flow := range flows { commandPath := commandPathFromFlowID(flow.id) diff --git a/internal/cli/cli_adapter_render_test.go b/internal/cli/cli_adapter_render_test.go index 553fe61..c561227 100644 --- a/internal/cli/cli_adapter_render_test.go +++ b/internal/cli/cli_adapter_render_test.go @@ -32,6 +32,8 @@ func TestRunAdapterRenderHostNativeOutputsMinimalMarkdown(t *testing.T) { "operation_identifier:", "command_path:", "usage:", + "interaction_rule:", + hostNativeNoQuestionRule, } { if !strings.Contains(text, token) { t.Fatalf("expected token %q in render output, got %q", token, text) @@ -89,6 +91,9 @@ func TestRunAdapterRenderHostNativeIndexForClaude(t *testing.T) { if !strings.Contains(text, "operation: `runecontext:change-decomposition-apply`") { t.Fatalf("expected indexed change-decomposition-apply operation, got %q", text) } + if !strings.Contains(text, "interaction_rule: "+hostNativeNoQuestionRule) { + t.Fatalf("expected no-question interaction rule in index output, got %q", text) + } } func TestRunAdapterRenderHostNativeRejectsUnsupportedTool(t *testing.T) { diff --git a/internal/cli/cli_adapter_sync_host_native.go b/internal/cli/cli_adapter_sync_host_native.go index b5d3af1..f3c7fc7 100644 --- a/internal/cli/cli_adapter_sync_host_native.go +++ b/internal/cli/cli_adapter_sync_host_native.go @@ -8,6 +8,7 @@ import ( ) const hostNativeOwnershipMarker = "runecontext-managed-artifact: host-native-v1" +const hostNativeNoQuestionRule = "Never use the `question` tool; ask in normal conversational text when clarification is needed." type hostNativeArtifact struct { relPath string @@ -125,6 +126,8 @@ func buildHostNativeFlowAssetContent(tool string, flow hostNativeFlow) []byte { "<!-- runecontext-id: runecontext:" + flow.id + " -->", "# RuneContext Skill: " + flow.name, "", + hostNativeNoQuestionRule, + "", body, }...) return []byte(strings.Join(lines, "\n") + "\n") @@ -139,6 +142,8 @@ func buildHostNativeCommandShimContent(tool string, flow hostNativeFlow) []byte "<!-- runecontext-id: runecontext:" + flow.id + " -->", "# RuneContext Command Shim: " + flow.name, "", + hostNativeNoQuestionRule, + "", body, }...) return []byte(strings.Join(lines, "\n") + "\n") @@ -170,6 +175,8 @@ func buildClaudeCommandIndexShimContent(flows []hostNativeFlow) []byte { ) } lines = append(lines, + "", + hostNativeNoQuestionRule, "", buildHostNativeBody("claude-code", "index", hostNativeKindDiscoverabilityShim), ) diff --git a/internal/cli/cli_adapter_test.go b/internal/cli/cli_adapter_test.go index a5a12d8..57b7ba0 100644 --- a/internal/cli/cli_adapter_test.go +++ b/internal/cli/cli_adapter_test.go @@ -141,6 +141,8 @@ func assertOpenCodeHostNativeArtifacts(t *testing.T, projectRoot string) { assertFrontmatterContains(t, filepath.Join(projectRoot, ".opencode", "commands", "runecontext-change-new.md"), "description: Create a new RuneContext change") assertManagedArtifactMarker(t, filepath.Join(projectRoot, ".opencode", "skills", "runecontext-change-new.md")) assertManagedArtifactMarker(t, filepath.Join(projectRoot, ".opencode", "commands", "runecontext-change-new.md")) + assertNoQuestionToolRulePresent(t, filepath.Join(projectRoot, ".opencode", "skills", "runecontext-change-new.md")) + assertNoQuestionToolRulePresent(t, filepath.Join(projectRoot, ".opencode", "commands", "runecontext-change-new.md")) } func assertClaudeCodeHostNativeArtifacts(t *testing.T, projectRoot string) { @@ -160,6 +162,8 @@ func assertClaudeCodeHostNativeArtifacts(t *testing.T, projectRoot string) { assertFrontmatterContains(t, filepath.Join(projectRoot, ".claude", "commands", "runecontext.md"), "name: runecontext") assertManagedArtifactMarker(t, filepath.Join(projectRoot, ".claude", "skills", "runecontext-change-new.md")) assertManagedArtifactMarker(t, filepath.Join(projectRoot, ".claude", "commands", "runecontext.md")) + assertNoQuestionToolRulePresent(t, filepath.Join(projectRoot, ".claude", "skills", "runecontext-change-new.md")) + assertNoQuestionToolRulePresent(t, filepath.Join(projectRoot, ".claude", "commands", "runecontext.md")) } func assertCodexHostNativeArtifacts(t *testing.T, projectRoot string) { @@ -176,6 +180,7 @@ func assertCodexHostNativeArtifacts(t *testing.T, projectRoot string) { assertFrontmatterContains(t, filepath.Join(projectRoot, ".agents", "skills", "runecontext-change-new.md"), "description: Create a new RuneContext change") assertNoShellInjectionCall(t, filepath.Join(projectRoot, ".agents", "skills", "runecontext-change-new.md")) assertManagedArtifactMarker(t, filepath.Join(projectRoot, ".agents", "skills", "runecontext-change-new.md")) + assertNoQuestionToolRulePresent(t, filepath.Join(projectRoot, ".agents", "skills", "runecontext-change-new.md")) } func TestRunAdapterSyncHostNativeConflictFailsClosed(t *testing.T) { diff --git a/internal/cli/cli_adapter_test_helpers_test.go b/internal/cli/cli_adapter_test_helpers_test.go index d7038f7..4bc8ef0 100644 --- a/internal/cli/cli_adapter_test_helpers_test.go +++ b/internal/cli/cli_adapter_test_helpers_test.go @@ -91,6 +91,17 @@ func assertNoShellInjectionCall(t *testing.T, path string) { } } +func assertNoQuestionToolRulePresent(t *testing.T, path string) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read host-native artifact %s: %v", path, err) + } + if !strings.Contains(string(data), hostNativeNoQuestionRule) { + t.Fatalf("expected no-question interaction rule in %s", path) + } +} + func assertFrontmatterContains(t *testing.T, path, token string) { t.Helper() data, err := os.ReadFile(path) From d2b7e1fd9f4ea823548e20202531575c9c1eb1fd Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich <zeb@runecode.org> Date: Wed, 1 Apr 2026 17:27:39 -0500 Subject: [PATCH 16/27] Adds new change that enhances the consistency of the generated adapter-specific files Signed-off-by: Zebulon Rouse-Frantzich <zeb@runecode.org> --- .../design.md | 8 +++++ .../proposal.md | 22 ++++++++++++++ .../references.md | 5 ++++ .../standards.md | 29 +++++++++++++++++++ .../status.yaml | 20 +++++++++++++ .../tasks.md | 7 +++++ .../verification.md | 10 +++++++ 7 files changed, 101 insertions(+) create mode 100644 runecontext/changes/CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts/design.md create mode 100644 runecontext/changes/CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts/proposal.md create mode 100644 runecontext/changes/CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts/references.md create mode 100644 runecontext/changes/CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts/standards.md create mode 100644 runecontext/changes/CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts/status.yaml create mode 100644 runecontext/changes/CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts/tasks.md create mode 100644 runecontext/changes/CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts/verification.md diff --git a/runecontext/changes/CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts/design.md b/runecontext/changes/CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts/design.md new file mode 100644 index 0000000..66963d2 --- /dev/null +++ b/runecontext/changes/CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts/design.md @@ -0,0 +1,8 @@ +# Design + +## Overview +Adopt a shared generated workflow contract for all adapter-native commands and skills. Replace the flat shared flows catalog with per-flow structured definitions that capture required outcome, guardrails, inputs to gather, decision rules, workflow steps, stop condition, recommended next commands, and examples. Extend tools/syncadapters to generate both richer flow markdown and a machine-readable workflow.json for every adapter pack. Update adapter render-host-native to load generated workflow.json from the installed/generated adapter root so shell-injection hosts render the same rich structure at runtime, while non-shell-injection hosts embed the same rendered content at sync time. Keep adapter sync as the only user-facing setup command and treat adapter render-host-native as an internal rendering primitive. Require every generated command or skill to stop after its immediate RuneContext outcome and only recommend the next RuneContext command instead of auto-chaining. Preserve the no-question-tool rule across all adapters. + +## Shape Rationale +- Full mode was requested explicitly to deepen the change. +- Minimum mode is sufficient for the current size and risk signal. diff --git a/runecontext/changes/CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts/proposal.md b/runecontext/changes/CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts/proposal.md new file mode 100644 index 0000000..1e6405d --- /dev/null +++ b/runecontext/changes/CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts/proposal.md @@ -0,0 +1,22 @@ +## Summary +Add generated structured adapter workflow contracts + +## Problem +The repository needs a reviewable RuneContext change record for this work. + +## Proposed Change +Track Add generated structured adapter workflow contracts through the minimum RuneContext change artifacts. + +## Why Now +The work needs stable intent, standards linkage, and verification planning before it moves further. + +## Assumptions +- Inferred size "medium" from the change type because no explicit size was provided. +- Used all non-draft standards as a conservative fallback because no standards were selected through context bundles. +- Inferred `just test` from the repository's justfile test target. + +## Out of Scope +Work outside the scoped change tracked here. + +## Impact +The change keeps intent, assumptions, and standards linkage reviewable. diff --git a/runecontext/changes/CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts/references.md b/runecontext/changes/CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts/references.md new file mode 100644 index 0000000..bb2b4f0 --- /dev/null +++ b/runecontext/changes/CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts/references.md @@ -0,0 +1,5 @@ +# References + +- runecontext/changes/CHG-2026-002-5739-add-guided-adapter-native-change-workflows +- AgentOS shape-spec command structure +- BMAD create-prd step-file workflow structure diff --git a/runecontext/changes/CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts/standards.md b/runecontext/changes/CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts/standards.md new file mode 100644 index 0000000..0c97ce5 --- /dev/null +++ b/runecontext/changes/CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts/standards.md @@ -0,0 +1,29 @@ +## Applicable Standards +- `standards/architecture/closed-schema-enforcement.md`: Selected from the current context bundles. +- `standards/architecture/derived-artifact-non-authority.md`: Selected from the current context bundles. +- `standards/assurance/additive-backfill-evidence.md`: Selected from the current context bundles. +- `standards/assurance/fail-closed-verified-boundary.md`: Selected from the current context bundles. +- `standards/assurance/portable-receipt-linkage.md`: Selected from the current context bundles. +- `standards/bundles/deterministic-bundle-linearization.md`: Selected from the current context bundles. +- `standards/change/explicit-umbrella-recursion-boundaries.md`: Selected from the current context bundles. +- `standards/change/immutable-terminal-change-records.md`: Selected from the current context bundles. +- `standards/change/verification-status-consistency.md`: Selected from the current context bundles. +- `standards/cli/completion-and-metadata-from-canonical-registry.md`: Selected from the current context bundles. +- `standards/cli/network-isolated-core-operations.md`: Selected from the current context bundles. +- `standards/context-packs/explicit-generated-at-and-stable-hashing.md`: Selected from the current context bundles. +- `standards/context-packs/portable-source-ref-requirements.md`: Selected from the current context bundles. +- `standards/global/adapter-thin-ux-over-cli.md`: Selected from the current context bundles. +- `standards/global/change-relationship-consistency.md`: Selected from the current context bundles. +- `standards/global/cli-mutation-safety.md`: Selected from the current context bundles. +- `standards/global/compatibility-is-not-upgradeability.md`: Selected from the current context bundles. +- `standards/global/structured-cli-contracts.md`: Selected from the current context bundles. +- `standards/go/thin-cli-fat-contracts.md`: Selected from the current context bundles. +- `standards/quality/source-quality-gates-are-policy.md`: Selected from the current context bundles. +- `standards/release/repo-first-canonical-distribution.md`: Selected from the current context bundles. +- `standards/release/self-update-runtime-asset-discovery.md`: Selected from the current context bundles. +- `standards/release/tag-driven-signed-release-process.md`: Selected from the current context bundles. +- `standards/source/deterministic-source-resolution.md`: Selected from the current context bundles. +- `standards/source/portable-path-canonicalization.md`: Selected from the current context bundles. + +## Resolution Notes +Generated from the current context bundle selection; review any automatic refresh before committing. diff --git a/runecontext/changes/CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts/status.yaml b/runecontext/changes/CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts/status.yaml new file mode 100644 index 0000000..0398667 --- /dev/null +++ b/runecontext/changes/CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts/status.yaml @@ -0,0 +1,20 @@ +schema_version: 1 +id: CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts +title: Add generated structured adapter workflow contracts +status: proposed +type: feature +size: medium +verification_status: pending +context_bundles: [] +related_specs: [] +related_decisions: [] +related_changes: [] +depends_on: [] +informed_by: [] +supersedes: [] +superseded_by: [] +created_at: "2026-04-01" +closed_at: null +promotion_assessment: + status: pending + suggested_targets: [] diff --git a/runecontext/changes/CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts/tasks.md b/runecontext/changes/CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts/tasks.md new file mode 100644 index 0000000..77558d0 --- /dev/null +++ b/runecontext/changes/CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts/tasks.md @@ -0,0 +1,7 @@ +# Tasks + +- Replace adapters/source/shared/flows.json with per-flow structured definitions under adapters/source/shared/flows/. +- Extend tools/syncadapters model and render pipeline to generate richer flow markdown plus workflow.json for each adapter pack. +- Update adapter render-host-native to read generated workflow.json from the located adapter pack and render the shared structured contract for shell-injection and embedded hosts. +- Tighten adapter root discovery so runtime rendering consumes generated adapter packs rather than adapters/source. +- Expand adapter render/sync/generator tests and dogfood runecontext-change-new so generated host-native commands create RuneContext changes and stop after the immediate CLI outcome. diff --git a/runecontext/changes/CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts/verification.md b/runecontext/changes/CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts/verification.md new file mode 100644 index 0000000..991e975 --- /dev/null +++ b/runecontext/changes/CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts/verification.md @@ -0,0 +1,10 @@ +# Verification + +## Notes +Verify shared flow contract loading and generation, assert generated flow docs include outcome/guardrails/workflow/stop-condition/next-command sections for every operation, verify adapter render-host-native output includes the structured workflow contract and no-question rule for OpenCode/Claude/Codex surfaces, run adapter sync dogfood checks in this repo, and pass just ci. + +## Planned Checks +- `just test` + +## Close Gate +Use the repository's standard verification flow before closing this change. From 35e460248386f0a2f1e20bbd66aa8f82e9ee1bcf Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich <zeb@runecode.org> Date: Wed, 1 Apr 2026 18:16:07 -0500 Subject: [PATCH 17/27] Implements generated structured adapter workflow contracts Signed-off-by: Zebulon Rouse-Frantzich <zeb@runecode.org> --- adapters/source/shared/flows.json | 50 ------ .../flows/change-assess-decomposition.json | 38 +++++ .../shared/flows/change-assess-intake.json | 42 ++++++ .../flows/change-decomposition-apply.json | 40 +++++ .../flows/change-decomposition-plan.json | 40 +++++ adapters/source/shared/flows/change-new.json | 43 ++++++ .../source/shared/flows/change-shape.json | 40 +++++ adapters/source/shared/flows/promote.json | 38 +++++ .../shared/flows/standard-discover.json | 39 +++++ internal/cli/cli_adapter_flow_mapping.go | 12 +- internal/cli/cli_adapter_render.go | 4 - ...cli_adapter_render_host_native_markdown.go | 142 ++++++++++++++---- internal/cli/cli_adapter_render_test.go | 35 ++++- internal/cli/cli_adapter_sync_discovery.go | 31 +++- internal/cli/cli_adapter_sync_host_native.go | 53 ++++--- .../cli/cli_adapter_sync_host_native_flows.go | 122 +++++++++++++-- internal/cli/cli_release_layout_test.go | 37 +++++ .../status.yaml | 1 + .../status.yaml | 5 +- tools/syncadapters/model.go | 137 ++++++++++++++--- tools/syncadapters/render.go | 42 +----- tools/syncadapters/render_flows.go | 131 ++++++++++++++++ tools/syncadapters/render_test.go | 77 ++++++++++ .../syncadapters/render_workflow_contract.go | 63 ++++++++ 24 files changed, 1075 insertions(+), 187 deletions(-) delete mode 100644 adapters/source/shared/flows.json create mode 100644 adapters/source/shared/flows/change-assess-decomposition.json create mode 100644 adapters/source/shared/flows/change-assess-intake.json create mode 100644 adapters/source/shared/flows/change-decomposition-apply.json create mode 100644 adapters/source/shared/flows/change-decomposition-plan.json create mode 100644 adapters/source/shared/flows/change-new.json create mode 100644 adapters/source/shared/flows/change-shape.json create mode 100644 adapters/source/shared/flows/promote.json create mode 100644 adapters/source/shared/flows/standard-discover.json create mode 100644 tools/syncadapters/render_flows.go create mode 100644 tools/syncadapters/render_workflow_contract.go diff --git a/adapters/source/shared/flows.json b/adapters/source/shared/flows.json deleted file mode 100644 index 6cb721f..0000000 --- a/adapters/source/shared/flows.json +++ /dev/null @@ -1,50 +0,0 @@ -[ - { - "id": "change-new", - "command_path": "change new", - "description": "Create a new RuneContext change", - "usage": "runectx change new --title \"<title>\" --type <type> [--size <size>] [--shape <minimum|full>] [--bundle <bundle-id>] [--description \"<text>\"] [--path <project-root>]" - }, - { - "id": "change-assess-intake", - "command_path": "change assess-intake", - "description": "Assess intake readiness and advisory shaping signals", - "usage": "runectx change assess-intake --title <title> --type <type> [--size <size>] [--bundle <bundle-id>] [--description \"<text>\"] [--path <project-root>]" - }, - { - "id": "change-assess-decomposition", - "command_path": "change assess-decomposition", - "description": "Assess decomposition and umbrella/sub-change signals", - "usage": "runectx change assess-decomposition <change-id> [--path <project-root>]" - }, - { - "id": "change-decomposition-plan", - "command_path": "change decomposition-plan", - "description": "Plan umbrella and sub-change graph rewrites", - "usage": "runectx change decomposition-plan <umbrella-change-id> --sub-change <id> [--sub-change <id> ...] [--depends-on <sub-change-id>:<change-id> ...] [--path <project-root>]" - }, - { - "id": "change-decomposition-apply", - "command_path": "change decomposition-apply", - "description": "Apply umbrella and sub-change graph rewrites", - "usage": "runectx change decomposition-apply <umbrella-change-id> --sub-change <id> [--sub-change <id> ...] [--depends-on <sub-change-id>:<change-id> ...] [--path <project-root>]" - }, - { - "id": "change-shape", - "command_path": "change shape", - "description": "Shape an existing RuneContext change", - "usage": "runectx change shape <change-id> [--design \"<text>\"] [--verification \"<text>\"] [--task \"<text>\"] [--reference \"<text>\"] [--path <project-root>]" - }, - { - "id": "standard-discover", - "command_path": "standard discover", - "description": "Discover standards candidates for promotion", - "usage": "runectx standard discover --scope-path <path> [--focus <topic>] [--path <project-root>]" - }, - { - "id": "promote", - "command_path": "promote", - "description": "Advance RuneContext promotion state", - "usage": "runectx promote [--target spec:<path>|standard:<path>|decision:<path>] [--path <project-root>]" - } -] diff --git a/adapters/source/shared/flows/change-assess-decomposition.json b/adapters/source/shared/flows/change-assess-decomposition.json new file mode 100644 index 0000000..af81d6f --- /dev/null +++ b/adapters/source/shared/flows/change-assess-decomposition.json @@ -0,0 +1,38 @@ +{ + "id": "change-assess-decomposition", + "command_path": "change assess-decomposition", + "description": "Assess decomposition and umbrella/sub-change signals", + "usage": "runectx change assess-decomposition <change-id> [--path <project-root>]", + "required_outcome": "Return decomposition advisory output for an existing change id.", + "guardrails": [ + "Require an explicit change id; never infer mutation targets.", + "Treat output as advisory and avoid modifying relationships in this step.", + "Do not auto-run decomposition plan or apply commands." + ], + "inputs_to_gather": [ + "change id" + ], + "decision_rules": [ + "If change id is missing, request it before proposing command execution.", + "Reflect advisory decomposition signals without rewriting their meaning.", + "Stop after presenting the assess-decomposition result." + ], + "workflow_steps": [ + "Collect the target change id.", + "Propose and run runectx change assess-decomposition.", + "Report decomposition signals and explicit suggested next command.", + "End the flow after reporting this immediate output." + ], + "stop_condition": "Stop after decomposition advisory output is reported for the requested change id.", + "recommended_next_commands": [ + "runectx change decomposition-plan <umbrella-change-id> --sub-change <id>", + "runectx change shape <change-id> --task \"<text>\"" + ], + "examples": [ + { + "scenario": "Assess if a large change should be decomposed", + "user_prompt": "Assess decomposition for CHG-2026-100-aaaa-large-refactor.", + "assistant_response": "I will run `runectx change assess-decomposition CHG-2026-100-aaaa-large-refactor` and return the advisory decomposition guidance without applying changes." + } + ] +} diff --git a/adapters/source/shared/flows/change-assess-intake.json b/adapters/source/shared/flows/change-assess-intake.json new file mode 100644 index 0000000..3fcac0c --- /dev/null +++ b/adapters/source/shared/flows/change-assess-intake.json @@ -0,0 +1,42 @@ +{ + "id": "change-assess-intake", + "command_path": "change assess-intake", + "description": "Assess intake readiness and advisory shaping signals", + "usage": "runectx change assess-intake --title <title> --type <type> [--size <size>] [--bundle <bundle-id>] [--description \"<text>\"] [--path <project-root>]", + "required_outcome": "Return authoritative intake advisory output for a proposed change request.", + "guardrails": [ + "Treat assess-intake as advisory and do not mutate repository state.", + "Preserve exact advisory fields from CLI output when summarizing.", + "Do not auto-run change new after intake assessment." + ], + "inputs_to_gather": [ + "proposed title", + "proposed change type", + "optional size", + "optional bundle", + "optional description" + ], + "decision_rules": [ + "Gather required title and type before proposing the command.", + "Surface advisory output as-is and keep recommendations clearly non-authoritative.", + "Stop after reporting intake assessment results." + ], + "workflow_steps": [ + "Gather required and optional assess-intake arguments.", + "Propose and run runectx change assess-intake.", + "Report advisory readiness, decomposition signals, and suggested next command.", + "End the flow without chaining further commands." + ], + "stop_condition": "Stop after presenting intake advisory output and suggested next command options.", + "recommended_next_commands": [ + "runectx change new --title \"<title>\" --type <type>", + "runectx change assess-decomposition <change-id>" + ], + "examples": [ + { + "scenario": "Assess readiness before creating a project change", + "user_prompt": "Assess intake for a large project change to revamp adapters.", + "assistant_response": "I will run `runectx change assess-intake --title \"Revamp adapters\" --type project --size large` and report the advisory output only." + } + ] +} diff --git a/adapters/source/shared/flows/change-decomposition-apply.json b/adapters/source/shared/flows/change-decomposition-apply.json new file mode 100644 index 0000000..2c4ee8e --- /dev/null +++ b/adapters/source/shared/flows/change-decomposition-apply.json @@ -0,0 +1,40 @@ +{ + "id": "change-decomposition-apply", + "command_path": "change decomposition-apply", + "description": "Apply umbrella and sub-change graph rewrites", + "usage": "runectx change decomposition-apply <umbrella-change-id> --sub-change <id> [--sub-change <id> ...] [--depends-on <sub-change-id>:<change-id> ...] [--path <project-root>]", + "required_outcome": "Apply decomposition relationship rewrites and report authoritative mutation output.", + "guardrails": [ + "Require explicit umbrella and sub-change identifiers before mutation.", + "Keep the operation bounded to provided relationship edits only.", + "Stop after the apply result and do not auto-run validation or promotion." + ], + "inputs_to_gather": [ + "umbrella change id", + "one or more sub-change ids", + "optional depends-on edges" + ], + "decision_rules": [ + "Use decomposition-plan first when the requested graph rewrite is not yet reviewed.", + "Preserve explicit edge inputs from the user without inferred dependencies.", + "After apply completion, return mutation fields and stop." + ], + "workflow_steps": [ + "Gather explicit umbrella, sub-change, and dependency inputs.", + "Propose and run runectx change decomposition-apply.", + "Report applied graph changes from command output.", + "End the flow and recommend the next command if requested." + ], + "stop_condition": "Stop after apply output is delivered for the requested decomposition mutation.", + "recommended_next_commands": [ + "runectx validate --path <project-root>", + "runectx promote" + ], + "examples": [ + { + "scenario": "Apply a reviewed decomposition plan", + "user_prompt": "Apply decomposition for CHG-2026-200-umbrella with sub changes CHG-2026-201-a and CHG-2026-202-b.", + "assistant_response": "I will run `runectx change decomposition-apply CHG-2026-200-umbrella --sub-change CHG-2026-201-a --sub-change CHG-2026-202-b` and return the apply result." + } + ] +} diff --git a/adapters/source/shared/flows/change-decomposition-plan.json b/adapters/source/shared/flows/change-decomposition-plan.json new file mode 100644 index 0000000..de85345 --- /dev/null +++ b/adapters/source/shared/flows/change-decomposition-plan.json @@ -0,0 +1,40 @@ +{ + "id": "change-decomposition-plan", + "command_path": "change decomposition-plan", + "description": "Plan umbrella and sub-change graph rewrites", + "usage": "runectx change decomposition-plan <umbrella-change-id> --sub-change <id> [--sub-change <id> ...] [--depends-on <sub-change-id>:<change-id> ...] [--path <project-root>]", + "required_outcome": "Produce a deterministic decomposition plan preview for umbrella and sub-change relationships.", + "guardrails": [ + "Never apply relationship changes in plan mode.", + "Require explicit sub-change ids and dependency edges from the user.", + "Keep plan output reviewable before any apply step." + ], + "inputs_to_gather": [ + "umbrella change id", + "one or more sub-change ids", + "optional depends-on edges" + ], + "decision_rules": [ + "If no sub-change values are provided, request at least one before command proposal.", + "Render dependency edges exactly in <sub-change-id>:<change-id> form.", + "Stop after showing the decomposition plan output." + ], + "workflow_steps": [ + "Collect umbrella, sub-change, and optional dependency inputs.", + "Propose and run runectx change decomposition-plan.", + "Present planned graph changes and review checkpoints.", + "Stop and recommend the explicit apply command if needed." + ], + "stop_condition": "Stop after plan output is reported and do not execute apply automatically.", + "recommended_next_commands": [ + "runectx change decomposition-apply <umbrella-change-id> --sub-change <id>", + "runectx change shape <change-id> --task \"<text>\"" + ], + "examples": [ + { + "scenario": "Preview decomposition graph changes", + "user_prompt": "Plan decomposition for CHG-2026-200-umbrella with sub changes CHG-2026-201-a and CHG-2026-202-b.", + "assistant_response": "I will run `runectx change decomposition-plan CHG-2026-200-umbrella --sub-change CHG-2026-201-a --sub-change CHG-2026-202-b` and show the plan output only." + } + ] +} diff --git a/adapters/source/shared/flows/change-new.json b/adapters/source/shared/flows/change-new.json new file mode 100644 index 0000000..bedd186 --- /dev/null +++ b/adapters/source/shared/flows/change-new.json @@ -0,0 +1,43 @@ +{ + "id": "change-new", + "command_path": "change new", + "description": "Create a new RuneContext change", + "usage": "runectx change new --title \"<title>\" --type <type> [--size <size>] [--shape <minimum|full>] [--bundle <bundle-id>] [--description \"<text>\"] [--path <project-root>]", + "required_outcome": "Create a new change directory with a stable change id and initialized change artifacts.", + "guardrails": [ + "Map every generated suggestion directly to the documented runectx command and flags.", + "Do not execute any follow-up command automatically after change creation.", + "Keep all proposed mutations reviewable and explicit to the user." + ], + "inputs_to_gather": [ + "change title", + "change type", + "optional size", + "optional shape", + "optional bundle", + "optional proposal description" + ], + "decision_rules": [ + "If required title or type is missing, ask for those values before proposing command execution.", + "If optional values are not provided, omit their flags instead of inventing defaults.", + "After command completion, stop and report the resulting change id and path." + ], + "workflow_steps": [ + "Collect missing required and optional command inputs.", + "Present a single explicit runectx command proposal for review.", + "Execute or instruct execution of runectx change new.", + "Return the created change id and immediate output fields only." + ], + "stop_condition": "Stop immediately after reporting change creation output and do not chain additional workflow commands.", + "recommended_next_commands": [ + "runectx change shape <change-id>", + "runectx standard discover --scope-path <path>" + ], + "examples": [ + { + "scenario": "Create a feature change with minimal inputs", + "user_prompt": "Create a new feature change titled Add adapter workflow contracts.", + "assistant_response": "I will run `runectx change new --title \"Add adapter workflow contracts\" --type feature` once you confirm. After it succeeds, I will report the new change id and stop." + } + ] +} diff --git a/adapters/source/shared/flows/change-shape.json b/adapters/source/shared/flows/change-shape.json new file mode 100644 index 0000000..e470bfe --- /dev/null +++ b/adapters/source/shared/flows/change-shape.json @@ -0,0 +1,40 @@ +{ + "id": "change-shape", + "command_path": "change shape", + "description": "Shape an existing RuneContext change", + "usage": "runectx change shape <change-id> [--design \"<text>\"] [--verification \"<text>\"] [--task \"<text>\"] [--reference \"<text>\"] [--path <project-root>]", + "required_outcome": "Append shaped design, verification, task, or reference entries to an existing change.", + "guardrails": [ + "Require explicit change id and scoped shape inputs.", + "Do not rewrite unrelated change artifacts during shaping.", + "Stop after shape output without chaining more edits automatically." + ], + "inputs_to_gather": [ + "target change id", + "one or more shape entries", + "shape entry types (design, verification, task, reference)" + ], + "decision_rules": [ + "If no shape entries are provided, request at least one before proposing command execution.", + "Map each provided entry to the corresponding repeated CLI flag.", + "Stop after reporting shape mutation output." + ], + "workflow_steps": [ + "Collect target change id and shape entry values.", + "Build and propose runectx change shape command with explicit flags.", + "Execute or guide execution and report output fields.", + "Conclude with optional next-command recommendation only." + ], + "stop_condition": "Stop after shape output is reported for the command that was run.", + "recommended_next_commands": [ + "runectx validate --path <project-root>", + "runectx standard discover --scope-path <path>" + ], + "examples": [ + { + "scenario": "Add task and verification notes", + "user_prompt": "Add a task and verification note to CHG-2026-300-aaaa-new-flow.", + "assistant_response": "I will run `runectx change shape CHG-2026-300-aaaa-new-flow --task \"Add workflow contract generator tests\" --verification \"Run just test\"` and report the shape result." + } + ] +} diff --git a/adapters/source/shared/flows/promote.json b/adapters/source/shared/flows/promote.json new file mode 100644 index 0000000..0fcde2e --- /dev/null +++ b/adapters/source/shared/flows/promote.json @@ -0,0 +1,38 @@ +{ + "id": "promote", + "command_path": "promote", + "description": "Advance RuneContext promotion state", + "usage": "runectx promote [--target spec:<path>|standard:<path>|decision:<path>] [--path <project-root>]", + "required_outcome": "Advance promotion workflow state for the requested target set and report canonical results.", + "guardrails": [ + "Only promote explicit or discoverable targets surfaced by RuneContext workflows.", + "Do not fabricate target identifiers outside the CLI contract.", + "Stop after promote output and avoid chaining additional mutations." + ], + "inputs_to_gather": [ + "optional promotion target" + ], + "decision_rules": [ + "If no target is provided, run promote with repository default behavior.", + "If target is provided, validate its prefix format before command proposal.", + "Stop after presenting promotion results and follow-up guidance." + ], + "workflow_steps": [ + "Gather optional target input.", + "Propose and run runectx promote with explicit flags.", + "Report promotion status and any required follow-up actions.", + "Conclude without automatic secondary command execution." + ], + "stop_condition": "Stop after the immediate promote command result is emitted.", + "recommended_next_commands": [ + "runectx validate --path <project-root>", + "runectx change shape <change-id> --task \"<text>\"" + ], + "examples": [ + { + "scenario": "Promote an explicitly selected standard", + "user_prompt": "Promote standard standards/global/structured-cli-contracts.md.", + "assistant_response": "I will run `runectx promote --target standard:standards/global/structured-cli-contracts.md` and return the promote output." + } + ] +} diff --git a/adapters/source/shared/flows/standard-discover.json b/adapters/source/shared/flows/standard-discover.json new file mode 100644 index 0000000..0e268f5 --- /dev/null +++ b/adapters/source/shared/flows/standard-discover.json @@ -0,0 +1,39 @@ +{ + "id": "standard-discover", + "command_path": "standard discover", + "description": "Discover standards candidates for promotion", + "usage": "runectx standard discover --scope-path <path> [--focus <topic>] [--path <project-root>]", + "required_outcome": "Return discoverable standards candidates and promotion target fields for the requested scope.", + "guardrails": [ + "Treat discovery results as advisory candidates, not automatic promotions.", + "Use explicit scope-path input and avoid scanning hidden implicit roots.", + "Do not execute promote automatically after discovery." + ], + "inputs_to_gather": [ + "scope-path", + "optional focus" + ], + "decision_rules": [ + "Require scope-path before command proposal.", + "When focus is absent, omit the flag instead of guessing a topic.", + "Stop after reporting candidate promotion targets." + ], + "workflow_steps": [ + "Collect scope-path and optional focus.", + "Propose and run runectx standard discover.", + "Report candidate promotion targets and diagnostics.", + "End flow without chaining promote automatically." + ], + "stop_condition": "Stop after discovery output is summarized and candidate targets are listed.", + "recommended_next_commands": [ + "runectx promote --target standard:<path>", + "runectx validate --path <project-root>" + ], + "examples": [ + { + "scenario": "Discover standards in a focused area", + "user_prompt": "Discover standards for adapter workflow generation under tools/syncadapters.", + "assistant_response": "I will run `runectx standard discover --scope-path tools/syncadapters --focus adapter-workflow` and report candidate targets only." + } + ] +} diff --git a/internal/cli/cli_adapter_flow_mapping.go b/internal/cli/cli_adapter_flow_mapping.go index 0e2169a..1beb90d 100644 --- a/internal/cli/cli_adapter_flow_mapping.go +++ b/internal/cli/cli_adapter_flow_mapping.go @@ -2,14 +2,18 @@ package cli import "strings" -func flowByOperation(tool, operation string) (hostNativeFlow, bool) { +func flowByOperation(tool, operation string) (hostNativeFlow, bool, error) { operation = sanitizeHostNativeOperation(operation) - for _, flow := range toolFlowMappings(tool) { + flows, err := toolFlowMappings(tool) + if err != nil { + return hostNativeFlow{}, false, err + } + for _, flow := range flows { if flow.id == operation { - return flow, true + return flow, true, nil } } - return hostNativeFlow{}, false + return hostNativeFlow{}, false, nil } func sanitizeHostNativeOperation(operation string) string { diff --git a/internal/cli/cli_adapter_render.go b/internal/cli/cli_adapter_render.go index 850d9cd..f02dca7 100644 --- a/internal/cli/cli_adapter_render.go +++ b/internal/cli/cli_adapter_render.go @@ -28,10 +28,6 @@ func runAdapterRenderHostNative(args []string, machine machineOptions, stdout, s emitOutput(stderr, machine, appendMachineOptionLines(buildCommandUsageErrorLines(adapterRenderCommand, adapterRenderUsage, err), machine), exitUsage, failureClassUsage) return exitUsage } - if !supportsShellInjection(request.tool) { - emitOutput(stderr, machine, appendMachineOptionLines(buildCommandInvalidLines(adapterRenderCommand, "", fmt.Errorf("adapter %q does not support shell-output injection render mode", request.tool)), machine), exitInvalid, failureClassInvalid) - return exitInvalid - } rendered, err := renderHostNativeOperationMarkdown(request) if err != nil { emitOutput(stderr, machine, appendMachineOptionLines(buildCommandInvalidLines(adapterRenderCommand, "", err), machine), exitInvalid, failureClassInvalid) diff --git a/internal/cli/cli_adapter_render_host_native_markdown.go b/internal/cli/cli_adapter_render_host_native_markdown.go index 53b9c58..e5626f8 100644 --- a/internal/cli/cli_adapter_render_host_native_markdown.go +++ b/internal/cli/cli_adapter_render_host_native_markdown.go @@ -11,7 +11,10 @@ func renderHostNativeOperationMarkdown(request adapterRenderRequest) (string, er if request.operation == "index" { return renderHostNativeIndexMarkdown(request) } - flow, ok := flowByOperation(request.tool, request.operation) + flow, ok, err := flowByOperation(request.tool, request.operation) + if err != nil { + return "", err + } if !ok { return "", fmt.Errorf("adapter render-host-native operation %q not defined for tool %q", request.operation, request.tool) } @@ -21,12 +24,15 @@ func renderHostNativeOperationMarkdown(request adapterRenderRequest) (string, er } lines := []string{ "- canonical_flow_source: `" + meta.source + "`", + "- canonical_workflow_contract: `" + meta.contractSource + "`", "- adapter_role: `" + meta.role + "`", "- operation_identifier: `" + meta.operationID + "`", "- command_path: `" + meta.commandPath + "`", "- usage: `" + meta.usage + "`", + "- required_outcome: " + meta.requiredOutcome, "- interaction_rule: " + hostNativeNoQuestionRule, } + lines = appendHostNativeStructuredSections(lines, meta) if len(meta.requiredFlags) > 0 { lines = append(lines, "- required_flags: `"+strings.Join(meta.requiredFlags, " ")+"`") } @@ -36,22 +42,92 @@ func renderHostNativeOperationMarkdown(request adapterRenderRequest) (string, er return strings.Join(lines, "\n") + "\n", nil } +func appendHostNativeStructuredSections(lines []string, meta hostNativeRenderMeta) []string { + lines = appendNestedList(lines, "guardrails", meta.guardrails) + lines = appendNestedList(lines, "inputs_to_gather", meta.inputsToGather) + lines = appendNestedList(lines, "decision_rules", meta.decisionRules) + lines = appendNumberedNestedList(lines, "workflow_steps", meta.workflowSteps) + if meta.stopCondition != "" { + lines = append(lines, "- stop_condition: "+meta.stopCondition) + } + lines = appendBacktickNestedList(lines, "recommended_next_commands", meta.recommendedNextCommands) + return appendExampleList(lines, meta.examples) +} + +func appendNestedList(lines []string, key string, items []string) []string { + if len(items) == 0 { + return lines + } + lines = append(lines, "- "+key+":") + for _, item := range items { + lines = append(lines, " - "+item) + } + return lines +} + +func appendNumberedNestedList(lines []string, key string, items []string) []string { + if len(items) == 0 { + return lines + } + lines = append(lines, "- "+key+":") + for index, item := range items { + lines = append(lines, fmt.Sprintf(" %d. %s", index+1, item)) + } + return lines +} + +func appendBacktickNestedList(lines []string, key string, items []string) []string { + if len(items) == 0 { + return lines + } + lines = append(lines, "- "+key+":") + for _, item := range items { + lines = append(lines, " - `"+item+"`") + } + return lines +} + +func appendExampleList(lines []string, examples []adapterWorkflowExample) []string { + if len(examples) == 0 { + return lines + } + lines = append(lines, "- examples:") + for _, example := range examples { + lines = append(lines, " - scenario: "+example.Scenario, " user_prompt: |") + lines = appendIndentedMultiline(lines, " ", example.UserPrompt) + lines = append(lines, " assistant_response: |") + lines = appendIndentedMultiline(lines, " ", example.AssistantResponse) + } + return lines +} + +func appendIndentedMultiline(lines []string, indent string, value string) []string { + for _, part := range strings.Split(value, "\n") { + lines = append(lines, indent+part) + } + return lines +} + func renderHostNativeIndexMarkdown(request adapterRenderRequest) (string, error) { if request.role != hostNativeKindDiscoverabilityShim { return "", fmt.Errorf("adapter render-host-native index requires role %q", hostNativeKindDiscoverabilityShim) } - flows := toolFlowMappings(request.tool) + flows, err := toolFlowMappings(request.tool) + if err != nil { + return "", err + } if len(flows) == 0 { return "", fmt.Errorf("adapter render-host-native index is not defined for tool %q", request.tool) } lines := []string{ "- canonical_flow_source: `build/generated/adapters/" + request.tool + "/flows/*.md`", + "- canonical_workflow_contract: `build/generated/adapters/" + request.tool + "/workflow.json`", "- adapter_role: `" + hostNativeKindDiscoverabilityShim + "`", "- operation_identifier: `runecontext:index`", "- interaction_rule: " + hostNativeNoQuestionRule, } for _, flow := range flows { - commandPath := commandPathFromFlowID(flow.id) + commandPath := flow.commandPath command, ok := commandByPath(commandPath) if !ok { continue @@ -66,17 +142,26 @@ func renderHostNativeIndexMarkdown(request adapterRenderRequest) (string, error) } type hostNativeRenderMeta struct { - operationID string - source string - role string - commandPath string - usage string - requiredFlags []string - requiredPositionals []string + operationID string + source string + contractSource string + role string + commandPath string + usage string + requiredOutcome string + guardrails []string + inputsToGather []string + decisionRules []string + workflowSteps []string + stopCondition string + recommendedNextCommands []string + examples []adapterWorkflowExample + requiredFlags []string + requiredPositionals []string } func hostNativeRenderMetadata(request adapterRenderRequest, flow hostNativeFlow) (hostNativeRenderMeta, error) { - path := commandPathFromFlowID(flow.id) + path := flow.commandPath command, ok := commandByPath(path) if !ok { return hostNativeRenderMeta{}, fmt.Errorf("adapter render-host-native metadata missing command path %q", path) @@ -86,28 +171,25 @@ func hostNativeRenderMetadata(request adapterRenderRequest, flow hostNativeFlow) role = hostNativeKindDiscoverabilityShim } return hostNativeRenderMeta{ - operationID: "runecontext:" + flow.id, - source: flow.source, - role: role, - commandPath: path, - usage: command.Usage, - requiredFlags: requiredFlagsFromMetadata(command.Flags), - requiredPositionals: requiredPositionalsFromUsage(command.Usage, command.Positionals), + operationID: "runecontext:" + flow.id, + source: flow.source, + contractSource: "build/generated/adapters/" + request.tool + "/workflow.json", + role: role, + commandPath: path, + usage: command.Usage, + requiredOutcome: flow.requiredOutcome, + guardrails: append([]string{}, flow.guardrails...), + inputsToGather: append([]string{}, flow.inputsToGather...), + decisionRules: append([]string{}, flow.decisionRules...), + workflowSteps: append([]string{}, flow.workflowSteps...), + stopCondition: flow.stopCondition, + recommendedNextCommands: append([]string{}, flow.recommendedNextCommands...), + examples: append([]adapterWorkflowExample{}, flow.examples...), + requiredFlags: requiredFlagsFromMetadata(command.Flags), + requiredPositionals: requiredPositionalsFromUsage(command.Usage, command.Positionals), }, nil } -func commandPathFromFlowID(flowID string) string { - if strings.HasPrefix(flowID, "change-assess-") { - suffix := strings.TrimPrefix(flowID, "change-assess-") - return "change assess-" + suffix - } - if strings.HasPrefix(flowID, "change-decomposition-") { - suffix := strings.TrimPrefix(flowID, "change-decomposition-") - return "change decomposition-" + suffix - } - return strings.ReplaceAll(flowID, "-", " ") -} - func commandByPath(path string) (CommandMetadata, bool) { registry := CommandMetadataRegistry() var walk func(items []CommandMetadata) (CommandMetadata, bool) diff --git a/internal/cli/cli_adapter_render_test.go b/internal/cli/cli_adapter_render_test.go index c561227..e83dd0f 100644 --- a/internal/cli/cli_adapter_render_test.go +++ b/internal/cli/cli_adapter_render_test.go @@ -28,10 +28,16 @@ func TestRunAdapterRenderHostNativeOutputsMinimalMarkdown(t *testing.T) { text := stdout.String() for _, token := range []string{ "canonical_flow_source:", + "canonical_workflow_contract:", "adapter_role:", "operation_identifier:", "command_path:", "usage:", + "required_outcome:", + "guardrails:", + "workflow_steps:", + "stop_condition:", + "recommended_next_commands:", "interaction_rule:", hostNativeNoQuestionRule, } { @@ -57,7 +63,7 @@ func TestRunAdapterRenderHostNativeSupportsAssessmentOperations(t *testing.T) { if !strings.Contains(text, "operation_identifier: `runecontext:"+operation+"`") { t.Fatalf("expected operation identifier for %s, got %q", operation, text) } - expectedPath := commandPathFromFlowID(operation) + expectedPath := expectedCommandPathForOperation(operation) if !strings.Contains(text, "command_path: `"+expectedPath+"`") { t.Fatalf("expected command path for %s, got %q", operation, text) } @@ -65,6 +71,21 @@ func TestRunAdapterRenderHostNativeSupportsAssessmentOperations(t *testing.T) { } } +func expectedCommandPathForOperation(operation string) string { + switch operation { + case "change-assess-intake": + return "change assess-intake" + case "change-assess-decomposition": + return "change assess-decomposition" + case "change-decomposition-plan": + return "change decomposition-plan" + case "change-decomposition-apply": + return "change decomposition-apply" + default: + return strings.ReplaceAll(operation, "-", " ") + } +} + func TestRunAdapterRenderHostNativeIndexForClaude(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer @@ -96,15 +117,15 @@ func TestRunAdapterRenderHostNativeIndexForClaude(t *testing.T) { } } -func TestRunAdapterRenderHostNativeRejectsUnsupportedTool(t *testing.T) { +func TestRunAdapterRenderHostNativeSupportsCodexRender(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := Run([]string{"adapter", "render-host-native", "codex", "change-new"}, &stdout, &stderr) - if code != exitInvalid { - t.Fatalf("expected invalid exit code, got %d (%s)", code, stderr.String()) + if code != exitOK { + t.Fatalf("expected success exit code, got %d (%s)", code, stderr.String()) } - if !strings.Contains(stderr.String(), "does not support shell-output injection") { - t.Fatalf("expected unsupported shell injection error, got %q", stderr.String()) + if !strings.Contains(stdout.String(), "operation_identifier: `runecontext:change-new`") { + t.Fatalf("expected codex flow output, got %q", stdout.String()) } } @@ -116,7 +137,7 @@ func TestRunAdapterRenderHostNativeJSONOutput(t *testing.T) { t.Fatalf("expected success exit code, got %d (%s)", code, stderr.String()) } text := stdout.String() - for _, token := range []string{"\"command\":\"adapter_render_host_native\"", "\"adapter\":\"opencode\"", "\"operation\":\"change-new\"", "\"body\":\"- canonical_flow_source:"} { + for _, token := range []string{"\"command\":\"adapter_render_host_native\"", "\"adapter\":\"opencode\"", "\"operation\":\"change-new\"", "\"body\":\"- canonical_flow_source:", "canonical_workflow_contract"} { if !strings.Contains(text, token) { t.Fatalf("expected token %q in JSON output, got %q", token, text) } diff --git a/internal/cli/cli_adapter_sync_discovery.go b/internal/cli/cli_adapter_sync_discovery.go index 0e46e1e..20adeb1 100644 --- a/internal/cli/cli_adapter_sync_discovery.go +++ b/internal/cli/cli_adapter_sync_discovery.go @@ -14,17 +14,36 @@ func locateAdaptersRoot() (string, error) { projectRoot := filepath.Dir(schemaRoot) candidates := []string{ filepath.Join(projectRoot, "build", "generated", "adapters"), - filepath.Join(schemaRoot, "adapters"), filepath.Join(projectRoot, "adapters"), } for _, candidate := range candidates { - if isDirectory(candidate) { + if isAdapterPackRoot(candidate) { return candidate, nil } } return "", fmt.Errorf("could not locate installed adapter packs") } +func isAdapterPackRoot(path string) bool { + if !isDirectory(path) { + return false + } + entries, err := os.ReadDir(path) + if err != nil { + return false + } + for _, entry := range entries { + if !entry.IsDir() || entry.Name() == "source" { + continue + } + workflowPath := filepath.Join(path, entry.Name(), "workflow.json") + if fileExists(workflowPath) { + return true + } + } + return false +} + func isDirectory(path string) bool { info, err := os.Stat(path) if err != nil { @@ -32,3 +51,11 @@ func isDirectory(path string) bool { } return info.IsDir() } + +func fileExists(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + return !info.IsDir() +} diff --git a/internal/cli/cli_adapter_sync_host_native.go b/internal/cli/cli_adapter_sync_host_native.go index f3c7fc7..3125e2f 100644 --- a/internal/cli/cli_adapter_sync_host_native.go +++ b/internal/cli/cli_adapter_sync_host_native.go @@ -20,20 +20,30 @@ type hostNativeArtifact struct { } type hostNativeFlow struct { - id string - name string - description string - source string + id string + name string + description string + source string + commandPath string + usage string + requiredOutcome string + guardrails []string + inputsToGather []string + decisionRules []string + workflowSteps []string + stopCondition string + recommendedNextCommands []string + examples []adapterWorkflowExample } func buildHostNativeArtifacts(tool string) ([]hostNativeArtifact, error) { switch tool { case "opencode": - return buildOpenCodeHostNativeArtifacts(), nil + return buildOpenCodeHostNativeArtifacts() case "claude-code": - return buildClaudeCodeHostNativeArtifacts(), nil + return buildClaudeCodeHostNativeArtifacts() case "codex": - return buildCodexHostNativeArtifacts(), nil + return buildCodexHostNativeArtifacts() case "generic": return nil, nil default: @@ -41,8 +51,11 @@ func buildHostNativeArtifacts(tool string) ([]hostNativeArtifact, error) { } } -func buildOpenCodeHostNativeArtifacts() []hostNativeArtifact { - flows := toolFlowMappings("opencode") +func buildOpenCodeHostNativeArtifacts() ([]hostNativeArtifact, error) { + flows, err := toolFlowMappings("opencode") + if err != nil { + return nil, err + } artifacts := make([]hostNativeArtifact, 0, len(flows)*2) for _, flow := range flows { name := "runecontext-" + flow.id + ".md" @@ -66,11 +79,14 @@ func buildOpenCodeHostNativeArtifacts() []hostNativeArtifact { ) } sortHostNativeArtifacts(artifacts) - return artifacts + return artifacts, nil } -func buildClaudeCodeHostNativeArtifacts() []hostNativeArtifact { - flows := toolFlowMappings("claude-code") +func buildClaudeCodeHostNativeArtifacts() ([]hostNativeArtifact, error) { + flows, err := toolFlowMappings("claude-code") + if err != nil { + return nil, err + } artifacts := make([]hostNativeArtifact, 0, len(flows)+1) for _, flow := range flows { name := "runecontext-" + flow.id + ".md" @@ -92,11 +108,14 @@ func buildClaudeCodeHostNativeArtifacts() []hostNativeArtifact { content: buildClaudeCommandIndexShimContent(flows), }) sortHostNativeArtifacts(artifacts) - return artifacts + return artifacts, nil } -func buildCodexHostNativeArtifacts() []hostNativeArtifact { - flows := toolFlowMappings("codex") +func buildCodexHostNativeArtifacts() ([]hostNativeArtifact, error) { + flows, err := toolFlowMappings("codex") + if err != nil { + return nil, err + } artifacts := make([]hostNativeArtifact, 0, len(flows)) for _, flow := range flows { name := "runecontext-" + flow.id + ".md" @@ -110,7 +129,7 @@ func buildCodexHostNativeArtifacts() []hostNativeArtifact { }) } sortHostNativeArtifacts(artifacts) - return artifacts + return artifacts, nil } func sortHostNativeArtifacts(artifacts []hostNativeArtifact) { @@ -164,7 +183,7 @@ func buildClaudeCommandIndexShimContent(flows []hostNativeFlow) []byte { "## Commands", }...) for _, flow := range flows { - commandPath := commandPathFromFlowID(flow.id) + commandPath := flow.commandPath lines = append(lines, "", "- `runecontext:"+flow.id+"`", diff --git a/internal/cli/cli_adapter_sync_host_native_flows.go b/internal/cli/cli_adapter_sync_host_native_flows.go index d36e426..de29353 100644 --- a/internal/cli/cli_adapter_sync_host_native_flows.go +++ b/internal/cli/cli_adapter_sync_host_native_flows.go @@ -1,23 +1,113 @@ package cli -func toolFlowMappings(tool string) []hostNativeFlow { - return []hostNativeFlow{ - flowForTool(tool, "change-new", "change new", "Create a new RuneContext change"), - flowForTool(tool, "change-assess-intake", "change assess-intake", "Assess intake readiness and advisory shaping signals"), - flowForTool(tool, "change-assess-decomposition", "change assess-decomposition", "Assess decomposition and umbrella/sub-change signals"), - flowForTool(tool, "change-decomposition-plan", "change decomposition-plan", "Plan umbrella and sub-change graph rewrites"), - flowForTool(tool, "change-decomposition-apply", "change decomposition-apply", "Apply umbrella and sub-change graph rewrites"), - flowForTool(tool, "change-shape", "change shape", "Shape an existing RuneContext change"), - flowForTool(tool, "standard-discover", "standard discover", "Discover standards candidates for promotion"), - flowForTool(tool, "promote", "promote", "Advance RuneContext promotion state"), +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" +) + +type adapterWorkflowDocument struct { + SchemaVersion string `json:"schema_version"` + Adapter string `json:"adapter"` + DisplayName string `json:"display_name"` + FlowIntro string `json:"flow_intro"` + Flows []adapterWorkflowFlow `json:"flows"` +} + +type adapterWorkflowFlow struct { + ID string `json:"id"` + CommandPath string `json:"command_path"` + Description string `json:"description"` + Usage string `json:"usage"` + RequiredOutcome string `json:"required_outcome"` + Guardrails []string `json:"guardrails"` + InputsToGather []string `json:"inputs_to_gather"` + DecisionRules []string `json:"decision_rules"` + WorkflowSteps []string `json:"workflow_steps"` + StopCondition string `json:"stop_condition"` + RecommendedNextCommands []string `json:"recommended_next_commands"` + Examples []adapterWorkflowExample `json:"examples"` +} + +type adapterWorkflowExample struct { + Scenario string `json:"scenario"` + UserPrompt string `json:"user_prompt"` + AssistantResponse string `json:"assistant_response"` +} + +func toolFlowMappings(tool string) ([]hostNativeFlow, error) { + doc, err := loadWorkflowDocument(tool) + if err != nil { + return nil, err + } + flows := make([]hostNativeFlow, 0, len(doc.Flows)) + for _, flow := range doc.Flows { + flows = append(flows, hostNativeFlow{ + id: flow.ID, + name: flow.CommandPath, + description: flow.Description, + source: workflowMarkdownSource(tool, flow.ID), + commandPath: flow.CommandPath, + usage: flow.Usage, + requiredOutcome: flow.RequiredOutcome, + guardrails: append([]string{}, flow.Guardrails...), + inputsToGather: append([]string{}, flow.InputsToGather...), + decisionRules: append([]string{}, flow.DecisionRules...), + workflowSteps: append([]string{}, flow.WorkflowSteps...), + stopCondition: flow.StopCondition, + recommendedNextCommands: append([]string{}, flow.RecommendedNextCommands...), + examples: append([]adapterWorkflowExample{}, flow.Examples...), + }) } + sort.Slice(flows, func(i, j int) bool { return flows[i].id < flows[j].id }) + return flows, nil } -func flowForTool(tool, id, name, description string) hostNativeFlow { - return hostNativeFlow{ - id: id, - name: name, - description: description, - source: "build/generated/adapters/" + tool + "/flows/" + id + ".md", +func loadWorkflowDocument(tool string) (adapterWorkflowDocument, error) { + adaptersRoot, err := locateAdaptersRoot() + if err != nil { + return adapterWorkflowDocument{}, err + } + path := filepath.Join(adaptersRoot, tool, "workflow.json") + raw, err := os.ReadFile(path) + if err != nil { + return adapterWorkflowDocument{}, fmt.Errorf("read adapter workflow contract %q: %w", path, err) + } + var doc adapterWorkflowDocument + if err := json.Unmarshal(raw, &doc); err != nil { + return adapterWorkflowDocument{}, fmt.Errorf("decode adapter workflow contract %q: %w", path, err) + } + if err := validateWorkflowDocument(tool, doc); err != nil { + return adapterWorkflowDocument{}, fmt.Errorf("invalid adapter workflow contract %q: %w", path, err) + } + return doc, nil +} + +func workflowMarkdownSource(tool, flowID string) string { + return "build/generated/adapters/" + tool + "/flows/" + flowID + ".md" +} + +func validateWorkflowDocument(tool string, doc adapterWorkflowDocument) error { + if doc.SchemaVersion == "" { + return fmt.Errorf("schema_version is required") + } + if doc.Adapter != tool { + return fmt.Errorf("adapter mismatch: expected %q, got %q", tool, doc.Adapter) + } + if len(doc.Flows) == 0 { + return fmt.Errorf("flows are required") + } + seen := make(map[string]struct{}, len(doc.Flows)) + for _, flow := range doc.Flows { + if flow.ID == "" { + return fmt.Errorf("flow id is required") + } + if _, ok := seen[flow.ID]; ok { + return fmt.Errorf("duplicate flow id %q", flow.ID) + } + seen[flow.ID] = struct{}{} } + return nil } diff --git a/internal/cli/cli_release_layout_test.go b/internal/cli/cli_release_layout_test.go index d8b544c..221a65c 100644 --- a/internal/cli/cli_release_layout_test.go +++ b/internal/cli/cli_release_layout_test.go @@ -31,6 +31,7 @@ func TestLocateAdaptersRootFromReleaseStyleLayout(t *testing.T) { if err := os.MkdirAll(stagedAdaptersDir, 0o755); err != nil { t.Fatalf("mkdir staged adapters dir: %v", err) } + seedAdapterPackForDiscovery(t, stagedAdaptersDir, "opencode") originalWD, err := os.Getwd() if err != nil { @@ -58,6 +59,42 @@ func TestLocateAdaptersRootFromReleaseStyleLayout(t *testing.T) { } } +func seedAdapterPackForDiscovery(t *testing.T, adaptersDir, tool string) { + t.Helper() + toolDir := filepath.Join(adaptersDir, tool) + if err := os.MkdirAll(toolDir, 0o755); err != nil { + t.Fatalf("mkdir adapter pack dir: %v", err) + } + if err := os.WriteFile(filepath.Join(toolDir, "workflow.json"), []byte("{}\n"), 0o644); err != nil { + t.Fatalf("write workflow contract: %v", err) + } +} + +func TestLocateAdaptersRootRejectsRepositoryAdaptersFallback(t *testing.T) { + root := t.TempDir() + schemaDir := filepath.Join(root, "schemas") + repoAdaptersDir := filepath.Join(root, "adapters") + seedReleaseStyleLayout(t, schemaDir, repoAdaptersDir) + seedAdapterPackForDiscovery(t, repoAdaptersDir, "opencode") + + originalWD, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(originalWD) }) + if err := os.Chdir(root); err != nil { + t.Fatalf("chdir test root: %v", err) + } + + if err := os.Remove(filepath.Join(repoAdaptersDir, "opencode", "workflow.json")); err != nil { + t.Fatalf("remove fallback workflow contract: %v", err) + } + + if _, err := locateAdaptersRoot(); err == nil { + t.Fatal("expected adapters root discovery to reject adapter roots without generated workflow contracts") + } +} + func TestLocateSchemaRootFromInstalledShareLayout(t *testing.T) { installRoot := t.TempDir() schemaDir := filepath.Join(installRoot, "share", "runecontext", "schemas") diff --git a/runecontext/changes/CHG-2026-002-5739-add-guided-adapter-native-change-workflows/status.yaml b/runecontext/changes/CHG-2026-002-5739-add-guided-adapter-native-change-workflows/status.yaml index c361f54..133cc81 100644 --- a/runecontext/changes/CHG-2026-002-5739-add-guided-adapter-native-change-workflows/status.yaml +++ b/runecontext/changes/CHG-2026-002-5739-add-guided-adapter-native-change-workflows/status.yaml @@ -17,6 +17,7 @@ related_changes: - CHG-2026-026-041d-add-canonical-adapter-source-generation-and-rendering-pipeline - CHG-2026-027-762d-stage-generated-adapter-packs-in-tests-and-release-builds - CHG-2026-028-1c18-stop-committing-rendered-adapter-packs-and-adopt-build-generated-outputs + - CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts depends_on: [] informed_by: [] supersedes: [] diff --git a/runecontext/changes/CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts/status.yaml b/runecontext/changes/CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts/status.yaml index 0398667..4d86d91 100644 --- a/runecontext/changes/CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts/status.yaml +++ b/runecontext/changes/CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts/status.yaml @@ -1,14 +1,15 @@ schema_version: 1 id: CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts title: Add generated structured adapter workflow contracts -status: proposed +status: implemented type: feature size: medium verification_status: pending context_bundles: [] related_specs: [] related_decisions: [] -related_changes: [] +related_changes: + - CHG-2026-002-5739-add-guided-adapter-native-change-workflows depends_on: [] informed_by: [] supersedes: [] diff --git a/tools/syncadapters/model.go b/tools/syncadapters/model.go index 4360787..1e2cc1c 100644 --- a/tools/syncadapters/model.go +++ b/tools/syncadapters/model.go @@ -11,10 +11,24 @@ import ( ) type flowDefinition struct { - ID string `json:"id"` - CommandPath string `json:"command_path"` - Description string `json:"description"` - Usage string `json:"usage"` + ID string `json:"id"` + CommandPath string `json:"command_path"` + Description string `json:"description"` + Usage string `json:"usage"` + RequiredOutcome string `json:"required_outcome"` + Guardrails []string `json:"guardrails"` + InputsToGather []string `json:"inputs_to_gather"` + DecisionRules []string `json:"decision_rules"` + WorkflowSteps []string `json:"workflow_steps"` + StopCondition string `json:"stop_condition"` + RecommendedNextCommands []string `json:"recommended_next_commands"` + Examples []flowExample `json:"examples"` +} + +type flowExample struct { + Scenario string `json:"scenario"` + UserPrompt string `json:"user_prompt"` + AssistantResponse string `json:"assistant_response"` } type toolDefinition struct { @@ -24,15 +38,29 @@ type toolDefinition struct { } func loadFlowDefinitions(root string) ([]flowDefinition, error) { - path := filepath.Join(root, "adapters", "source", "shared", "flows.json") - var defs []flowDefinition - if err := loadJSON(path, &defs); err != nil { - return nil, err - } - for i := range defs { - if err := validateFlowDefinition(defs[i]); err != nil { - return nil, fmt.Errorf("invalid flow definition %q: %w", defs[i].ID, err) + pattern := filepath.Join(root, "adapters", "source", "shared", "flows", "*.json") + files, err := filepath.Glob(pattern) + if err != nil { + return nil, fmt.Errorf("list flow definitions: %w", err) + } + if len(files) == 0 { + return nil, errors.New("no adapter source flow definitions found") + } + defs := make([]flowDefinition, 0, len(files)) + seen := make(map[string]struct{}, len(files)) + for _, path := range files { + var def flowDefinition + if err := loadJSON(path, &def); err != nil { + return nil, err } + if err := validateFlowDefinition(def); err != nil { + return nil, fmt.Errorf("invalid flow definition %q: %w", path, err) + } + if _, ok := seen[def.ID]; ok { + return nil, fmt.Errorf("duplicate flow definition id %q", def.ID) + } + seen[def.ID] = struct{}{} + defs = append(defs, def) } sort.Slice(defs, func(i, j int) bool { return defs[i].ID < defs[j].ID }) return defs, nil @@ -42,14 +70,87 @@ func validateFlowDefinition(def flowDefinition) error { if strings.TrimSpace(def.ID) == "" { return errors.New("id is required") } - if strings.TrimSpace(def.CommandPath) == "" { - return errors.New("command_path is required") + if err := validateFlowRequiredStrings(def); err != nil { + return err + } + if err := validateFlowLists(def); err != nil { + return err + } + return validateFlowExamples(def.Examples) +} + +func validateFlowRequiredStrings(def flowDefinition) error { + checks := []struct { + name string + value string + }{ + {name: "command_path", value: def.CommandPath}, + {name: "description", value: def.Description}, + {name: "usage", value: def.Usage}, + {name: "required_outcome", value: def.RequiredOutcome}, + {name: "stop_condition", value: def.StopCondition}, + } + for _, check := range checks { + if strings.TrimSpace(check.value) == "" { + return fmt.Errorf("%s is required", check.name) + } + } + return nil +} + +func validateFlowLists(def flowDefinition) error { + listChecks := []struct { + name string + values []string + }{ + {name: "guardrails", values: def.Guardrails}, + {name: "inputs_to_gather", values: def.InputsToGather}, + {name: "decision_rules", values: def.DecisionRules}, + {name: "workflow_steps", values: def.WorkflowSteps}, + {name: "recommended_next_commands", values: def.RecommendedNextCommands}, + } + for _, check := range listChecks { + if err := validateNonEmptyList(check.name, check.values); err != nil { + return err + } + } + return nil +} + +func validateFlowExamples(examples []flowExample) error { + if len(examples) == 0 { + return errors.New("examples are required") + } + for i := range examples { + if err := validateFlowExample(examples[i], i); err != nil { + return err + } + } + return nil +} + +func validateNonEmptyList(name string, values []string) error { + if len(values) == 0 { + return fmt.Errorf("%s is required", name) + } + for _, value := range values { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("%s must not contain empty entries", name) + } + } + return nil +} + +func validateFlowExample(example flowExample, index int) error { + prefix := fmt.Sprintf("examples[%d]", index) + if strings.TrimSpace(example.Scenario) == "" { + return fmt.Errorf("%s.scenario is required", prefix) } - if strings.TrimSpace(def.Description) == "" { - return errors.New("description is required") + if strings.TrimSpace(example.UserPrompt) == "" { + return fmt.Errorf("%s.user_prompt is required", prefix) } - if strings.TrimSpace(def.Usage) == "" { - return errors.New("usage is required") + if strings.TrimSpace(example.AssistantResponse) == "" { + return fmt.Errorf("%s.assistant_response is required", prefix) } return nil } diff --git a/tools/syncadapters/render.go b/tools/syncadapters/render.go index 7353d71..b52a9b9 100644 --- a/tools/syncadapters/render.go +++ b/tools/syncadapters/render.go @@ -142,7 +142,10 @@ func renderTool(root, output, toolID string, flows []flowDefinition, tool toolDe if err := writeCapabilities(targetDir, toolID, tool); err != nil { return err } - return writeFlows(targetDir, tool, flows) + if err := writeFlows(targetDir, tool, flows); err != nil { + return err + } + return writeWorkflowContract(targetDir, toolID, tool, flows) } func generatedExcludes(flows []flowDefinition) map[string]struct{} { @@ -152,6 +155,7 @@ func generatedExcludes(flows []flowDefinition) map[string]struct{} { for _, flow := range flows { excludes[filepath.ToSlash(filepath.Join(generatedFlowsDir, flow.ID+".md"))] = struct{}{} } + excludes["workflow.json"] = struct{}{} return excludes } @@ -211,39 +215,3 @@ func writeCapabilities(targetDir, toolID string, tool toolDefinition) error { } return os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644) } - -func writeFlows(targetDir string, tool toolDefinition, flows []flowDefinition) error { - flowsDir := filepath.Join(targetDir, generatedFlowsDir) - if err := os.MkdirAll(flowsDir, 0o755); err != nil { - return fmt.Errorf("create flows dir: %w", err) - } - for _, flow := range flows { - if err := writeFlowFile(flowsDir, tool, flow); err != nil { - return err - } - } - return nil -} - -func writeFlowFile(flowsDir string, tool toolDefinition, flow flowDefinition) error { - path := filepath.Join(flowsDir, flow.ID+".md") - lines := []string{ - "# " + tool.DisplayName + " Flow: " + flow.CommandPath, - "", - tool.FlowIntro, - "", - "## Intent", - "", - "- " + flow.Description, - "", - "## Command Mapping", - "", - "```sh", - flow.Usage, - "```", - } - if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil { - return fmt.Errorf("write flow %q: %w", flow.ID, err) - } - return nil -} diff --git a/tools/syncadapters/render_flows.go b/tools/syncadapters/render_flows.go new file mode 100644 index 0000000..9a4a3d6 --- /dev/null +++ b/tools/syncadapters/render_flows.go @@ -0,0 +1,131 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +func writeFlows(targetDir string, tool toolDefinition, flows []flowDefinition) error { + flowsDir := filepath.Join(targetDir, generatedFlowsDir) + if err := os.MkdirAll(flowsDir, 0o755); err != nil { + return fmt.Errorf("create flows dir: %w", err) + } + for _, flow := range flows { + if err := writeFlowFile(flowsDir, tool, flow); err != nil { + return err + } + } + return nil +} + +func writeFlowFile(flowsDir string, tool toolDefinition, flow flowDefinition) error { + path := filepath.Join(flowsDir, flow.ID+".md") + lines := buildFlowHeaderLines(tool, flow) + lines = appendFlowStructuredSections(lines, flow) + lines = append(lines, + "", + "## Intent", + "", + "- "+flow.Description, + "", + "## Command Mapping", + "", + "```sh", + flow.Usage, + "```", + "", + "## Examples", + ) + lines = appendFlowExampleSections(lines, flow.Examples) + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil { + return fmt.Errorf("write flow %q: %w", flow.ID, err) + } + return nil +} + +func buildFlowHeaderLines(tool toolDefinition, flow flowDefinition) []string { + return []string{ + "# " + tool.DisplayName + " Flow: " + flow.CommandPath, + "", + tool.FlowIntro, + "", + } +} + +func appendFlowStructuredSections(lines []string, flow flowDefinition) []string { + lines = append(lines, + "## Required Outcome", + "", + "- "+flow.RequiredOutcome, + "", + "## Guardrails", + "", + ) + lines = appendBulletLines(lines, flow.Guardrails) + lines = append(lines, + "", + "## Inputs To Gather", + "", + ) + lines = appendBulletLines(lines, flow.InputsToGather) + lines = append(lines, + "", + "## Decision Rules", + "", + ) + lines = appendBulletLines(lines, flow.DecisionRules) + lines = append(lines, + "", + "## Workflow Steps", + "", + ) + lines = appendNumberedLines(lines, flow.WorkflowSteps) + lines = append(lines, + "", + "## Stop Condition", + "", + "- "+flow.StopCondition, + "", + "## Recommended Next Commands", + "", + ) + return appendBulletLines(lines, flow.RecommendedNextCommands) +} + +func appendFlowExampleSections(lines []string, examples []flowExample) []string { + for _, example := range examples { + lines = append(lines, + "", + "### "+example.Scenario, + "", + "User prompt:", + "", + "```text", + example.UserPrompt, + "```", + "", + "Assistant response:", + "", + "```text", + example.AssistantResponse, + "```", + ) + } + return lines +} + +func appendBulletLines(lines []string, values []string) []string { + for _, value := range values { + lines = append(lines, "- "+value) + } + return lines +} + +func appendNumberedLines(lines []string, values []string) []string { + for i, value := range values { + lines = append(lines, fmt.Sprintf("%d. %s", i+1, value)) + } + return lines +} diff --git a/tools/syncadapters/render_test.go b/tools/syncadapters/render_test.go index 667831d..378aef3 100644 --- a/tools/syncadapters/render_test.go +++ b/tools/syncadapters/render_test.go @@ -1,8 +1,10 @@ package main import ( + "encoding/json" "os" "path/filepath" + "strings" "testing" ) @@ -54,3 +56,78 @@ func assertRepositoryDescendantOutput(t *testing.T, root, output string) { } }) } + +func TestRunGeneratesStructuredFlowContracts(t *testing.T) { + root := repoRootForSyncAdaptersTests(t) + output := filepath.Join(root, "build", "generated", "adapters-test", t.Name()) + t.Cleanup(func() { + _ = os.RemoveAll(filepath.Join(root, "build", "generated", "adapters-test")) + }) + if err := run(root, output); err != nil { + t.Fatalf("run syncadapters: %v", err) + } + + assertWorkflowContractGenerated(t, output) + assertFlowMarkdownSections(t, output) +} + +func assertWorkflowContractGenerated(t *testing.T, output string) { + t.Helper() + workflowPath := filepath.Join(output, "opencode", "workflow.json") + raw, err := os.ReadFile(workflowPath) + if err != nil { + t.Fatalf("read workflow contract: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(raw, &payload); err != nil { + t.Fatalf("decode workflow contract: %v", err) + } + if payload["schema_version"] != "1" { + t.Fatalf("expected schema_version 1, got %#v", payload["schema_version"]) + } + if payload["adapter"] != "opencode" { + t.Fatalf("expected adapter opencode, got %#v", payload["adapter"]) + } +} + +func assertFlowMarkdownSections(t *testing.T, output string) { + t.Helper() + flowPath := filepath.Join(output, "opencode", "flows", "change-new.md") + content, err := os.ReadFile(flowPath) + if err != nil { + t.Fatalf("read generated flow markdown: %v", err) + } + text := string(content) + for _, section := range []string{ + "## Required Outcome", + "## Guardrails", + "## Inputs To Gather", + "## Decision Rules", + "## Workflow Steps", + "## Stop Condition", + "## Recommended Next Commands", + "## Examples", + } { + if !strings.Contains(text, section) { + t.Fatalf("expected section %q in generated flow markdown", section) + } + } +} + +func repoRootForSyncAdaptersTests(t *testing.T) string { + t.Helper() + wd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + for { + if _, err := os.Stat(filepath.Join(wd, "go.mod")); err == nil { + return wd + } + next := filepath.Dir(wd) + if next == wd { + t.Fatal("could not locate repository root") + } + wd = next + } +} diff --git a/tools/syncadapters/render_workflow_contract.go b/tools/syncadapters/render_workflow_contract.go new file mode 100644 index 0000000..28b8a4e --- /dev/null +++ b/tools/syncadapters/render_workflow_contract.go @@ -0,0 +1,63 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +type generatedWorkflowContract struct { + SchemaVersion string `json:"schema_version"` + Adapter string `json:"adapter"` + DisplayName string `json:"display_name"` + FlowIntro string `json:"flow_intro"` + Flows []generatedWorkflowFlow `json:"flows"` +} + +type generatedWorkflowFlow struct { + ID string `json:"id"` + CommandPath string `json:"command_path"` + Description string `json:"description"` + Usage string `json:"usage"` + RequiredOutcome string `json:"required_outcome"` + Guardrails []string `json:"guardrails"` + InputsToGather []string `json:"inputs_to_gather"` + DecisionRules []string `json:"decision_rules"` + WorkflowSteps []string `json:"workflow_steps"` + StopCondition string `json:"stop_condition"` + RecommendedNextCommands []string `json:"recommended_next_commands"` + Examples []flowExample `json:"examples"` +} + +func writeWorkflowContract(targetDir, toolID string, tool toolDefinition, flows []flowDefinition) error { + path := filepath.Join(targetDir, "workflow.json") + contract := generatedWorkflowContract{ + SchemaVersion: "1", + Adapter: toolID, + DisplayName: tool.DisplayName, + FlowIntro: tool.FlowIntro, + Flows: make([]generatedWorkflowFlow, 0, len(flows)), + } + for _, flow := range flows { + contract.Flows = append(contract.Flows, generatedWorkflowFlow{ + ID: flow.ID, + CommandPath: flow.CommandPath, + Description: flow.Description, + Usage: flow.Usage, + RequiredOutcome: flow.RequiredOutcome, + Guardrails: append([]string{}, flow.Guardrails...), + InputsToGather: append([]string{}, flow.InputsToGather...), + DecisionRules: append([]string{}, flow.DecisionRules...), + WorkflowSteps: append([]string{}, flow.WorkflowSteps...), + StopCondition: flow.StopCondition, + RecommendedNextCommands: append([]string{}, flow.RecommendedNextCommands...), + Examples: append([]flowExample{}, flow.Examples...), + }) + } + raw, err := json.MarshalIndent(contract, "", " ") + if err != nil { + return fmt.Errorf("encode workflow contract: %w", err) + } + return os.WriteFile(path, append(raw, '\n'), 0o644) +} From d007c9f23054d46e7f24f4363e2660ea09c0dbd8 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich <zeb@runecode.org> Date: Wed, 1 Apr 2026 18:46:50 -0500 Subject: [PATCH 18/27] Updates based on PR comments. Signed-off-by: Zebulon Rouse-Frantzich <zeb@runecode.org> --- .../cli/cli_change_decomposition_flow_test.go | 1 + .../cli/cli_parse_change_decomposition.go | 9 ++++++-- .../contracts/standard_commands_create.go | 5 ++++- .../contracts/standard_commands_helpers.go | 8 +++++++ internal/contracts/standard_commands_test.go | 21 +++++++++++++++++++ .../contracts/standard_commands_update.go | 2 +- 6 files changed, 42 insertions(+), 4 deletions(-) diff --git a/internal/cli/cli_change_decomposition_flow_test.go b/internal/cli/cli_change_decomposition_flow_test.go index 1405e9c..6f95970 100644 --- a/internal/cli/cli_change_decomposition_flow_test.go +++ b/internal/cli/cli_change_decomposition_flow_test.go @@ -137,6 +137,7 @@ func TestRunChangeDecompositionUsageErrors(t *testing.T) { {name: "missing umbrella", args: []string{"change", "decomposition-plan"}, message: "requires exactly one umbrella change ID"}, {name: "plan dry-run", args: []string{"change", "decomposition-plan", "CHG-2026-001-a3f2-auth-gateway", "--dry-run"}, message: "--dry-run is not supported"}, {name: "malformed dependency", args: []string{"change", "decomposition-plan", "CHG-2026-001-a3f2-auth-gateway", "--sub-change", "CHG-2026-002-b4c3-api", "--depends-on", "CHG-2026-002-b4c3-api"}, message: "--depends-on must use SUB_CHANGE_ID:CHANGE_ID"}, + {name: "depends-on before sub-change", args: []string{"change", "decomposition-plan", "CHG-2026-001-a3f2-auth-gateway", "--depends-on", "CHG-2026-002-b4c3-api:CHG-2026-003-c5d4-ui", "--sub-change", "CHG-2026-002-b4c3-api"}, message: "before it is declared with --sub-change"}, {name: "missing sub-change", args: []string{"change", "decomposition-apply", "CHG-2026-001-a3f2-auth-gateway"}, message: "requires at least one --sub-change ID"}, {name: "unknown apply flag", args: []string{"change", "decomposition-apply", "CHG-2026-001-a3f2-auth-gateway", "--sub-change", "CHG-2026-002-b4c3-api", "--recursive"}, message: "unknown change decomposition-apply flag"}, } { diff --git a/internal/cli/cli_parse_change_decomposition.go b/internal/cli/cli_parse_change_decomposition.go index 9edadd7..9114fa2 100644 --- a/internal/cli/cli_parse_change_decomposition.go +++ b/internal/cli/cli_parse_change_decomposition.go @@ -10,6 +10,7 @@ import ( type pendingSubChangeCLI struct { id string dependsOn []string + explicit bool } func parseChangeDecompositionArgs(args []string, commandLabel string) (changeDecompositionRequest, error) { @@ -64,7 +65,8 @@ func handleSubChangeFlag(args []string, flag parsedFlag, commandLabel string, pe if id == "" { return flag.next, fmt.Errorf("%s requires non-empty --sub-change value", commandLabel) } - ensurePendingSubChange(id, pending, order) + node := ensurePendingSubChange(id, pending, order) + node.explicit = true return next, nil } @@ -77,7 +79,10 @@ func handleDependsOnFlag(args []string, flag parsedFlag, commandLabel string, pe if err != nil { return flag.next, err } - node := ensurePendingSubChange(subID, pending, order) + node, ok := pending[subID] + if !ok || !node.explicit { + return flag.next, fmt.Errorf("%s --depends-on references sub-change %q before it is declared with --sub-change", commandLabel, subID) + } node.dependsOn = append(node.dependsOn, depID) return next, nil } diff --git a/internal/contracts/standard_commands_create.go b/internal/contracts/standard_commands_create.go index 561de35..9d1ccb5 100644 --- a/internal/contracts/standard_commands_create.go +++ b/internal/contracts/standard_commands_create.go @@ -53,7 +53,10 @@ func prepareCreateStandardOptions(options StandardCreateOptions) (preparedCreate if id == "" { return preparedCreateStandardOptions{}, fmt.Errorf("standard create could not infer id from %q", path) } - replacedBy := strings.TrimSpace(options.ReplacedBy) + replacedBy, err := normalizeOptionalStandardReplacement(options.ReplacedBy) + if err != nil { + return preparedCreateStandardOptions{}, err + } if err := validateStandardReplacementFields(status, replacedBy); err != nil { return preparedCreateStandardOptions{}, err } diff --git a/internal/contracts/standard_commands_helpers.go b/internal/contracts/standard_commands_helpers.go index 40cbb44..cb44c62 100644 --- a/internal/contracts/standard_commands_helpers.go +++ b/internal/contracts/standard_commands_helpers.go @@ -151,6 +151,14 @@ func validateStandardReplacementFields(status StandardStatus, replacedBy string) return nil } +func normalizeOptionalStandardReplacement(value string) (string, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "", nil + } + return normalizeStandardArtifactPath(trimmed) +} + func standardIDFromPath(path string) string { if !strings.HasPrefix(path, "standards/") || !strings.HasSuffix(path, ".md") { return "" diff --git a/internal/contracts/standard_commands_test.go b/internal/contracts/standard_commands_test.go index f18aa0f..e9e788e 100644 --- a/internal/contracts/standard_commands_test.go +++ b/internal/contracts/standard_commands_test.go @@ -119,3 +119,24 @@ func TestUpdateStandardRejectsReplacedByWithoutDeprecatedStatus(t *testing.T) { t.Fatalf("expected replaced_by status rejection, got %v", err) } } + +func TestCreateStandardNormalizesReplacedByBeforeValidation(t *testing.T) { + root := copyChangeWorkflowTemplate(t) + v, loaded := mustLoadWorkflowProject(t, root) + defer loaded.Close() + + result, err := CreateStandard(v, loaded, StandardCreateOptions{ + Path: "custom/deprecated-auth", + Title: "Deprecated Auth Standard", + Status: StandardStatusDeprecated, + ReplacedBy: "global/base", + }) + if err != nil { + t.Fatalf("create standard with replacement shorthand: %v", err) + } + path := filepath.Join(root, "runecontext", filepath.FromSlash(result.Path)) + text := strings.ReplaceAll(string(mustReadBytes(t, path)), "\r\n", "\n") + if !strings.Contains(text, "replaced_by: standards/global/base.md") { + t.Fatalf("expected normalized replaced_by path, got:\n%s", text) + } +} diff --git a/internal/contracts/standard_commands_update.go b/internal/contracts/standard_commands_update.go index 5309ad0..70c767e 100644 --- a/internal/contracts/standard_commands_update.go +++ b/internal/contracts/standard_commands_update.go @@ -156,7 +156,7 @@ func applyStandardUpdateReplacement(updated *standardFrontmatter, standardPath s updated.ReplacedBy = "" } if replacedBy := strings.TrimSpace(options.ReplacedBy); replacedBy != "" { - normalized, err := normalizeStandardArtifactPath(replacedBy) + normalized, err := normalizeOptionalStandardReplacement(replacedBy) if err != nil { return err } From 1ceb749f4dd6d82139c3c03a94be236384eaea14 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich <zeb@runecode.org> Date: Wed, 1 Apr 2026 22:57:17 -0500 Subject: [PATCH 19/27] Updates to further align to a user one command install of all adapter files Signed-off-by: Zebulon Rouse-Frantzich <zeb@runecode.org> --- internal/cli/cli_adapter_render_test.go | 43 +++++++++++ .../cli/cli_adapter_sync_host_native_flows.go | 71 +++++++++++++++++-- internal/cli/cli_adapter_test.go | 35 +++++++++ tools/syncadapters/main.go | 3 +- tools/syncadapters/render.go | 51 ++++++++++++- tools/syncadapters/render_test.go | 19 ++++- 6 files changed, 211 insertions(+), 11 deletions(-) diff --git a/internal/cli/cli_adapter_render_test.go b/internal/cli/cli_adapter_render_test.go index e83dd0f..b25408e 100644 --- a/internal/cli/cli_adapter_render_test.go +++ b/internal/cli/cli_adapter_render_test.go @@ -2,6 +2,9 @@ package cli import ( "bytes" + "os" + "os/exec" + "path/filepath" "strings" "testing" ) @@ -143,3 +146,43 @@ func TestRunAdapterRenderHostNativeJSONOutput(t *testing.T) { } } } + +func TestRunAdapterRenderHostNativeRegeneratesMissingRequestedPack(t *testing.T) { + root, err := repoRootForTests() + if err != nil { + t.Fatalf("locate repo root: %v", err) + } + adaptersRoot := filepath.Join(root, "build", "generated", "adapters") + t.Cleanup(func() { + regen := exec.Command("go", "run", "./tools/syncadapters", "--root", root, "--output", "build/generated/adapters", "--tool", "opencode") + regen.Dir = root + if output, err := regen.CombinedOutput(); err != nil { + t.Fatalf("restore staged opencode pack: %v\n%s", err, string(output)) + } + }) + if err := os.RemoveAll(filepath.Join(adaptersRoot, "opencode")); err != nil { + t.Fatalf("remove staged opencode pack: %v", err) + } + + originalWD, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(originalWD) }) + if err := os.Chdir(root); err != nil { + t.Fatalf("chdir repo root: %v", err) + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Run([]string{"adapter", "render-host-native", "opencode", "change-new"}, &stdout, &stderr) + if code != exitOK { + t.Fatalf("expected render-host-native to regenerate missing pack, got %d (%s)", code, stderr.String()) + } + if _, err := os.Stat(filepath.Join(adaptersRoot, "opencode", "workflow.json")); err != nil { + t.Fatalf("expected regenerated opencode workflow contract: %v", err) + } + if !strings.Contains(stdout.String(), "operation_identifier: `runecontext:change-new`") { + t.Fatalf("expected rendered change-new output, got %q", stdout.String()) + } +} diff --git a/internal/cli/cli_adapter_sync_host_native_flows.go b/internal/cli/cli_adapter_sync_host_native_flows.go index de29353..57ede7f 100644 --- a/internal/cli/cli_adapter_sync_host_native_flows.go +++ b/internal/cli/cli_adapter_sync_host_native_flows.go @@ -4,8 +4,10 @@ import ( "encoding/json" "fmt" "os" + "os/exec" "path/filepath" "sort" + "strings" ) type adapterWorkflowDocument struct { @@ -66,15 +68,10 @@ func toolFlowMappings(tool string) ([]hostNativeFlow, error) { } func loadWorkflowDocument(tool string) (adapterWorkflowDocument, error) { - adaptersRoot, err := locateAdaptersRoot() + raw, path, err := loadWorkflowContractBytes(tool) if err != nil { return adapterWorkflowDocument{}, err } - path := filepath.Join(adaptersRoot, tool, "workflow.json") - raw, err := os.ReadFile(path) - if err != nil { - return adapterWorkflowDocument{}, fmt.Errorf("read adapter workflow contract %q: %w", path, err) - } var doc adapterWorkflowDocument if err := json.Unmarshal(raw, &doc); err != nil { return adapterWorkflowDocument{}, fmt.Errorf("decode adapter workflow contract %q: %w", path, err) @@ -85,6 +82,68 @@ func loadWorkflowDocument(tool string) (adapterWorkflowDocument, error) { return doc, nil } +func loadWorkflowContractBytes(tool string) ([]byte, string, error) { + path, err := workflowContractPath(tool) + if err != nil { + if genErr := ensureGeneratedAdapterPack(tool); genErr != nil { + return nil, "", fmt.Errorf("%w (while recovering from %v)", genErr, err) + } + path, err = workflowContractPath(tool) + if err != nil { + return nil, "", err + } + } + raw, err := os.ReadFile(path) + if err == nil { + return raw, path, nil + } + if !os.IsNotExist(err) { + return nil, "", fmt.Errorf("read adapter workflow contract %q: %w", path, err) + } + if genErr := ensureGeneratedAdapterPack(tool); genErr != nil { + return nil, "", fmt.Errorf("read adapter workflow contract %q: %w (regeneration failed: %v)", path, err, genErr) + } + raw, err = os.ReadFile(path) + if err != nil { + return nil, "", fmt.Errorf("read adapter workflow contract %q: %w", path, err) + } + return raw, path, nil +} + +func workflowContractPath(tool string) (string, error) { + adaptersRoot, err := locateAdaptersRoot() + if err != nil { + return "", err + } + return filepath.Join(adaptersRoot, tool, "workflow.json"), nil +} + +func ensureGeneratedAdapterPack(tool string) error { + tool = strings.TrimSpace(tool) + if tool == "" { + return fmt.Errorf("adapter tool is required") + } + schemaRoot, err := locateSchemaRoot() + if err != nil { + return err + } + projectRoot := filepath.Dir(schemaRoot) + if _, err := os.Stat(filepath.Join(projectRoot, "go.mod")); err != nil { + return fmt.Errorf("could not locate installed adapter packs") + } + cmd := exec.Command("go", "run", "./tools/syncadapters", "--root", projectRoot, "--output", "build/generated/adapters", "--tool", tool) + cmd.Dir = projectRoot + output, err := cmd.CombinedOutput() + if err != nil { + message := strings.TrimSpace(string(output)) + if message == "" { + return fmt.Errorf("generate adapter pack %q: %w", tool, err) + } + return fmt.Errorf("generate adapter pack %q: %w: %s", tool, err, message) + } + return nil +} + func workflowMarkdownSource(tool, flowID string) string { return "build/generated/adapters/" + tool + "/flows/" + flowID + ".md" } diff --git a/internal/cli/cli_adapter_test.go b/internal/cli/cli_adapter_test.go index 57b7ba0..e9de555 100644 --- a/internal/cli/cli_adapter_test.go +++ b/internal/cli/cli_adapter_test.go @@ -114,6 +114,41 @@ func TestRunAdapterSyncAppliesHostNativeFiles(t *testing.T) { } } +func TestRunAdapterSyncRegeneratesMissingRequestedAdapterPack(t *testing.T) { + root, err := repoRootForTests() + if err != nil { + t.Fatalf("locate repo root: %v", err) + } + workspaceRoot := t.TempDir() + adaptersRoot := filepath.Join(root, "build", "generated", "adapters") + t.Cleanup(func() { + regen := exec.Command("go", "run", "./tools/syncadapters", "--root", root, "--output", "build/generated/adapters", "--tool", "opencode") + regen.Dir = root + if output, err := regen.CombinedOutput(); err != nil { + t.Fatalf("restore staged opencode pack: %v\n%s", err, string(output)) + } + }) + if err := os.RemoveAll(filepath.Join(adaptersRoot, "opencode")); err != nil { + t.Fatalf("remove staged opencode pack: %v", err) + } + if _, err := os.Stat(filepath.Join(adaptersRoot, "opencode", "workflow.json")); !os.IsNotExist(err) { + t.Fatalf("expected opencode workflow contract to be absent before sync, err=%v", err) + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Run([]string{"adapter", "sync", "--path", workspaceRoot, "opencode"}, &stdout, &stderr) + if code != exitOK { + t.Fatalf("expected adapter sync to regenerate missing pack, got %d (%s)", code, stderr.String()) + } + if _, err := os.Stat(filepath.Join(adaptersRoot, "opencode", "workflow.json")); err != nil { + t.Fatalf("expected opencode workflow contract after sync: %v", err) + } + if _, err := os.Stat(filepath.Join(workspaceRoot, ".opencode", "skills", "runecontext-change-new.md")); err != nil { + t.Fatalf("expected synced opencode host-native skill after regeneration: %v", err) + } +} + func TestRunAdapterSyncWritesHostNativeArtifactsByTool(t *testing.T) { projectRoot := t.TempDir() diff --git a/tools/syncadapters/main.go b/tools/syncadapters/main.go index 30ff53f..a4094f9 100644 --- a/tools/syncadapters/main.go +++ b/tools/syncadapters/main.go @@ -10,9 +10,10 @@ import ( func main() { rootFlag := flag.String("root", ".", "repository root path") outputFlag := flag.String("output", filepath.Join("build", "generated", "adapters"), "rendered adapter output root") + toolFlag := flag.String("tool", "", "optional adapter tool id to generate") flag.Parse() - if err := run(*rootFlag, *outputFlag); err != nil { + if err := run(*rootFlag, *outputFlag, *toolFlag); err != nil { fatalf("sync adapters: %v", err) } } diff --git a/tools/syncadapters/render.go b/tools/syncadapters/render.go index b52a9b9..28d5bc1 100644 --- a/tools/syncadapters/render.go +++ b/tools/syncadapters/render.go @@ -10,7 +10,7 @@ import ( const generatedFlowsDir = "flows" -func run(root, output string) error { +func run(root, output, toolID string) error { absRoot, err := filepath.Abs(root) if err != nil { return fmt.Errorf("resolve root: %w", err) @@ -27,20 +27,65 @@ func run(root, output string) error { if err != nil { return err } + trimmedToolID := strings.TrimSpace(toolID) + if trimmedToolID != "" { + return runSingleTool(absRoot, absOutput, trimmedToolID, flows, tools) + } if err := os.RemoveAll(absOutput); err != nil { return fmt.Errorf("reset output root %q: %w", absOutput, err) } if err := os.MkdirAll(absOutput, 0o755); err != nil { return fmt.Errorf("create output root %q: %w", absOutput, err) } - for _, toolID := range sortedToolIDs(tools) { - if err := renderTool(absRoot, absOutput, toolID, flows, tools[toolID]); err != nil { + for _, id := range sortedToolIDs(tools) { + if err := renderTool(absRoot, absOutput, id, flows, tools[id]); err != nil { return err } } return nil } +func runSingleTool(absRoot, absOutput, toolID string, flows []flowDefinition, tools map[string]toolDefinition) error { + if _, ok := tools[toolID]; !ok { + return fmt.Errorf("unknown tool %q", toolID) + } + if err := prepareSingleToolOutput(absOutput, toolID); err != nil { + return err + } + return renderRequestedTool(absRoot, absOutput, toolID, flows, tools) +} + +func prepareSingleToolOutput(absOutput, toolID string) error { + toolDir := filepath.Join(absOutput, strings.TrimSpace(toolID)) + if err := ensurePathWithinRoot(absOutput, toolDir); err != nil { + return err + } + if err := os.RemoveAll(toolDir); err != nil { + return fmt.Errorf("reset adapter output %q: %w", toolDir, err) + } + return nil +} + +func ensurePathWithinRoot(root, path string) error { + rel, err := filepath.Rel(root, path) + if err != nil { + return fmt.Errorf("resolve %q relative to %q: %w", path, root, err) + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return fmt.Errorf("path %q must remain under root %q", path, root) + } + return nil +} + +func renderRequestedTool(absRoot, absOutput, toolID string, flows []flowDefinition, tools map[string]toolDefinition) error { + toolID = strings.TrimSpace(toolID) + tool, ok := tools[toolID] + if !ok { + return fmt.Errorf("unknown tool %q", toolID) + } + return renderTool(absRoot, absOutput, toolID, flows, tool) +} + func resolveOutput(absRoot, output string) (string, error) { if strings.TrimSpace(output) == "" { return "", fmt.Errorf("output root must be non-empty") diff --git a/tools/syncadapters/render_test.go b/tools/syncadapters/render_test.go index 378aef3..d4fd532 100644 --- a/tools/syncadapters/render_test.go +++ b/tools/syncadapters/render_test.go @@ -63,7 +63,7 @@ func TestRunGeneratesStructuredFlowContracts(t *testing.T) { t.Cleanup(func() { _ = os.RemoveAll(filepath.Join(root, "build", "generated", "adapters-test")) }) - if err := run(root, output); err != nil { + if err := run(root, output, ""); err != nil { t.Fatalf("run syncadapters: %v", err) } @@ -71,6 +71,23 @@ func TestRunGeneratesStructuredFlowContracts(t *testing.T) { assertFlowMarkdownSections(t, output) } +func TestRunGeneratesSingleRequestedTool(t *testing.T) { + root := repoRootForSyncAdaptersTests(t) + output := filepath.Join(root, "build", "generated", "adapters-test", t.Name()) + t.Cleanup(func() { + _ = os.RemoveAll(filepath.Join(root, "build", "generated", "adapters-test")) + }) + if err := run(root, output, "opencode"); err != nil { + t.Fatalf("run syncadapters for one tool: %v", err) + } + if _, err := os.Stat(filepath.Join(output, "opencode", "workflow.json")); err != nil { + t.Fatalf("expected opencode workflow contract: %v", err) + } + if _, err := os.Stat(filepath.Join(output, "codex")); !os.IsNotExist(err) { + t.Fatalf("expected only requested tool output, codex err=%v", err) + } +} + func assertWorkflowContractGenerated(t *testing.T, output string) { t.Helper() workflowPath := filepath.Join(output, "opencode", "workflow.json") From 1e06fafc06095c3f367d18a6874f1884e2df01e2 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich <zeb@runecode.org> Date: Thu, 2 Apr 2026 08:46:08 -0500 Subject: [PATCH 20/27] Updates based on PR comments Signed-off-by: Zebulon Rouse-Frantzich <zeb@runecode.org> --- ...cli_adapter_render_host_native_markdown.go | 15 +++- internal/cli/cli_adapter_render_test.go | 37 ++++++++++ .../cli/cli_adapter_sync_host_native_flows.go | 39 +++++++++- ...cli_adapter_sync_host_native_flows_test.go | 12 ++++ tools/syncadapters/copy_passthrough.go | 72 +++++++++++++++++++ tools/syncadapters/render.go | 42 +---------- tools/syncadapters/render_test.go | 48 +++++++++++++ 7 files changed, 218 insertions(+), 47 deletions(-) create mode 100644 internal/cli/cli_adapter_sync_host_native_flows_test.go create mode 100644 tools/syncadapters/copy_passthrough.go diff --git a/internal/cli/cli_adapter_render_host_native_markdown.go b/internal/cli/cli_adapter_render_host_native_markdown.go index e5626f8..3713afd 100644 --- a/internal/cli/cli_adapter_render_host_native_markdown.go +++ b/internal/cli/cli_adapter_render_host_native_markdown.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "path/filepath" "slices" "strings" ) @@ -119,9 +120,13 @@ func renderHostNativeIndexMarkdown(request adapterRenderRequest) (string, error) if len(flows) == 0 { return "", fmt.Errorf("adapter render-host-native index is not defined for tool %q", request.tool) } + referenceRoot, err := adapterReferenceRoot() + if err != nil { + return "", err + } lines := []string{ - "- canonical_flow_source: `build/generated/adapters/" + request.tool + "/flows/*.md`", - "- canonical_workflow_contract: `build/generated/adapters/" + request.tool + "/workflow.json`", + "- canonical_flow_source: `" + filepath.ToSlash(filepath.Join(referenceRoot, request.tool, "flows", "*.md")) + "`", + "- canonical_workflow_contract: `" + workflowContractReferencePath(referenceRoot, request.tool) + "`", "- adapter_role: `" + hostNativeKindDiscoverabilityShim + "`", "- operation_identifier: `runecontext:index`", "- interaction_rule: " + hostNativeNoQuestionRule, @@ -170,10 +175,14 @@ func hostNativeRenderMetadata(request adapterRenderRequest, flow hostNativeFlow) if request.role == hostNativeKindDiscoverabilityShim { role = hostNativeKindDiscoverabilityShim } + referenceRoot, err := adapterReferenceRoot() + if err != nil { + return hostNativeRenderMeta{}, err + } return hostNativeRenderMeta{ operationID: "runecontext:" + flow.id, source: flow.source, - contractSource: "build/generated/adapters/" + request.tool + "/workflow.json", + contractSource: workflowContractReferencePath(referenceRoot, request.tool), role: role, commandPath: path, usage: command.Usage, diff --git a/internal/cli/cli_adapter_render_test.go b/internal/cli/cli_adapter_render_test.go index b25408e..9519881 100644 --- a/internal/cli/cli_adapter_render_test.go +++ b/internal/cli/cli_adapter_render_test.go @@ -186,3 +186,40 @@ func TestRunAdapterRenderHostNativeRegeneratesMissingRequestedPack(t *testing.T) t.Fatalf("expected rendered change-new output, got %q", stdout.String()) } } + +func TestRunAdapterRenderHostNativeUsesInstalledShareCanonicalPaths(t *testing.T) { + root := t.TempDir() + runtimeRoot := filepath.Join(root, "share", "runecontext") + schemaDir := filepath.Join(runtimeRoot, "schemas") + adaptersDir := filepath.Join(runtimeRoot, "adapters") + seedReleaseStyleLayout(t, schemaDir, adaptersDir) + seedAdapterPackForDiscovery(t, adaptersDir, "opencode") + + workflow := `{"schema_version":"1","adapter":"opencode","display_name":"OpenCode","flow_intro":"Intro","flows":[{"id":"change-new","command_path":"change new","description":"Create a new RuneContext change","usage":"runectx change new --title TITLE --type TYPE","required_outcome":"Create a new change directory with a stable change id and initialized change artifacts.","guardrails":["Map every generated suggestion directly to the documented runectx command and flags."],"inputs_to_gather":["change title","change type"],"decision_rules":["If required title or type is missing, ask for those values before proposing command execution."],"workflow_steps":["Collect missing required and optional command inputs."],"stop_condition":"Stop immediately after reporting change creation output and do not chain additional workflow commands.","recommended_next_commands":["runectx change shape <change-id>"],"examples":[{"scenario":"Create feature","user_prompt":"Create change","assistant_response":"I will run runectx change new..."}]}]}` + if err := os.WriteFile(filepath.Join(adaptersDir, "opencode", "workflow.json"), []byte(workflow), 0o644); err != nil { + t.Fatalf("write workflow contract: %v", err) + } + + originalWD, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(originalWD) }) + if err := os.Chdir(runtimeRoot); err != nil { + t.Fatalf("chdir runtime root: %v", err) + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Run([]string{"adapter", "render-host-native", "opencode", "change-new"}, &stdout, &stderr) + if code != exitOK { + t.Fatalf("expected success exit code, got %d (%s)", code, stderr.String()) + } + text := stdout.String() + if !strings.Contains(text, "canonical_flow_source: `share/runecontext/adapters/opencode/flows/change-new.md`") { + t.Fatalf("expected share-layout canonical flow source, got %q", text) + } + if !strings.Contains(text, "canonical_workflow_contract: `share/runecontext/adapters/opencode/workflow.json`") { + t.Fatalf("expected share-layout canonical workflow contract, got %q", text) + } +} diff --git a/internal/cli/cli_adapter_sync_host_native_flows.go b/internal/cli/cli_adapter_sync_host_native_flows.go index 57ede7f..a59014b 100644 --- a/internal/cli/cli_adapter_sync_host_native_flows.go +++ b/internal/cli/cli_adapter_sync_host_native_flows.go @@ -44,13 +44,17 @@ func toolFlowMappings(tool string) ([]hostNativeFlow, error) { if err != nil { return nil, err } + referenceRoot, err := adapterReferenceRoot() + if err != nil { + return nil, err + } flows := make([]hostNativeFlow, 0, len(doc.Flows)) for _, flow := range doc.Flows { flows = append(flows, hostNativeFlow{ id: flow.ID, name: flow.CommandPath, description: flow.Description, - source: workflowMarkdownSource(tool, flow.ID), + source: workflowMarkdownSource(referenceRoot, tool, flow.ID), commandPath: flow.CommandPath, usage: flow.Usage, requiredOutcome: flow.RequiredOutcome, @@ -118,6 +122,31 @@ func workflowContractPath(tool string) (string, error) { return filepath.Join(adaptersRoot, tool, "workflow.json"), nil } +func adapterReferenceRoot() (string, error) { + adaptersRoot, err := locateAdaptersRoot() + if err != nil { + return "", err + } + return adapterReferenceRootForPath(adaptersRoot), nil +} + +func adapterReferenceRootForPath(adaptersRoot string) string { + normalized := filepath.ToSlash(filepath.Clean(adaptersRoot)) + for _, suffix := range []string{"build/generated/adapters", "share/runecontext/adapters"} { + slashedSuffix := "/" + suffix + if normalized == suffix || strings.HasSuffix(normalized, slashedSuffix) { + return suffix + } + } + for _, suffix := range []string{"adapters"} { + slashedSuffix := "/" + suffix + if normalized == suffix || strings.HasSuffix(normalized, slashedSuffix) { + return suffix + } + } + return normalized +} + func ensureGeneratedAdapterPack(tool string) error { tool = strings.TrimSpace(tool) if tool == "" { @@ -144,8 +173,12 @@ func ensureGeneratedAdapterPack(tool string) error { return nil } -func workflowMarkdownSource(tool, flowID string) string { - return "build/generated/adapters/" + tool + "/flows/" + flowID + ".md" +func workflowMarkdownSource(referenceRoot, tool, flowID string) string { + return filepath.ToSlash(filepath.Join(referenceRoot, tool, "flows", flowID+".md")) +} + +func workflowContractReferencePath(referenceRoot, tool string) string { + return filepath.ToSlash(filepath.Join(referenceRoot, tool, "workflow.json")) } func validateWorkflowDocument(tool string, doc adapterWorkflowDocument) error { diff --git a/internal/cli/cli_adapter_sync_host_native_flows_test.go b/internal/cli/cli_adapter_sync_host_native_flows_test.go new file mode 100644 index 0000000..36b6931 --- /dev/null +++ b/internal/cli/cli_adapter_sync_host_native_flows_test.go @@ -0,0 +1,12 @@ +package cli + +import "testing" + +func TestAdapterReferenceRootForPathSupportsAdaptersFallback(t *testing.T) { + if got, want := adapterReferenceRootForPath("/tmp/work/adapters"), "adapters"; got != want { + t.Fatalf("expected adapters fallback root %q, got %q", want, got) + } + if got, want := adapterReferenceRootForPath("adapters"), "adapters"; got != want { + t.Fatalf("expected adapters fallback root %q, got %q", want, got) + } +} diff --git a/tools/syncadapters/copy_passthrough.go b/tools/syncadapters/copy_passthrough.go new file mode 100644 index 0000000..6604ad5 --- /dev/null +++ b/tools/syncadapters/copy_passthrough.go @@ -0,0 +1,72 @@ +package main + +import ( + "fmt" + "io" + "io/fs" + "os" + "path/filepath" +) + +func copyPassthroughTree(sourceRoot, targetRoot string, excludes map[string]struct{}) error { + return filepath.WalkDir(sourceRoot, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + rel, skip, err := passthroughRelativePath(sourceRoot, path, excludes) + if err != nil || skip { + return err + } + targetPath := filepath.Join(targetRoot, filepath.FromSlash(rel)) + if d.IsDir() { + return os.MkdirAll(targetPath, 0o755) + } + if d.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("passthrough source entry %q must not be a symlink", rel) + } + return copyFileWithMode(path, targetPath) + }) +} + +func passthroughRelativePath(sourceRoot, path string, excludes map[string]struct{}) (string, bool, error) { + rel, err := filepath.Rel(sourceRoot, path) + if err != nil { + return "", false, err + } + if rel == "." { + return "", true, nil + } + rel = filepath.ToSlash(rel) + _, excluded := excludes[rel] + return rel, excluded, nil +} + +func copyFileWithMode(source, target string) error { + lstatInfo, err := os.Lstat(source) + if err != nil { + return err + } + if lstatInfo.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("passthrough source file %q must not be a symlink", source) + } + file, err := os.Open(source) + if err != nil { + return err + } + defer file.Close() + fstatInfo, err := file.Stat() + if err != nil { + return err + } + if !os.SameFile(lstatInfo, fstatInfo) { + return fmt.Errorf("passthrough source file %q changed during copy", source) + } + data, err := io.ReadAll(file) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + return os.WriteFile(target, data, fstatInfo.Mode().Perm()) +} diff --git a/tools/syncadapters/render.go b/tools/syncadapters/render.go index 28d5bc1..d192f67 100644 --- a/tools/syncadapters/render.go +++ b/tools/syncadapters/render.go @@ -2,7 +2,6 @@ package main import ( "fmt" - "io/fs" "os" "path/filepath" "strings" @@ -196,53 +195,14 @@ func renderTool(root, output, toolID string, flows []flowDefinition, tool toolDe func generatedExcludes(flows []flowDefinition) map[string]struct{} { excludes := map[string]struct{}{ "capabilities.yaml": {}, + "workflow.json": {}, } for _, flow := range flows { excludes[filepath.ToSlash(filepath.Join(generatedFlowsDir, flow.ID+".md"))] = struct{}{} } - excludes["workflow.json"] = struct{}{} return excludes } -func copyPassthroughTree(sourceRoot, targetRoot string, excludes map[string]struct{}) error { - return filepath.WalkDir(sourceRoot, func(path string, d fs.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - rel, err := filepath.Rel(sourceRoot, path) - if err != nil { - return err - } - if rel == "." { - return nil - } - rel = filepath.ToSlash(rel) - if _, excluded := excludes[rel]; excluded { - return nil - } - targetPath := filepath.Join(targetRoot, filepath.FromSlash(rel)) - if d.IsDir() { - return os.MkdirAll(targetPath, 0o755) - } - return copyFileWithMode(path, targetPath) - }) -} - -func copyFileWithMode(source, target string) error { - data, err := os.ReadFile(source) - if err != nil { - return err - } - info, err := os.Stat(source) - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - return err - } - return os.WriteFile(target, data, info.Mode().Perm()) -} - func writeCapabilities(targetDir, toolID string, tool toolDefinition) error { path := filepath.Join(targetDir, "capabilities.yaml") var lines []string diff --git a/tools/syncadapters/render_test.go b/tools/syncadapters/render_test.go index d4fd532..ffa94cd 100644 --- a/tools/syncadapters/render_test.go +++ b/tools/syncadapters/render_test.go @@ -88,6 +88,54 @@ func TestRunGeneratesSingleRequestedTool(t *testing.T) { } } +func TestRunRejectsSymlinkedPassthroughFile(t *testing.T) { + root := t.TempDir() + seedSingleToolFixtureRoot(t, root) + packDir := filepath.Join(root, "adapters", "source", "packs", "opencode") + outside := filepath.Join(root, "outside.md") + if err := os.WriteFile(outside, []byte("outside\n"), 0o644); err != nil { + t.Fatalf("write outside file: %v", err) + } + symlinkPath := filepath.Join(packDir, "README.md") + if err := os.Symlink(outside, symlinkPath); err != nil { + if os.IsPermission(err) { + t.Skipf("symlink creation not permitted: %v", err) + } + t.Fatalf("create symlinked passthrough file: %v", err) + } + + output := filepath.Join(root, "build", "generated", "adapters") + err := run(root, output, "opencode") + if err == nil { + t.Fatal("expected symlinked passthrough file to be rejected") + } + if !strings.Contains(err.Error(), "must not be a symlink") { + t.Fatalf("expected symlink rejection error, got %v", err) + } +} + +func seedSingleToolFixtureRoot(t *testing.T, root string) { + t.Helper() + writeFile(t, filepath.Join(root, "go.mod"), "module example.com/test\n") + writeFile(t, filepath.Join(root, "adapters", "source", "shared", "flows", "change-new.json"), singleToolFlowDefinitionJSON) + writeFile(t, filepath.Join(root, "adapters", "source", "tools", "opencode.json"), singleToolDefinitionJSON) + writeFile(t, filepath.Join(root, "adapters", "source", "packs", "opencode", "flows", "conversational-parity.md"), "ok\n") +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir parent for %s: %v", path, err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +const singleToolFlowDefinitionJSON = `{"id":"change-new","command_path":"change new","description":"desc","usage":"runectx change new --title T --type feature","required_outcome":"create change","guardrails":["g"],"inputs_to_gather":["i"],"decision_rules":["d"],"workflow_steps":["w"],"stop_condition":"stop","recommended_next_commands":["runectx change shape <change-id>"],"examples":[{"scenario":"s","user_prompt":"u","assistant_response":"a"}]}` + +const singleToolDefinitionJSON = `{"display_name":"OpenCode","flow_intro":"Intro","capabilities":{"prompts":"supported","shell_access":"supported","hooks":"supported","dynamic_suggestions":"supported","structured_output":"supported"}}` + func assertWorkflowContractGenerated(t *testing.T, output string) { t.Helper() workflowPath := filepath.Join(output, "opencode", "workflow.json") From 4aa3b70642a5de596603c67943751ec4a5f54141 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich <zeb@runecode.org> Date: Thu, 2 Apr 2026 09:54:08 -0500 Subject: [PATCH 21/27] Fixes based on PR comments. Signed-off-by: Zebulon Rouse-Frantzich <zeb@runecode.org> --- adapters/source/shared/flows/promote.json | 20 +++++++++++------- .../shared/flows/standard-discover.json | 21 ++++++++++++------- internal/cli/cli_release_layout_test.go | 2 +- 3 files changed, 26 insertions(+), 17 deletions(-) diff --git a/adapters/source/shared/flows/promote.json b/adapters/source/shared/flows/promote.json index 0fcde2e..740ac99 100644 --- a/adapters/source/shared/flows/promote.json +++ b/adapters/source/shared/flows/promote.json @@ -2,23 +2,27 @@ "id": "promote", "command_path": "promote", "description": "Advance RuneContext promotion state", - "usage": "runectx promote [--target spec:<path>|standard:<path>|decision:<path>] [--path <project-root>]", + "usage": "runectx promote [--json] [--non-interactive] [--dry-run] [--explain] CHANGE_ID [--accept | --complete] [--target TYPE:PATH (summary auto-filled per target type)] [--path PATH]", "required_outcome": "Advance promotion workflow state for the requested target set and report canonical results.", "guardrails": [ - "Only promote explicit or discoverable targets surfaced by RuneContext workflows.", + "Only promote explicit change ids and optional targets surfaced by RuneContext workflows.", "Do not fabricate target identifiers outside the CLI contract.", "Stop after promote output and avoid chaining additional mutations." ], "inputs_to_gather": [ - "optional promotion target" + "change id", + "optional promote mode (accept or complete)", + "optional promotion target", + "optional project path" ], "decision_rules": [ - "If no target is provided, run promote with repository default behavior.", - "If target is provided, validate its prefix format before command proposal.", + "Require CHANGE_ID before proposing command execution.", + "When promote mode is omitted, do not add --accept or --complete automatically.", + "If target is provided, preserve TYPE:PATH formatting in the proposed command.", "Stop after presenting promotion results and follow-up guidance." ], "workflow_steps": [ - "Gather optional target input.", + "Gather CHANGE_ID and optional promote mode, target, and project path inputs.", "Propose and run runectx promote with explicit flags.", "Report promotion status and any required follow-up actions.", "Conclude without automatic secondary command execution." @@ -31,8 +35,8 @@ "examples": [ { "scenario": "Promote an explicitly selected standard", - "user_prompt": "Promote standard standards/global/structured-cli-contracts.md.", - "assistant_response": "I will run `runectx promote --target standard:standards/global/structured-cli-contracts.md` and return the promote output." + "user_prompt": "For CHG-2026-001-1234, accept and promote standard standards/global/structured-cli-contracts.md.", + "assistant_response": "I will run `runectx promote CHG-2026-001-1234 --accept --target standard:standards/global/structured-cli-contracts.md` and return the promote output." } ] } diff --git a/adapters/source/shared/flows/standard-discover.json b/adapters/source/shared/flows/standard-discover.json index 0e268f5..603935b 100644 --- a/adapters/source/shared/flows/standard-discover.json +++ b/adapters/source/shared/flows/standard-discover.json @@ -2,24 +2,29 @@ "id": "standard-discover", "command_path": "standard discover", "description": "Discover standards candidates for promotion", - "usage": "runectx standard discover --scope-path <path> [--focus <topic>] [--path <project-root>]", + "usage": "runectx standard discover [--json] [--non-interactive] [--explain] [--path PATH] [--change CHANGE_ID] [--scope-path PATH] [--focus TEXT] [--confirm-handoff] [--target TYPE:PATH]", "required_outcome": "Return discoverable standards candidates and promotion target fields for the requested scope.", "guardrails": [ "Treat discovery results as advisory candidates, not automatic promotions.", - "Use explicit scope-path input and avoid scanning hidden implicit roots.", + "Use explicit scope-path and change inputs when supplied, and avoid hidden implicit scope assumptions.", "Do not execute promote automatically after discovery." ], "inputs_to_gather": [ - "scope-path", - "optional focus" + "optional project path", + "optional change id", + "optional scope-path", + "optional focus", + "optional confirm-handoff", + "optional target" ], "decision_rules": [ - "Require scope-path before command proposal.", - "When focus is absent, omit the flag instead of guessing a topic.", + "When optional inputs are absent, omit their flags instead of inventing values.", + "If confirm-handoff is requested, include --confirm-handoff explicitly.", + "If target is provided, preserve TYPE:PATH formatting in the proposed command.", "Stop after reporting candidate promotion targets." ], "workflow_steps": [ - "Collect scope-path and optional focus.", + "Collect optional project path, change id, scope-path, focus, confirm-handoff, and target inputs.", "Propose and run runectx standard discover.", "Report candidate promotion targets and diagnostics.", "End flow without chaining promote automatically." @@ -33,7 +38,7 @@ { "scenario": "Discover standards in a focused area", "user_prompt": "Discover standards for adapter workflow generation under tools/syncadapters.", - "assistant_response": "I will run `runectx standard discover --scope-path tools/syncadapters --focus adapter-workflow` and report candidate targets only." + "assistant_response": "I will run `runectx standard discover --scope-path tools/syncadapters --focus adapter-workflow` and report candidate targets only. If you want explicit handoff confirmation, I can add `--confirm-handoff`." } ] } diff --git a/internal/cli/cli_release_layout_test.go b/internal/cli/cli_release_layout_test.go index 221a65c..a327c4e 100644 --- a/internal/cli/cli_release_layout_test.go +++ b/internal/cli/cli_release_layout_test.go @@ -7,7 +7,7 @@ import ( "testing" ) -func TestReleaseMetadataIncludesAdaptersDirectory(t *testing.T) { +func TestReleaseMetadataExcludesAdaptersDirectory(t *testing.T) { root, err := repoRootForTests() if err != nil { t.Fatal(err) From 962897b0b9f83215b45ffe36e0cd61734e2a2825 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich <zeb@runecode.org> Date: Thu, 2 Apr 2026 11:59:12 -0500 Subject: [PATCH 22/27] Updates to adapter flows to enhance the experience Signed-off-by: Zebulon Rouse-Frantzich <zeb@runecode.org> --- .../flows/change-assess-decomposition.json | 3 +- .../shared/flows/change-assess-intake.json | 6 ++- .../flows/change-decomposition-apply.json | 5 ++- .../flows/change-decomposition-plan.json | 7 +++- adapters/source/shared/flows/change-new.json | 37 +++++++++++-------- .../source/shared/flows/change-shape.json | 3 +- adapters/source/shared/flows/promote.json | 3 +- .../shared/flows/standard-discover.json | 6 ++- 8 files changed, 43 insertions(+), 27 deletions(-) diff --git a/adapters/source/shared/flows/change-assess-decomposition.json b/adapters/source/shared/flows/change-assess-decomposition.json index af81d6f..76fd355 100644 --- a/adapters/source/shared/flows/change-assess-decomposition.json +++ b/adapters/source/shared/flows/change-assess-decomposition.json @@ -7,7 +7,8 @@ "guardrails": [ "Require an explicit change id; never infer mutation targets.", "Treat output as advisory and avoid modifying relationships in this step.", - "Do not auto-run decomposition plan or apply commands." + "Do not auto-run decomposition plan or apply commands.", + "Do not implement product code in this flow." ], "inputs_to_gather": [ "change id" diff --git a/adapters/source/shared/flows/change-assess-intake.json b/adapters/source/shared/flows/change-assess-intake.json index 3fcac0c..ce510bc 100644 --- a/adapters/source/shared/flows/change-assess-intake.json +++ b/adapters/source/shared/flows/change-assess-intake.json @@ -7,7 +7,8 @@ "guardrails": [ "Treat assess-intake as advisory and do not mutate repository state.", "Preserve exact advisory fields from CLI output when summarizing.", - "Do not auto-run change new after intake assessment." + "Do not auto-run change new after intake assessment.", + "Do not implement product code in this flow." ], "inputs_to_gather": [ "proposed title", @@ -19,6 +20,7 @@ "decision_rules": [ "Gather required title and type before proposing the command.", "Surface advisory output as-is and keep recommendations clearly non-authoritative.", + "Use advisory output to recommend one feature, multiple features, or project-plus-features where applicable.", "Stop after reporting intake assessment results." ], "workflow_steps": [ @@ -30,7 +32,7 @@ "stop_condition": "Stop after presenting intake advisory output and suggested next command options.", "recommended_next_commands": [ "runectx change new --title \"<title>\" --type <type>", - "runectx change assess-decomposition <change-id>" + "runectx standard discover --scope-path <path>" ], "examples": [ { diff --git a/adapters/source/shared/flows/change-decomposition-apply.json b/adapters/source/shared/flows/change-decomposition-apply.json index 2c4ee8e..35a9323 100644 --- a/adapters/source/shared/flows/change-decomposition-apply.json +++ b/adapters/source/shared/flows/change-decomposition-apply.json @@ -7,7 +7,8 @@ "guardrails": [ "Require explicit umbrella and sub-change identifiers before mutation.", "Keep the operation bounded to provided relationship edits only.", - "Stop after the apply result and do not auto-run validation or promotion." + "Stop after the apply result and do not auto-run validation or promotion.", + "Do not implement product code in this flow." ], "inputs_to_gather": [ "umbrella change id", @@ -28,7 +29,7 @@ "stop_condition": "Stop after apply output is delivered for the requested decomposition mutation.", "recommended_next_commands": [ "runectx validate --path <project-root>", - "runectx promote" + "runectx promote <change-id> --accept" ], "examples": [ { diff --git a/adapters/source/shared/flows/change-decomposition-plan.json b/adapters/source/shared/flows/change-decomposition-plan.json index de85345..7f2d362 100644 --- a/adapters/source/shared/flows/change-decomposition-plan.json +++ b/adapters/source/shared/flows/change-decomposition-plan.json @@ -7,7 +7,9 @@ "guardrails": [ "Never apply relationship changes in plan mode.", "Require explicit sub-change ids and dependency edges from the user.", - "Keep plan output reviewable before any apply step." + "Keep plan output reviewable before any apply step.", + "Do not create missing sub-changes in this flow; plan only among existing change ids.", + "Do not implement product code in this flow." ], "inputs_to_gather": [ "umbrella change id", @@ -16,6 +18,7 @@ ], "decision_rules": [ "If no sub-change values are provided, request at least one before command proposal.", + "If required sub-changes do not exist yet, stop and ask the user to create those changes before planning decomposition.", "Render dependency edges exactly in <sub-change-id>:<change-id> form.", "Stop after showing the decomposition plan output." ], @@ -28,7 +31,7 @@ "stop_condition": "Stop after plan output is reported and do not execute apply automatically.", "recommended_next_commands": [ "runectx change decomposition-apply <umbrella-change-id> --sub-change <id>", - "runectx change shape <change-id> --task \"<text>\"" + "runectx change new --title \"<title>\" --type feature" ], "examples": [ { diff --git a/adapters/source/shared/flows/change-new.json b/adapters/source/shared/flows/change-new.json index bedd186..aaf0053 100644 --- a/adapters/source/shared/flows/change-new.json +++ b/adapters/source/shared/flows/change-new.json @@ -3,41 +3,46 @@ "command_path": "change new", "description": "Create a new RuneContext change", "usage": "runectx change new --title \"<title>\" --type <type> [--size <size>] [--shape <minimum|full>] [--bundle <bundle-id>] [--description \"<text>\"] [--path <project-root>]", - "required_outcome": "Create a new change directory with a stable change id and initialized change artifacts.", + "required_outcome": "Create the appropriate RuneContext change record set for the request (single feature, multiple features, or one project plus associated features) and report created change ids.", "guardrails": [ - "Map every generated suggestion directly to the documented runectx command and flags.", - "Do not execute any follow-up command automatically after change creation.", + "Map every generated suggestion directly to the documented runectx change new command and flags.", + "Do not execute a different RuneContext command automatically after change creation.", + "Do not implement product code in this flow; create RuneContext change records only.", "Keep all proposed mutations reviewable and explicit to the user." ], "inputs_to_gather": [ - "change title", - "change type", + "requested outcome and scope", + "whether work is one feature, multiple features, or one project with feature children", + "change titles and types for each change to create", "optional size", "optional shape", "optional bundle", "optional proposal description" ], "decision_rules": [ - "If required title or type is missing, ask for those values before proposing command execution.", + "If required title or type values are missing for any planned change, ask for them before command execution.", + "Use one feature change for a single bounded deliverable.", + "Use multiple feature changes for multiple unrelated deliverables.", + "Use one project change plus associated feature changes for multiple related deliverables.", "If optional values are not provided, omit their flags instead of inventing defaults.", - "After command completion, stop and report the resulting change id and path." + "After command completion, stop and report resulting change ids and paths." ], "workflow_steps": [ - "Collect missing required and optional command inputs.", - "Present a single explicit runectx command proposal for review.", - "Execute or instruct execution of runectx change new.", - "Return the created change id and immediate output fields only." + "Collect missing required and optional inputs and determine the change topology.", + "Present the planned set of runectx change new commands for review.", + "Execute or instruct execution of runectx change new one or more times as needed for that topology.", + "Return created change ids and immediate output fields only." ], - "stop_condition": "Stop immediately after reporting change creation output and do not chain additional workflow commands.", + "stop_condition": "Stop immediately after reporting created change ids and outputs; recommend next commands only and do not execute them.", "recommended_next_commands": [ "runectx change shape <change-id>", - "runectx standard discover --scope-path <path>" + "runectx change assess-decomposition <change-id>" ], "examples": [ { - "scenario": "Create a feature change with minimal inputs", - "user_prompt": "Create a new feature change titled Add adapter workflow contracts.", - "assistant_response": "I will run `runectx change new --title \"Add adapter workflow contracts\" --type feature` once you confirm. After it succeeds, I will report the new change id and stop." + "scenario": "Create a project with associated feature changes", + "user_prompt": "Set up change records for improving adapter workflows across sync, render, and tests.", + "assistant_response": "I will create one project change and three associated feature changes by running `runectx change new` multiple times. I will report every created change id and stop without running shape or other commands automatically." } ] } diff --git a/adapters/source/shared/flows/change-shape.json b/adapters/source/shared/flows/change-shape.json index e470bfe..e4e1c3a 100644 --- a/adapters/source/shared/flows/change-shape.json +++ b/adapters/source/shared/flows/change-shape.json @@ -7,7 +7,8 @@ "guardrails": [ "Require explicit change id and scoped shape inputs.", "Do not rewrite unrelated change artifacts during shaping.", - "Stop after shape output without chaining more edits automatically." + "Stop after shape output without chaining more edits automatically.", + "Do not implement product code in this flow." ], "inputs_to_gather": [ "target change id", diff --git a/adapters/source/shared/flows/promote.json b/adapters/source/shared/flows/promote.json index 740ac99..ea05798 100644 --- a/adapters/source/shared/flows/promote.json +++ b/adapters/source/shared/flows/promote.json @@ -7,7 +7,8 @@ "guardrails": [ "Only promote explicit change ids and optional targets surfaced by RuneContext workflows.", "Do not fabricate target identifiers outside the CLI contract.", - "Stop after promote output and avoid chaining additional mutations." + "Stop after promote output and avoid chaining additional mutations.", + "Do not create standards, discover standards, or implement product code in this flow." ], "inputs_to_gather": [ "change id", diff --git a/adapters/source/shared/flows/standard-discover.json b/adapters/source/shared/flows/standard-discover.json index 603935b..17bc183 100644 --- a/adapters/source/shared/flows/standard-discover.json +++ b/adapters/source/shared/flows/standard-discover.json @@ -7,7 +7,9 @@ "guardrails": [ "Treat discovery results as advisory candidates, not automatic promotions.", "Use explicit scope-path and change inputs when supplied, and avoid hidden implicit scope assumptions.", - "Do not execute promote automatically after discovery." + "Do not execute promote automatically after discovery.", + "Do not create or update standards in this flow.", + "Do not implement product code in this flow." ], "inputs_to_gather": [ "optional project path", @@ -31,7 +33,7 @@ ], "stop_condition": "Stop after discovery output is summarized and candidate targets are listed.", "recommended_next_commands": [ - "runectx promote --target standard:<path>", + "runectx promote <change-id> --target standard:<path>", "runectx validate --path <project-root>" ], "examples": [ From ec206cb8f58196987d420f26ee645a677d20fa80 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich <zeb@runecode.org> Date: Thu, 2 Apr 2026 13:04:25 -0500 Subject: [PATCH 23/27] Fixes check issues from failing check logs. Signed-off-by: Zebulon Rouse-Frantzich <zeb@runecode.org> --- tools/syncadapters/render.go | 35 +++++++++++++++++++++++++++++-- tools/syncadapters/render_test.go | 22 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/tools/syncadapters/render.go b/tools/syncadapters/render.go index d192f67..d5df0aa 100644 --- a/tools/syncadapters/render.go +++ b/tools/syncadapters/render.go @@ -103,10 +103,41 @@ func resolveOutput(absRoot, output string) (string, error) { } else { absOutput = filepath.Clean(filepath.Join(canonicalRoot, output)) } - if err := validateOutputRoot(canonicalRoot, absOutput); err != nil { + canonicalOutput, err := canonicalizePathAllowMissing(absOutput) + if err != nil { + return "", err + } + if err := validateOutputRoot(canonicalRoot, canonicalOutput); err != nil { return "", err } - return absOutput, nil + return canonicalOutput, nil +} + +func canonicalizePathAllowMissing(path string) (string, error) { + cleanPath := filepath.Clean(path) + if !filepath.IsAbs(cleanPath) { + return "", fmt.Errorf("path %q must be absolute", path) + } + current := cleanPath + missing := make([]string, 0, 4) + for { + resolved, err := filepath.EvalSymlinks(current) + if err == nil { + for i := len(missing) - 1; i >= 0; i-- { + resolved = filepath.Join(resolved, missing[i]) + } + return filepath.Clean(resolved), nil + } + if !os.IsNotExist(err) { + return "", fmt.Errorf("resolve output root symlinks %q: %w", current, err) + } + parent := filepath.Dir(current) + if parent == current { + return "", fmt.Errorf("resolve output root symlinks %q: %w", cleanPath, err) + } + missing = append(missing, filepath.Base(current)) + current = parent + } } func validateOutputRoot(absRoot, absOutput string) error { diff --git a/tools/syncadapters/render_test.go b/tools/syncadapters/render_test.go index ffa94cd..fe8d2c0 100644 --- a/tools/syncadapters/render_test.go +++ b/tools/syncadapters/render_test.go @@ -40,6 +40,28 @@ func TestResolveOutputRejectsSymlinkedOutputAncestor(t *testing.T) { } } +func TestResolveOutputAllowsAbsoluteSymlinkAliasUnderRepositoryRoot(t *testing.T) { + realRoot := t.TempDir() + aliasParent := t.TempDir() + aliasRoot := filepath.Join(aliasParent, "repo-alias") + if err := os.Symlink(realRoot, aliasRoot); err != nil { + if os.IsPermission(err) { + t.Skipf("symlink creation not permitted: %v", err) + } + t.Fatalf("create repository alias symlink: %v", err) + } + + output := filepath.Join(aliasRoot, "build", "generated", "adapters") + got, err := resolveOutput(realRoot, output) + if err != nil { + t.Fatalf("resolveOutput returned error: %v", err) + } + expected := filepath.Join(realRoot, "build", "generated", "adapters") + if got != expected { + t.Fatalf("expected canonical output %q, got %q", expected, got) + } +} + func assertRepositoryDescendantOutput(t *testing.T, root, output string) { t.Helper() t.Run(output, func(t *testing.T) { From eebab5d58df5eb59376deadd128809dfbcd1fdec Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich <zeb@runecode.org> Date: Thu, 2 Apr 2026 13:12:39 -0500 Subject: [PATCH 24/27] More fixes for failing checks Signed-off-by: Zebulon Rouse-Frantzich <zeb@runecode.org> --- internal/cli/cli_release_artifacts_test.go | 13 +++++++++++++ nix/packages/release-artifacts.nix | 4 +++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/internal/cli/cli_release_artifacts_test.go b/internal/cli/cli_release_artifacts_test.go index b47bee7..0b8dce2 100644 --- a/internal/cli/cli_release_artifacts_test.go +++ b/internal/cli/cli_release_artifacts_test.go @@ -65,6 +65,19 @@ func TestReleaseArtifactBuilderRecordsManifestAndChecksumCoverage(t *testing.T) ) } +func TestReleaseArtifactsPackageIncludesBuildOnlyAdapterSourceInput(t *testing.T) { + packageNix := readReleaseFileForTests(t, filepath.Join("nix", "packages", "release-artifacts.nix")) + requireSubstrings(t, packageNix, + `buildOnlyPrefixes = [ "adapters/source" ];`, + `|| lib.any matchesPrefix buildOnlyPrefixes;`, + ) + + metadata := readReleaseFileForTests(t, filepath.Join("nix", "release", "metadata.nix")) + if strings.Contains(metadata, `"adapters/source"`) { + t.Fatal("expected adapters/source to remain excluded from shipped release layout") + } +} + func TestInstallScriptsInstallRuntimeAssets(t *testing.T) { sh := readReleaseFileForTests(t, filepath.Join("scripts", "install-runectx.sh")) requireSubstrings(t, sh, diff --git a/nix/packages/release-artifacts.nix b/nix/packages/release-artifacts.nix index 379f2d8..3fdd572 100644 --- a/nix/packages/release-artifacts.nix +++ b/nix/packages/release-artifacts.nix @@ -23,6 +23,7 @@ let pathString = toString path; relativePath = if pathString == root then "." else lib.removePrefix "${root}/" pathString; keepPrefixes = releaseMetadata.topLevelDirectories; + buildOnlyPrefixes = [ "adapters/source" ]; matchesPrefix = prefix: relativePath == prefix @@ -31,7 +32,8 @@ let in relativePath == "." || lib.elem relativePath releaseMetadata.topLevelFiles - || lib.any matchesPrefix keepPrefixes; + || lib.any matchesPrefix keepPrefixes + || lib.any matchesPrefix buildOnlyPrefixes; }; layoutEntriesFile = pkgs.writeText "runecontext-release-layout.txt" ( From 5f380400772d8989ecf9f9a2099eab9d3aa51e90 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich <zeb@runecode.org> Date: Thu, 2 Apr 2026 13:20:52 -0500 Subject: [PATCH 25/27] Updates based on failing checks. Signed-off-by: Zebulon Rouse-Frantzich <zeb@runecode.org> --- tools/syncadapters/render.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tools/syncadapters/render.go b/tools/syncadapters/render.go index d5df0aa..8c1f2e9 100644 --- a/tools/syncadapters/render.go +++ b/tools/syncadapters/render.go @@ -123,10 +123,7 @@ func canonicalizePathAllowMissing(path string) (string, error) { for { resolved, err := filepath.EvalSymlinks(current) if err == nil { - for i := len(missing) - 1; i >= 0; i-- { - resolved = filepath.Join(resolved, missing[i]) - } - return filepath.Clean(resolved), nil + return appendMissingSegments(resolved, missing), nil } if !os.IsNotExist(err) { return "", fmt.Errorf("resolve output root symlinks %q: %w", current, err) @@ -140,6 +137,14 @@ func canonicalizePathAllowMissing(path string) (string, error) { } } +func appendMissingSegments(base string, missing []string) string { + resolved := base + for i := len(missing) - 1; i >= 0; i-- { + resolved = filepath.Join(resolved, missing[i]) + } + return filepath.Clean(resolved) +} + func validateOutputRoot(absRoot, absOutput string) error { if absOutput == string(filepath.Separator) { return fmt.Errorf("output root %q is not allowed", absOutput) From 1b7c370260ec229a73a83b2f29cabc15722bf73a Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich <zeb@runecode.org> Date: Thu, 2 Apr 2026 13:30:54 -0500 Subject: [PATCH 26/27] Fixes failing macOS check. Signed-off-by: Zebulon Rouse-Frantzich <zeb@runecode.org> --- tools/syncadapters/render_test.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tools/syncadapters/render_test.go b/tools/syncadapters/render_test.go index fe8d2c0..d04b242 100644 --- a/tools/syncadapters/render_test.go +++ b/tools/syncadapters/render_test.go @@ -56,7 +56,10 @@ func TestResolveOutputAllowsAbsoluteSymlinkAliasUnderRepositoryRoot(t *testing.T if err != nil { t.Fatalf("resolveOutput returned error: %v", err) } - expected := filepath.Join(realRoot, "build", "generated", "adapters") + expected, err := canonicalizePathAllowMissing(filepath.Join(realRoot, "build", "generated", "adapters")) + if err != nil { + t.Fatalf("canonicalize expected output path: %v", err) + } if got != expected { t.Fatalf("expected canonical output %q, got %q", expected, got) } @@ -69,7 +72,11 @@ func assertRepositoryDescendantOutput(t *testing.T, root, output string) { if err != nil { t.Fatalf("resolveOutput returned error: %v", err) } - rel, err := filepath.Rel(root, got) + canonicalRoot, err := canonicalizePathAllowMissing(root) + if err != nil { + t.Fatalf("canonicalize root: %v", err) + } + rel, err := filepath.Rel(canonicalRoot, got) if err != nil { t.Fatalf("compute relative path: %v", err) } From ff269ba762d5b0708e67feb53a9af268bda24756 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich <zeb@runecode.org> Date: Thu, 2 Apr 2026 14:27:16 -0500 Subject: [PATCH 27/27] Updates main README.md and other documentation files so they are not stale. Also updates AGENTS.md. Signed-off-by: Zebulon Rouse-Frantzich <zeb@runecode.org> --- AGENTS.md | 10 ++++++++++ README.md | 14 +++++++------- adapters/README.md | 1 + docs/compatibility-matrix.md | 2 +- docs/install-verify.md | 6 +++--- docs/release-process.md | 8 ++++---- .../standards/global/adapter-thin-ux-over-cli.md | 6 +++++- .../standards/global/structured-cli-contracts.md | 1 + 8 files changed, 32 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ba37014..4dc66af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,7 @@ effectively in the `runecontext` repository. - Run main local CI gate: `just ci` - Run full Nix CI gate: `just nix-ci` - Run flake checks only: `just check` +- Regenerate adapter packs: `just sync-adapters` - Build release artifacts only: `just release` or `just release-check` ## What Those Commands Do @@ -53,6 +54,7 @@ effectively in the `runecontext` repository. - `just test` runs `go test ./...`. - `just ci` runs `just lint` and `just test`. - `just nix-ci` runs lint, tests, release build checks, and flake checks. +- `just sync-adapters` runs `go run ./tools/syncadapters --root . --output build/generated/adapters`. ## Fast Targeted Test Commands @@ -68,7 +70,9 @@ effectively in the `runecontext` repository. - Small focused Go change: run the affected package tests first. - CLI behavior change: run `go test ./internal/cli` and then `just lint`. - Contract/resolution/change-workflow change: run `go test ./internal/contracts` and then `just lint`. +- Adapter source or host-native adapter rendering change: run `go test ./tools/syncadapters ./internal/cli`, then `just sync-adapters`, then `just lint`. - Tooling or source-quality checker change: run the relevant tool tests, then `just lint`, then `go test ./...`. +- Release packaging or install/release-doc change: run `go test ./internal/cli -run 'TestRelease|TestCompatibilityMatrix|TestInstallScripts'` and `just release-check`. - Before finishing a non-trivial change: prefer at least `just lint` and `just test`. ## Repository Layout @@ -77,8 +81,11 @@ effectively in the `runecontext` repository. - `internal/cli/`: CLI parsing, output contracts, and command behavior. - `internal/contracts/`: core validation, resolution, change workflow, bundles, packs, promotion. - `tools/`: repo-owned developer tooling such as `gofmtcheck` and `checksourcequality`. +- `adapters/source/`: canonical adapter flow/tool definitions and passthrough inputs used by generation. +- `build/generated/adapters/`: generated adapter packs for local/release staging; treat as ephemeral build output. - `fixtures/`: shared test fixtures and golden inputs. - `docs/`: implementation plan, install docs, release docs, and policy docs. +- `runecontext/standards/`: normative standards and policy requirements. - `schemas/` and `core/`: normative contract surfaces. ## Code Organization Conventions @@ -175,10 +182,12 @@ effectively in the `runecontext` repository. ## Protected / High-Trust Surfaces - `flake.nix` and `flake.lock`: high-trust local tooling and release inputs. +- `nix/release/metadata.nix`: canonical release version/tag metadata. - `.golangci.yml`: linter policy. - `justfile`: canonical developer command surface. - `.source-quality-config.json` and `.source-quality-baseline.json`: quality policy. - `tools/checksourcequality/**` and `tools/gofmtcheck/**`: policy-enforcement tools. +- `runecontext/standards/`: normative standards surface. - `schemas/` and `core/`: normative contract surfaces. - `docs/implementation-plan/`: current milestone contract and planning guidance. @@ -187,5 +196,6 @@ effectively in the `runecontext` repository. - Prefer minimal, targeted changes that fit existing patterns. - Do not silently broaden scope by editing protected policy files unless required. - If you change command behavior, update tests and relevant docs in the same patch. +- If you change adapter source or host-native adapter behavior, regenerate with `just sync-adapters`, update related tests/docs, and do not commit `build/generated/adapters/` outputs. - If you change a protected surface, call it out explicitly in your summary. - If you commit, use DCO sign-off: `git commit -s`. diff --git a/README.md b/README.md index 75540fb..8532a3d 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # RuneContext - Portable, markdown-first project knowledge, standards, and context bundles -[![CI](https://github.com/runecode-systems/runecontext/actions/workflows/ci.yml/badge.svg)](https://github.com/runecode-systems/runecontext/actions/workflows/ci.yml) [![Status: alpha.13](https://img.shields.io/badge/status-alpha.13-orange)](docs/implementation-plan/README.md) [![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) +[![CI](https://github.com/runecode-systems/runecontext/actions/workflows/ci.yml/badge.svg)](https://github.com/runecode-systems/runecontext/actions/workflows/ci.yml) [![Status: alpha.14](https://img.shields.io/badge/status-alpha.14-orange)](docs/implementation-plan/README.md) [![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) RuneContext is a portable, markdown-first, git-native system for project knowledge, standards, changes, and reusable context bundles. It is AI-tooling-agnostic by design, so teams can use the same source artifacts with different tools or manual workflows without turning the format into a product-specific silo. ## Status -RuneContext is still pre-MVP and not production-ready. The current repository now includes the alpha.1 through alpha.13 slices: frozen core contracts and versioned schemas, embedded/git/path source resolution with monorepo discovery, signed-tag verification with explicit trusted-signer input, deterministic bundle semantics, standards linkage and traceability, stable change IDs, deterministic context-pack generation and hashing, generated indexes/manifests, reviewable promotion assessment, local-first `init`, `bundle resolve`, `doctor`, explicit `promote`, advisory `standard discover`, verified assurance enable/backfill/capture flows, canonical completion metadata and dynamic suggestions, thin generic/tool adapters, repo-local host-native adapter sync, preview-first `runectx upgrade` / `runectx upgrade apply`, explicit `runectx upgrade cli` / `runectx upgrade cli apply` self-update flows, human-friendly grouped upgrade rendering, version-bump-only alpha upgrade semantics where no migration logic is required, real staged migration-edge execution for on-disk layout updates, synced-tool-only host-native refresh behavior during upgrade, richer human-friendly `runectx status` output, checked-in standards under `runecontext/standards/`, canonical release artifacts and verification docs, compatibility guidance, and MVP readiness/reference-fixture coverage. Remaining work toward `v0.1.0` is final stabilization and sign-off. +RuneContext is still pre-MVP and not production-ready. The current repository now includes the alpha.1 through alpha.14 slices: frozen core contracts and versioned schemas, embedded/git/path source resolution with monorepo discovery, signed-tag verification with explicit trusted-signer input, deterministic bundle semantics, standards linkage and traceability, stable change IDs, deterministic context-pack generation and hashing, generated indexes/manifests, reviewable promotion assessment, local-first `init`, `bundle resolve`, `doctor`, explicit `promote`, advisory `standard discover`, verified assurance enable/backfill/capture flows, canonical completion metadata and dynamic suggestions, thin generic/tool adapters, repo-local host-native adapter sync, preview-first `runectx upgrade` / `runectx upgrade apply`, explicit `runectx upgrade cli` / `runectx upgrade cli apply` self-update flows, human-friendly grouped upgrade rendering, version-bump-only alpha upgrade semantics where no migration logic is required, real staged migration-edge execution for on-disk layout updates, synced-tool-only host-native refresh behavior during upgrade, richer human-friendly `runectx status` output, generated structured adapter workflow contracts and host-native render guidance, checked-in standards under `runecontext/standards/`, canonical release artifacts and verification docs, compatibility guidance, and MVP readiness/reference-fixture coverage. Remaining work toward `v0.1.0` is final stabilization and sign-off. ## Quick Start @@ -26,7 +26,7 @@ bin/runectx doctor --path /path/to/project ## Install / Try The CLI -With alpha.13 upgrade UX, CLI self-update, release/runtime metadata alignment, portability hardening, and assurance-layout migration support now implemented, `runectx` is the primary supported interface for installing, initializing, validating, diagnosing, upgrading, and syncing RuneContext-managed project assets. +With alpha.14 release/runtime metadata alignment, adapter-pack generation hardening, structured host-native adapter guidance, portability hardening, upgrade UX, and assurance-layout migration support now implemented, `runectx` is the primary supported interface for installing, initializing, validating, diagnosing, upgrading, and syncing RuneContext-managed project assets. RuneContext is distributed as reviewable repo bundles from GitHub Releases, and those bundles remain the canonical release contents. The standalone `runectx` binary archive is only an optional delivery format; once RuneContext is installed or vendored, the CLI is the normal way to manage it inside projects. In day-to-day use, project setup and maintenance are centered on `runectx init`, `runectx validate`, `runectx doctor`, `runectx adapter sync`, and `runectx upgrade` / `runectx upgrade apply`. @@ -49,7 +49,7 @@ bash install-runectx.sh Pinned version example: ```sh -TAG="v0.1.0-alpha.13" +TAG="v0.1.0-alpha.14" curl -fsSLO "https://github.com/runecode-systems/runecontext/releases/download/${TAG}/install-runectx.sh" bash install-runectx.sh --version "$TAG" ``` @@ -66,7 +66,7 @@ powershell -ExecutionPolicy Bypass -File .\install-runectx.ps1 Pinned version example: ```powershell -$Tag = "v0.1.0-alpha.13" +$Tag = "v0.1.0-alpha.14" Invoke-WebRequest -Uri "https://github.com/runecode-systems/runecontext/releases/download/$Tag/install-runectx.ps1" -OutFile install-runectx.ps1 powershell -ExecutionPolicy Bypass -File .\install-runectx.ps1 -Version $Tag ``` @@ -123,7 +123,7 @@ For release verification and maintainer workflow details, see `docs/install-veri - Stable generated `manifest.yaml`, `indexes/changes-by-status.yaml`, and `indexes/bundles.yaml` artifacts plus reviewable close-time promotion assessment suggestions and explicit promotion transitions. - CLI support for `runectx version`, `runectx init`, `runectx validate`, `runectx status`, `runectx change new`, `runectx change shape`, `runectx change close`, `runectx change reallocate`, `runectx change update`, `runectx bundle resolve`, `runectx doctor`, `runectx metadata`, `runectx upgrade`, `runectx upgrade apply`, `runectx upgrade cli`, `runectx upgrade cli apply`, `runectx promote`, `runectx standard discover`, `runectx assurance enable|backfill|capture`, `runectx adapter sync`, `runectx adapter render-host-native`, `runectx completion <bash|zsh|fish>`, `runectx completion suggest`, and `runectx completion metadata`. - Shared machine-facing CLI behavior across command operations with stable `key=value` output by default plus `--json`, `--non-interactive`, `--dry-run` for write flows, and incremental `--explain` support (`runectx completion` is the plain-text script-output exception). -- Repo-local host-native adapter artifacts for OpenCode, Claude Code, and Codex with strict ownership headers, fail-closed overwrite protection, stale cleanup by scan, and shell-output injection support for supported hosts. +- Repo-local host-native adapter artifacts for OpenCode, Claude Code, and Codex with strict ownership headers, fail-closed overwrite protection, stale cleanup by scan, shell-output injection support for supported hosts, and generated structured workflow contracts rendered from canonical adapter sources. - Canonical `nix build .#release-artifacts` packaging with repo bundles, schema bundle, adapter packs, Linux/macOS `runectx` archives, checksums, release manifest, signatures, attestations, and verification docs. - Nix, `just`, and GitHub Actions scaffolding for repeatable development, checks, and release work. @@ -258,7 +258,7 @@ Current CLI scope: - `fixtures/` - shared fixture families for schema, markdown, traceability, source/bundle resolution, and change-workflow validation. - `cmd/runectx/` - the Go CLI entrypoint for `runectx`. - `internal/` - shared Go implementation packages for validation, source resolution, and change workflow. -- `adapters/` - tool-specific adapter packs and docs as that surface grows. +- `adapters/` - canonical adapter sources, tool capability definitions, and passthrough adapter content used to generate tool-specific packs. - `docs/` - design, planning, installation, and release-process documentation. - `tools/` - repository-owned quality and maintenance tooling. - `nix/` - canonical dev-shell, check, and release-artifact definitions. diff --git a/adapters/README.md b/adapters/README.md index a98f41f..56ddfb8 100644 --- a/adapters/README.md +++ b/adapters/README.md @@ -8,6 +8,7 @@ Canonical adapter definitions live under `adapters/source/`. Rendered adapter packs are build-generated at `build/generated/adapters/` via `just sync-adapters`. Those generated outputs are ephemeral and must not be committed. +Generated packs include per-tool `capabilities.yaml`, generated flow markdown under `flows/`, and a machine-readable `workflow.json` contract used by host-native rendering. Contributor workflow: diff --git a/docs/compatibility-matrix.md b/docs/compatibility-matrix.md index b8b4561..16d4726 100644 --- a/docs/compatibility-matrix.md +++ b/docs/compatibility-matrix.md @@ -7,7 +7,7 @@ compatibility gate for project upgrades and runtime wiring. | RuneCode release | Acceptable `runecontext_version` range | Adapter-pack compatibility | Notes | | --- | --- | --- | --- | -| `v0.1.0-alpha.*` | `0.1.0-alpha.5` – `0.1.0-alpha.8` | `adapter-generic`, `adapter-codex`, `adapter-claude-code`, `adapter-opencode` (same release tag) | Alpha flows remain repo-first; RuneCode should require matching release-line assets from the same signed release set. | +| `v0.1.0-alpha.14` | `0.1.0-alpha.5`, `0.1.0-alpha.6`, `0.1.0-alpha.7`, `0.1.0-alpha.8`, `0.1.0-alpha.14` | `adapter-generic`, `adapter-codex`, `adapter-claude-code`, `adapter-opencode` (same release tag) | Matches the current `runectx metadata` compatibility descriptor: alpha.14 directly supports the legacy alpha.5-alpha.8 project line plus alpha.14 itself. | | `v0.1.0` | `0.1.0` (planned GA) | Same adapter packs plus any GA follow-up adapters | At GA, compatibility freezes to the GA contract and older alpha `runecontext_version` values should be rejected. | If a project reports an out-of-range `runecontext_version`, validation and diff --git a/docs/install-verify.md b/docs/install-verify.md index 32e11d0..47dbb01 100644 --- a/docs/install-verify.md +++ b/docs/install-verify.md @@ -212,7 +212,7 @@ If you prefer not to resolve `latest`, set the version explicitly and run the same flow. ```bash -VERSION="v0.1.0-alpha.8" +VERSION="v0.1.0-alpha.14" ``` Replace the `VERSION=...` line in the previous script with the pinned tag you @@ -297,7 +297,7 @@ try { ## Windows PowerShell: installing `runectx` -Windows binary archives are not published yet in the current alpha.8 asset set. Use +Windows binary archives are not published yet in the current alpha.14 asset set. Use the repo bundle flow on Windows for now, or build `runectx` locally from source. ## Windows PowerShell: pinned release, full verification, extract @@ -306,7 +306,7 @@ If you prefer not to resolve `latest`, set the version explicitly and run the same flow. ```powershell -$Version = "v0.1.0-alpha.8" +$Version = "v0.1.0-alpha.14" ``` Replace the `$Version=...` line in the previous script with the pinned tag you diff --git a/docs/release-process.md b/docs/release-process.md index 9bd81b2..4f487fc 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -179,8 +179,8 @@ If a published release is bad: ## Notes for future expansion -This initial release flow already ships canonical repo bundles and Linux/macOS -`runectx` binaries. When future alphas add explicit schema-only bundles, -adapter-pack bundles, or more platform binaries, extend the workflow and -verification docs to include them as additional signed and attested release +This release flow already ships canonical repo bundles, schema bundles, +adapter-pack bundles, and Linux/macOS `runectx` binaries. When future alphas +add more platform binaries or other signed bundle variants, extend the workflow +and verification docs to include them as additional signed and attested release assets. diff --git a/runecontext/standards/global/adapter-thin-ux-over-cli.md b/runecontext/standards/global/adapter-thin-ux-over-cli.md index b0d483c..01ad6f2 100644 --- a/runecontext/standards/global/adapter-thin-ux-over-cli.md +++ b/runecontext/standards/global/adapter-thin-ux-over-cli.md @@ -19,9 +19,13 @@ Keep adapter-specific experiences conversational and ergonomic without creating ## Requirements - Adapters must treat the CLI and core contracts as the source of truth for semantics and mutation behavior. -- Adapter-native assets may gather context, ask clarifying questions, and present next steps, but they must not invent hidden state or alternate workflow rules. +- Adapter-native assets may gather context and present next steps, but they must not invent hidden state or alternate workflow rules. - Host-specific generated files must remain additive tool UX layers rather than authoritative project data. - Adapter flows should prefer reviewable command proposals and structured CLI outputs over opaque prompt-only behavior. +- Generated host-native adapter assets must not depend on host-specific interactive question primitives. +- Generated adapter workflows must stay within one explicit RuneContext command family per invoked flow; they may repeat the same RuneContext command when needed, but must not auto-chain into a different command. +- Generated adapter workflows must stop after the immediate RuneContext outcome and recommend next commands instead of executing them automatically. +- Generated adapter guidance must not drift into product-code implementation behavior when the immediate RuneContext workflow outcome has been reached. ## Rationale diff --git a/runecontext/standards/global/structured-cli-contracts.md b/runecontext/standards/global/structured-cli-contracts.md index f85522b..c686d27 100644 --- a/runecontext/standards/global/structured-cli-contracts.md +++ b/runecontext/standards/global/structured-cli-contracts.md @@ -22,6 +22,7 @@ Keep RuneContext operations machine-friendly, stable, and reusable across direct - New command behavior should prefer explicit structured fields over output text that requires fragile parsing. - Read-only advisory flows should emit reusable candidate data and next-step hints without hidden session state. - Completion metadata, command docs, and adapter-native command surfaces should derive from the same canonical operation metadata where possible. +- Generated adapter-native workflow docs should expose the immediate RuneContext outcome and recommended next commands rather than hide multi-step execution behind host-specific automation. - Release manifests and generated docs/reference artifacts should derive from the same canonical metadata descriptor when they expose CLI capability or compatibility information. - Compatibility reporting should remain distinct from explicit upgrade-path reporting when both concepts are surfaced to machines.