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/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 `, `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 664f63d..56ddfb8 100644 --- a/adapters/README.md +++ b/adapters/README.md @@ -1,3 +1,17 @@ # 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. +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: + +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/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-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 "" --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-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-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-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 81% rename from adapters/claude-code/flows/conversational-parity.md rename to adapters/source/packs/claude-code/flows/conversational-parity.md index 572cde7..b33d27d 100644 --- a/adapters/claude-code/flows/conversational-parity.md +++ b/adapters/source/packs/claude-code/flows/conversational-parity.md @@ -10,6 +10,10 @@ 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 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/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 80% rename from adapters/codex/flows/conversational-parity.md rename to adapters/source/packs/codex/flows/conversational-parity.md index eff8316..9653531 100644 --- a/adapters/codex/flows/conversational-parity.md +++ b/adapters/source/packs/codex/flows/conversational-parity.md @@ -10,6 +10,10 @@ 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 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/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 81% rename from adapters/generic/flows/conversational-parity.md rename to adapters/source/packs/generic/flows/conversational-parity.md index 835b417..6d522b9 100644 --- a/adapters/generic/flows/conversational-parity.md +++ b/adapters/source/packs/generic/flows/conversational-parity.md @@ -10,6 +10,10 @@ 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 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/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 80% rename from adapters/opencode/flows/conversational-parity.md rename to adapters/source/packs/opencode/flows/conversational-parity.md index 2d3b1cb..7e26a00 100644 --- a/adapters/opencode/flows/conversational-parity.md +++ b/adapters/source/packs/opencode/flows/conversational-parity.md @@ -10,6 +10,10 @@ 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 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/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/adapters/source/shared/flows/change-assess-decomposition.json b/adapters/source/shared/flows/change-assess-decomposition.json new file mode 100644 index 0000000..76fd355 --- /dev/null +++ b/adapters/source/shared/flows/change-assess-decomposition.json @@ -0,0 +1,39 @@ +{ + "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.", + "Do not implement product code in this flow." + ], + "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..ce510bc --- /dev/null +++ b/adapters/source/shared/flows/change-assess-intake.json @@ -0,0 +1,44 @@ +{ + "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.", + "Do not implement product code in this flow." + ], + "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.", + "Use advisory output to recommend one feature, multiple features, or project-plus-features where applicable.", + "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 standard discover --scope-path <path>" + ], + "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..35a9323 --- /dev/null +++ b/adapters/source/shared/flows/change-decomposition-apply.json @@ -0,0 +1,41 @@ +{ + "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.", + "Do not implement product code in this flow." + ], + "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 <change-id> --accept" + ], + "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..7f2d362 --- /dev/null +++ b/adapters/source/shared/flows/change-decomposition-plan.json @@ -0,0 +1,43 @@ +{ + "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.", + "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", + "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.", + "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." + ], + "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 new --title \"<title>\" --type feature" + ], + "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..aaf0053 --- /dev/null +++ b/adapters/source/shared/flows/change-new.json @@ -0,0 +1,48 @@ +{ + "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 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 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": [ + "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 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 resulting change ids and paths." + ], + "workflow_steps": [ + "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 created change ids and outputs; recommend next commands only and do not execute them.", + "recommended_next_commands": [ + "runectx change shape <change-id>", + "runectx change assess-decomposition <change-id>" + ], + "examples": [ + { + "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 new file mode 100644 index 0000000..e4e1c3a --- /dev/null +++ b/adapters/source/shared/flows/change-shape.json @@ -0,0 +1,41 @@ +{ + "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.", + "Do not implement product code in this flow." + ], + "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..ea05798 --- /dev/null +++ b/adapters/source/shared/flows/promote.json @@ -0,0 +1,43 @@ +{ + "id": "promote", + "command_path": "promote", + "description": "Advance RuneContext promotion state", + "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 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.", + "Do not create standards, discover standards, or implement product code in this flow." + ], + "inputs_to_gather": [ + "change id", + "optional promote mode (accept or complete)", + "optional promotion target", + "optional project path" + ], + "decision_rules": [ + "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 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." + ], + "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": "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 new file mode 100644 index 0000000..17bc183 --- /dev/null +++ b/adapters/source/shared/flows/standard-discover.json @@ -0,0 +1,46 @@ +{ + "id": "standard-discover", + "command_path": "standard discover", + "description": "Discover standards candidates for promotion", + "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 and change inputs when supplied, and avoid hidden implicit scope assumptions.", + "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", + "optional change id", + "optional scope-path", + "optional focus", + "optional confirm-handoff", + "optional target" + ], + "decision_rules": [ + "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 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." + ], + "stop_condition": "Stop after discovery output is summarized and candidate targets are listed.", + "recommended_next_commands": [ + "runectx promote <change-id> --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. If you want explicit handoff confirmation, I can add `--confirm-handoff`." + } + ] +} 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/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 c216fae..47dbb01 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. @@ -207,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 @@ -292,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 @@ -301,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/reference/generated/runecontext-reference.json b/docs/reference/generated/runecontext-reference.json index 56d3302..4ff612b 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,10 +49,26 @@ "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" @@ -113,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" @@ -163,7 +191,11 @@ "bundle", "bundle_resolve", "change", + "change_assess_decomposition", + "change_assess_intake", "change_close", + "change_decomposition_apply", + "change_decomposition_plan", "change_new", "change_reallocate", "change_shape", @@ -179,7 +211,10 @@ "metadata", "promote", "standard", + "standard_create", "standard_discover", + "standard_list", + "standard_update", "status", "upgrade", "upgrade_apply", @@ -201,13 +236,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", @@ -224,7 +259,7 @@ { "profile": "repo_bundle", "schema_path": "schemas", - "adapters_path": "adapters" + "adapters_path": "build/generated/adapters" }, { "profile": "installed_share_layout", diff --git a/docs/release-process.md b/docs/release-process.md index 23c0068..4f487fc 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 @@ -178,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/fixtures/cli-completion/bash.golden b/fixtures/cli-completion/bash.golden index a678bc6..5182808 100644 --- a/fixtures/cli-completion/bash.golden +++ b/fixtures/cli-completion/bash.golden @@ -13,7 +13,11 @@ _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 decomposition-apply') return 0 ;; + 'change decomposition-plan') return 0 ;; 'change new') return 0 ;; 'change reallocate') return 0 ;; 'change shape') return 0 ;; @@ -29,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 ;; @@ -55,8 +62,12 @@ _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 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 '' ;; @@ -71,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 '' ;; @@ -98,19 +112,26 @@ _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 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' ;; - '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' ;; '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' ;; @@ -130,10 +151,23 @@ _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 ;; '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 ;; @@ -147,7 +181,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 ;; @@ -159,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 ;; @@ -180,6 +236,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' ;; @@ -187,25 +245,35 @@ _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 } _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' ;; '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 } _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-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' ;; @@ -220,7 +288,10 @@ _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 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 1d31fe2..f9a5881 100644 --- a/fixtures/cli-completion/fish.golden +++ b/fixtures/cli-completion/fish.golden @@ -61,7 +61,11 @@ 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 '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' @@ -69,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' @@ -122,6 +129,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 @@ -133,6 +156,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 @@ -162,12 +200,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 @@ -203,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 @@ -217,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 @@ -248,26 +330,42 @@ 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 --alias + return 0 + case --body + return 0 case --bundle return 0 case --change return 0 case --closed-at return 0 + case --depends-on + return 0 case --description return 0 case --design 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 @@ -282,6 +380,10 @@ function __runectx_flag_name_takes_value return 0 case --status return 0 + case --sub-change + return 0 + case --suggested-context-bundle + return 0 case --superseded-by return 0 case --target @@ -368,7 +470,10 @@ 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 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 9b9be6d..d2c6603 100644 --- a/fixtures/cli-completion/zsh.golden +++ b/fixtures/cli-completion/zsh.golden @@ -16,7 +16,11 @@ _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 decomposition-apply') return 0 ;; + 'change decomposition-plan') return 0 ;; 'change new') return 0 ;; 'change reallocate') return 0 ;; 'change shape') return 0 ;; @@ -32,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 ;; @@ -58,8 +65,12 @@ _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 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 '' ;; @@ -74,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 '' ;; @@ -101,19 +115,26 @@ _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 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' ;; - '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' ;; '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' ;; @@ -133,10 +154,23 @@ _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 ;; '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 ;; @@ -150,7 +184,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 ;; @@ -162,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 ;; @@ -183,6 +239,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' ;; @@ -190,25 +248,35 @@ _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 } _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' ;; '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 } _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-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' ;; @@ -223,7 +291,10 @@ _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 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 b3dbfe0..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 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 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 38c97d2..77d079a 100644 --- a/fixtures/release/release-manifest-with-metadata.json +++ b/fixtures/release/release-manifest-with-metadata.json @@ -67,10 +67,26 @@ "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" @@ -131,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" @@ -181,13 +209,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": [ { @@ -202,7 +230,7 @@ }, "distribution_layouts": [ { - "adapters_path": "adapters", + "adapters_path": "build/generated/adapters", "profile": "repo_bundle", "schema_path": "schemas" }, @@ -238,8 +266,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 +286,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..6c42cfa 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|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>" @@ -25,11 +25,18 @@ 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]" + 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>" + 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>" @@ -160,9 +167,9 @@ 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"}, + {name: "standard", description: "Discover, list, and author standards"}, }, }, { @@ -198,6 +205,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_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_pack_test.go b/internal/cli/cli_adapter_pack_test.go index d7f3a2e..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() { @@ -32,6 +27,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) }) @@ -147,7 +145,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 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 +163,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-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.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 1823052..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" ) @@ -11,7 +12,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,11 +25,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, " ")+"`") } @@ -35,21 +43,96 @@ 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) } + referenceRoot, err := adapterReferenceRoot() + if err != nil { + return "", err + } lines := []string{ - "- canonical_flow_source: `adapters/" + request.tool + "/flows/*.md`", + "- 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, } for _, flow := range flows { - commandPath := strings.ReplaceAll(flow.id, "-", " ") + commandPath := flow.commandPath command, ok := commandByPath(commandPath) if !ok { continue @@ -64,17 +147,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 := strings.ReplaceAll(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) @@ -83,14 +175,27 @@ 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, - 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: workflowContractReferencePath(referenceRoot, request.tool), + 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 } diff --git a/internal/cli/cli_adapter_render_test.go b/internal/cli/cli_adapter_render_test.go index df83cfc..9519881 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" ) @@ -28,10 +31,18 @@ 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, } { if !strings.Contains(text, token) { t.Fatalf("expected token %q in render output, got %q", token, text) @@ -42,6 +53,42 @@ func TestRunAdapterRenderHostNativeOutputsMinimalMarkdown(t *testing.T) { } } +func TestRunAdapterRenderHostNativeSupportsAssessmentOperations(t *testing.T) { + 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 + 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 := expectedCommandPathForOperation(operation) + if !strings.Contains(text, "command_path: `"+expectedPath+"`") { + t.Fatalf("expected command path for %s, got %q", operation, text) + } + }) + } +} + +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 @@ -56,17 +103,32 @@ 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) + } + 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) + } + 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) { +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()) } } @@ -78,9 +140,86 @@ 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) } } } + +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()) + } +} + +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_discovery.go b/internal/cli/cli_adapter_sync_discovery.go index fa3fdc9..20adeb1 100644 --- a/internal/cli/cli_adapter_sync_discovery.go +++ b/internal/cli/cli_adapter_sync_discovery.go @@ -11,18 +11,39 @@ func locateAdaptersRoot() (string, error) { if err != nil { return "", err } + projectRoot := filepath.Dir(schemaRoot) candidates := []string{ - filepath.Join(schemaRoot, "adapters"), - filepath.Join(filepath.Dir(schemaRoot), "adapters"), + filepath.Join(projectRoot, "build", "generated", "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 { @@ -30,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 282ff81..3125e2f 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 @@ -19,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: @@ -40,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" @@ -65,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" @@ -91,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" @@ -109,42 +129,13 @@ func buildCodexHostNativeArtifacts() []hostNativeArtifact { }) } sortHostNativeArtifacts(artifacts) - return artifacts + return artifacts, nil } 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-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{ @@ -154,6 +145,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") @@ -168,6 +161,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") @@ -188,7 +183,7 @@ func buildClaudeCommandIndexShimContent(flows []hostNativeFlow) []byte { "## Commands", }...) for _, flow := range flows { - commandPath := strings.ReplaceAll(flow.id, "-", " ") + commandPath := flow.commandPath lines = append(lines, "", "- `runecontext:"+flow.id+"`", @@ -199,6 +194,8 @@ func buildClaudeCommandIndexShimContent(flows []hostNativeFlow) []byte { ) } lines = append(lines, + "", + hostNativeNoQuestionRule, "", buildHostNativeBody("claude-code", "index", hostNativeKindDiscoverabilityShim), ) 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..a59014b --- /dev/null +++ b/internal/cli/cli_adapter_sync_host_native_flows.go @@ -0,0 +1,205 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" +) + +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 + } + 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(referenceRoot, 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 loadWorkflowDocument(tool string) (adapterWorkflowDocument, error) { + raw, path, err := loadWorkflowContractBytes(tool) + if err != nil { + return adapterWorkflowDocument{}, 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 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 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 == "" { + 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(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 { + 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_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/internal/cli/cli_adapter_test.go b/internal/cli/cli_adapter_test.go index 996ebde..e9de555 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" @@ -115,39 +114,108 @@ 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() + 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"], "8"; 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") + 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", "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")) + 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) { + t.Helper() 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"], "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")) + 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) { + t.Helper() codex := runAdapterSyncAndParse(t, projectRoot, "codex") - if got, want := codex["host_native_file_count"], "4"; 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")) + assertNoQuestionToolRulePresent(t, filepath.Join(projectRoot, ".agents", "skills", "runecontext-change-new.md")) } func TestRunAdapterSyncHostNativeConflictFailsClosed(t *testing.T) { @@ -232,7 +300,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) } @@ -268,7 +336,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()) } } @@ -336,102 +404,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 @@ -484,55 +456,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..4bc8ef0 --- /dev/null +++ b/internal/cli/cli_adapter_test_helpers_test.go @@ -0,0 +1,169 @@ +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 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) + 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", "source", "packs", "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 fe5d394..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) @@ -37,6 +40,14 @@ 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) + 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 @@ -180,7 +191,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_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..6f95970 --- /dev/null +++ b/internal/cli/cli_change_decomposition_flow_test.go @@ -0,0 +1,161 @@ +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: "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"}, + } { + 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 dd33e26..a284f66 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) @@ -102,6 +104,95 @@ 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 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 277ced2..0b95558 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") @@ -369,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 @@ -463,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_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_completion_suggest_test.go b/internal/cli/cli_completion_suggest_test.go index e308f03..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 @@ -258,33 +252,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["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_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..7934800 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"}, @@ -178,6 +182,69 @@ 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 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_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_metadata_flags.go b/internal/cli/cli_metadata_flags.go index eb61c26..6bd4aac 100644 --- a/internal/cli/cli_metadata_flags.go +++ b/internal/cli/cli_metadata_flags.go @@ -36,11 +36,32 @@ 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()}, } } +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 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()}, @@ -76,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 ed8eb02..1112903 100644 --- a/internal/cli/cli_metadata_registry.go +++ b/internal/cli/cli_metadata_registry.go @@ -93,6 +93,10 @@ 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)}}}, + {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)}}}, }, } } @@ -126,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_metadata_registry_adapter.go b/internal/cli/cli_metadata_registry_adapter.go index ebb1106..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-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 db514f9..2ff78c4 100644 --- a/internal/cli/cli_metadata_registry_test.go +++ b/internal/cli/cli_metadata_registry_test.go @@ -73,6 +73,89 @@ 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 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 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) + 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 { @@ -94,6 +177,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") @@ -103,6 +188,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 2c2472c..7ed5491 100644 --- a/internal/cli/cli_parse.go +++ b/internal/cli/cli_parse.go @@ -49,12 +49,21 @@ 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 changeDecompositionRequest struct { + root string + explicitRoot bool + umbrellaID string + subChanges []contracts.SplitSubChange } func parseChangeNewArgs(args []string) (changeNewRequest, error) { @@ -208,44 +217,3 @@ func parseChangeReallocateArgs(args []string) (changeReallocateRequest, error) { request.changeID = changeID 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 -} 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..9114fa2 --- /dev/null +++ b/internal/cli/cli_parse_change_decomposition.go @@ -0,0 +1,112 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/runecode-systems/runecontext/internal/contracts" +) + +type pendingSubChangeCLI struct { + id string + dependsOn []string + explicit bool +} + +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) + } + node := ensurePendingSubChange(id, pending, order) + node.explicit = true + 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, 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 +} + +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/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/cli/cli_release_artifacts_test.go b/internal/cli/cli_release_artifacts_test.go index f183021..0b8dce2 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,8 +48,11 @@ 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"`, - `"${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`, @@ -59,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/internal/cli/cli_release_layout_test.go b/internal/cli/cli_release_layout_test.go index 30c0c74..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) @@ -17,16 +17,21 @@ 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") } } 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) + } + seedAdapterPackForDiscovery(t, stagedAdaptersDir, "opencode") originalWD, err := os.Getwd() if err != nil { @@ -45,7 +50,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) } @@ -54,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/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/cli/cli_upgrade_adapter_plans.go b/internal/cli/cli_upgrade_adapter_plans.go index 07b1bc5..b0b0ae0 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,17 +66,41 @@ func hasManagedHostNativeArtifactsInDir(root, tool string) (bool, error) { } func scanManagedHostNativeArtifactsInDir(root, tool string) (bool, error) { - var managed bool + 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, &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) { +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 } @@ -82,16 +108,12 @@ 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 } -func markManagedHostNativeFile(path, tool string, managed *bool) error { +func markManagedHostNativeFile(path, tool string) error { owned, err := isManagedHostNativeFileForTool(path, tool) if err != nil { return err @@ -99,8 +121,7 @@ func markManagedHostNativeFile(path, tool string, managed *bool) error { if !owned { return nil } - *managed = true - return filepath.SkipAll + return errManagedHostNativeArtifactFound } func existingHostNativeDir(root string) (bool, error) { 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/cli/testhelpers_test.go b/internal/cli/testhelpers_test.go index 0d8c0fd..a94e56e 100644 --- a/internal/cli/testhelpers_test.go +++ b/internal/cli/testhelpers_test.go @@ -91,6 +91,29 @@ 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) + 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", workspace, "--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/internal/contracts/change_assessment.go b/internal/contracts/change_assessment.go new file mode 100644 index 0000000..f836205 --- /dev/null +++ b/internal/contracts/change_assessment.go @@ -0,0 +1,224 @@ +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, err := normalizeAssessedChangeType(options.Type) + if 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, assumptions, standards, err := resolveIntakeContextAndStandards(index, loaded, options, assessment) + if err != nil { + return nil, err + } + + 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 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 + } + 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..3755384 --- /dev/null +++ b/internal/contracts/change_assessment_test.go @@ -0,0 +1,160 @@ +package contracts + +import ( + "path/filepath" + "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) + } +} + +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 df25212..e7ebb58 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 @@ -46,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 { @@ -72,6 +82,7 @@ type ChangeOperationResult struct { Mode ChangeMode RecommendedMode ChangeMode Status string + RelatedChanges []string ClosedAt string ContextBundles []string ApplicableStandards []string @@ -88,6 +99,58 @@ 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 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_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_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 +} 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) +} diff --git a/internal/contracts/standard_commands_create.go b/internal/contracts/standard_commands_create.go new file mode 100644 index 0000000..9d1ccb5 --- /dev/null +++ b/internal/contracts/standard_commands_create.go @@ -0,0 +1,130 @@ +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, err := normalizeOptionalStandardReplacement(options.ReplacedBy) + if err != nil { + return preparedCreateStandardOptions{}, err + } + 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..cb44c62 --- /dev/null +++ b/internal/contracts/standard_commands_helpers.go @@ -0,0 +1,197 @@ +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 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 "" + } + 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..1c32e39 --- /dev/null +++ b/internal/contracts/standard_commands_list.go @@ -0,0 +1,109 @@ +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 + } + defer index.Close() + 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..e9e788e --- /dev/null +++ b/internal/contracts/standard_commands_test.go @@ -0,0 +1,142 @@ +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) + } +} + +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_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..70c767e --- /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 := normalizeOptionalStandardReplacement(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 +} 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/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" ( diff --git a/nix/release/metadata.nix b/nix/release/metadata.nix index de041c6..61b9894 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" @@ -21,7 +21,6 @@ let topLevelDirectories = [ "docs" "core" - "adapters" "schemas" "fixtures" "scripts" @@ -55,25 +54,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..83e0e93 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}" @@ -55,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' {} + @@ -199,7 +204,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/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-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..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 @@ -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 @@ -14,6 +14,10 @@ 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 + - CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts depends_on: [] informed_by: [] supersedes: [] 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 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: [] 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..8fba2f2 --- /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: implemented +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..3996aa7 --- /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: implemented +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..b77cef9 --- /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: implemented +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. 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..4d86d91 --- /dev/null +++ b/runecontext/changes/CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts/status.yaml @@ -0,0 +1,21 @@ +schema_version: 1 +id: CHG-2026-029-010a-add-generated-structured-adapter-workflow-contracts +title: Add generated structured adapter workflow contracts +status: implemented +type: feature +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: [] +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. 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. 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/main.go b/tools/syncadapters/main.go new file mode 100644 index 0000000..a4094f9 --- /dev/null +++ b/tools/syncadapters/main.go @@ -0,0 +1,24 @@ +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") + toolFlag := flag.String("tool", "", "optional adapter tool id to generate") + flag.Parse() + + if err := run(*rootFlag, *outputFlag, *toolFlag); 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..1e2cc1c --- /dev/null +++ b/tools/syncadapters/model.go @@ -0,0 +1,241 @@ +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"` + 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 { + DisplayName string `json:"display_name"` + FlowIntro string `json:"flow_intro"` + Capabilities map[string]string `json:"capabilities"` +} + +func loadFlowDefinitions(root string) ([]flowDefinition, error) { + 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 +} + +func validateFlowDefinition(def flowDefinition) error { + if strings.TrimSpace(def.ID) == "" { + return errors.New("id 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(example.UserPrompt) == "" { + return fmt.Errorf("%s.user_prompt is required", prefix) + } + if strings.TrimSpace(example.AssistantResponse) == "" { + return fmt.Errorf("%s.assistant_response is required", prefix) + } + 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..8c1f2e9 --- /dev/null +++ b/tools/syncadapters/render.go @@ -0,0 +1,258 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +const generatedFlowsDir = "flows" + +func run(root, output, toolID 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 + } + 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 _, 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") + } + 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) { + absOutput = filepath.Clean(output) + } else { + absOutput = filepath.Clean(filepath.Join(canonicalRoot, output)) + } + canonicalOutput, err := canonicalizePathAllowMissing(absOutput) + if err != nil { + return "", err + } + if err := validateOutputRoot(canonicalRoot, canonicalOutput); err != nil { + return "", err + } + 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 { + return appendMissingSegments(resolved, missing), 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 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) + } + 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 outputSegments(relOutput) { + current = filepath.Join(current, segment) + exists, symlinked, err := outputComponentState(current) + if err != nil { + return err + } + if !exists { + return nil + } + 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() { + return fmt.Errorf("adapter passthrough 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 + } + if err := writeFlows(targetDir, tool, flows); err != nil { + return err + } + return writeWorkflowContract(targetDir, toolID, tool, flows) +} + +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{}{} + } + return excludes +} + +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) +} 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 new file mode 100644 index 0000000..d04b242 --- /dev/null +++ b/tools/syncadapters/render_test.go @@ -0,0 +1,227 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "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() + assertRepositoryDescendantOutput(t, root, "build/generated/adapters") + assertRepositoryDescendantOutput(t, root, filepath.Join(root, "build", "generated", "adapters")) +} + +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") + } +} + +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, 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) + } +} + +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) + } + 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) + } + if rel == "." || rel == ".." || rel == "" { + t.Fatalf("expected repository descendant output, got %q", got) + } + }) +} + +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 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 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") + 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) +}