diff --git a/.github/agents/architect.agent.md b/.github/agents/architect.agent.md index e708bbe..d16b7ac 100644 --- a/.github/agents/architect.agent.md +++ b/.github/agents/architect.agent.md @@ -10,12 +10,13 @@ tools: - read - search - edit + - execute - web - vscode - todo - agent agents: - - * + - '*' model: - Claude Sonnet 4.6 (copilot) - GPT-5.3-Codex (copilot) @@ -23,7 +24,7 @@ model: user-invocable: true target: vscode handoffs: - - label: 'Go to next stage: Design' + - label: 'Go to next stage: Designer' agent: designer prompt: >- Architecture outputs are approved. Assess the current state and produce design specifications as @@ -150,6 +151,15 @@ what work is needed: | `docs/architecture/overview.md` | | `docs/architecture/adr/*.md` | +### baseline docs you maintain + +Keep these files current. Update them whenever the relevant scope, design, or implementation changes — do not let them go stale. + +| Artifact | +| ------------------------------- | +| `docs/architecture/overview.md` | +| `docs/architecture/adr/*.md` | + Agents do not write to artifacts owned by other roles. If you discover something that requires changes to upstream artifacts, flag it and trigger a reverse handoff. diff --git a/.github/agents/designer.agent.md b/.github/agents/designer.agent.md index 4151ba0..a5a1949 100644 --- a/.github/agents/designer.agent.md +++ b/.github/agents/designer.agent.md @@ -9,19 +9,20 @@ tools: - read - search - edit + - execute - web - vscode - todo - agent agents: - - * + - '*' model: - Claude Sonnet 4.6 (copilot) - GPT-5.3-Codex (copilot) user-invocable: true target: vscode handoffs: - - label: 'Go to next stage: Engineering' + - label: 'Go to next stage: Engineer' agent: engineer prompt: >- Design outputs are approved. Assess the current state and implement code and tests as needed. If @@ -165,6 +166,15 @@ what work is needed: | `docs/design/ux.md` | frontend/fullstack scope only | | `docs/design/**/*.md` | additional detail docs per component, model, system, or domain (when scope warrants it) | +### baseline docs you maintain + +Keep these files current. Update them whenever the relevant scope, design, or implementation changes — do not let them go stale. + +| Artifact | Notes | +| ------------------------- | ----------------------------- | +| `docs/design/overview.md` | | +| `docs/design/ux.md` | frontend/fullstack scope only | + Agents do not write to artifacts owned by other roles. If you discover something that requires changes to upstream artifacts, flag it and trigger a reverse handoff. diff --git a/.github/agents/engineer.agent.md b/.github/agents/engineer.agent.md index a2078ab..b89a799 100644 --- a/.github/agents/engineer.agent.md +++ b/.github/agents/engineer.agent.md @@ -15,14 +15,14 @@ tools: - todo - agent agents: - - * + - '*' model: - GPT-5.3-Codex (copilot) - Claude Sonnet 4.6 (copilot) user-invocable: true target: vscode handoffs: - - label: 'Go to next stage: Verification' + - label: 'Go to next stage: Tester' agent: tester prompt: >- Implementation is approved. Assess the current state and verify the implementation as needed — run @@ -160,6 +160,8 @@ what work is needed: | `issues/{id}-{slug}-rca.md` | when working on an issue | | `issues/{id}-{slug}-postmortem.md` | when stakeholder impact is significant | + + Agents do not write to artifacts owned by other roles. If you discover something that requires changes to upstream artifacts, flag it and trigger a reverse handoff. diff --git a/.github/agents/product.agent.md b/.github/agents/product.agent.md index c030281..17f92e1 100644 --- a/.github/agents/product.agent.md +++ b/.github/agents/product.agent.md @@ -9,12 +9,13 @@ tools: - read - search - edit + - execute - web - vscode - todo - agent agents: - - * + - '*' model: - Claude Sonnet 4.6 (copilot) - GPT-5.3-Codex (copilot) @@ -22,7 +23,7 @@ model: user-invocable: true target: vscode handoffs: - - label: 'Go to next stage: Architecture' + - label: 'Go to next stage: Architect' agent: architect prompt: >- Product outputs are approved. Assess the current state and produce or update the architecture as @@ -133,6 +134,16 @@ Handoffs you own: | `docs/product/changes/*.md` | | `docs/product/issues/*.md` | +### baseline docs you maintain + +Keep these files current. Update them whenever the relevant scope, design, or implementation changes — do not let them go stale. + +| Artifact | +| ------------------------------ | +| `docs/product/vision.md` | +| `docs/product/requirements.md` | +| `docs/product/roadmap.md` | + Agents do not write to artifacts owned by other roles. If you discover something that requires changes to upstream artifacts, flag it and trigger a reverse handoff. diff --git a/.github/agents/release.agent.md b/.github/agents/release.agent.md index e0a0c85..add10cb 100644 --- a/.github/agents/release.agent.md +++ b/.github/agents/release.agent.md @@ -11,11 +11,12 @@ tools: - search - edit - execute + - web - vscode - todo - agent agents: - - * + - '*' model: - Claude Sonnet 4.6 (copilot) - GPT-5.3-Codex (copilot) @@ -123,6 +124,8 @@ and wait for explicit user routing decisions. | -------------------- | ------------------------------------------ | | `docs/releases/*.md` | includes release notes and sign-off record | + + Agents do not write to artifacts owned by other roles. If you discover something that requires changes to upstream artifacts, flag it and trigger a reverse handoff. diff --git a/.github/agents/tester.agent.md b/.github/agents/tester.agent.md index 9403956..baae432 100644 --- a/.github/agents/tester.agent.md +++ b/.github/agents/tester.agent.md @@ -15,14 +15,14 @@ tools: - todo - agent agents: - - * + - '*' model: - Claude Sonnet 4.6 (copilot) - GPT-5.3-Codex (copilot) user-invocable: true target: vscode handoffs: - - label: 'Go to next stage: Release readiness' + - label: 'Go to next stage: Release' agent: release prompt: >- Verification outputs are approved. Assess the current state and prepare the release as needed. @@ -145,6 +145,8 @@ what work is needed: | `docs/reports/**/*.md` | | `tests/**/*` | + + Agents do not write to artifacts owned by other roles. If you discover something that requires changes to upstream artifacts, flag it and trigger a reverse handoff. diff --git a/.github/skills/adr/SKILL.md b/.github/skills/adr/SKILL.md index e0e9a3a..7bd56f5 100644 --- a/.github/skills/adr/SKILL.md +++ b/.github/skills/adr/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access a metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit' argument-hint: '[decision to record]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/analyse/SKILL.md b/.github/skills/analyse/SKILL.md index aca2109..3856249 100644 --- a/.github/skills/analyse/SKILL.md +++ b/.github/skills/analyse/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access a metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search' argument-hint: '[topic, change, or question to analyse]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/architecture/SKILL.md b/.github/skills/architecture/SKILL.md index 2e4243e..e55a8d3 100644 --- a/.github/skills/architecture/SKILL.md +++ b/.github/skills/architecture/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access a metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit' argument-hint: '[plan or system to review]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/aws-cli/SKILL.md b/.github/skills/aws-cli/SKILL.md index 25586cb..c28e4af 100644 --- a/.github/skills/aws-cli/SKILL.md +++ b/.github/skills/aws-cli/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires AWS CLI v2 installed and configured (aws configure or e metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit web' argument-hint: '[service: iam | ec2 | s3 | rds | ecs | lambda | cloudwatch | ssm | secrets]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/cicd/SKILL.md b/.github/skills/cicd/SKILL.md index 70be947..421c19a 100644 --- a/.github/skills/cicd/SKILL.md +++ b/.github/skills/cicd/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access a metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit' argument-hint: '[service or workflow to configure]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/cloudformation/SKILL.md b/.github/skills/cloudformation/SKILL.md index a015aa9..2ad5f01 100644 --- a/.github/skills/cloudformation/SKILL.md +++ b/.github/skills/cloudformation/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access. metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit' argument-hint: '[resource type or stack name, e.g. VPC | RDS | ECS service | Lambda function]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/code-review/SKILL.md b/.github/skills/code-review/SKILL.md index 979c681..4d3a47b 100644 --- a/.github/skills/code-review/SKILL.md +++ b/.github/skills/code-review/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access a metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search' argument-hint: '[files, PR, or change to review]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/codeql/SKILL.md b/.github/skills/codeql/SKILL.md index ce8f67a..855da54 100644 --- a/.github/skills/codeql/SKILL.md +++ b/.github/skills/codeql/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access a metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit web' argument-hint: '[languages and setup type: default or advanced]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/concise/SKILL.md b/.github/skills/concise/SKILL.md index 8ff9138..dac193d 100644 --- a/.github/skills/concise/SKILL.md +++ b/.github/skills/concise/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with session memory and repos metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search' argument-hint: '[normal|compact|ultra|status|on|off]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/consult/SKILL.md b/.github/skills/consult/SKILL.md index 6532b9f..2571f4e 100644 --- a/.github/skills/consult/SKILL.md +++ b/.github/skills/consult/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access a metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search' argument-hint: '[API, tool, or workflow to consult]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/container/SKILL.md b/.github/skills/container/SKILL.md index 8f0a472..2d9c924 100644 --- a/.github/skills/container/SKILL.md +++ b/.github/skills/container/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access a metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit' argument-hint: '[service to containerise]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/conventional-commit/SKILL.md b/.github/skills/conventional-commit/SKILL.md index 5f66f2d..77a4b00 100644 --- a/.github/skills/conventional-commit/SKILL.md +++ b/.github/skills/conventional-commit/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access a metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit' argument-hint: '[changes to commit and desired release intent]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/debug/SKILL.md b/.github/skills/debug/SKILL.md index d808802..76ec3b7 100644 --- a/.github/skills/debug/SKILL.md +++ b/.github/skills/debug/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access a metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit' argument-hint: '[issue or error to debug]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/dependabot/SKILL.md b/.github/skills/dependabot/SKILL.md index 5fd9934..ad9aeff 100644 --- a/.github/skills/dependabot/SKILL.md +++ b/.github/skills/dependabot/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access. metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit' argument-hint: '[repository type: library | service | monorepo, and ecosystems to cover]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/dependency/SKILL.md b/.github/skills/dependency/SKILL.md index 1b5db8d..3313f88 100644 --- a/.github/skills/dependency/SKILL.md +++ b/.github/skills/dependency/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access a metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit web' argument-hint: '[project or package manifest to audit]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/design/SKILL.md b/.github/skills/design/SKILL.md index 3d8428c..e732ac8 100644 --- a/.github/skills/design/SKILL.md +++ b/.github/skills/design/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access a metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit' argument-hint: '[API or service to design]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/docs/SKILL.md b/.github/skills/docs/SKILL.md index 98ef5c2..be97300 100644 --- a/.github/skills/docs/SKILL.md +++ b/.github/skills/docs/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access a metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit' argument-hint: '[release or change to document]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/explore/SKILL.md b/.github/skills/explore/SKILL.md index 5ef71cf..aa6af6a 100644 --- a/.github/skills/explore/SKILL.md +++ b/.github/skills/explore/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access a metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search' argument-hint: '[repository or system to explore]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/gdpr/SKILL.md b/.github/skills/gdpr/SKILL.md index 387f14a..b6ff4fd 100644 --- a/.github/skills/gdpr/SKILL.md +++ b/.github/skills/gdpr/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access.' metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit' argument-hint: '[component or feature: data model | API | logging | retention | erasure | infra | PR review]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/gh-issues/SKILL.md b/.github/skills/gh-issues/SKILL.md index 9a6bede..3b5a19a 100644 --- a/.github/skills/gh-issues/SKILL.md +++ b/.github/skills/gh-issues/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with terminal command executi metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit web' argument-hint: '[what to create or which issue number to update]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/gh-release/SKILL.md b/.github/skills/gh-release/SKILL.md index 11960e8..e3b1c68 100644 --- a/.github/skills/gh-release/SKILL.md +++ b/.github/skills/gh-release/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access, metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit web' argument-hint: '[version/tag and release notes source]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/guardrails/SKILL.md b/.github/skills/guardrails/SKILL.md index 3a85808..820fe2e 100644 --- a/.github/skills/guardrails/SKILL.md +++ b/.github/skills/guardrails/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access a metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit' argument-hint: '[task]' user-invocable: true disable-model-invocation: true diff --git a/.github/skills/helm/SKILL.md b/.github/skills/helm/SKILL.md index acea595..1746edd 100644 --- a/.github/skills/helm/SKILL.md +++ b/.github/skills/helm/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access. metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit' argument-hint: '[chart path, release name, namespace, and scope: chart review | install | upgrade | rollback]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/incident/SKILL.md b/.github/skills/incident/SKILL.md index 7b17180..784e4ac 100644 --- a/.github/skills/incident/SKILL.md +++ b/.github/skills/incident/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access a metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit' argument-hint: '[incident or outage to analyse]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/inspect/SKILL.md b/.github/skills/inspect/SKILL.md index c1dba2d..9a2346a 100644 --- a/.github/skills/inspect/SKILL.md +++ b/.github/skills/inspect/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access a metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search' argument-hint: '[component or service to inspect]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/k8s/SKILL.md b/.github/skills/k8s/SKILL.md index 7375f27..42ca2a6 100644 --- a/.github/skills/k8s/SKILL.md +++ b/.github/skills/k8s/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access. metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit' argument-hint: '[cluster/context, namespace, and scope: manifest review | deploy | rollout debug | hardening]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/migrate/SKILL.md b/.github/skills/migrate/SKILL.md index 21f26b6..07e1934 100644 --- a/.github/skills/migrate/SKILL.md +++ b/.github/skills/migrate/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access a metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit' argument-hint: '[migration file or schema change to review]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/onboard/SKILL.md b/.github/skills/onboard/SKILL.md index 929214c..da290f0 100644 --- a/.github/skills/onboard/SKILL.md +++ b/.github/skills/onboard/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access a metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit' argument-hint: '[repository or service to document]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/openapi/SKILL.md b/.github/skills/openapi/SKILL.md index 976d3ee..77a8b91 100644 --- a/.github/skills/openapi/SKILL.md +++ b/.github/skills/openapi/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access a metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit' argument-hint: '[API or spec file to write or review]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/performance/SKILL.md b/.github/skills/performance/SKILL.md index 26c45d2..4bd99dc 100644 --- a/.github/skills/performance/SKILL.md +++ b/.github/skills/performance/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access a metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit' argument-hint: '[endpoint or function to profile]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/postmortem/SKILL.md b/.github/skills/postmortem/SKILL.md index 07df9dc..12508ec 100644 --- a/.github/skills/postmortem/SKILL.md +++ b/.github/skills/postmortem/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access a metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit' argument-hint: '[incident to write a post-mortem for]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/pr/SKILL.md b/.github/skills/pr/SKILL.md index 23c4910..bd8368a 100644 --- a/.github/skills/pr/SKILL.md +++ b/.github/skills/pr/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access a metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit web' argument-hint: '[task]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/rancher/SKILL.md b/.github/skills/rancher/SKILL.md index 17f1727..3d58218 100644 --- a/.github/skills/rancher/SKILL.md +++ b/.github/skills/rancher/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access. metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit' argument-hint: '[rancher server/context, cluster/project, and scope: deploy | governance | fleet | troubleshooting]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/rca/SKILL.md b/.github/skills/rca/SKILL.md index e5114e9..0a302a5 100644 --- a/.github/skills/rca/SKILL.md +++ b/.github/skills/rca/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access a metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit' argument-hint: '[incident or issue to analyse]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/refactor/SKILL.md b/.github/skills/refactor/SKILL.md index efb2840..1110f6b 100644 --- a/.github/skills/refactor/SKILL.md +++ b/.github/skills/refactor/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access a metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit' argument-hint: '[module, file, or area to refactor]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/release-notes/SKILL.md b/.github/skills/release-notes/SKILL.md index 19081b7..39d4c98 100644 --- a/.github/skills/release-notes/SKILL.md +++ b/.github/skills/release-notes/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access a metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit' argument-hint: '[version or changes to release]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/requirements/SKILL.md b/.github/skills/requirements/SKILL.md index efe5ee4..5d1e7d7 100644 --- a/.github/skills/requirements/SKILL.md +++ b/.github/skills/requirements/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access a metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit' argument-hint: '[feature or system to document]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/secret-scan/SKILL.md b/.github/skills/secret-scan/SKILL.md index a378d37..0c0674b 100644 --- a/.github/skills/secret-scan/SKILL.md +++ b/.github/skills/secret-scan/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires repository access and GitHub Advanced Security (private metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit web' argument-hint: '[scope: enable | configure push-protection | custom-pattern | triage alerts | remediate]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/security/SKILL.md b/.github/skills/security/SKILL.md index a237075..eb35c48 100644 --- a/.github/skills/security/SKILL.md +++ b/.github/skills/security/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access a metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit' argument-hint: '[component or service to audit]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/terraform/SKILL.md b/.github/skills/terraform/SKILL.md index 2554e0c..3ba5145 100644 --- a/.github/skills/terraform/SKILL.md +++ b/.github/skills/terraform/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access. metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit' argument-hint: '[provider: aws | azure | gcp | generic, and scope: new resource | module | state migration | security review]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/terragrunt/SKILL.md b/.github/skills/terragrunt/SKILL.md index baccafa..77326de 100644 --- a/.github/skills/terragrunt/SKILL.md +++ b/.github/skills/terragrunt/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access. metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit' argument-hint: '[scope: new layout | dependency graph | state migration | run-all workflow | security review]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/threat-model/SKILL.md b/.github/skills/threat-model/SKILL.md index fb8e7ce..d2a81e5 100644 --- a/.github/skills/threat-model/SKILL.md +++ b/.github/skills/threat-model/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access a metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit' argument-hint: '[system, component, or architecture to threat model]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/verify/SKILL.md b/.github/skills/verify/SKILL.md index 55cd71c..585a7df 100644 --- a/.github/skills/verify/SKILL.md +++ b/.github/skills/verify/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access a metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit' argument-hint: '[component or feature to verify]' user-invocable: true disable-model-invocation: false diff --git a/.github/skills/vision/SKILL.md b/.github/skills/vision/SKILL.md index 2e0d25f..16dbb1b 100644 --- a/.github/skills/vision/SKILL.md +++ b/.github/skills/vision/SKILL.md @@ -6,6 +6,7 @@ compatibility: 'Requires a skills-compatible agent with repository file access a metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit' argument-hint: '[plan or idea to review]' user-invocable: true disable-model-invocation: false diff --git a/.github/workflows/automerge.yml b/.github/workflows/automerge.yml index 68e2486..712a65d 100644 --- a/.github/workflows/automerge.yml +++ b/.github/workflows/automerge.yml @@ -13,6 +13,9 @@ permissions: contents: write pull-requests: write +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + jobs: auto-merge: if: github.actor == 'dependabot[bot]' diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 4a4a383..e82876c 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -27,6 +27,7 @@ env: PYTHON_VERSION: "3.11" POETRY_VERSION: "2.3.4" POETRY_VIRTUALENVS_IN_PROJECT: "true" + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" jobs: test: diff --git a/.github/workflows/commit.yml b/.github/workflows/commit.yml index 20fc7c2..db1c6f7 100644 --- a/.github/workflows/commit.yml +++ b/.github/workflows/commit.yml @@ -28,6 +28,7 @@ env: PYTHON_VERSION: "3.11" POETRY_VERSION: "2.3.4" POETRY_VIRTUALENVS_IN_PROJECT: "true" + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" jobs: validate: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7e62612..7eef947 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -88,8 +88,6 @@ jobs: uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_VERSION }} - cache: poetry - cache-dependency-path: poetry.lock - name: Validate release tag checkout run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7912aae..16db063 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,9 @@ concurrency: permissions: contents: read +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + jobs: release: if: github.ref == 'refs/heads/main' diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 8c85521..f8dd294 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -22,8 +22,11 @@ env: PYTHON_VERSION: "3.11" # Pin Poetry CLI version for deterministic CI behavior. POETRY_VERSION: "2.3.4" + # Keep Poetry virtual environments inside the workspace for deterministic paths. + POETRY_VIRTUALENVS_IN_PROJECT: "true" # Pin pip-audit to avoid non-deterministic CI failures from tool updates. PIP_AUDIT_VERSION: "2.9.0" + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" jobs: security: diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index ef79361..b8b3ef4 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -24,6 +24,7 @@ env: POETRY_VERSION: "2.3.4" # Keep Poetry virtual environments inside the workspace for deterministic paths. POETRY_VIRTUALENVS_IN_PROJECT: "true" + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" jobs: test-matrix: diff --git a/.vstack/config.yaml b/.vstack/config.yaml index 840037a..f6a2fb5 100644 --- a/.vstack/config.yaml +++ b/.vstack/config.yaml @@ -26,3 +26,65 @@ # # artifacts: # root: docs + +# Pipeline workflow — seeded by vstack install, owned by this project. +# +# gate: required — stage always runs +# gate: optional — stage may be skipped when its domain is unaffected +# gate: skip — stage is never executed (explicit opt-out) +# +# hitl: always — pipeline pauses for human approval before handoff (default for required) +# hitl: on-change — pipeline pauses only when the stage made changes (default for optional) +# hitl: never — pipeline continues without human approval (explicit opt-out) +# +workflow: + version: 1 + stages: + - role: product + gate: required + hitl: always + handoffs: + prompt: > + Product outputs are approved. Assess the current state and produce or + update the architecture as needed. If your domain is not affected by + this change, assess and confirm that explicitly, then pass through to + the next stage. + - role: architect + gate: required + hitl: always + handoffs: + prompt: > + Architecture outputs are approved. Assess the current state and produce + design specifications as needed. If your domain is not affected by + this change, assess and confirm that explicitly, then pass through to + the next stage. + - role: designer + gate: optional + hitl: on-change + handoffs: + prompt: > + Design outputs are approved. Assess the current state and implement + code and tests as needed. If your domain is not affected by this + change, assess and confirm that explicitly, then pass through to + engineering. + - role: engineer + gate: required + hitl: always + handoffs: + prompt: > + Implementation is approved. Assess the current state and verify the + implementation as needed — run tests, security checks, and performance + analysis. + - role: tester + gate: required + hitl: always + handoffs: + prompt: > + Verification outputs are approved. Assess the current state and prepare + the release as needed. Create and/or update the relevant artifacts if + needed, as well as any sign-offs. + - role: release + gate: required + hitl: always + handoffs: + prompt: "" diff --git a/.vstack/vstack.json b/.vstack/vstack.json index c85cee4..85f21fa 100644 --- a/.vstack/vstack.json +++ b/.vstack/vstack.json @@ -2,315 +2,315 @@ "manifest_version": 2, "hash_algorithm": "sha256", "vstack_version": "0.0.0.post3.dev0+df3fe6e", - "installed_at": "2026-05-06T22:03:21.641306+00:00", + "installed_at": "2026-05-09T22:36:50.257153+00:00", "artifacts": { "skills": [ { "name": "adr", "file": "skills/adr/SKILL.md", "version": "20260421003", - "checksum": "0838ffc14c5b86ea8b3df93cf6ce76c9bcd27b6c55c4ba47a3d01cc000fd7ee0", + "checksum": "871263fddee0b3ae8e2c2e2243aad4f2b3aa9e0dea0d2519c7d21c3e98430a83", "checksum_algorithm": "sha256" }, { "name": "analyse", "file": "skills/analyse/SKILL.md", "version": "20260421004", - "checksum": "8ff12f1d1f12ac9c46a2cb36981b85aeea97a8bc0876bbef37300511ea0eb7b3", + "checksum": "79ced25692671a5286a26cbcd0e59ed4d641ac7493fd8e2b58b232f97e467224", "checksum_algorithm": "sha256" }, { "name": "architecture", "file": "skills/architecture/SKILL.md", "version": "20260421005", - "checksum": "4ed22957e392aa89cf4949d2a88e707bf913948c2805a257039f5376810af754", + "checksum": "e2d0342184829c888a70ece6f82ad6a7450ad5d8c958ff6e028d7f245f1e5960", "checksum_algorithm": "sha256" }, { "name": "aws-cli", "file": "skills/aws-cli/SKILL.md", "version": "20260502033", - "checksum": "e5b2688de029ab0cc6d3e3862237c5bdb7f3ad4aab9baa4e1bceac433d699b79", + "checksum": "4868a5adc17b6ac52839c85f3a09986991c2498501913ef48a8f85a6291e1b21", "checksum_algorithm": "sha256" }, { "name": "cicd", "file": "skills/cicd/SKILL.md", "version": "20260421006", - "checksum": "ffe0df7fe8c425844e9fc1a20976a9d6c116d828af96c1a568317728bfdefdcf", + "checksum": "ba6c0cf0e644e24b1290c1b78969bad86dd7d5b39030edc7bd430d4d1832cfd7", "checksum_algorithm": "sha256" }, { "name": "cloudformation", "file": "skills/cloudformation/SKILL.md", "version": "20260502032", - "checksum": "d172ffc2b30c75986446a72b625baecae123d1cc3882b20ca803c60b46dcd75d", + "checksum": "0e61163cfc356105a309bbecf89ee26a5b10b9b5db58c331d76efa4a508e760e", "checksum_algorithm": "sha256" }, { "name": "code-review", "file": "skills/code-review/SKILL.md", "version": "20260421007", - "checksum": "5bcdddc03ce0a54997037210b38e7b4ed22828cfba6a76153afaa94cf616527a", + "checksum": "58b7caa9dd3a5da6bef8d8d4d1f7b6d513740d33ad16731a06b1b2a907d93a13", "checksum_algorithm": "sha256" }, { "name": "codeql", "file": "skills/codeql/SKILL.md", "version": "20260502026", - "checksum": "1b1b5800be204cc0e5dc6b4a8fcb9a7b916cfba2f96d5f99dbb6d7e213b8e95c", + "checksum": "95f29c975e5850307620c21a68df174b90ecaafd5b1e36659bb3c34d0027b5df", "checksum_algorithm": "sha256" }, { "name": "concise", "file": "skills/concise/SKILL.md", "version": "20260421008", - "checksum": "3a07860ba6c83a97c9ad5496e124be4e5277fc0b811fe25dcd5bfdb7d8252b98", + "checksum": "9555de7da6a925e76992b6f5e51ddccd6b4de3887ea31ed2ceb9113c9fe9bc7e", "checksum_algorithm": "sha256" }, { "name": "consult", "file": "skills/consult/SKILL.md", "version": "20260421009", - "checksum": "90e1b0d0ff757c8e5879832c5ac6c41ec4054e9bff5b12a14531d427c09bcbad", + "checksum": "a340883900309261213ec0ea0d4e9e5b72ca83fb25c7cd6c3506e1f95f40cf76", "checksum_algorithm": "sha256" }, { "name": "container", "file": "skills/container/SKILL.md", "version": "20260421010", - "checksum": "e135b18cb972d30b77de4492db0437853ff1f49bad60a9113a034e39154d30e1", + "checksum": "f0a4408a756195faca9a58dca76e56f950df227415d77af293bf55280191abd4", "checksum_algorithm": "sha256" }, { "name": "conventional-commit", "file": "skills/conventional-commit/SKILL.md", "version": "20260502024", - "checksum": "76477b42b17c9f6eec92baa36dc172e7ee98b20a86399eabdd2b7d2a90923509", + "checksum": "8f1ce569106b5210a8d4ffe5bc9123eb0dec6b24ff56cf680922eccdd9891012", "checksum_algorithm": "sha256" }, { "name": "debug", "file": "skills/debug/SKILL.md", "version": "20260421011", - "checksum": "8e46a2723004bc86f6aee50f492b73045acdab66c964787c58a988c98684750b", + "checksum": "b4c1c6de7142b7b74326d1c19a5cc27f3957d1faa48aa48bd6b1fc45832edf3b", "checksum_algorithm": "sha256" }, { "name": "dependabot", "file": "skills/dependabot/SKILL.md", "version": "20260502027", - "checksum": "3ce5836bf870f73805800f672d379aa10f5aff5a522017a77875d9339bb99984", + "checksum": "fa3f595740faa0b8093ffe4f32fb8e4b85f5834ea10817752ee9c94be8f38c2d", "checksum_algorithm": "sha256" }, { "name": "dependency", "file": "skills/dependency/SKILL.md", "version": "20260421012", - "checksum": "46e75e8a28b1af5a60da7d9b3d1e46f0914b2f10d8733b37e6577e62b0074724", + "checksum": "2cbbecca8e6697b8523301325a889a5ca88c520f6d35a3b98807b6c3449239f5", "checksum_algorithm": "sha256" }, { "name": "design", "file": "skills/design/SKILL.md", "version": "20260421013", - "checksum": "e21a674324244412f7b4a5bc010da4a7b9dfcf3522d54281bc9cf69f0b1126bd", + "checksum": "2d56e34aca8d3cf5a3f327e3b69ab15707393ffc47607c91ec91dd5be86cd65b", "checksum_algorithm": "sha256" }, { "name": "docs", "file": "skills/docs/SKILL.md", "version": "20260421014", - "checksum": "7d34421800bf04fe5a0782abfc5702a421419d31a5c66fd495d7dc8a6e2c64e3", + "checksum": "d6ebf7d270eac7911aea09a34cb7ec4006c70064b7e6f58dfa84a405e6620d18", "checksum_algorithm": "sha256" }, { "name": "explore", "file": "skills/explore/SKILL.md", "version": "20260421015", - "checksum": "a257941d7b577f782b5d41703b1fbd25c5ab13f664e696d83cc3a9fd91a5565f", + "checksum": "914323f796ac95a0f73d404f1b7eb38d94ba8950ed1a4acc5d80600ba03fae8f", "checksum_algorithm": "sha256" }, { "name": "gdpr", "file": "skills/gdpr/SKILL.md", "version": "20260502029", - "checksum": "94207650498b56f246392449f57b4af4659bdfe511e7a6c2f23022ff2f68b08d", + "checksum": "dfe60c4c59b7ae4dc66e98a885ec42b0e60eccc61fa1cb6fd269cd7eb3e0a94d", "checksum_algorithm": "sha256" }, { "name": "gh-issues", "file": "skills/gh-issues/SKILL.md", "version": "20260502025", - "checksum": "7a2b9b7463a40436fe77c77d066dd455ddb4abef877726ba041b89cbaf030280", + "checksum": "bc1f595283e7643de63c7519e994500b9434a0343154bfc404b842ce8e7028a8", "checksum_algorithm": "sha256" }, { "name": "gh-release", "file": "skills/gh-release/SKILL.md", "version": "20260502023", - "checksum": "beed7f98cb52222f688fed5a83d376acea1215c6654c570487412792bb577462", + "checksum": "10abe52f681d261588608418a546580e7798e883179888061ed6d2285050d870", "checksum_algorithm": "sha256" }, { "name": "guardrails", "file": "skills/guardrails/SKILL.md", "version": "20260421016", - "checksum": "8ec7213e1f8c85b4975ebb032d897e84e372c0279e3265da1098fc86cf98695f", + "checksum": "1c1a44c95461eb4eeef7ad64b5846a1efbb202cf1b75186421c5d0b8f9fd03f1", "checksum_algorithm": "sha256" }, { "name": "helm", "file": "skills/helm/SKILL.md", "version": "20260502037", - "checksum": "13600308860723f683802431ac7a8c2c3834c40e521055854cdf3d3290570689", + "checksum": "6e6579ede2dadb6dbe895d07c362d208e62414338445bd035f47bd1404be501b", "checksum_algorithm": "sha256" }, { "name": "incident", "file": "skills/incident/SKILL.md", "version": "20260503002", - "checksum": "d337c4f145c09af856f4a05f41b11f2e46f3822f77e067e1a6e39de5f2af1fe8", + "checksum": "546229fe91121dae2192aceb0b215096cc8cf5ba0cfb3d1300e30c73ff3f6276", "checksum_algorithm": "sha256" }, { "name": "inspect", "file": "skills/inspect/SKILL.md", "version": "20260421018", - "checksum": "7997cb4477f9b1a8a6753082a4f3ea0c9b5a1a77520a3f20527f8c153c968a27", + "checksum": "3a4aee3620bbd669602001c041cf81788da5fac9dc055a9f1b0c155e50e88b46", "checksum_algorithm": "sha256" }, { "name": "k8s", "file": "skills/k8s/SKILL.md", "version": "20260502036", - "checksum": "d178a4d86f6ca5f4144cd8212eaf09979bb48afa55cb482409609aa50c5f0f88", + "checksum": "2bfbe14c187809211a7296578644262e2e47c89b4a2fc28fc3a30a79fdaeb580", "checksum_algorithm": "sha256" }, { "name": "migrate", "file": "skills/migrate/SKILL.md", "version": "20260421019", - "checksum": "c6c18c750319c0feb14526d637cb591f51ffde341b1385b4063aeb1edf12fd8b", + "checksum": "eecc00aae2c58c573000a434fbf7ae891a3e85dcc99a42fec0e8bcadc3047b7d", "checksum_algorithm": "sha256" }, { "name": "onboard", "file": "skills/onboard/SKILL.md", "version": "20260421020", - "checksum": "b96fb4067121eabbdf686641b55d32f253282e0b734fa4b2df6e4a9e85938cde", + "checksum": "f16f2d78002e3be8e7d1d9e2bf376c1b37eb181d821f3cd113e0d5aecf5236ae", "checksum_algorithm": "sha256" }, { "name": "openapi", "file": "skills/openapi/SKILL.md", "version": "20260421021", - "checksum": "75d7cab59b1cde6dc91490867eb8537821059960957ba21ebd24d922e9fe7a91", + "checksum": "a3dbcded8e1a8b268a1976b9185f41de74ad0ad6e7aae98c399d6135de920529", "checksum_algorithm": "sha256" }, { "name": "performance", "file": "skills/performance/SKILL.md", "version": "20260421022", - "checksum": "e8cf2eba2a3fdc162d03afeaba260bd0c2f0aac47121c26dc9d63614bff971ec", + "checksum": "fa4062c77381facacedac289b54a3ce7e6cf242dfa2b36f55401aebfe5fb8ae1", "checksum_algorithm": "sha256" }, { "name": "postmortem", "file": "skills/postmortem/SKILL.md", "version": "20260503001", - "checksum": "ead97f2a5b28672f4ac483e467eec5b0d2a7e3b96eceaaa2017c918972c2d827", + "checksum": "fc70f26420e705e0979ff76476ea8176386258f2315fcb57f5ac9049a37f6c90", "checksum_algorithm": "sha256" }, { "name": "pr", "file": "skills/pr/SKILL.md", "version": "20260502013", - "checksum": "ed4b13b69b325b21d9abf17ae9be34c2b2fa6ff1dd78cc6ee04af73d428438b8", + "checksum": "fc7b2b08696982afd48033a3cc29b9fa4ac8b590fc61c828b84135585c88b345", "checksum_algorithm": "sha256" }, { "name": "rancher", "file": "skills/rancher/SKILL.md", "version": "20260502038", - "checksum": "c86759f257553554f2069cb6d0eda93bd98fd5c2fb251b1c8cc423302f33b01d", + "checksum": "3000f24c2ee2f99fdb363f30d6075993ed26533663d081089b7800098a28e648", "checksum_algorithm": "sha256" }, { "name": "rca", "file": "skills/rca/SKILL.md", "version": "20260503001", - "checksum": "4d2347e1bab1d0717669b78160a5703ba4d1bb3486a9499924ca569e6f049f0e", + "checksum": "8bebb57c166c153d89e4589097ea419266b249df871a79e24c840112b5f8ad0c", "checksum_algorithm": "sha256" }, { "name": "refactor", "file": "skills/refactor/SKILL.md", "version": "20260421023", - "checksum": "7559d9148ee0ed7434162f672fe47329a417fd332af92a3637c47030e6b8f271", + "checksum": "aa7fb995b6ac649b24b61066f15a6fe7fddab81bdcec8af27eba65092b957d68", "checksum_algorithm": "sha256" }, { "name": "release-notes", "file": "skills/release-notes/SKILL.md", "version": "20260502014", - "checksum": "81fd326b93789aad264e1ad27a73d00efd76c4486b5a20935bafcb18ebd43758", + "checksum": "589f001bcbf0e27c4da6ff9c530371a1f183b7936e3a7fc421879282bd7f9af3", "checksum_algorithm": "sha256" }, { "name": "requirements", "file": "skills/requirements/SKILL.md", "version": "20260421024", - "checksum": "312e1c24bc43828798e86b881204196ebcad4f90ff5cfa63d099b8836e4f1e49", + "checksum": "7d3338f0b7c5d493ab714a90b7d1732de56467503a3fe4dacdd40031bfea356c", "checksum_algorithm": "sha256" }, { "name": "secret-scan", "file": "skills/secret-scan/SKILL.md", "version": "20260502028", - "checksum": "7a83d376b062bcf497ea89dd1e44a8f9fb945349d6b66513018ce1abe265e277", + "checksum": "95df047af89a93a8fa3716899b8fe703b270d7f40b93fe65915f7d1bdb849370", "checksum_algorithm": "sha256" }, { "name": "security", "file": "skills/security/SKILL.md", "version": "20260421025", - "checksum": "f58a629804bec6f6b5839eea1e7754458bf85cceb7403b9470f80513119a306d", + "checksum": "e9bcd451ee25b1c752239e76e20ca67302061356b59214fae7b134a180022df8", "checksum_algorithm": "sha256" }, { "name": "terraform", "file": "skills/terraform/SKILL.md", "version": "20260502030", - "checksum": "b24b008c171aafbf81902b3f52661b4acc09e8980e42ebf579ac3566dc138de5", + "checksum": "2bb933512f72b61dae3a3b632a7afd43c62aad066de003ee0a565fc8f1895215", "checksum_algorithm": "sha256" }, { "name": "terragrunt", "file": "skills/terragrunt/SKILL.md", "version": "20260502031", - "checksum": "33a208ccc4dfff01b59110445e0fd851e5c9d5e094711456c1cd1efdc68d440d", + "checksum": "e0d3dc0feefeafb9cd41d8d6885d8b4d53baed52ba70df7dc8d33bfc7410bae8", "checksum_algorithm": "sha256" }, { "name": "threat-model", "file": "skills/threat-model/SKILL.md", "version": "20260502021", - "checksum": "11504d508ddfc6e9e2ded8dfac8fe6fb7af691c7ef27fdc539c5f8f870fc06bb", + "checksum": "7fdf69113167e9bb8c866681a86f4b4d6416145bb9def0ebe18ceba690c8fbe9", "checksum_algorithm": "sha256" }, { "name": "verify", "file": "skills/verify/SKILL.md", "version": "20260421026", - "checksum": "b8f80fda903c0d55c556374626e308dcc61787d59e2e642f9c9513d06b0ac2b9", + "checksum": "aa56d1a674ed827606af34b8e7c5bd9c2de61a6cb6f92f5fd9bdce28e46bcb30", "checksum_algorithm": "sha256" }, { "name": "vision", "file": "skills/vision/SKILL.md", "version": "20260421027", - "checksum": "a74271c10816e3cee75fc1489d0b2defb391035b22f8d3da0cc951ae1afb7ef2", + "checksum": "c0058202130905d8495a3e565b5db546ad2d0ff6d654596358a0b12c484ce310", "checksum_algorithm": "sha256" } ], @@ -319,42 +319,42 @@ "name": "architect", "file": "agents/architect.agent.md", "version": "20260503022", - "checksum": "524572e506ea4836237141a1bb2f9eee36de9f7342bf42f41442576b032e916a", + "checksum": "95f6d8b98e8cbd8e6309b1708ff58462791959868b4052b44199c62dea4e0960", "checksum_algorithm": "sha256" }, { "name": "designer", "file": "agents/designer.agent.md", "version": "20260503024", - "checksum": "a7afb743e80494e6a4a4fa2a93cc469b9eff87eafb5bde1a27ab90391f590aca", + "checksum": "2d593399bcf4c16475e0139d255bff80e4180856720e5bbc0735e226349c2727", "checksum_algorithm": "sha256" }, { "name": "engineer", "file": "agents/engineer.agent.md", "version": "20260503024", - "checksum": "1d06a70f56a98b30b0148a6b6d7df5b75f8cefb34aaa5100521faa7afe068ebf", + "checksum": "08e905f3f6e724d02f774717c6c1cdd9c4bc5139f60053d9ec2864cf0d80f930", "checksum_algorithm": "sha256" }, { "name": "product", "file": "agents/product.agent.md", "version": "20260503021", - "checksum": "ac22faaeb1577458a88d6314e83d0c79b1479c45b066f7f989afb59f86b190a5", + "checksum": "4bc343ed7b912988016aad7572da280a3824e46b00c86fa305fb334499a973b2", "checksum_algorithm": "sha256" }, { "name": "release", "file": "agents/release.agent.md", "version": "20260503020", - "checksum": "5dcee462a58ec95b03110fdf59986ded0f86c1cc1db6b817e6c9a44fc5ba5f14", + "checksum": "f8496412e799033356ac67eeaaeb5addf29981d7f22235bf72971a533f6efc04", "checksum_algorithm": "sha256" }, { "name": "tester", "file": "agents/tester.agent.md", "version": "20260503026", - "checksum": "ecce547f703fcee918d1b4991f5ce5e850392d4f3fbf01b186723cc0a8961ecf", + "checksum": "6c31332714089743d7c5bfe2e45fec5471f03b21795f30edc920d110199f1d27", "checksum_algorithm": "sha256" } ], diff --git a/CHANGELOG.md b/CHANGELOG.md index f58630f..6de4fcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,51 +4,46 @@ ## [3.0.0](https://github.com/eschaar/vstack/compare/2.2.0...3.0.0) (2026-05-06) - ### ⚠ BREAKING CHANGES -* **manifest:** vstack.json moves from .github/vstack.json to .vstack/vstack.json; run `vstack manifest upgrade` to migrate existing projects +- **manifest:** vstack.json moves from .github/vstack.json to .vstack/vstack.json; run `vstack manifest upgrade` to migrate existing projects ### Features -* **agents:** generate artifacts section from config.yaml ([b47f410](https://github.com/eschaar/vstack/commit/b47f4108f2c6165f0f2d37913774fd46061af65c)) -* **config:** implement exclude filter in .vstack/config.yaml ([036b9ae](https://github.com/eschaar/vstack/commit/036b9ae51d322da0aa27ef2056a06ee011792480)) -* **config:** support artifacts_root override in .vstack/config.yaml ([2a7de61](https://github.com/eschaar/vstack/commit/2a7de61df40317109a80f0857ddc3106d84192f2)) -* **install:** write .vstack/.gitignore on every install ([526c6c7](https://github.com/eschaar/vstack/commit/526c6c7dfdecb233eed3e057b6b02c5842c1ef16)) -* **manifest:** move install manifest from .github to .vstack ([3a11063](https://github.com/eschaar/vstack/commit/3a11063f537bc307e70675d94421029791d30ad3)) -* **workflow:** update skill templates, ADRs, and agent configs for genericity ([db0b2f1](https://github.com/eschaar/vstack/commit/db0b2f124019464140545f7a1c2eeeda3388c3cf)) - +- **agents:** generate artifacts section from config.yaml ([b47f410](https://github.com/eschaar/vstack/commit/b47f4108f2c6165f0f2d37913774fd46061af65c)) +- **config:** implement exclude filter in .vstack/config.yaml ([036b9ae](https://github.com/eschaar/vstack/commit/036b9ae51d322da0aa27ef2056a06ee011792480)) +- **config:** support artifacts_root override in .vstack/config.yaml ([2a7de61](https://github.com/eschaar/vstack/commit/2a7de61df40317109a80f0857ddc3106d84192f2)) +- **install:** write .vstack/.gitignore on every install ([526c6c7](https://github.com/eschaar/vstack/commit/526c6c7dfdecb233eed3e057b6b02c5842c1ef16)) +- **manifest:** move install manifest from .github to .vstack ([3a11063](https://github.com/eschaar/vstack/commit/3a11063f537bc307e70675d94421029791d30ad3)) +- **workflow:** update skill templates, ADRs, and agent configs for genericity ([db0b2f1](https://github.com/eschaar/vstack/commit/db0b2f124019464140545f7a1c2eeeda3388c3cf)) ### Fixes -* **exclude:** raise ValueError when agents excluded; add ADR-021/022 to overview ([3298340](https://github.com/eschaar/vstack/commit/3298340b4f5af7ac4d82b3f13a5b36f8cd2a502a)) -* **review:** address PR review comments ([771ab31](https://github.com/eschaar/vstack/commit/771ab31dff70178cde83d27edfb0145e9ff7ff66)) - +- **exclude:** raise ValueError when agents excluded; add ADR-021/022 to overview ([3298340](https://github.com/eschaar/vstack/commit/3298340b4f5af7ac4d82b3f13a5b36f8cd2a502a)) +- **review:** address PR review comments ([771ab31](https://github.com/eschaar/vstack/commit/771ab31dff70178cde83d27edfb0145e9ff7ff66)) ### Refactoring -* **config:** rename artifacts_root to artifacts.root in config.yaml ([e9c14b4](https://github.com/eschaar/vstack/commit/e9c14b449fe7179333e96eca7fccf8165a9440a0)) - +- **config:** rename artifacts_root to artifacts.root in config.yaml ([e9c14b4](https://github.com/eschaar/vstack/commit/e9c14b449fe7179333e96eca7fccf8165a9440a0)) ### Documentation -* ADR-022 selective exclude filter ([56771d3](https://github.com/eschaar/vstack/commit/56771d3d50bafada8751f790b442026a05e4d513)) -* **agents:** update artifacts schema docs to reflect dir/ARTIFACTS_DOCS_ROOT design ([a408468](https://github.com/eschaar/vstack/commit/a4084684e7449ce55f569d11d0dccbd535f2f141)) -* document exclude filter, install vs init flow, and config.yaml schema ([3296020](https://github.com/eschaar/vstack/commit/3296020229a950ce6932a5a8322e9e81959587f7)) -* **readme:** add no-target variants for install and init to CLI table ([9c06f5a](https://github.com/eschaar/vstack/commit/9c06f5ab10e79eab5bd110cf9bdf756ba617257f)) -* rename Option A/B to direct execution / orchestrated pipeline in ADRs 017, 018, 020, 021, 022 ([56771d3](https://github.com/eschaar/vstack/commit/56771d3d50bafada8751f790b442026a05e4d513)) -* **roadmap:** rationalise candidate items and restructure roadmap ([831e3e0](https://github.com/eschaar/vstack/commit/831e3e02f87d61c279d62ad5726474fb06217f59)) -* **roadmap:** record install target override as not planned ([168f499](https://github.com/eschaar/vstack/commit/168f499c9081b6b4620683c4d5c8c611f1e3634e)) -* split roadmap row into selective install (shipped) and template overlays (candidate) ([56771d3](https://github.com/eschaar/vstack/commit/56771d3d50bafada8751f790b442026a05e4d513)) - +- ADR-022 selective exclude filter ([56771d3](https://github.com/eschaar/vstack/commit/56771d3d50bafada8751f790b442026a05e4d513)) +- **agents:** update artifacts schema docs to reflect dir/ARTIFACTS_DOCS_ROOT design ([a408468](https://github.com/eschaar/vstack/commit/a4084684e7449ce55f569d11d0dccbd535f2f141)) +- document exclude filter, install vs init flow, and config.yaml schema ([3296020](https://github.com/eschaar/vstack/commit/3296020229a950ce6932a5a8322e9e81959587f7)) +- **readme:** add no-target variants for install and init to CLI table ([9c06f5a](https://github.com/eschaar/vstack/commit/9c06f5ab10e79eab5bd110cf9bdf756ba617257f)) +- rename Option A/B to direct execution / orchestrated pipeline in ADRs 017, 018, 020, 021, 022 ([56771d3](https://github.com/eschaar/vstack/commit/56771d3d50bafada8751f790b442026a05e4d513)) +- **roadmap:** rationalise candidate items and restructure roadmap ([831e3e0](https://github.com/eschaar/vstack/commit/831e3e02f87d61c279d62ad5726474fb06217f59)) +- **roadmap:** record install target override as not planned ([168f499](https://github.com/eschaar/vstack/commit/168f499c9081b6b4620683c4d5c8c611f1e3634e)) +- split roadmap row into selective install (shipped) and template overlays (candidate) ([56771d3](https://github.com/eschaar/vstack/commit/56771d3d50bafada8751f790b442026a05e4d513)) ### Maintenance -* **cleanup:** remove stale .github/vstack.json and add missing init command ([99ded6b](https://github.com/eschaar/vstack/commit/99ded6b428a661a184b73d592da0df27dfd5f9ee)) -* **install:** regenerate artifacts and populate manifest ([56771d3](https://github.com/eschaar/vstack/commit/56771d3d50bafada8751f790b442026a05e4d513)) -* **lint:** disable MD012 for release-please CHANGELOG double blank lines ([5044cd7](https://github.com/eschaar/vstack/commit/5044cd73e248be3a0dc53a50adde3e39ec968f55)) -* **release:** add v3.0.0 release notes and mark roadmap rows as shipped ([55bfb69](https://github.com/eschaar/vstack/commit/55bfb6956758b3f4703905d4e337ce20f2a64cd1)) -* **release:** update reports, finalise v3.0.0 release notes for 2026-05-06 ([ad641f0](https://github.com/eschaar/vstack/commit/ad641f091324a1f9428e0cace67dca61acd41ce1)) +- **cleanup:** remove stale .github/vstack.json and add missing init command ([99ded6b](https://github.com/eschaar/vstack/commit/99ded6b428a661a184b73d592da0df27dfd5f9ee)) +- **install:** regenerate artifacts and populate manifest ([56771d3](https://github.com/eschaar/vstack/commit/56771d3d50bafada8751f790b442026a05e4d513)) +- **lint:** disable MD012 for release-please CHANGELOG double blank lines ([5044cd7](https://github.com/eschaar/vstack/commit/5044cd73e248be3a0dc53a50adde3e39ec968f55)) +- **release:** add v3.0.0 release notes and mark roadmap rows as shipped ([55bfb69](https://github.com/eschaar/vstack/commit/55bfb6956758b3f4703905d4e337ce20f2a64cd1)) +- **release:** update reports, finalise v3.0.0 release notes for 2026-05-06 ([ad641f0](https://github.com/eschaar/vstack/commit/ad641f091324a1f9428e0cace67dca61acd41ce1)) ## [2.2.0](https://github.com/eschaar/vstack/compare/2.1.0...2.2.0) (2026-05-02) diff --git a/README-pypi.md b/README-pypi.md index b3ad40f..eb6c8ac 100644 --- a/README-pypi.md +++ b/README-pypi.md @@ -6,7 +6,7 @@ [![Python version](https://img.shields.io/badge/python-3.11--3.14-0B8A6F)](https://github.com/eschaar/vstack/blob/main/pyproject.toml) [![Verify status](https://img.shields.io/github/actions/workflow/status/eschaar/vstack/verify.yml?label=verify&color=1D6FA5)](https://github.com/eschaar/vstack/actions/workflows/verify.yml) [![Security checks](https://img.shields.io/github/actions/workflow/status/eschaar/vstack/security.yml?label=security&color=B15E00)](https://github.com/eschaar/vstack/actions/workflows/security.yml) -[![Runtime: stdlib only](https://img.shields.io/badge/runtime-stdlib%20only-5B6C8F)](https://github.com/eschaar/vstack/blob/main/pyproject.toml) +[![Runtime: PyYAML](https://img.shields.io/badge/runtime-PyYAML-5B6C8F)](https://github.com/eschaar/vstack/blob/main/pyproject.toml) [![License: MIT](https://img.shields.io/github/license/eschaar/vstack?color=5F7A1F)](https://github.com/eschaar/vstack/blob/main/LICENSE) [![GitHub Discussions](https://img.shields.io/badge/discussions-ask%20%26%20share-blueviolet?logo=github)](https://github.com/eschaar/vstack/discussions) @@ -27,7 +27,7 @@ It provides a fixed role model for end-to-end software delivery: `product`, `arc - Fixed role model: `product`, `architect`, `designer`, `engineer`, `tester`, `release` - Template-driven install model from `src/vstack/_templates/` - Backend-first verification, security, and release discipline -- Standard-library-only runtime dependencies +- One runtime dependency: PyYAML ## Building blocks @@ -53,16 +53,23 @@ VS Code command palette (`Chat: Run Prompt File`) or the Copilot Chat attach but | `migration-safety` | Review DB migration safety, rollback, and zero-downtime | | `release-readiness` | Evaluate release readiness from reports and open blockers | -## Quick start +## Quickstart — fresh install Install with `pipx`, then install vstack artifacts into your repository: ```bash +# Install the CLI once, globally pipx install vstack -vstack install --target /path/to/your/project -vstack validate + +# Move to your repository root and run install — no --target needed +cd /path/to/your/project +vstack install # seeds .vstack/config.yaml and generates .github/ in the current directory +vstack validate # confirm no errors ``` +When you omit `--target`, vstack uses the current working directory. The equivalent +explicit form is `vstack install --target /path/to/your/project`. + Run a first task in Copilot Agent Mode: ```text @@ -74,6 +81,57 @@ Expected result: - `vstack validate` reports no unresolved template tokens - Agent command returns a concrete verification summary for your repository +## Quick upgrade + +### Patch or minor version (e.g. v3.1 → v3.2, same major) + +Docs paths never change within a major version. Only `.github/` artifacts are updated. + +```bash +pipx upgrade vstack + +cd /path/to/your/project +vstack init # idempotent — safe to run in CI +``` + +### Major version (e.g. v2 → v3) + +Docs paths may change on a major version bump. Run `vstack migrate` before `vstack init`. + +```bash +pipx upgrade vstack + +cd /path/to/your/project +vstack migrate # moves docs files to their new paths (auto-detects installed version) +vstack init # regenerates .github/ artifacts + +# Only if you see "Legacy manifest schema detected" in the output above: +vstack manifest upgrade +vstack init +``` + +Preview the docs moves without touching any files: + +```bash +vstack migrate --dry-run +``` + +For upgrades spanning multiple major versions (e.g. v1 → v3), `vstack migrate` chains +all intermediate steps automatically. Use `--from` and `--to` to specify the range +explicitly if auto-detection from the manifest fails: + +```bash +vstack migrate --from 1 --to 3 +vstack init +``` + +### Force reinstall (overwrite local edits) + +```bash +vstack install --force # overwrite all managed artifacts +vstack install --force-name agent/engineer # overwrite one specific artifact +``` + ## Why this helps - Consistent role boundaries for planning, implementation, validation, and release @@ -85,10 +143,17 @@ Expected result: ```bash vstack --version vstack validate + +# Run from your repository root (--target defaults to the current directory) +vstack install +vstack init +vstack migrate +vstack manifest verify +vstack manifest status +vstack manifest upgrade + +# Or specify a path explicitly vstack install --target /path/to/your/project -vstack manifest verify --target /path/to/your/project -vstack manifest status --target /path/to/your/project -vstack manifest upgrade --target /path/to/your/project ``` ## Common usage patterns @@ -96,6 +161,11 @@ vstack manifest upgrade --target /path/to/your/project Repository-scoped install (recommended for teams): ```bash +# Move to your repository root and install there +cd /path/to/your/project +vstack install + +# Or specify a path explicitly from any directory vstack install --target /path/to/your/project ``` @@ -122,7 +192,8 @@ exclude: If you already have agents, skills, or other files in `.github/`, run a dry-run first to see what would be preserved before committing: ```bash -vstack install --dry-run --target /path/to/your/project +# Run from your repository root +vstack install --dry-run ``` The summary lists preserved files as `type/name` selectors (e.g. `agent/engineer`). Resolve each conflict with `--force-name type/name` to overwrite, `--adopt-name type/name` to take ownership without overwriting, or `--force` to overwrite everything. @@ -130,7 +201,7 @@ The summary lists preserved files as `type/name` selectors (e.g. `agent/engineer ## Fast troubleshooting - Command not found after install: ensure your `pipx` binary path is in `PATH` -- Validation error: rerun `vstack install --target ...` and then `vstack validate` +- Validation error: rerun `vstack install` from your repository root and then `vstack validate` - Agent results look generic: explicitly invoke a role (for example `@tester`) before a skill ## Full documentation diff --git a/README.md b/README.md index af9f74d..5a43a8c 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![Python version](https://img.shields.io/badge/python-3.11--3.14-0B8A6F "Supported Python versions")](pyproject.toml) [![Verify status](https://img.shields.io/github/actions/workflow/status/eschaar/vstack/verify.yml?label=verify&color=1D6FA5 "Build and test status")](https://github.com/eschaar/vstack/actions/workflows/verify.yml) [![Security checks](https://img.shields.io/github/actions/workflow/status/eschaar/vstack/security.yml?label=security&color=B15E00 "Security workflow status")](https://github.com/eschaar/vstack/actions/workflows/security.yml) -[![Runtime: stdlib only](https://img.shields.io/badge/runtime-stdlib%20only-5B6C8F "No runtime dependencies")](pyproject.toml) +[![Runtime: PyYAML](https://img.shields.io/badge/runtime-PyYAML-5B6C8F "One runtime dependency: PyYAML")](pyproject.toml) [![License: MIT](https://img.shields.io/github/license/eschaar/vstack?color=5F7A1F "Project license")](LICENSE) [![GitHub Discussions](https://img.shields.io/badge/discussions-ask%20%26%20share-blueviolet?logo=github "GitHub Discussions")](https://github.com/eschaar/vstack/discussions) @@ -35,7 +35,7 @@ ______________________________________________________________________ - Fixed role model with explicit ownership boundaries - Template-driven install model from `src/vstack/_templates/` - Backend-first verification, security, and release discipline -- No runtime dependencies beyond the Python standard library +- One runtime dependency: [PyYAML](https://pypi.org/project/PyYAML/) - Works at project scope or globally in the VS Code user profile ______________________________________________________________________ @@ -55,38 +55,75 @@ For experienced users: - Role summary - Example usage - All vstack CLI commands +- Install and upgrade guide - Workflow - Development - CI and Release Automation ### ⚡ Quick paths -#### New user path (2 minutes) +#### Quickstart — fresh install (2 minutes) ```bash +# 1. Install the CLI once, globally pipx install vstack -vstack install --target /path/to/your/project + +# 2. Move to your repository root — all commands default to the current directory +cd /path/to/your/project +vstack install # seeds .vstack/config.yaml and generates .github/ here + +# 3. Confirm everything is in order vstack validate ``` -Then in Copilot Agent Mode: +When you omit `--target`, vstack uses the current working directory. +The explicit form `vstack install --target /path/to/your/project` is equivalent +and useful when running from a different directory. + +Then open Copilot Agent Mode and run: ```text @tester /verify Check this repository and summarize findings ``` -#### Power user path (30 seconds) +#### Quick upgrade — patch or minor version (same major) + +Docs paths never change within a major version. Only `.github/` artifacts are updated. ```bash -vstack install --target /path/to/your/project && vstack verify --target /path/to/your/project +# 1. Upgrade the CLI +pipx upgrade vstack + +# 2. From your repository root — regenerate .github/ artifacts +cd /path/to/your/project +vstack init ``` -Then jump straight into your role workflow: +#### Quick upgrade — major version (e.g. v2 → v3) -```text -@architect Review contracts in src/api/ -@engineer /code-review -@tester /security +Docs paths may change on a major version bump. Run `vstack migrate` before `vstack init`. + +```bash +# 1. Upgrade the CLI +pipx upgrade vstack + +# 2. From your repository root +cd /path/to/your/project + +# 3. Move any docs files that changed path (reads installed version from .vstack/vstack.json) +vstack migrate + +# 4. Regenerate .github/ artifacts +vstack init + +# 5. If the manifest schema is outdated (you will see an error message telling you to do this) +vstack manifest upgrade +``` + +Preview the docs moves without touching any files: + +```bash +vstack migrate --dry-run ``` ```mermaid @@ -101,7 +138,7 @@ ______________________________________________________________________ ## 🚀 Quickstart -> New here? Start with `pipx install ...`, then run `vstack install --target ...`, then try `@tester /verify` in Copilot Agent Mode. +> New here? Run `pipx install vstack`, move to your repository root, run `vstack install`, then try `@tester /verify` in Copilot Agent Mode. ### ⚡ Install with pipx (recommended) @@ -116,7 +153,11 @@ pipx install vstack Afterwards, the `vstack` command is available everywhere: ```bash -# Recommended: install vstack artifacts per project/repository +# Recommended: move to your repository root and install there +cd /path/to/your/project +vstack install # generates .github/ in the current directory + +# Or specify a path explicitly when running from a different directory vstack install --target /path/to/your/project # Optional: install profile-wide defaults for all VS Code projects @@ -156,8 +197,8 @@ Use repository-scoped installation so every contributor and CI run uses the same 1. Require `commit.yml`, `check.yml`, `verify.yml`, and `security.yml` checks before merge. ```bash -# From your repository root -vstack install --target /path/to/your/project +cd /path/to/your/project +vstack install git add .github git commit -m "chore: install vstack artifacts" ``` @@ -344,7 +385,7 @@ A: In a specific repository, run `vstack install --target /path/to/your/project` A: Python 3.11–3.14 (see badges above). **Q: How do I reset the install?** -A: For one repository, run `vstack uninstall --target /path/to/your/project` and then reinstall with `vstack install --target /path/to/your/project`. Use `--global` only for profile-wide defaults. +A: Move to your repository root and run `vstack uninstall`, then `vstack install`. You can also use `vstack uninstall --target /path/to/your/project` from any directory. Use `--global` only for profile-wide defaults. **Q: Where can I ask questions or give feedback?** A: [Start a discussion or ask a question here.](https://github.com/eschaar/vstack/discussions) @@ -356,7 +397,11 @@ ______________________________________________________________________ To remove vstack artifacts from your project or profile, use the CLI: ```bash -# Uninstall vstack artifacts from your current project +# Move to your repository root and uninstall +cd /path/to/your/project +vstack uninstall + +# Or specify a path explicitly from any directory vstack uninstall --target /path/to/your/project # Uninstall vstack artifacts from your global VS Code profile @@ -416,27 +461,30 @@ ______________________________________________________________________ | `vstack uninstall --target DIR` | Uninstall tracked artifacts that still match the manifest | | `vstack uninstall --global` | Uninstall vstack artifacts from your VS Code profile | | `vstack uninstall` | Uninstall from the current directory default target | +| `vstack migrate --target DIR` | Move docs files from old paths to new paths after a major vstack upgrade | +| `vstack migrate --from M --to N` | Migrate docs paths across major versions M through N (chains intermediate steps) | +| `vstack migrate --dry-run` | Preview docs path moves without touching any files | By default, `vstack install` is conservative: if a target file already exists but is not tracked by `vstack`, it is left in place. For tracked files, `--update` only rewrites artifacts whose on-disk content still matches the SHA-256 checksum of the last installed version recorded in `.vstack/vstack.json`. Use `--force` to overwrite everything, `--force-name ` to overwrite one specific managed artifact, or `--adopt-name ` to start tracking one existing unmanaged file without overwriting it. If you already have agents, skills, or other files in `.github/`, run a dry-run first to see what would be preserved before committing: ```bash -# Preview what install would do — no files are written -vstack install --dry-run --target /path/to/your/project +# Preview what install would do — no files are written (run from your repository root) +vstack install --dry-run ``` The summary shows every preserved file as a `type/name` selector (e.g. `agent/engineer`, `skill/verify`). You can then resolve each conflict selectively: ```bash # Overwrite a specific preserved artifact -vstack install --target . --force-name agent/engineer +vstack install --force-name agent/engineer # Take ownership of an existing file without overwriting it -vstack install --target . --adopt-name agent/engineer +vstack install --adopt-name agent/engineer # Overwrite everything -vstack install --target . --force +vstack install --force ``` When multiple artifact types share the same name (e.g. an `agent` and a `skill` both named `engineer`), use the `type/name` form to target one precisely. @@ -489,6 +537,139 @@ All fields are optional. An absent or commented-out block restores the default b ______________________________________________________________________ +## ⬆️ Install and upgrade guide + +vstack manages two separate layers. Knowing which layer each command touches prevents mistakes: + +| Layer | What it contains | Updated by | +| --------------------- | -------------------------------------------------------------------------------- | -------------------------------- | +| `.github/` | Agent, skill, instruction, and prompt files that Copilot reads | `vstack install` / `vstack init` | +| `docs/` | Docs files that agents read and write (paths may change on a major version bump) | `vstack migrate` | +| `.vstack/vstack.json` | Manifest — tracks which `.github/` files are managed and stores their checksums | `vstack manifest upgrade` | + +### Scenario 1 — Fresh install (no previous vstack) + +```bash +# Install the CLI once, globally +pipx install vstack + +# Move to your repository root +cd /path/to/your/project +vstack install # seeds .vstack/config.yaml and generates .github/ in the current directory +vstack validate # confirm no errors +``` + +All commands default to the current working directory when `--target` is omitted. +Run them from the repository root. The explicit form `vstack install --target /path/to/your/project` +is equivalent and useful when running from a different directory. + +### Scenario 2 — Fresh install over an existing version (force) + +Replace all managed artifacts, even if you have made local edits: + +```bash +# Preview what would happen first +vstack install --dry-run + +# Overwrite everything +vstack install --force +``` + +Or target a single artifact without touching the rest: + +```bash +vstack install --force-name agent/engineer +vstack install --force-name skill/verify +``` + +To take ownership of an existing unmanaged file without overwriting it: + +```bash +vstack install --adopt-name agent/architect +``` + +### Scenario 3 — Patch or minor upgrade (e.g. v3.1 → v3.2, same major) + +Docs paths never change within a major version. Only `.github/` artifacts need updating. + +```bash +pipx upgrade vstack + +cd /path/to/your/project +vstack init # idempotent regeneration — safe to run in CI +``` + +`vstack init` is safe to re-run at any time. It reads `.vstack/config.yaml` and regenerates `.github/` artifacts without touching anything else. + +### Scenario 4 — Major upgrade, single step (e.g. v2 → v3) + +Docs paths may change on a major version bump. Run `vstack migrate` before `vstack init`. + +```bash +pipx upgrade vstack + +cd /path/to/your/project + +# Preview what migrate would move (no files are touched) +vstack migrate --dry-run + +# Apply the docs path moves (auto-detects your installed version from .vstack/vstack.json) +vstack migrate + +# Regenerate .github/ artifacts +vstack init + +# Only needed if you see: "Legacy manifest schema detected" in the output above +vstack manifest upgrade +vstack init +``` + +### Scenario 5 — Major upgrade, multiple steps (e.g. v1 → v3) + +`vstack migrate` chains all intermediate steps automatically. You do not need to run it once per version. + +```bash +pipx upgrade vstack + +cd /path/to/your/project + +# Auto-detects v1 from manifest, chains v1→v2→v3 automatically +vstack migrate + +# Or specify the range explicitly if auto-detection fails +vstack migrate --from 1 --to 3 + +vstack init +``` + +If no migration record exists for an intermediate step (for example v1 → v2), that step is silently skipped. + +### Scenario 6 — Manifest schema is outdated + +If a command fails with: + +```text +Legacy manifest schema detected in vstack.json. Run: vstack manifest upgrade +``` + +Run: + +```bash +vstack manifest upgrade +vstack init +``` + +### Common mistakes to avoid + +| Mistake | Symptom | Fix | +| ---------------------------------------------------------------- | --------------------------------------------------------- | --------------------------------------------------------------------- | +| Running `vstack init` before `vstack migrate` on a major upgrade | Agents reference docs paths that no longer exist | Run `vstack migrate` first, then `vstack init` | +| Running `vstack migrate` without a manifest | `ERROR: could not detect installed version from manifest` | Run `vstack migrate --from ` to specify explicitly | +| Local edits not overwritten | `vstack init` skips modified files silently | Use `vstack install --force-name type/name` to overwrite one artifact | +| Manifest schema error after upgrade | `vstack init` fails with a schema error | Run `vstack manifest upgrade` first | + +______________________________________________________________________ + ## 🤝 How to contribute Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, code style, and how to get started. diff --git a/docs/architecture/adr/004-option-a-to-b-pipeline.md b/docs/architecture/adr/004-option-a-to-b-pipeline.md index aa78cb3..7cc1be7 100644 --- a/docs/architecture/adr/004-option-a-to-b-pipeline.md +++ b/docs/architecture/adr/004-option-a-to-b-pipeline.md @@ -3,7 +3,7 @@ > Maintained by: **architect** role **date:** 2026-03-27\ -**status:** accepted +**status:** superseded by [ADR-024](024-subagent-orchestration.md) ## context @@ -51,3 +51,11 @@ is a runner addition, not a skill rewrite. ## impact on future orchestrated pipeline This is the foundational decision. See also `docs/design/workflow.md`. + +## amendment — 2026-05-09 + +The assumption that VS Code lacked native subagent support was invalidated when +Microsoft shipped the `runSubagent` tool and `agents:` frontmatter in VS Code +Copilot (documented 2026-05-06). The `scripts/runner.py` approach in the +"Refactoring required" section above is therefore not needed. See +[ADR-024](024-subagent-orchestration.md) for the updated decision. diff --git a/docs/architecture/adr/006-no-runtime-dependency.md b/docs/architecture/adr/006-no-runtime-dependency.md index e8112e6..d39ed83 100644 --- a/docs/architecture/adr/006-no-runtime-dependency.md +++ b/docs/architecture/adr/006-no-runtime-dependency.md @@ -3,7 +3,11 @@ > Maintained by: **architect** role **date:** 2026-03-27\ -**status:** accepted +**status:** accepted\ +**scope note:** This ADR covers **skill template content** only — the Markdown files +the AI agent executes. It does not govern the Python package's own `pip` dependencies. +The decision to introduce `pyyaml` as a runtime package dependency is documented in +ADR-025. ## context @@ -32,5 +36,7 @@ any prerequisites beyond the project's own toolchain. ## impact on future orchestrated pipeline -The future orchestrated pipeline runner will be `scripts/runner.py` (stdlib only), not a -skill runtime dependency. +The orchestrated pipeline uses VS Code native subagents via the `runSubagent` tool +(see ADR-024) — no separate `scripts/runner.py` runner is needed. +The sole runtime dependency (`pyyaml`) is a pip package, not a binary, and does +not affect the skill portability guarantee this ADR establishes. diff --git a/docs/architecture/adr/023-workflow-contract.md b/docs/architecture/adr/023-workflow-contract.md new file mode 100644 index 0000000..eaec07f --- /dev/null +++ b/docs/architecture/adr/023-workflow-contract.md @@ -0,0 +1,201 @@ +# ADR-023: Workflow contract as source of truth for pipeline configuration + +> Maintained by: **architect** role + +**date:** 2026-05-08\ +**status:** accepted + +## context + +vstack's six-role agent model (ADR-009) defines a sequential pipeline: +`product → architect → designer → engineer → tester → release`. Each role produces +artifacts that become the input of the next role. The pipeline can be executed manually +(a user clicks a handoff button in VS Code) or, in future, automatically by an orchestrator +agent. + +Before this change, pipeline configuration was scattered and partially hardcoded: + +- **Stage order** was implicit in the order that `handoffs:` entries were written in each agent's + `config.yaml`. There was no single file that described the full pipeline. +- **Handoff targets** were encoded inside each agent's own config as `agent: `. If a team + wanted to reorder stages or insert a custom role, they had to edit multiple agent configs. +- **Handoff prompts** were tightly coupled to the next stage's name: the product agent's prompt + said "produce or update the architecture", making it incorrect if architect was moved later in + the pipeline. +- **Gate policy** (required vs. optional stages) did not exist as a concept; every stage was + implicitly required. + +This created a structural problem for the planned `planner` orchestrator role (ADR roadmap): +an orchestrator needs a complete, deterministic, machine-readable description of the pipeline +to dispatch subagents in order. It cannot reconstruct the pipeline by reading handoff entries +spread across six separate agent configs. + +A secondary problem was the absence of any distinction between *baseline* artifacts (living docs +that must be kept current throughout a project's lifetime) and *deliverable* artifacts (output +produced per session or release, such as reports and changelogs). Agents had no explicit +instruction to maintain their baseline docs — the responsibility was implied at best. + +## decision + +### 1. Workflow config block in `.vstack/config.yaml` + +A `workflow:` block is added to the project config schema. It is seeded once by `vstack install` +with the default six-stage pipeline and is thereafter owned by the project. `vstack init` reads +this block on every run. + +```yaml +workflow: + version: 1 + stages: + - role: product + gate: required + hitl: always + handoffs: + prompt: > + Product outputs are approved. Assess the current state and produce + or update the architecture as needed. If your domain is not affected + by this change, assess and confirm explicitly, then pass to the next stage. + - role: architect + gate: required + hitl: always + handoffs: + prompt: > + Architecture outputs are approved. Produce design specifications as + needed. If your domain is not affected, assess and confirm explicitly, + then pass to the next stage. + - role: designer + gate: optional + hitl: on-change + handoffs: + prompt: > + Design outputs are approved. Implement code and tests as needed. If + your domain is not affected, assess and confirm explicitly, then pass + to the next stage. + - role: engineer + gate: required + hitl: always + handoffs: + prompt: > + Implementation is approved. Verify the implementation — run tests, + security checks, and performance analysis as needed. + - role: tester + gate: required + hitl: always + handoffs: + prompt: > + Verification outputs are approved. Prepare the release — produce or + update release artifacts and sign-offs. + - role: release + gate: required + hitl: always + handoffs: + prompt: "" +``` + +The `version` key allows `vstack init` to detect schema drift after a vstack upgrade and +emit a warning, without blocking the run. + +`gate` values: `required` (stage always runs), `optional` (stage may be skipped when its domain +is unaffected by the current change), and `skip` (stage is never executed — explicit opt-out). + +`hitl` (human-in-the-loop) values control when the pipeline pauses for human approval before the +handoff is activated: + +- `always` — pipeline always pauses; the human must explicitly approve before progression + (default for `gate: required`) +- `on-change` — pipeline pauses only when the stage made changes; if the stage reports no + changes the pipeline may continue automatically (default for `gate: optional`) +- `never` — pipeline continues without human approval; must be set explicitly as a conscious + opt-out + +Both fields are informational in the current release (Option A) and are reserved for enforcement +by the future orchestrator (Option B). The decision of whether changes occurred is always made +by the agent and confirmed by the human — never inferred automatically. + +### 2. Handoffs generated from workflow config + +Agent `config.yaml` files drop the top-level `handoffs:` block. In its place, each agent carries +a `defaults.handoffs.prompt` string — the text to send to the next stage. The generator reads the +workflow config to determine the next stage's role name and combines it with the agent's +`defaults.handoffs.prompt` to produce the full handoff block in the generated `.agent.md`. + +```yaml +# agent config.yaml +defaults: + handoffs: + prompt: > + Product outputs are approved. Assess the current state and produce or + update the architecture as needed. +``` + +When the workflow config has a `handoffs:` list for a stage, those entries drive the generated +handoffs directly; the agent's `defaults.handoffs.prompt` overrides the prompt of the first +workflow-level handoff that has no explicit `agent:` override, allowing per-template +customisation without editing the central config. + +When no workflow config is present (absent or empty `workflow:` block), the generator falls back +to a generic label ("Continue to next stage") and omits the `agent:` field, preserving v3 behavior +with no breaking change. + +### 3. `baseline:` flag on output artifacts + +Each agent `config.yaml` output entry gains an optional `baseline: true` flag. The generator +renders flagged items into a dedicated `### baseline docs you maintain` table inside the agent's +artifacts section. Agents are explicitly instructed to keep these files current. + +Artifacts without the flag are treated as deliverables — produced per session, not maintained +indefinitely. + +### 4. Project-level artifact overrides — deferred + +An `agents:` overlay block in `.vstack/config.yaml` (allowing teams to promote deliverables to +baseline status or adjust artifact paths without forking templates) is **not implemented** in +this decision. It is reserved as a follow-on change once the workflow contract proves stable +in practice. Until then, teams that need per-project artifact customisation should fork the +relevant agent template. + +## alternatives considered + +**Keep handoffs fully in agent config.yaml.** The existing approach keeps each agent +self-contained, but makes the pipeline order implicit and couples prompt text to stage names. +An orchestrator would have to reconstruct the pipeline by following handoff chains — fragile and +order-dependent. Rejected because it does not support a configurable or orchestrated pipeline. + +**Separate `workflow.yaml` file in `.vstack/`.** A dedicated file would have cleaner separation +of concerns. Rejected in favor of adding a `workflow:` block to the existing `config.yaml` to +keep project configuration in one place and reduce the number of files a team must manage. + +**Pipeline order as a CLI argument.** Allows ad-hoc reordering without config changes. Rejected +because pipeline preferences are stable project decisions that should survive across all +invocations (CI, local, upgrade flows) without repeating flags. + +**`depends_on` for parallel stages.** Would allow independent stages (e.g., security audit and +performance test) to run in parallel. Rejected for this release: sequential execution is correct +for the current input/output dependency model. Parallel stages require a `depends_on` mechanism +and dynamic prompt composition when multiple upstream stages complete simultaneously. Reserved +for a future ADR. + +## rationale + +The workflow config block is the minimum necessary change to make the pipeline machine-readable +without breaking existing consumers. Teams that do not configure a `workflow:` block get identical +behavior to v3. Teams that do configure it gain a single, explicit source of truth for stage +order, gate policy, and handoff text. + +Separating `handoff_prompt` (agent-owned, role-specific knowledge) from handoff target and label +(pipeline-owned, derived from workflow order) is the correct ownership boundary. The agent knows +what it produced and what context the next stage needs; it does not know which role comes next. +The workflow knows the sequence. + +The `baseline:` flag addresses a recurring problem in practice: agents produced docs but had no +explicit instruction to maintain them. Making baseline status an explicit, generated instruction +inside the agent reduces the chance of stale docs accumulating unnoticed. + +## impact on orchestrated pipeline + +This ADR is the direct prerequisite for the `planner` orchestrator role. The planner reads +`workflow.stages` to determine dispatch order, uses `gate` to decide whether to run or skip a +stage, uses `hitl` to decide whether to pause for human approval after each stage completes, and +uses `handoffs[].prompt` as the context passed to the next subagent. No further changes to the +workflow schema are required to implement sequential orchestrated dispatch. Parallel dispatch and +`depends_on` semantics are deferred to a follow-on ADR. diff --git a/docs/architecture/adr/024-subagent-orchestration.md b/docs/architecture/adr/024-subagent-orchestration.md new file mode 100644 index 0000000..955e244 --- /dev/null +++ b/docs/architecture/adr/024-subagent-orchestration.md @@ -0,0 +1,119 @@ +# ADR-024: Subagent Orchestration via VS Code Native Subagents + +> Maintained by: **architect** role + +**date:** 2026-05-09\ +**status:** accepted\ +**supersedes:** [ADR-004](004-option-a-to-b-pipeline.md) + +## context + +[ADR-004](004-option-a-to-b-pipeline.md) deferred pipeline orchestration to a future +"Option B" model, citing the absence of platform support for an agent invoking another +agent inside VS Code Copilot. The planned fallback was a `scripts/runner.py` process runner +outside the editor. + +As of May 2026, VS Code Copilot ships native subagent support: + +- The `runSubagent` tool lets a coordinator agent invoke a named custom agent as a + subagent, pass it a prompt, and receive the result back in the same session context. +- The `agents:` frontmatter property in `.agent.md` restricts which custom agents a + coordinator is allowed to invoke. +- The `user-invocable: false` property hides worker agents from the dropdown while + keeping them available as subagents. +- Nested subagents are supported up to depth 5 (opt-in via + `chat.subagents.allowInvocationsFromSubagents`). + +The workflow contract established in [ADR-023](023-workflow-contract.md) already captures +the pipeline order (`workflow.stages`), gate policy (`gate`), human-in-the-loop policy +(`hitl`), and handoff prompts (`handoffs[].prompt`) in `.vstack/config.yaml`. All inputs +the orchestrator needs are already present. + +## decision + +Implement the orchestrated pipeline using the VS Code coordinator/worker subagent pattern. +Add a `planner` agent as the coordinator. The six role agents (`product`, `architect`, +`designer`, `engineer`, `tester`, `release`) continue to exist as both user-invocable +standalone agents and as subagent workers callable by the planner. + +### planner agent responsibilities + +1. Read the workflow contract from `.vstack/config.yaml` (`workflow.stages`). +1. Dispatch each stage in sequence by invoking the role agent as a subagent with the + stage's `handoffs[].prompt` as the prompt. +1. After each stage completes, apply `hitl` policy: + - `always` — pause and present the result to the user for explicit approval before + continuing. + - `on-change` — pause only if the subagent reports making changes; continue + automatically if it reports no changes. + - `never` — continue automatically without user confirmation. +1. Apply `gate` policy before dispatching: + - `required` — always dispatch the stage. + - `optional` — skip the stage if the subagent's domain is not affected by the current + change; the planner assesses this from context. + - `skip` — never dispatch the stage. + +### agent frontmatter + +The planner agent file includes: + +```yaml +tools: ['agent', 'read_file', 'semantic_search', 'file_search'] +agents: ['product', 'architect', 'designer', 'engineer', 'tester', 'release'] +user-invocable: true +``` + +The role agents remain `user-invocable: true` so teams can still invoke them directly +without the planner. The planner is additive — it does not replace direct invocation. + +### handoff prompt composition + +The planner passes the stage's `handoffs[].prompt` text from `config.yaml` as the +subagent prompt. This is the same text that was previously rendered into each agent's +`handoffs:` frontmatter block. The workflow contract is therefore the single source of +truth for both the planner-driven pipeline and the manual handoff buttons. + +## alternatives considered + +**`scripts/runner.py` process runner (original ADR-004 plan).** A Python script outside +the editor invokes agents via the VS Code CLI or a subprocess. Rejected because the +platform now provides the same capability natively, with better context passing, model +selection, and user visibility. A process runner would also violate ADR-006 (no runtime +dependency). + +**MCP server as orchestrator.** An MCP server exposes a `run_pipeline` tool that drives +stage dispatch. Rejected because it requires an external process and a network transport, +adds a runtime dependency, and provides no user experience benefit over native subagents. + +**Planner-only, no direct role invocation.** Make the role agents non-user-invocable +(`user-invocable: false`) and require all use to go through the planner. Rejected because +direct invocation is the primary use pattern for teams that want focused single-role +assistance without running a full pipeline. + +**Separate orchestrator skill instead of a new agent.** A skill that can be added to any +agent to give it planner behaviour. Rejected because the planner has a distinct identity, +a specific tool set, and a workflow-contract dependency that makes it a natural agent, not +a reusable skill. + +## rationale + +Using VS Code native subagents keeps the orchestrator inside the editor context, preserving +workspace access, model selection, and the user review loop. No external process, no new +runtime dependency (ADR-006 preserved). The coordinator/worker pattern documented by +Microsoft maps directly onto vstack's role model: one coordinator (planner), six workers +(the existing role agents). + +The workflow contract from ADR-023 already carries everything the planner needs. Implementing +the planner is a template authoring task — no changes to the vstack Python package, CLI, or +generator are required. + +## impact on the pipeline + +The planner agent is the concrete realisation of the Option B orchestrated pipeline +described in ADR-004. The original decision to keep stage boundaries explicit and +artifacts canonical (ADR-004, "Preparation done now") is validated: those properties are +exactly what makes the subagent handoff model work without additional scaffolding. + +Parallel stage execution (e.g. simultaneous security and performance tests inside +`tester`) remains deferred. It requires a `depends_on` model and prompt composition when +multiple upstream results arrive simultaneously. This is reserved for a follow-on ADR. diff --git a/docs/architecture/adr/025-pyyaml-runtime-dependency.md b/docs/architecture/adr/025-pyyaml-runtime-dependency.md new file mode 100644 index 0000000..3c1f002 --- /dev/null +++ b/docs/architecture/adr/025-pyyaml-runtime-dependency.md @@ -0,0 +1,77 @@ +# ADR-025: Introduce PyYAML as Sole Runtime Dependency + +> Maintained by: **architect** role + +**date:** 2026-05-09\ +**status:** accepted\ +**amends:** ADR-006 (scope clarification — see that document) + +## context + +vstack was initially released with `dependencies = []`, keeping `pip install vstack` +free of any transitive pulls. The frontmatter parser (`FrontmatterParser._parse_yaml_block`) +was hand-rolled specifically to preserve that property. + +The YAML subset that vstack must parse has grown steadily as new config features were +added — `defaults:` blocks, `baseline:` flags, `hitl:` fields, `workflow:` blocks with +nested `stages` / `handoffs` object-lists, and raw mapping blocks for MCP-server config. +The parser now handles eight distinct structural cases, each represented by its own +state-machine flag, and relies on fragile indentation-level checks +(`line.startswith(" ")`, `" "`, `" "`) with no named constants and no +protection against tabs or irregular indentation. + +Continuing to extend the hand-rolled parser as the config schema grows is a maintenance +liability that outweighs the benefit of zero runtime dependencies. + +## decision + +- Add `pyyaml` as the sole entry in `[project] dependencies` in `pyproject.toml`. +- Replace the `_parse_yaml_block` state-machine body with a single `yaml.safe_load` call. +- Keep the `FrontmatterContent` dataclass and `FrontmatterParser.parse` / `parse_yaml` + public API surface unchanged so all callers remain unaffected. +- Retain `FrontmatterParser` as a class and its static methods; only the internal + `_parse_yaml_block` implementation changes. + +## alternatives considered + +- **Keep the hand-rolled parser** — avoids the dependency but means every new config + key may require new state-machine branches and indentation heuristics. Risk grows + linearly with schema complexity. +- **Use `ruamel.yaml`** — preserves comments and round-trips YAML faithfully; valuable + for tools that rewrite config files. vstack only reads config at install time, so + round-trip fidelity is not needed. `ruamel.yaml` also carries more transitive weight + than PyYAML. +- **Restrict the config schema** — deliberately keep frontmatter simple enough for the + hand-rolled parser. This constrains future workflow and orchestration features + (ADR-023, ADR-024) that already depend on nested YAML structures. +- **Optional dependency with stdlib fallback** — adds dead code paths and makes test + coverage of the fallback artificial. The fallback would need its own maintenance, + negating most of the benefit. + +## rationale + +PyYAML is the reference YAML 1.1 implementation for Python. It ships as a single +compiled wheel, has no runtime dependencies of its own on CPython 3.x, and is already +a transitive dependency of the vast majority of Python projects. `yaml.safe_load` has +a strong security track record when object deserialization is disabled (which `safe_load` +enforces by construction). + +The trade-off is clear: one well-audited, widely-deployed library in exchange for +permanently removing a class of parser fragility from the codebase. + +## impact + +- `pip install vstack` will pull `pyyaml` (≈ 143 kB wheel on CPython). +- `pyproject.toml`: `dependencies = ["pyyaml>=6.0"]`. +- `FrontmatterParser._parse_yaml_block`: implementation replaced; public API unchanged. +- Tests: existing parser tests continue to pass against the new implementation; + test_parser coverage remains at 100 %. +- ADR-006 is amended with a scope note; its decision (no external binaries in skill + content) is unaffected. + +## impact on future orchestrated pipeline + +The orchestrated pipeline runner (ADR-024) generates and reads `config.yaml` files +for each agent. Having a proper YAML parser available as a runtime dependency means +those config files can use the full YAML 1.1 feature set without risking parser +regressions. diff --git a/docs/architecture/adr/026-docs-artifact-migration-policy.md b/docs/architecture/adr/026-docs-artifact-migration-policy.md new file mode 100644 index 0000000..4d888ca --- /dev/null +++ b/docs/architecture/adr/026-docs-artifact-migration-policy.md @@ -0,0 +1,178 @@ +# ADR-026: Docs Artifact Path Stability and Migration Policy + +> Maintained by: **architect** role + +**date:** 2026-05-09\ +**status:** accepted\ +**updated:** 2026-05-10 — `vstack migrate` shipped in this version\ +**depends on:** ADR-014 (manifest schema versioning), ADR-019 (vstack project directory), ADR-021 (config-driven artifact paths) + +## context + +vstack manages two distinct classes of output artifacts: + +1. **`.github/` artifacts** — skills, agents, instructions, and prompts generated + from templates. These paths are constrained by VS Code and GitHub Copilot conventions + (e.g. `.github/skills//SKILL.md`). They are tracked in the manifest + (`.vstack/vstack.json`) with checksums and can be upgraded, reinstalled, or removed + via CLI commands. Path changes here are infrequent and externally motivated. + +1. **`docs/` artifacts** — living documentation produced and maintained by agent roles + (architect, designer, product, tester, release). Examples: `docs/architecture/overview.md`, + `docs/product/requirements.md`, `docs/reports/security-report.md`. These are **not** + tracked in the manifest; they are owned and continuously updated by agents as project + outputs, not by vstack as generated files. + +ADR-021 established that agent `config.yaml` files are the machine-readable source of truth +for which paths each agent writes to (`artifacts.dir`, `artifacts.output`). Skill template +prose duplicates these paths as LLM guidance (e.g. "write to `docs/architecture/overview.md`") +— this is intentional, acceptable duplication. The prose is informational; the config is +authoritative. + +### The migration problem + +When `docs/` paths change across vstack versions (e.g. a subdirectory is renamed or a file +moves), no automated mechanism exists to relocate the files. Because they are untracked, +`vstack status` does not report them as stale. Because they are agent-owned content, not +generated content, a simple reinstall does not help. The user is left with orphaned files +at old paths and agents writing to new paths, resulting in divergent documentation. + +Two related questions arise: + +- **When may docs paths change?** What stability commitment do consumers of vstack receive? +- **When they do change, what is the upgrade path?** How does a user migrate their + existing files? + +## decision + +### 1. Path stability commitment + +Docs artifact paths — the `artifacts.dir` value and the glob patterns in `artifacts.output` +for each agent — are **stable within a minor version** and **may break only on a major version +boundary** (semver major bump, e.g. v2→v3, v3→v4). + +This commitment applies to: + +- Agent `config.yaml` `artifacts.dir` values +- Agent `config.yaml` `artifacts.output` paths +- Skill template prose references to the same paths (kept in sync as a consequence) + +It does **not** apply to: + +- `.github/` artifact paths, which follow VS Code / Copilot conventions (covered by ADR-002) +- Project-specific paths the user configures via `artifacts.root` or workflow overrides + +### 2. Agent config.yaml is the source of truth; skill prose is informational + +Skill templates reference docs paths in their markdown bodies (e.g. "Primary deliverable: +`docs/architecture/overview.md`"). This duplication is accepted as a usability aid for LLM +agents. It is not a second source of truth. When a path changes: + +- Update `artifacts.dir` / `artifacts.output` in the relevant agent `config.yaml` — this is + the authoritative change. +- Update all skill template prose references to the same paths for consistency. +- Never change skill prose without also updating the corresponding agent config. + +### 3. Manifest upgrade handles `.github/` relocations; `vstack migrate` handles `docs/` + +Two migration mechanisms exist, each scoped to its artifact class: + +| Artifact class | Location tracked | Migration command | +| ---------------------------------------------------------- | --------------------- | ---------------------------- | +| `.github/` (skills, agents, instructions, prompts) | `.vstack/vstack.json` | `vstack manifest upgrade` | +| `docs/` (architecture, design, product, reports, releases) | Not tracked | `vstack migrate` (see below) | + +`vstack manifest upgrade` already handles schema migrations and file relocation for `.github/` +artifacts (ADR-017). Docs artifact migration is a separate concern with a separate command. + +### 4. `vstack migrate` — implemented + +The `vstack migrate` command is shipped as of this decision. Run it from the project root +after upgrading vstack to a new major version: + +``` +vstack migrate [--target ] [--from ] [--to ] [--dry-run] +``` + +| Flag | Description | +| ---------------- | --------------------------------------------------------------- | +| `--target ` | Project root to migrate (default: current directory) | +| `--from ` | Source major version (default: read from `.vstack/vstack.json`) | +| `--to ` | Target major version (default: current vstack package major) | +| `--dry-run` | Print planned moves without touching the filesystem | + +Migration records live in `src/vstack/_migrations/` within the package source. Each file +covers one major version transition: + +``` +src/vstack/_migrations/ +└── v2_to_v3.yaml # applied when upgrading from any 2.x to 3.x +``` + +A migration record lists path moves: + +```yaml +from_version: "3.x" +to_version: "4.0" +moves: + - old: docs/reports/security-report.md + new: docs/security/security-report.md + type: docs + notes: > + tester role artifact dir changed from reports/ to security/ to align + with expanded scope. Files at the old path are safe to remove after + migration. +``` + +For each `type: docs` move, the command checks whether the old path exists; if so, it moves +the file to the new path (creating parent directories as needed) and reports the result. +Chained moves across multiple major versions are applied in order (e.g. v1→v2→v3 when +upgrading from major 1 to major 3). The command exits non-zero if any move fails. + +`CHANGELOG.md` entries for major releases must include a "Migration" section that restates +the same moves in prose for users who prefer to migrate manually. + +### 5. Skill prose references are resolved at LLM runtime, not install time + +No variable substitution is applied to skill template prose at install time (e.g. +`{{artifacts_root}}/architecture/overview.md`). The agent config `artifacts.dir` is +the machine-readable source; prose is a human-readable aid for the LLM. If a user +sets a custom `artifacts.root` in `.vstack/config.yaml`, the agent's generated +`.agent.md` body will contain the resolved root because the agent template uses the +config value at generation time. Skill markdown bodies remain as authored. + +## alternatives considered + +### A — Track `docs/` files in the manifest + +Add docs artifacts to `.vstack/vstack.json` so `vstack status` and `vstack manifest upgrade` +can detect and migrate them. Rejected because docs files are agent-owned content that changes +continuously; tracking them like generated files would produce constant checksum drift and +requires agents to update the manifest on every write — which is not feasible in the +current execution model. + +### B — Variable substitution in skill template prose + +Replace hardcoded paths in skill markdown (e.g. `docs/architecture/overview.md`) with +install-time variables (`{{artifacts_root}}/architecture/overview.md`). Rejected because: +the agent config is already the machine-readable authority; prose is LLM guidance only; +adding a substitution pass increases template complexity for marginal gain. The LLM reads +the agent's own `.agent.md` (which does resolve `artifacts_root`) before executing a skill — +the agent already has the correct context. + +### C — Stability promise only, no migration tooling + +Commit to path stability without defining a `vstack migrate` command. Rejected because +stability promises are still best-effort — external reasons (tooling integration, naming +mistakes, file reorganisations) can require a path change within a major cycle. A defined +migration convention ensures that when a break does occur, the upgrade path is documented +and eventually automatable. + +## impact on the Option B pipeline + +The future `planner` orchestrator (ADR-004) resolves agent artifact paths from `config.yaml` +`artifacts.dir` and `artifacts.output` to hand off context between stages. This ADR's +stability commitment means the orchestrator can hard-code path resolution logic against +those config fields for the lifetime of a major version. The `vstack migrate` convention +ensures that when the orchestrator's expected paths change across majors, a machine-readable +migration record is available to update the planner's context without manual intervention. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index d791b84..fe4eed1 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -169,15 +169,15 @@ ______________________________________________________________________ These bind architecture decisions. Full list in `docs/product/requirements.md`. -| ID | Requirement | Architectural binding | -| ----- | --------------------------------------------------------------------------------- | ----------------------------------------- | -| NFR-1 | No runtime dependencies beyond the Python standard library | ADR-006, ADR-007 | -| NFR-2 | Python 3.11–3.14 compatibility | ADR-007 | -| NFR-3 | Manifest writes are atomic | ADR-016 | -| NFR-4 | All public behavior covered by automated tests; CI enforces test pass | `tests/` structure, `verify.yml` workflow | -| NFR-5 | CLI operates standalone; no VS Code process required for CLI operations | ADR-006, stdlib-only runtime | -| NFR-6 | Lint and type checking pass on every commit; CI gate enforces zero violations | `pyproject.toml` ruff + mypy config | -| NFR-7 | Generated output lives under `.github/` only; templates never modified at runtime | ADR-012 | +| ID | Requirement | Architectural binding | +| ----- | --------------------------------------------------------------------------------- | ---------------------------------------------------------- | +| NFR-1 | No external binary dependencies in skill template content | ADR-006; one pip dependency (`pyyaml`) allowed per ADR-025 | +| NFR-2 | Python 3.11–3.14 compatibility | ADR-007 | +| NFR-3 | Manifest writes are atomic | ADR-016 | +| NFR-4 | All public behavior covered by automated tests; CI enforces test pass | `tests/` structure, `verify.yml` workflow | +| NFR-5 | CLI operates standalone; no VS Code process required for CLI operations | ADR-006; only `pyyaml` required at runtime (ADR-025) | +| NFR-6 | Lint and type checking pass on every commit; CI gate enforces zero violations | `pyproject.toml` ruff + mypy config | +| NFR-7 | Generated output lives under `.github/` only; templates never modified at runtime | ADR-012 | ______________________________________________________________________ @@ -215,8 +215,8 @@ flowchart TD GR --> PR[PR opened] ``` -See `docs/architecture/adr/004-option-a-to-b-pipeline.md`, `docs/architecture/adr/010-artifact-flow.md`, -and `docs/design/workflow.md` for pipeline and gate detail. +See `docs/architecture/adr/023-workflow-contract.md`, `docs/architecture/adr/024-subagent-orchestration.md`, +`docs/architecture/adr/010-artifact-flow.md`, and `docs/design/workflow.md` for pipeline and gate detail. ______________________________________________________________________ @@ -230,7 +230,7 @@ See individual files for context, decision, alternatives, and rationale. | 001 | VS Code-native variant | accepted | | | 002 | Artifact naming and compatibility policy | accepted | | | 003 | Backend-first verify | accepted | | -| 004 | Direct execution and orchestrated pipeline | accepted | | +| 004 | Direct execution and orchestrated pipeline | superseded | Superseded by ADR-024 | | 005 | VS Code prompt format | accepted | | | 006 | No runtime dependency on external binaries | accepted | | | 007 | Python runtime | accepted | | @@ -249,3 +249,6 @@ See individual files for context, decision, alternatives, and rationale. | 020 | `install` and `init` command semantics | accepted | Breaking change; supersedes ADR-015 | | 021 | Config-driven artifact paths in agent config | accepted | Machine-readable artifact ownership | | 022 | Selective exclude filter in `.vstack/config.yaml` | accepted | Agents cannot be excluded (atomic unit) | +| 023 | Workflow contract in `.vstack/config.yaml` | accepted | Pipeline order, gate, hitl, handoffs | +| 024 | Subagent orchestration via VS Code native subagents | accepted | Supersedes ADR-004; planner coordinator | +| 025 | PyYAML as sole runtime dependency | accepted | Replaces hand-rolled frontmatter parser | diff --git a/docs/product/requirements.md b/docs/product/requirements.md index 7831834..28b7c81 100644 --- a/docs/product/requirements.md +++ b/docs/product/requirements.md @@ -10,8 +10,8 @@ ______________________________________________________________________ vstack is a VS Code-native AI engineering workflow system. It installs structured agents, skills, instructions, and prompts into `.github/` so GitHub Copilot Agent Mode has a clear operating model. vstack is distributed as a standalone Python CLI -tool (`pipx install vstack`) with no runtime dependencies beyond the Python standard -library. +tool (`pipx install vstack`) with a single runtime dependency (`pyyaml>=6.0`) +for YAML frontmatter parsing (see ADR-025). ______________________________________________________________________ diff --git a/docs/product/roadmap.md b/docs/product/roadmap.md index 8f8e277..bbbe49c 100644 --- a/docs/product/roadmap.md +++ b/docs/product/roadmap.md @@ -7,36 +7,36 @@ ______________________________________________________________________ ## feature status table -| Feature | Version | Status | Notes | -| ---------------------------------------- | ------- | ----------- | ----------------------------------------------------------------------------------------------------------- | -| foundation | v1.0.0 | shipped | Core template-driven install model is in place | -| backend-first verification | v1.0.0 | shipped | Verify/inspect focus on contracts, observability, security | -| VS Code agent migration | v1.x | shipped | Native `.github/agents/*.agent.md` output format implemented | -| role model + doc restructure | v1.1.0 | shipped | 6-role model, agent templates, and docs baseline established | -| new skill scaffolding | v2.2.0 | shipped | 42-skill set with canonical naming | -| agent skill wiring | v2.2.0 | shipped | Role-to-skill mapping, handoffs, and concise modes wired into all agents | -| CLI modularisation | v2.0.0 | shipped | 12 focused CLI modules; BaseCommand + CommandContext contract | -| manifest package | v2.0.0 | shipped | Dedicated `manifest/` package; atomic writes (ADR-016) | -| mypy type checking | v2.0.0 | shipped | Full mypy coverage enforced in CI; 100% test coverage gate | -| manifest schema versioning | v2.0.0 | shipped | `manifest_version: 2`; upgrade path via `manifest upgrade` (ADR-014) | -| checksum backfill | v2.0.0 | shipped | `manifest upgrade --backfill` adds SHA-256 for VSTACK-META-tagged files (ADR-017) | -| conservative install | v2.0.0 | shipped | Untracked files never overwritten; checksum-gated update (ADR-015, superseded by ADR-020) | -| dry-run install | v2.1.0 | shipped | `vstack install --dry-run` previews actions; type/name selectors in summary | -| project-scope directory | v3.0.0 | shipped | `.vstack/` directory: `config.yaml`, manifest, delta templates (ADR-019) | -| install/init command semantics | v3.0.0 | shipped | `install` = first-run setup; `init` = idempotent CI regeneration (ADR-020, breaking change) | -| manifest relocation | v3.0.0 | shipped | `vstack.json` moves from `.github/` to `.vstack/`; migration via `manifest upgrade` (ADR-014) | -| selective install | v3.0.0 | shipped | Per-type and per-name exclusions via `exclude:` in `.vstack/config.yaml`; agents always installed (ADR-022) | -| agent hooks support | t.b.d. | candidate | Generate `.github/hooks/.json` from vstack templates; enforce quality gates at session boundaries | -| new skills (next batch) | t.b.d. | candidate | `spaces`: set up Copilot Spaces; `copilot-admin`: manage Copilot settings via `gh api` | -| team customization layer | t.b.d. | candidate | Custompacks on top of vstack defaults; agents non-removable, skills fully overridable; overlay merge model | -| workflow contract source-of-truth | t.b.d. | candidate | Central contract file + generator validation of role I/O chains; prereq for orchestrated pipeline | -| optional orchestrated role pipeline | t.b.d. | candidate | Optional future model, only if coordination bottlenecks appear | -| multi-IDE support (IntelliJ first) | t.b.d. | candidate | Not planned before current model stabilizes | -| heavy agent runtime framework | — | not planned | Keeps runtime lightweight and transparent | -| cloud control plane dependency | — | not planned | Keeps operation local/offline-capable | -| VS Code extension packaging | — | not planned | Not required for current install model | -| browser automation as default dependency | — | not planned | Backend/microservice-first remains default | -| install target directory override | — | not planned | Won't implement unless a concrete tool incompatibility with `.github/` arises | +| Feature | Version | Status | Notes | +| ---------------------------------------- | ------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| foundation | v1.0.0 | shipped | Core template-driven install model is in place | +| backend-first verification | v1.0.0 | shipped | Verify/inspect focus on contracts, observability, security | +| VS Code agent migration | v1.x | shipped | Native `.github/agents/*.agent.md` output format implemented | +| role model + doc restructure | v1.1.0 | shipped | 6-role model, agent templates, and docs baseline established | +| new skill scaffolding | v2.2.0 | shipped | 42-skill set with canonical naming | +| agent skill wiring | v2.2.0 | shipped | Role-to-skill mapping, handoffs, and concise modes wired into all agents | +| CLI modularisation | v2.0.0 | shipped | 12 focused CLI modules; BaseCommand + CommandContext contract | +| manifest package | v2.0.0 | shipped | Dedicated `manifest/` package; atomic writes (ADR-016) | +| mypy type checking | v2.0.0 | shipped | Full mypy coverage enforced in CI; 100% test coverage gate | +| manifest schema versioning | v2.0.0 | shipped | `manifest_version: 2`; upgrade path via `manifest upgrade` (ADR-014) | +| checksum backfill | v2.0.0 | shipped | `manifest upgrade --backfill` adds SHA-256 for VSTACK-META-tagged files (ADR-017) | +| conservative install | v2.0.0 | shipped | Untracked files never overwritten; checksum-gated update (ADR-015, superseded by ADR-020) | +| dry-run install | v2.1.0 | shipped | `vstack install --dry-run` previews actions; type/name selectors in summary | +| project-scope directory | v3.0.0 | shipped | `.vstack/` directory: `config.yaml`, manifest, delta templates (ADR-019) | +| install/init command semantics | v3.0.0 | shipped | `install` = first-run setup; `init` = idempotent CI regeneration (ADR-020, breaking change) | +| manifest relocation | v3.0.0 | shipped | `vstack.json` moves from `.github/` to `.vstack/`; migration via `manifest upgrade` (ADR-014) | +| selective install | v3.0.0 | shipped | Per-type and per-name exclusions via `exclude:` in `.vstack/config.yaml`; agents always installed (ADR-022) | +| agent hooks support | t.b.d. | candidate | Generate `.github/hooks/.json` from vstack templates; enforce quality gates at session boundaries | +| new skills (next batch) | t.b.d. | candidate | `spaces`: set up Copilot Spaces; `copilot-admin`: manage Copilot settings via `gh api` | +| team customization layer | t.b.d. | candidate | Custompacks on top of vstack defaults; agents non-removable, skills fully overridable; overlay merge model | +| workflow contract source-of-truth | t.b.d. | shipped | `workflow:` block in `.vstack/config.yaml`; `gate`, `hitl`, `handoffs` schema; `vstack migrate` command (ADR-023, ADR-026) | +| optional orchestrated role pipeline | t.b.d. | candidate | `planner` coordinator agent using VS Code native subagents (ADR-024); prerequisite workflow contract shipped in this release; implementation not yet started | +| multi-IDE support (IntelliJ first) | t.b.d. | candidate | Not planned before current model stabilizes | +| heavy agent runtime framework | — | not planned | Keeps runtime lightweight and transparent | +| cloud control plane dependency | — | not planned | Keeps operation local/offline-capable | +| VS Code extension packaging | — | not planned | Not required for current install model | +| browser automation as default dependency | — | not planned | Backend/microservice-first remains default | +| install target directory override | — | not planned | Won't implement unless a concrete tool incompatibility with `.github/` arises | ______________________________________________________________________ @@ -240,35 +240,34 @@ Overlay model (template source priority): Ref: [GitHub — Customize Copilot for your project](https://docs.github.com/en/copilot/how-tos/copilot-on-github/customize-copilot/customize-copilot-overview) -### workflow contract source-of-truth [candidate — t.b.d.] +### workflow contract source-of-truth [shipped — t.b.d.] -Partially realized: each agent's `config.yaml` already declares `artifacts.input`, `artifacts.output`, -and `handoffs` (target role, label, prompt). Role boundaries and artifact ownership are machine-readable -per agent today. +Shipped in this release: -What remains: +- `workflow:` block seeded in `.vstack/config.yaml` by `vstack install`, with `stage`, `gate`, + `hitl`, and `handoffs.prompt` for all six roles (ADR-023) +- `handoffs:` section in generated `.agent.md` files is now driven by the workflow config rather + than hardcoded in each agent's `config.yaml` +- `baseline:` flag on output artifacts; agents explicitly maintain baseline docs vs. per-session deliverables +- `vstack migrate` command applies docs artifact path moves between major versions, + with `--dry-run`, `--from`, and `--to` flags; migration records in `src/vstack/_migrations/` (ADR-026) +- `docs/design/workflow.md` remains the human-readable explanation layer -- A **central contract file** that aggregates all role I/O and gate definitions in one place, so an - orchestrator or validator can inspect the full pipeline without reading six separate files. -- **Generator-level validation** that input/output chains are consistent across roles (e.g. role B's - declared inputs exist in role A's declared outputs). -- **`docs/design/workflow.md`** stays as the human-readable explanation layer; the contract file - becomes the machine-readable source of truth it is derived from. +Not yet implemented (deferred to orchestrated pipeline milestone): -This item is a prerequisite for the optional orchestrated role pipeline. It has no value in the -current single-call execution model beyond what the per-agent configs already provide. +- Generator-level cross-role validation of input/output chains +- Central read-only contract export for external orchestrator consumption ### optional orchestrated role pipeline [candidate — t.b.d.] -Possible future workflow with explicit orchestration (only if real coordination bottlenecks appear): +ADR-024 is accepted and the prerequisite workflow contract (`workflow.stages`, `gate`, `hitl`, +`handoffs`) is shipped as of this release. The `planner` coordinator agent and worker-agent +wiring are not yet implemented. Next steps: -- Each role makes its own model call -- Artifacts pass between roles via disk files -- User gates pause the pipeline at defined checkpoints -- Orchestrator role (product) manages pipeline state -- Parallel execution possible for multiple tester passes - -See `docs/design/workflow.md` for current execution and the orchestrated future model. +- Add `planner` agent template (`src/vstack/_templates/agents/planner/`) +- Set `user-invocable: false` on worker agents (or add a `planner`-scoped variant) +- Implement gate evaluation and `hitl` pause logic in the planner body +- Wire `runSubagent` calls based on `workflow.stages` order ### multi-IDE support [candidate — t.b.d.] diff --git a/poetry.lock b/poetry.lock index 6640268..bb286f2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -577,7 +577,7 @@ version = "6.0.3" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, @@ -721,6 +721,18 @@ virtualenv = ">=21.1" [package.extras] completion = ["argcomplete (>=3.6.3)"] +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260508" +description = "Typing stubs for PyYAML" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "types_pyyaml-6.0.12.20260508-py3-none-any.whl", hash = "sha256:edc094ed3a918b0c6232f71a5b67fdf38e76e17517b7d87bfbb9fc27d442fb51"}, + {file = "types_pyyaml-6.0.12.20260508.tar.gz", hash = "sha256:5ae42149c3ebf7aaaf6c65ee49af590c80f0ba52e9e3f75a75c5564b33556fa6"}, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -754,4 +766,4 @@ python-discovery = ">=1.2.2" [metadata] lock-version = "2.1" python-versions = ">=3.11,<3.15" -content-hash = "70281e6bd9bca0658ec7da4e5647a65a071fe717d39cb61c1bf2d51226421222" +content-hash = "b3b6750041a168225c31625cd64b77b78229506f5b66edc8bebbe468bc7ba586" diff --git a/pyproject.toml b/pyproject.toml index d4d9764..f43aa58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "VS Code-native AI engineering workflow system for microservices, license = "MIT" readme = "README-pypi.md" requires-python = ">=3.11,<3.15" -dependencies = [] +dependencies = ["pyyaml>=6.0"] keywords = [ "github-copilot", "vscode", @@ -19,8 +19,19 @@ keywords = [ "api", "workflow-automation", "release-engineering", + "agentic-workflow", + "agentic-ai", + "ai-agent", + "llm", + "llm-agents", + "prompt-engineering", + "code-generation", + "scaffolding", + "cli", + "agents", ] classifiers = [ + "Development Status :: 4 - Beta", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", @@ -30,7 +41,9 @@ classifiers = [ "Environment :: Console", "Intended Audience :: Developers", "Topic :: Software Development :: Build Tools", + "Topic :: Software Development :: Code Generators", "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Utilities", ] [project.urls] @@ -54,6 +67,7 @@ vstack = "vstack:main" packages = [{include = "vstack", from = "src"}] include = [ { path = "src/vstack/_templates", format = ["sdist", "wheel"] }, + { path = "src/vstack/_migrations", format = ["sdist", "wheel"] }, ] # Keep the placeholder version last because the plugin may remove/re-add it in # PEP 621 mode, which otherwise causes noisy key reordering. @@ -69,6 +83,7 @@ ruff = ">=0.15.12" mypy = ">=1.20.2" pre-commit = ">=4.6.0" tox = ">=4.15" +types-PyYAML = ">=6.0" # --------------------------------------------------------------------------- @@ -163,6 +178,7 @@ package = skip deps = pytest>=9.0 pytest-cov>=6.0 + pyyaml>=6.0 setenv = PYTHONPATH = {tox_root}/src commands = @@ -181,6 +197,7 @@ description = Run mypy type checks package = skip deps = mypy>=1.20 + types-PyYAML>=6.0 commands = mypy src tests """ diff --git a/src/vstack/_migrations/README.md b/src/vstack/_migrations/README.md new file mode 100644 index 0000000..d5b9cf2 --- /dev/null +++ b/src/vstack/_migrations/README.md @@ -0,0 +1,44 @@ +# vstack migration records + +This directory contains machine-readable migration records for `docs/` artifact path +changes across major vstack version boundaries. + +Each file covers one major version transition and is named `v{M}_to_v{N}.yaml` where +`M` is the source major version and `N` is the target major version. + +These files are read by `vstack migrate` to relocate agent-owned docs files when their +paths change between major versions. + +## Usage + +``` +vstack migrate [--target ] [--from ] [--to ] [--dry-run] +``` + +Run from the project root (or pass `--target`) after upgrading vstack to a new major +version. The command chains all necessary migration steps between the installed major +and the current package major. + +| Flag | Description | +| ---------------- | --------------------------------------------------------------- | +| `--target ` | Project root to migrate (default: current directory) | +| `--from ` | Source major version (default: read from `.vstack/vstack.json`) | +| `--to ` | Target major version (default: current vstack package major) | +| `--dry-run` | Print moves without touching the filesystem | + +`vstack migrate` only moves files that exist at the old path; absent files are silently +skipped. It reads `artifacts.root` from `.vstack/config.yaml` and adjusts destination +paths when the project uses a custom docs root. + +## Schema + +```yaml +from_version: "3.x" # semver range for the source version +to_version: "4.0" # first minor of the target major +moves: + - old: docs/path/old.md # path relative to project root + new: docs/path/new.md # path relative to project root + type: docs # always "docs" for this directory + notes: > # optional: human-readable explanation + Why this path changed and what to do with the old file after migration. +``` diff --git a/src/vstack/_migrations/v2_to_v3.yaml b/src/vstack/_migrations/v2_to_v3.yaml new file mode 100644 index 0000000..7c408b8 --- /dev/null +++ b/src/vstack/_migrations/v2_to_v3.yaml @@ -0,0 +1,39 @@ +from_version: "2.x" +to_version: "3.0" +moves: + - old: docs/architecture/architecture.md + new: docs/architecture/overview.md + type: docs + notes: > + architect role primary output renamed from architecture.md to overview.md + in v3 to align with the generic overview.md convention used by all roles. + Content is the same — only the filename changed. + + - old: docs/design/design.md + new: docs/design/overview.md + type: docs + notes: > + designer role primary output renamed from design.md to overview.md in v3 + to align with the generic overview.md convention used by all roles. + Content is the same — only the filename changed. + + - old: docs/test-report.md + new: docs/reports/test-report.md + type: docs + notes: > + tester role reports moved from the docs/ root into docs/reports/ in v3. + This groups all tester outputs under a single subdirectory. + + - old: docs/security-report.md + new: docs/reports/security-report.md + type: docs + notes: > + tester role reports moved from the docs/ root into docs/reports/ in v3. + This groups all tester outputs under a single subdirectory. + + - old: docs/performance-baseline.md + new: docs/reports/performance-baseline.md + type: docs + notes: > + tester role reports moved from the docs/ root into docs/reports/ in v3. + This groups all tester outputs under a single subdirectory. diff --git a/src/vstack/_templates/agents/architect/config.yaml b/src/vstack/_templates/agents/architect/config.yaml index 2f67021..e36ad57 100644 --- a/src/vstack/_templates/agents/architect/config.yaml +++ b/src/vstack/_templates/agents/architect/config.yaml @@ -11,6 +11,7 @@ tools: - read - search - edit + - execute - web - vscode - todo @@ -20,22 +21,23 @@ model: - GPT-5.3-Codex (copilot) - Claude Opus 4.7 (copilot) agents: ["*"] -handoffs: - - label: "Go to next stage: Design" - agent: designer +target: vscode +defaults: + handoffs: prompt: > Architecture outputs are approved. Assess the current state and produce design specifications as needed. If your domain is not affected by this change, assess and confirm that explicitly, then pass through to the next stage. -artifacts: - dir: architecture - input: - - product/**/*.md - output: - - overview.md - - adr/*.md -target: vscode + artifacts: + dir: architecture + input: + - product/**/*.md + output: + - path: overview.md + baseline: true + - path: adr/*.md + baseline: true user-invocable: true # Optional when needed: diff --git a/src/vstack/_templates/agents/architect/template.md b/src/vstack/_templates/agents/architect/template.md index 141a1e9..de2e43b 100644 --- a/src/vstack/_templates/agents/architect/template.md +++ b/src/vstack/_templates/agents/architect/template.md @@ -105,6 +105,8 @@ what work is needed: {{AGENT_ARTIFACTS_OUTPUT}} +{{AGENT_ARTIFACTS_BASELINE}} + Agents do not write to artifacts owned by other roles. If you discover something that requires changes to upstream artifacts, flag it and trigger a reverse handoff. diff --git a/src/vstack/_templates/agents/designer/config.yaml b/src/vstack/_templates/agents/designer/config.yaml index bdd0bf9..3408cd4 100644 --- a/src/vstack/_templates/agents/designer/config.yaml +++ b/src/vstack/_templates/agents/designer/config.yaml @@ -10,6 +10,7 @@ tools: - read - search - edit + - execute - web - vscode - todo @@ -18,26 +19,27 @@ model: - Claude Sonnet 4.6 (copilot) - GPT-5.3-Codex (copilot) agents: ["*"] -handoffs: - - label: "Go to next stage: Engineering" - agent: engineer +target: vscode +defaults: + handoffs: prompt: > Design outputs are approved. Assess the current state and implement code and tests as needed. If your domain is not affected by this change, assess and confirm that explicitly, then pass through to engineering. If working on an issue, document findings in RCA or post-mortem artifacts as relevant. -artifacts: - dir: design - input: - - architecture/**/*.md - output: - - path: overview.md - - path: ux.md - notes: frontend/fullstack scope only - - path: "**/*.md" - notes: additional detail docs per component, model, system, or domain (when scope warrants it) -target: vscode + artifacts: + dir: design + input: + - architecture/**/*.md + output: + - path: overview.md + baseline: true + - path: ux.md + baseline: true + notes: frontend/fullstack scope only + - path: "**/*.md" + notes: additional detail docs per component, model, system, or domain (when scope warrants it) user-invocable: true # Optional when needed: diff --git a/src/vstack/_templates/agents/designer/template.md b/src/vstack/_templates/agents/designer/template.md index f264149..fb79d5f 100644 --- a/src/vstack/_templates/agents/designer/template.md +++ b/src/vstack/_templates/agents/designer/template.md @@ -121,6 +121,8 @@ what work is needed: {{AGENT_ARTIFACTS_OUTPUT}} +{{AGENT_ARTIFACTS_BASELINE}} + Agents do not write to artifacts owned by other roles. If you discover something that requires changes to upstream artifacts, flag it and trigger a reverse handoff. diff --git a/src/vstack/_templates/agents/engineer/config.yaml b/src/vstack/_templates/agents/engineer/config.yaml index ef5d39f..676e789 100644 --- a/src/vstack/_templates/agents/engineer/config.yaml +++ b/src/vstack/_templates/agents/engineer/config.yaml @@ -19,28 +19,27 @@ model: - GPT-5.3-Codex (copilot) - Claude Sonnet 4.6 (copilot) agents: ["*"] -handoffs: - - label: "Go to next stage: Verification" - agent: tester +target: vscode +defaults: + handoffs: prompt: > Implementation is approved. Assess the current state and verify the implementation as needed — run tests, security checks, and performance analysis. If this is an issue (bug, problem, or incident), also produce or update an RCA and, if stakeholder impact is significant, a post-mortem. -artifacts: - input: - - product/**/*.md - - architecture/**/*.md - - design/**/*.md - output: - - path: ./src/**/* - - path: ./tests/**/* - - path: ./issues/{id}-{slug}-rca.md - notes: when working on an issue - - path: ./issues/{id}-{slug}-postmortem.md - notes: when stakeholder impact is significant -target: vscode + artifacts: + input: + - product/**/*.md + - architecture/**/*.md + - design/**/*.md + output: + - path: ./src/**/* + - path: ./tests/**/* + - path: ./issues/{id}-{slug}-rca.md + notes: when working on an issue + - path: ./issues/{id}-{slug}-postmortem.md + notes: when stakeholder impact is significant user-invocable: true # Optional when needed: diff --git a/src/vstack/_templates/agents/engineer/template.md b/src/vstack/_templates/agents/engineer/template.md index babef0b..44ae66f 100644 --- a/src/vstack/_templates/agents/engineer/template.md +++ b/src/vstack/_templates/agents/engineer/template.md @@ -112,6 +112,8 @@ what work is needed: {{AGENT_ARTIFACTS_OUTPUT}} +{{AGENT_ARTIFACTS_BASELINE}} + Agents do not write to artifacts owned by other roles. If you discover something that requires changes to upstream artifacts, flag it and trigger a reverse handoff. diff --git a/src/vstack/_templates/agents/product/config.yaml b/src/vstack/_templates/agents/product/config.yaml index ecf5688..0b7b554 100644 --- a/src/vstack/_templates/agents/product/config.yaml +++ b/src/vstack/_templates/agents/product/config.yaml @@ -10,6 +10,7 @@ tools: - read - search - edit + - execute - web - vscode - todo @@ -19,23 +20,25 @@ model: - GPT-5.3-Codex (copilot) - Claude Opus 4.7 (copilot) agents: ["*"] -handoffs: - - label: "Go to next stage: Architecture" - agent: architect +target: vscode +defaults: + handoffs: prompt: > Product outputs are approved. Assess the current state and produce or update the architecture as needed. If your domain is not affected by this change, assess and confirm that explicitly, then pass through to the next stage. -artifacts: - dir: product - output: - - vision.md - - requirements.md - - roadmap.md - - changes/*.md - - issues/*.md -target: vscode + artifacts: + dir: product + output: + - path: vision.md + baseline: true + - path: requirements.md + baseline: true + - path: roadmap.md + baseline: true + - path: changes/*.md + - path: issues/*.md user-invocable: true # Optional when needed: diff --git a/src/vstack/_templates/agents/product/template.md b/src/vstack/_templates/agents/product/template.md index 4008026..d0944a2 100644 --- a/src/vstack/_templates/agents/product/template.md +++ b/src/vstack/_templates/agents/product/template.md @@ -90,6 +90,8 @@ Handoffs you own: {{AGENT_ARTIFACTS_OUTPUT}} +{{AGENT_ARTIFACTS_BASELINE}} + Agents do not write to artifacts owned by other roles. If you discover something that requires changes to upstream artifacts, flag it and trigger a reverse handoff. diff --git a/src/vstack/_templates/agents/release/config.yaml b/src/vstack/_templates/agents/release/config.yaml index 84b9420..23debee 100644 --- a/src/vstack/_templates/agents/release/config.yaml +++ b/src/vstack/_templates/agents/release/config.yaml @@ -11,6 +11,7 @@ tools: - search - edit - execute + - web - vscode - todo - agent @@ -18,14 +19,16 @@ model: - Claude Sonnet 4.6 (copilot) - GPT-5.3-Codex (copilot) agents: ["*"] -artifacts: - dir: releases - input: - - "**/*.md" - output: - - path: "*.md" - notes: includes release notes and sign-off record target: vscode +defaults: + artifacts: + dir: releases + input: + - "**/*.md" + output: + - path: "*.md" + notes: includes release notes and sign-off record + baseline: false user-invocable: true # Optional when needed: diff --git a/src/vstack/_templates/agents/release/template.md b/src/vstack/_templates/agents/release/template.md index d644644..ef6679b 100644 --- a/src/vstack/_templates/agents/release/template.md +++ b/src/vstack/_templates/agents/release/template.md @@ -87,6 +87,8 @@ and wait for explicit user routing decisions. {{AGENT_ARTIFACTS_OUTPUT}} +{{AGENT_ARTIFACTS_BASELINE}} + Agents do not write to artifacts owned by other roles. If you discover something that requires changes to upstream artifacts, flag it and trigger a reverse handoff. diff --git a/src/vstack/_templates/agents/tester/config.yaml b/src/vstack/_templates/agents/tester/config.yaml index 433853a..7cfe6aa 100644 --- a/src/vstack/_templates/agents/tester/config.yaml +++ b/src/vstack/_templates/agents/tester/config.yaml @@ -18,22 +18,21 @@ model: - Claude Sonnet 4.6 (copilot) - GPT-5.3-Codex (copilot) agents: ["*"] -handoffs: - - label: "Go to next stage: Release readiness" - agent: release +target: vscode +defaults: + handoffs: prompt: > Verification outputs are approved. Assess the current state and prepare the release as needed. Create and/or update the relevant artifacts if needed, as well as any sign-offs. -artifacts: - dir: reports - input: - - architecture/**/*.md - - design/**/*.md - output: - - "**/*.md" - - ./tests/**/* -target: vscode + artifacts: + dir: reports + input: + - architecture/**/*.md + - design/**/*.md + output: + - path: "**/*.md" + - path: ./tests/**/* user-invocable: true # Optional when needed: diff --git a/src/vstack/_templates/agents/tester/template.md b/src/vstack/_templates/agents/tester/template.md index 6be8546..4b3005e 100644 --- a/src/vstack/_templates/agents/tester/template.md +++ b/src/vstack/_templates/agents/tester/template.md @@ -101,6 +101,8 @@ what work is needed: {{AGENT_ARTIFACTS_OUTPUT}} +{{AGENT_ARTIFACTS_BASELINE}} + Agents do not write to artifacts owned by other roles. If you discover something that requires changes to upstream artifacts, flag it and trigger a reverse handoff. diff --git a/src/vstack/_templates/project/.vstack/config.yaml b/src/vstack/_templates/project/.vstack/config.yaml index 840037a..e02a856 100644 --- a/src/vstack/_templates/project/.vstack/config.yaml +++ b/src/vstack/_templates/project/.vstack/config.yaml @@ -26,3 +26,71 @@ # # artifacts: # root: docs + +# Pipeline workflow — seeded by vstack install, owned by this project. +# +# Edit stages, gates, hitl policy, and handoff prompts to match your team's process. +# version allows vstack to warn on schema changes after upgrades. +# +# gate: required — stage always runs +# gate: optional — stage may be skipped when its domain is unaffected +# gate: skip — stage is never executed (explicit opt-out) +# +# hitl: always — pipeline pauses for human approval before handoff (default for required) +# hitl: on-change — pipeline pauses only when the stage made changes (default for optional) +# hitl: never — pipeline continues without human approval (explicit opt-out) +# +# workflow: +# version: 1 +# stages: +# - role: product +# gate: required +# hitl: always +# handoffs: +# prompt: > +# Product outputs are approved. Assess the current state and produce or +# update the architecture as needed. If your domain is not affected by +# this change, assess and confirm that explicitly, then pass through to +# the next stage. +# - role: architect +# gate: required +# hitl: always +# handoffs: +# prompt: > +# Architecture outputs are approved. Assess the current state and produce +# design specifications as needed. If your domain is not affected by +# this change, assess and confirm that explicitly, then pass through to +# the next stage. +# - role: designer +# gate: optional +# hitl: on-change +# handoffs: +# prompt: > +# Design outputs are approved. Assess the current state and implement +# code and tests as needed. If your domain is not affected by this +# change, assess and confirm that explicitly, then pass through to +# engineering. If working on an issue, document findings in RCA or +# post-mortem artifacts as relevant. +# - role: engineer +# gate: required +# hitl: always +# handoffs: +# prompt: > +# Implementation is approved. Assess the current state and verify the +# implementation as needed — run tests, security checks, and performance +# analysis. If this is an issue (bug, problem, or incident), also produce +# or update an RCA and, if stakeholder impact is significant, a +# post-mortem. +# - role: tester +# gate: required +# hitl: always +# handoffs: +# prompt: > +# Verification outputs are approved. Assess the current state and prepare +# the release as needed. Create and/or update the relevant artifacts if +# needed, as well as any sign-offs. +# - role: release +# gate: required +# hitl: always +# handoffs: +# prompt: "" diff --git a/src/vstack/_templates/skills/adr/config.yaml b/src/vstack/_templates/skills/adr/config.yaml index 912219f..c8e1da0 100644 --- a/src/vstack/_templates/skills/adr/config.yaml +++ b/src/vstack/_templates/skills/adr/config.yaml @@ -6,6 +6,7 @@ description: | Use when asked to "write an ADR", "document this decision", "record why we chose X", or when a significant technical decision needs a permanent record. Runs after a decision is made or while evaluating options. +allowed-tools: 'execute read search edit' argument-hint: '[decision to record]' license: MIT diff --git a/src/vstack/_templates/skills/analyse/config.yaml b/src/vstack/_templates/skills/analyse/config.yaml index b7d5f98..039d4d1 100644 --- a/src/vstack/_templates/skills/analyse/config.yaml +++ b/src/vstack/_templates/skills/analyse/config.yaml @@ -5,6 +5,7 @@ description: | or feasibility without implementing changes. Use when asked to "analyse this", "investigate the impact", "what are the tradeoffs", "root cause analysis", "is this feasible?", or "compare these approaches". Produces an analysis report. +allowed-tools: 'execute read search' argument-hint: '[topic, change, or question to analyse]' license: MIT diff --git a/src/vstack/_templates/skills/architecture/config.yaml b/src/vstack/_templates/skills/architecture/config.yaml index 585a286..54b08d6 100644 --- a/src/vstack/_templates/skills/architecture/config.yaml +++ b/src/vstack/_templates/skills/architecture/config.yaml @@ -7,6 +7,7 @@ description: | issues with opinionated recommendations. Use when asked to "review the architecture", "engineering review", or "lock in the plan". Proactively suggest when the user has a plan and is about to start coding — catch architecture issues before implementation. +allowed-tools: 'execute read search edit' argument-hint: '[plan or system to review]' license: MIT diff --git a/src/vstack/_templates/skills/aws-cli/config.yaml b/src/vstack/_templates/skills/aws-cli/config.yaml index 4d00d35..5188899 100644 --- a/src/vstack/_templates/skills/aws-cli/config.yaml +++ b/src/vstack/_templates/skills/aws-cli/config.yaml @@ -6,6 +6,7 @@ description: | SSM Parameter Store, and cross-account operations. Use when asked to "query AWS", "list resources", "rotate secrets", "check CloudWatch logs", "scale ECS", "run an SSM command", or "script an AWS operation". +allowed-tools: 'execute read search edit web' argument-hint: '[service: iam | ec2 | s3 | rds | ecs | lambda | cloudwatch | ssm | secrets]' license: MIT diff --git a/src/vstack/_templates/skills/cicd/config.yaml b/src/vstack/_templates/skills/cicd/config.yaml index b7769bf..ab961a0 100644 --- a/src/vstack/_templates/skills/cicd/config.yaml +++ b/src/vstack/_templates/skills/cicd/config.yaml @@ -5,6 +5,7 @@ description: | security scan, container publish, and deployment trigger workflows. Use when asked to "write a CI pipeline", "set up GitHub Actions", "add a workflow", or "configure CD". +allowed-tools: 'execute read search edit' argument-hint: '[service or workflow to configure]' license: MIT diff --git a/src/vstack/_templates/skills/cloudformation/config.yaml b/src/vstack/_templates/skills/cloudformation/config.yaml index 533b2e4..5e07388 100644 --- a/src/vstack/_templates/skills/cloudformation/config.yaml +++ b/src/vstack/_templates/skills/cloudformation/config.yaml @@ -7,6 +7,7 @@ description: | configuration, and security hardening. Use when asked to "write a CloudFormation template", "review this CFN stack", "create a SAM template", "add a CloudFormation resource", or "migrate from CDK to CloudFormation". +allowed-tools: 'execute read search edit' argument-hint: '[resource type or stack name, e.g. VPC | RDS | ECS service | Lambda function]' license: MIT diff --git a/src/vstack/_templates/skills/code-review/config.yaml b/src/vstack/_templates/skills/code-review/config.yaml index 7cfc74f..c91e15b 100644 --- a/src/vstack/_templates/skills/code-review/config.yaml +++ b/src/vstack/_templates/skills/code-review/config.yaml @@ -5,6 +5,7 @@ description: | race conditions, missing error handling, API contract violations, observability gaps, security issues, and performance landmines. Use when asked to "review", "code review", "review this PR", or before merging. +allowed-tools: 'execute read search' argument-hint: '[files, PR, or change to review]' license: MIT diff --git a/src/vstack/_templates/skills/codeql/config.yaml b/src/vstack/_templates/skills/codeql/config.yaml index 8d6a759..efb5ea4 100644 --- a/src/vstack/_templates/skills/codeql/config.yaml +++ b/src/vstack/_templates/skills/codeql/config.yaml @@ -6,6 +6,7 @@ description: | configuration, SARIF output, and alert triage. Use when asked to "set up CodeQL", "configure code scanning", "add a codeql workflow", or "scan for vulnerabilities with CodeQL". +allowed-tools: 'execute read search edit web' argument-hint: '[languages and setup type: default or advanced]' license: MIT diff --git a/src/vstack/_templates/skills/concise/config.yaml b/src/vstack/_templates/skills/concise/config.yaml index e932117..c5d4f9f 100644 --- a/src/vstack/_templates/skills/concise/config.yaml +++ b/src/vstack/_templates/skills/concise/config.yaml @@ -5,6 +5,7 @@ description: | normal, compact, and ultra output density without regenerating agents. Use when asked for shorter responses, token efficiency, or to check active style mode. +allowed-tools: 'execute read search' argument-hint: '[normal|compact|ultra|status|on|off]' license: MIT diff --git a/src/vstack/_templates/skills/consult/config.yaml b/src/vstack/_templates/skills/consult/config.yaml index c604164..496b8fc 100644 --- a/src/vstack/_templates/skills/consult/config.yaml +++ b/src/vstack/_templates/skills/consult/config.yaml @@ -7,6 +7,7 @@ description: | specialized skill (analyse/debug/security/performance/design/verify/code-review). Use when asked to "review DX", "review API usability", "review CLI experience", or "review developer workflow friction". +allowed-tools: 'execute read search' argument-hint: '[API, tool, or workflow to consult]' license: MIT diff --git a/src/vstack/_templates/skills/container/config.yaml b/src/vstack/_templates/skills/container/config.yaml index 4511551..70b402f 100644 --- a/src/vstack/_templates/skills/container/config.yaml +++ b/src/vstack/_templates/skills/container/config.yaml @@ -6,6 +6,7 @@ description: | layer optimisation, and local development compose setup. Use when asked to "containerise", "write a Dockerfile", "add docker-compose", or "harden the container image". +allowed-tools: 'execute read search edit' argument-hint: '[service to containerise]' license: MIT diff --git a/src/vstack/_templates/skills/conventional-commit/config.yaml b/src/vstack/_templates/skills/conventional-commit/config.yaml index faf8d21..cc6377a 100644 --- a/src/vstack/_templates/skills/conventional-commit/config.yaml +++ b/src/vstack/_templates/skills/conventional-commit/config.yaml @@ -7,6 +7,7 @@ description: | non-compliant messages before commit. Use when asked to "write a commit message", "make a conventional commit", or "prepare commits before PR". +allowed-tools: 'execute read search edit' argument-hint: '[changes to commit and desired release intent]' license: MIT diff --git a/src/vstack/_templates/skills/debug/config.yaml b/src/vstack/_templates/skills/debug/config.yaml index b2e9920..e6cfed5 100644 --- a/src/vstack/_templates/skills/debug/config.yaml +++ b/src/vstack/_templates/skills/debug/config.yaml @@ -5,6 +5,7 @@ description: | No fixes without investigation. Follows the scientific method: observe → hypothesize → test → conclude → fix → prevent. Use when asked to "debug", "investigate", "find the root cause", or "why is this broken?". +allowed-tools: 'execute read search edit' argument-hint: '[issue or error to debug]' license: MIT diff --git a/src/vstack/_templates/skills/dependabot/config.yaml b/src/vstack/_templates/skills/dependabot/config.yaml index d6a7c82..1219d81 100644 --- a/src/vstack/_templates/skills/dependabot/config.yaml +++ b/src/vstack/_templates/skills/dependabot/config.yaml @@ -6,6 +6,7 @@ description: | update configuration, schedule optimization, and PR customization. Use when asked to "set up Dependabot", "configure dependency updates", "add dependabot.yml", or "reduce Dependabot PR noise". +allowed-tools: 'execute read search edit' argument-hint: '[repository type: library | service | monorepo, and ecosystems to cover]' license: MIT diff --git a/src/vstack/_templates/skills/dependency/config.yaml b/src/vstack/_templates/skills/dependency/config.yaml index d9e47a6..c9a5f94 100644 --- a/src/vstack/_templates/skills/dependency/config.yaml +++ b/src/vstack/_templates/skills/dependency/config.yaml @@ -7,6 +7,7 @@ description: | licence obligations, and long-term dependency health. Use when asked to "audit dependencies", "check for outdated packages", "licence compliance", "pin versions", or "dependency health check". +allowed-tools: 'execute read search edit web' argument-hint: '[project or package manifest to audit]' license: MIT diff --git a/src/vstack/_templates/skills/design/config.yaml b/src/vstack/_templates/skills/design/config.yaml index be7eca5..95858ce 100644 --- a/src/vstack/_templates/skills/design/config.yaml +++ b/src/vstack/_templates/skills/design/config.yaml @@ -5,6 +5,7 @@ description: | specs, error conventions, naming standards, pagination patterns, and versioning policies. Use when asked to "design the API", "create a design system for this service", "define the API standards", or "design this service interface". +allowed-tools: 'execute read search edit' argument-hint: '[API or service to design]' license: MIT diff --git a/src/vstack/_templates/skills/docs/config.yaml b/src/vstack/_templates/skills/docs/config.yaml index e0fbaf4..d3db53f 100644 --- a/src/vstack/_templates/skills/docs/config.yaml +++ b/src/vstack/_templates/skills/docs/config.yaml @@ -6,6 +6,7 @@ description: | generation or CHANGELOG updates. Use after release or deploy, or when asked to "update docs", "align documentation", or "refresh README/API docs". +allowed-tools: 'execute read search edit' argument-hint: '[release or change to document]' license: MIT diff --git a/src/vstack/_templates/skills/explore/config.yaml b/src/vstack/_templates/skills/explore/config.yaml index 9b5a79e..e2fb9c7 100644 --- a/src/vstack/_templates/skills/explore/config.yaml +++ b/src/vstack/_templates/skills/explore/config.yaml @@ -6,6 +6,7 @@ description: | summary. Use at the start of any engagement with an unfamiliar codebase, when asked to "understand this codebase", "map the architecture", "explore the repo", or "what does this service do?". +allowed-tools: 'execute read search' argument-hint: '[repository or system to explore]' license: MIT diff --git a/src/vstack/_templates/skills/gdpr/config.yaml b/src/vstack/_templates/skills/gdpr/config.yaml index b667cc1..b2c965b 100644 --- a/src/vstack/_templates/skills/gdpr/config.yaml +++ b/src/vstack/_templates/skills/gdpr/config.yaml @@ -8,6 +8,7 @@ description: | checklists. Use when asked to "GDPR review", "is this GDPR-compliant?", "privacy by design", "data retention policy", "right to erasure", or "DPIA". Proactively suggest before any feature that handles personal data. +allowed-tools: 'execute read search edit' argument-hint: '[component or feature: data model | API | logging | retention | erasure | infra | PR review]' license: MIT diff --git a/src/vstack/_templates/skills/gh-issues/config.yaml b/src/vstack/_templates/skills/gh-issues/config.yaml index 84a82d5..60c6457 100644 --- a/src/vstack/_templates/skills/gh-issues/config.yaml +++ b/src/vstack/_templates/skills/gh-issues/config.yaml @@ -5,6 +5,7 @@ description: | feature requests, tasks, labels, assignees, milestones, sub-issues, and issue workflows. Use when asked to "create an issue", "file a bug", "create a feature request", "update issue #N", "add a label", or "close an issue". +allowed-tools: 'execute read search edit web' argument-hint: '[what to create or which issue number to update]' license: MIT diff --git a/src/vstack/_templates/skills/gh-release/config.yaml b/src/vstack/_templates/skills/gh-release/config.yaml index 282ecce..ca53566 100644 --- a/src/vstack/_templates/skills/gh-release/config.yaml +++ b/src/vstack/_templates/skills/gh-release/config.yaml @@ -6,6 +6,7 @@ description: | selection, optional asset upload, and release metadata verification before publication. Use when asked to "create a GitHub release", "publish a release", or "draft release with gh". +allowed-tools: 'execute read search edit web' argument-hint: '[version/tag and release notes source]' license: MIT diff --git a/src/vstack/_templates/skills/guardrails/config.yaml b/src/vstack/_templates/skills/guardrails/config.yaml index b8213d3..ea66391 100644 --- a/src/vstack/_templates/skills/guardrails/config.yaml +++ b/src/vstack/_templates/skills/guardrails/config.yaml @@ -12,5 +12,6 @@ compatibility: Requires a skills-compatible agent with repository file access an metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit' argument-hint: '[task]' user-invocable: true diff --git a/src/vstack/_templates/skills/helm/config.yaml b/src/vstack/_templates/skills/helm/config.yaml index 81eb68d..e31b569 100644 --- a/src/vstack/_templates/skills/helm/config.yaml +++ b/src/vstack/_templates/skills/helm/config.yaml @@ -5,6 +5,7 @@ description: | structure, values layering, lint/template validation, install/upgrade/rollback, dependency handling, and release troubleshooting. Use when asked to "create a Helm chart", "review Helm values", "upgrade Helm release", or "debug Helm deployment". +allowed-tools: 'execute read search edit' argument-hint: '[chart path, release name, namespace, and scope: chart review | install | upgrade | rollback]' license: MIT diff --git a/src/vstack/_templates/skills/incident/config.yaml b/src/vstack/_templates/skills/incident/config.yaml index bc3af18..62ce328 100644 --- a/src/vstack/_templates/skills/incident/config.yaml +++ b/src/vstack/_templates/skills/incident/config.yaml @@ -6,6 +6,7 @@ description: | analysis to `rca` and stakeholder documentation to `postmortem`. Use when asked to "incident review", "analyse this outage", "what went wrong?", or to coordinate a full incident response retrospective. +allowed-tools: 'execute read search edit' argument-hint: '[incident or outage to analyse]' license: MIT diff --git a/src/vstack/_templates/skills/inspect/config.yaml b/src/vstack/_templates/skills/inspect/config.yaml index c2a38a2..997f75f 100644 --- a/src/vstack/_templates/skills/inspect/config.yaml +++ b/src/vstack/_templates/skills/inspect/config.yaml @@ -5,6 +5,7 @@ description: | produces severity-ranked findings, and makes no code or commit changes. Use when asked to "inspect", "assess", "check without fixing", or "what's wrong with this" before deciding whether to run verify fix loops. +allowed-tools: 'execute read search' argument-hint: '[component or service to inspect]' license: MIT diff --git a/src/vstack/_templates/skills/k8s/config.yaml b/src/vstack/_templates/skills/k8s/config.yaml index 328f662..0edb9f1 100644 --- a/src/vstack/_templates/skills/k8s/config.yaml +++ b/src/vstack/_templates/skills/k8s/config.yaml @@ -6,6 +6,7 @@ description: | RBAC, namespace isolation, and kubectl-based diagnostics. Use when asked to "deploy to Kubernetes", "review Kubernetes manifests", "debug Kubernetes rollout", "harden Kubernetes config", or "operate a workload on a cluster". +allowed-tools: 'execute read search edit' argument-hint: '[cluster/context, namespace, and scope: manifest review | deploy | rollout debug | hardening]' license: MIT diff --git a/src/vstack/_templates/skills/migrate/config.yaml b/src/vstack/_templates/skills/migrate/config.yaml index a821389..faa6b75 100644 --- a/src/vstack/_templates/skills/migrate/config.yaml +++ b/src/vstack/_templates/skills/migrate/config.yaml @@ -6,6 +6,7 @@ description: | Use when asked to "write a migration", "review this migration", "is this migration safe?", or "zero-downtime schema change". Proactively suggest before any DDL change ships to production. +allowed-tools: 'execute read search edit' argument-hint: '[migration file or schema change to review]' license: MIT diff --git a/src/vstack/_templates/skills/onboard/config.yaml b/src/vstack/_templates/skills/onboard/config.yaml index c8ed6b4..4afedd3 100644 --- a/src/vstack/_templates/skills/onboard/config.yaml +++ b/src/vstack/_templates/skills/onboard/config.yaml @@ -7,6 +7,7 @@ description: | guide", "create a contributor guide", "help new devs get started", or "document how to contribute". Produces or updates CONTRIBUTING.md and supplements README with a dev setup section. +allowed-tools: 'execute read search edit' argument-hint: '[repository or service to document]' license: MIT diff --git a/src/vstack/_templates/skills/openapi/config.yaml b/src/vstack/_templates/skills/openapi/config.yaml index 5e612d8..aeb0efb 100644 --- a/src/vstack/_templates/skills/openapi/config.yaml +++ b/src/vstack/_templates/skills/openapi/config.yaml @@ -6,6 +6,7 @@ description: | security schemes, and schema validation. Use when asked to "write an OpenAPI spec", "review this API spec", "add an endpoint to the spec", or "validate this OpenAPI file". +allowed-tools: 'execute read search edit' argument-hint: '[API or spec file to write or review]' license: MIT diff --git a/src/vstack/_templates/skills/performance/config.yaml b/src/vstack/_templates/skills/performance/config.yaml index 7aa35d0..de8e98c 100644 --- a/src/vstack/_templates/skills/performance/config.yaml +++ b/src/vstack/_templates/skills/performance/config.yaml @@ -4,6 +4,7 @@ description: | Performance profiling and regression detection. Establishes baselines, detects regressions, profiles bottlenecks, and recommends optimizations. Use when asked to "profile", "benchmark", "performance test", or "is this faster?". +allowed-tools: 'execute read search edit' argument-hint: '[endpoint or function to profile]' license: MIT diff --git a/src/vstack/_templates/skills/postmortem/config.yaml b/src/vstack/_templates/skills/postmortem/config.yaml index 5d89989..4f20b3d 100644 --- a/src/vstack/_templates/skills/postmortem/config.yaml +++ b/src/vstack/_templates/skills/postmortem/config.yaml @@ -5,6 +5,7 @@ description: | post-mortem document linked to the triggering issue and RCA. Use when asked to "write a post-mortem", "blameless post-mortem", or "incident post-mortem". Called by the incident skill; the RCA should be available before invoking this. +allowed-tools: 'execute read search edit' argument-hint: '[incident to write a post-mortem for]' license: MIT diff --git a/src/vstack/_templates/skills/pr/config.yaml b/src/vstack/_templates/skills/pr/config.yaml index e21790c..6726ab8 100644 --- a/src/vstack/_templates/skills/pr/config.yaml +++ b/src/vstack/_templates/skills/pr/config.yaml @@ -10,6 +10,7 @@ compatibility: Requires a skills-compatible agent with repository file access an metadata: owner: vstack maturity: stable +allowed-tools: 'execute read search edit web' argument-hint: '[task]' user-invocable: true disable-model-invocation: false diff --git a/src/vstack/_templates/skills/rancher/config.yaml b/src/vstack/_templates/skills/rancher/config.yaml index a4aa2a1..31f2318 100644 --- a/src/vstack/_templates/skills/rancher/config.yaml +++ b/src/vstack/_templates/skills/rancher/config.yaml @@ -5,6 +5,7 @@ description: | project context, role-based access, app deployment workflows, Fleet/GitOps basics, and multi-cluster operational checks. Use when asked to "deploy through Rancher", "review Rancher setup", "manage Rancher projects", or "troubleshoot Rancher-managed clusters". +allowed-tools: 'execute read search edit' argument-hint: '[rancher server/context, cluster/project, and scope: deploy | governance | fleet | troubleshooting]' license: MIT diff --git a/src/vstack/_templates/skills/rca/config.yaml b/src/vstack/_templates/skills/rca/config.yaml index 0111cd4..f3c2781 100644 --- a/src/vstack/_templates/skills/rca/config.yaml +++ b/src/vstack/_templates/skills/rca/config.yaml @@ -6,6 +6,7 @@ description: | Use when asked to "write an RCA", "root cause this incident", or "document what went wrong technically". Called by the incident skill; also invoked directly by the engineer role. +allowed-tools: 'execute read search edit' argument-hint: '[incident or issue to analyse]' license: MIT diff --git a/src/vstack/_templates/skills/refactor/config.yaml b/src/vstack/_templates/skills/refactor/config.yaml index 04f438a..dcd2a3a 100644 --- a/src/vstack/_templates/skills/refactor/config.yaml +++ b/src/vstack/_templates/skills/refactor/config.yaml @@ -7,6 +7,7 @@ description: | "clean up this module", "reduce duplication", or "improve structure without changing behavior". Never changes behavior — if behavior must change, stop and use the engineering role. +allowed-tools: 'execute read search edit' argument-hint: '[module, file, or area to refactor]' license: MIT diff --git a/src/vstack/_templates/skills/release-notes/config.yaml b/src/vstack/_templates/skills/release-notes/config.yaml index 8244405..cc7cdb4 100644 --- a/src/vstack/_templates/skills/release-notes/config.yaml +++ b/src/vstack/_templates/skills/release-notes/config.yaml @@ -5,6 +5,7 @@ description: | own CHANGELOG.md updates, and produce docs/releases/{date}.md. Use when asked to "write release notes", "update the changelog", or "prepare release artifacts". +allowed-tools: 'execute read search edit' argument-hint: '[version or changes to release]' license: MIT diff --git a/src/vstack/_templates/skills/requirements/config.yaml b/src/vstack/_templates/skills/requirements/config.yaml index 591a0cc..b7f81f5 100644 --- a/src/vstack/_templates/skills/requirements/config.yaml +++ b/src/vstack/_templates/skills/requirements/config.yaml @@ -6,6 +6,7 @@ description: | Produces a requirements.md document. Use when asked to "gather requirements", "write the requirements", "define the spec", or "what are we building?". Runs before architecture and design work begins. +allowed-tools: 'execute read search edit' argument-hint: '[feature or system to document]' license: MIT diff --git a/src/vstack/_templates/skills/secret-scan/config.yaml b/src/vstack/_templates/skills/secret-scan/config.yaml index 6c70031..cffdb5a 100644 --- a/src/vstack/_templates/skills/secret-scan/config.yaml +++ b/src/vstack/_templates/skills/secret-scan/config.yaml @@ -6,6 +6,7 @@ description: | of exposed credentials. Use when asked to "set up secret scanning", "configure push protection", "define custom secret patterns", "triage a secret alert", or "fix a leaked credential". +allowed-tools: 'execute read search edit web' argument-hint: '[scope: enable | configure push-protection | custom-pattern | triage alerts | remediate]' license: MIT diff --git a/src/vstack/_templates/skills/security/config.yaml b/src/vstack/_templates/skills/security/config.yaml index 0e141b6..476cb87 100644 --- a/src/vstack/_templates/skills/security/config.yaml +++ b/src/vstack/_templates/skills/security/config.yaml @@ -6,6 +6,7 @@ description: | exposed secrets, dependency vulnerabilities, and broken access control. Use when asked to "security audit", "security review", or "check for vulnerabilities". Proactively suggest before any code ships to production. +allowed-tools: 'execute read search edit' argument-hint: '[component or service to audit]' license: MIT diff --git a/src/vstack/_templates/skills/terraform/config.yaml b/src/vstack/_templates/skills/terraform/config.yaml index 61aa9d9..00f013c 100644 --- a/src/vstack/_templates/skills/terraform/config.yaml +++ b/src/vstack/_templates/skills/terraform/config.yaml @@ -7,6 +7,7 @@ description: | security hardening. Use when asked to "write Terraform", "review this Terraform", "refactor IaC", "add a Terraform module", "plan state migration", or "harden Terraform configuration". +allowed-tools: 'execute read search edit' argument-hint: '[provider: aws | azure | gcp | generic, and scope: new resource | module | state migration | security review]' license: MIT diff --git a/src/vstack/_templates/skills/terragrunt/config.yaml b/src/vstack/_templates/skills/terragrunt/config.yaml index f0f0247..f1ed5b0 100644 --- a/src/vstack/_templates/skills/terragrunt/config.yaml +++ b/src/vstack/_templates/skills/terragrunt/config.yaml @@ -7,6 +7,7 @@ description: | run-all workflows. Use when asked to "write Terragrunt", "set up Terragrunt", "DRY Terraform across environments", "configure Terragrunt dependencies", or "migrate from plain Terraform to Terragrunt". +allowed-tools: 'execute read search edit' argument-hint: '[scope: new layout | dependency graph | state migration | run-all workflow | security review]' license: MIT diff --git a/src/vstack/_templates/skills/threat-model/config.yaml b/src/vstack/_templates/skills/threat-model/config.yaml index 6d96e7d..7d1709a 100644 --- a/src/vstack/_templates/skills/threat-model/config.yaml +++ b/src/vstack/_templates/skills/threat-model/config.yaml @@ -6,6 +6,7 @@ description: | contexts. Produces actionable threat scenarios, mitigations, and risk priorities. Use when asked to "threat model", "analyze attack paths", "STRIDE review", or "prioritize security design risks". +allowed-tools: 'execute read search edit' argument-hint: '[system, component, or architecture to threat model]' license: MIT diff --git a/src/vstack/_templates/skills/verify/config.yaml b/src/vstack/_templates/skills/verify/config.yaml index 523c4ce..685cebf 100644 --- a/src/vstack/_templates/skills/verify/config.yaml +++ b/src/vstack/_templates/skills/verify/config.yaml @@ -6,6 +6,7 @@ description: | Routes report-only requests to inspect and escalates deep security/performance concerns to specialized skills. Use when asked to "verify", "fix failing checks", "run QA with fixes", or "re-verify before shipping". +allowed-tools: 'execute read search edit' argument-hint: '[component or feature to verify]' license: MIT diff --git a/src/vstack/_templates/skills/vision/config.yaml b/src/vstack/_templates/skills/vision/config.yaml index 68fe9d8..be3646e 100644 --- a/src/vstack/_templates/skills/vision/config.yaml +++ b/src/vstack/_templates/skills/vision/config.yaml @@ -8,6 +8,7 @@ description: | Use when asked to "think bigger", "strategy review", "rethink this", or "is this ambitious enough". Proactively suggest when a plan feels under-scoped or when the user is questioning ambition. +allowed-tools: 'execute read search edit' argument-hint: '[plan or idea to review]' license: MIT diff --git a/src/vstack/agents/config.py b/src/vstack/agents/config.py index a4b832b..984d215 100644 --- a/src/vstack/agents/config.py +++ b/src/vstack/agents/config.py @@ -17,7 +17,7 @@ HANDOFF_ITEM_SCHEMA = FrontmatterSchema( [ FieldSpec("label", required=True), - FieldSpec("agent", required=True, quoted=False), + FieldSpec("agent", required=False, quoted=False), FieldSpec("prompt", required=True), FieldSpec("send", type="bool"), FieldSpec("model"), diff --git a/src/vstack/agents/generator.py b/src/vstack/agents/generator.py index f2b08d5..f226682 100644 --- a/src/vstack/agents/generator.py +++ b/src/vstack/agents/generator.py @@ -54,6 +54,7 @@ def __init__( templates_root: Path | None = None, *, artifacts_root: str = ARTIFACTS_DOCS_ROOT, + workflow_stages: list[dict] | None = None, ) -> None: """Create an agent generator bound to *templates_root*. @@ -65,32 +66,32 @@ def __init__( via ``artifacts.root`` in ``.vstack/config.yaml`` to relocate generated artifact paths (e.g. ``"documentation"`` instead of ``"docs"``). + workflow_stages: Ordered list of pipeline stage dicts read from + ``workflow.stages`` in ``.vstack/config.yaml``. Each dict has + ``role`` and ``gate`` string keys, a ``handoffs`` key containing + a list of dicts with ``prompt``, ``agent``, and ``label`` string + keys, and an optional ``hitl`` string key. When ``None`` or + empty the generator falls back to v3 behaviour: a generic + handoff label without an explicit ``agent:`` target. """ super().__init__( AGENT_TYPE, templates_root if templates_root is not None else TEMPLATES_ROOT ) self.artifacts_root = artifacts_root + self.workflow_stages: list[dict] = workflow_stages or [] def template_partials(self, tmpl_dir: Path) -> dict[str, str]: """Inject per-template artifact placeholder tokens. - Returns a dict with four keys: + Returns a dict with five keys: ``AGENT_ARTIFACTS_INPUT``, ``AGENT_ARTIFACTS_OUTPUT``, - ``AGENT_ARTIFACTS_INPUT_COMMENTS``, ``AGENT_ARTIFACTS_OUTPUT_COMMENTS``. + ``AGENT_ARTIFACTS_INPUT_COMMENTS``, ``AGENT_ARTIFACTS_OUTPUT_COMMENTS``, + and ``AGENT_ARTIFACTS_BASELINE``. """ - from vstack.frontmatter import FrontmatterParser artifact_config = self.load_artifact_config(tmpl_dir) artifacts = artifact_config.get("artifacts") or {} - # The minimal YAML parser stores nested dicts as raw indented strings. - # Re-parse by stripping the 2-space indent that the raw-block mode preserves. - if isinstance(artifacts, str): - dedented = "\n".join( - line[2:] if line.startswith(" ") else line for line in artifacts.split("\n") - ) - artifacts = FrontmatterParser.parse_yaml(dedented) or {} - if not isinstance(artifacts, dict): artifacts = {} @@ -104,38 +105,164 @@ def template_partials(self, tmpl_dir: Path) -> dict[str, str]: if not isinstance(raw_outputs, list): raw_outputs = [] - input_entries = [ - {"path": f"{doc_root}/{item}", "notes": ""} + input_entries: list[dict[str, str | bool]] = [ + {"path": f"{doc_root}/{item}", "notes": "", "baseline": False} for item in raw_inputs if isinstance(item, str) ] - output_entries = self._resolve_output_entries(raw_outputs, doc_root, agent_dir) + output_entries = self._resolve_output_entries(raw_outputs, agent_dir) + baseline_entries = [e for e in output_entries if e.get("baseline")] return { "AGENT_ARTIFACTS_INPUT": self._build_section("input", input_entries), "AGENT_ARTIFACTS_OUTPUT": self._build_section("output", output_entries), "AGENT_ARTIFACTS_INPUT_COMMENTS": str(artifacts.get("input_comments", "") or ""), "AGENT_ARTIFACTS_OUTPUT_COMMENTS": str(artifacts.get("output_comments", "") or ""), + "AGENT_ARTIFACTS_BASELINE": self._build_baseline_section(baseline_entries), } - @staticmethod + def _extract_defaults(self, config: dict) -> dict: + """Return the ``defaults:`` block from *config*, or an empty dict. + + PyYAML always returns ``defaults:`` as a dict when the block is + present in ``config.yaml``. Non-dict values (e.g. ``None`` when the + key is absent) are normalised to ``{}``. + + :param config: Raw config dict as returned by ``load_artifact_config``. + :returns: Parsed ``defaults`` dict, or ``{}`` when absent or not a mapping. + """ + defaults = config.get("defaults") or {} + return defaults if isinstance(defaults, dict) else {} + + def load_artifact_config(self, tmpl_dir: Path) -> dict: + """Load agent config and inject workflow-derived ``handoffs`` into the result. + + Extends :meth:`~vstack.artifacts.generator.GenericArtifactGenerator.load_artifact_config` + by extracting the ``defaults:`` block from the agent's ``config.yaml``, + resolving the handoff prompt from ``defaults.handoffs.prompt``, and + injecting a fully resolved ``handoffs`` list that the frontmatter + serializer can emit directly. The ``defaults:`` key is removed from the + config so it never appears in the generated ``agent.md`` frontmatter. + + When no workflow stages are configured the fallback ``handoffs`` list + uses the agent's own prompt without an explicit ``agent:`` target, + preserving v3 behaviour. + """ + config = super().load_artifact_config(tmpl_dir) + agent_role = tmpl_dir.name + defaults = self._extract_defaults(config) + config.pop("defaults", None) + # Re-expose artifacts at top level so template_partials can read them + # via the standard artifact_config.get("artifacts") path. + if "artifacts" not in config: + artifacts_from_defaults = defaults.get("artifacts") + if artifacts_from_defaults: + config["artifacts"] = artifacts_from_defaults + handoffs_block = defaults.get("handoffs") or {} + if isinstance(handoffs_block, dict): + handoff_prompt: str = str(handoffs_block.get("prompt", "") or "") + else: + handoff_prompt = "" + handoffs = self._resolve_handoffs(agent_role, handoff_prompt) + if handoffs: + config["handoffs"] = handoffs + return config + + def _resolve_handoffs(self, agent_role: str, handoff_prompt: str) -> list[dict[str, str]]: + """Resolve the handoffs list for *agent_role* from workflow stages. + + Each workflow stage may define one or more handoffs under + ``workflow.stages[].handoffs``. Each handoff dict has ``prompt`` + (required), and optional ``agent`` and ``label`` overrides. + + When no explicit ``agent`` is set on a handoff, it defaults to the + next stage in the workflow sequence. When no explicit ``label`` is + set, it defaults to ``"Go to next stage: {Agent}"``. + + The *handoff_prompt* argument (from the agent template's own + ``defaults.handoffs.prompt``) overrides the ``prompt`` of the first + handoff that targets the natural next stage (no ``agent`` override), + allowing per-template prompt customisation without editing the central + config. + + :param agent_role: Role name (template directory name). + :param handoff_prompt: Raw prompt text from the agent's ``config.yaml``. + :returns: List of handoff dicts suitable for frontmatter serialization, + or an empty list when this is the last stage or no prompts exist. + """ + if not self.workflow_stages: + # No workflow configured — emit a generic handoff without an + # explicit ``agent:`` target when a prompt is available, so that + # projects without a workflow: block still get a usable handoff + # entry rather than silently dropping the configured prompt. + if not handoff_prompt.strip(): + return [] + return [ + { + "label": "Continue to next stage", + "prompt": handoff_prompt.strip(), + } + ] + + roles = [s["role"] for s in self.workflow_stages] + try: + idx = roles.index(agent_role) + except ValueError: + return [] + if idx >= len(roles) - 1: + return [] + next_role = roles[idx + 1] + + stage_handoffs: list[dict[str, str]] = self.workflow_stages[idx].get("handoffs", []) + if not isinstance(stage_handoffs, list): + stage_handoffs = [] + + result: list[dict[str, str]] = [] + agent_override_applied = False + + for h in stage_handoffs: + if not isinstance(h, dict): + continue + h_agent = str(h.get("agent", "") or "").strip() + target_agent = h_agent or next_role + + # Apply per-agent handoff_prompt override to the first handoff that + # targets the natural next stage (no explicit agent override). + if handoff_prompt.strip() and not h_agent and not agent_override_applied: + prompt = handoff_prompt.strip() + agent_override_applied = True + else: + prompt = str(h.get("prompt", "") or "").strip() + + if not prompt: + continue + + label = str(h.get("label", "") or "").strip() or ( + f"Go to next stage: {target_agent.capitalize()}" + ) + result.append({"label": label, "agent": target_agent, "prompt": prompt}) + + return result + def _resolve_output_entries( - raw_outputs: list, doc_root: str, agent_dir: str - ) -> list[dict[str, str]]: + self, raw_outputs: list, agent_dir: str + ) -> list[dict[str, str | bool]]: """Resolve raw output config items to display-path dicts. :param raw_outputs: List of strings or dicts from ``artifacts.output``. - :param doc_root: Global artifacts root directory (e.g. ``"docs"``). :param agent_dir: Subdirectory for this agent (e.g. ``"architecture"``). When empty, output paths are used verbatim. - :returns: List of ``{"path": ..., "notes": ...}`` dicts. + :returns: List of ``{"path": ..., "notes": ..., "baseline": ...}`` dicts. """ - result: list[dict[str, str]] = [] + doc_root = self.artifacts_root + result: list[dict[str, str | bool]] = [] for item in raw_outputs: if isinstance(item, str): - path, notes = item, "" + path, notes, baseline = item, "", False elif isinstance(item, dict): - path, notes = str(item.get("path", "")), str(item.get("notes", "")) + path = str(item.get("path", "")) + notes = str(item.get("notes", "")) + baseline = bool(item.get("baseline", False)) else: continue @@ -147,11 +274,10 @@ def _resolve_output_entries( else: display = path - result.append({"path": display, "notes": notes}) + result.append({"path": display, "notes": notes, "baseline": baseline}) return result - @staticmethod - def _build_table(entries: list[dict[str, str]]) -> str: + def _build_table(self, entries: list[dict[str, str | bool]]) -> str: """Build a Markdown table from normalised artifact entry dicts. Produces a single-column ``Artifact`` table when no entry has notes, @@ -159,7 +285,7 @@ def _build_table(entries: list[dict[str, str]]) -> str: All rows are padded to equal column widths. """ cells = [f"`{e['path']}`" for e in entries] - notes_cells = [e.get("notes", "") for e in entries] + notes_cells = [str(e.get("notes", "")) for e in entries] has_notes = any(notes_cells) if has_notes: @@ -183,12 +309,47 @@ def _build_table(entries: list[dict[str, str]]) -> str: return "\n".join(lines) - @staticmethod - def _build_section(heading: str, entries: list[dict[str, str]]) -> str: + def _build_section(self, heading: str, entries: list[dict[str, str | bool]]) -> str: """Build a ``### {heading}`` Markdown subsection with a table. Returns an empty string when *entries* is empty. """ if not entries: return "" - return f"### {heading}\n\n{AgentGenerator._build_table(entries)}" + return f"### {heading}\n\n{self._build_table(entries)}" + + def _build_baseline_section(self, entries: list[dict[str, str | bool]]) -> str: + """Build the ``### baseline docs you maintain`` subsection. + + Renders a table of output artifacts flagged with ``baseline: true``. + Returns an empty string when no baseline entries are present. + + :param entries: Resolved output entries where ``baseline`` is ``True``. + :returns: Markdown subsection string, or empty string. + """ + if not entries: + return "" + return ( + "### baseline docs you maintain\n\n" + "Keep these files current. Update them whenever the relevant scope, " + "design, or implementation changes — do not let them go stale.\n\n" + f"{self._build_table(entries)}" + ) + + def _build_handoffs(self, agent_role: str, handoff_prompt: str) -> str: + """Build the ``handoffs:`` frontmatter block for this agent. + + .. deprecated:: + Use :meth:`_resolve_handoffs` instead. This method is retained + only for backward compatibility with any direct call sites in tests. + """ + entries = self._resolve_handoffs(agent_role, handoff_prompt) + if not entries: + return "" + entry = entries[0] + lines = [f'handoffs:\n - label: "{entry["label"]}"'] + if "agent" in entry: + lines.append(f" agent: {entry['agent']}") + lines.append(" prompt: >") + lines.extend(f" {line}" for line in entry["prompt"].splitlines()) + return "\n".join(lines) diff --git a/src/vstack/cli/base.py b/src/vstack/cli/base.py index 476d648..98ca027 100644 --- a/src/vstack/cli/base.py +++ b/src/vstack/cli/base.py @@ -27,6 +27,11 @@ def require_install_dir(self, command_name: str) -> Path: class BaseCommand(ABC): """Abstract base class for top-level CLI command handlers.""" + @staticmethod + def _normalize_targeted_names(names: list[str] | None) -> set[str]: + """Normalize targeted artifact names for force/adopt operations.""" + return {name.strip() for name in names or [] if name.strip()} + @abstractmethod def run( self, diff --git a/src/vstack/cli/catalog.py b/src/vstack/cli/catalog.py index d09cfd4..cd2f84d 100644 --- a/src/vstack/cli/catalog.py +++ b/src/vstack/cli/catalog.py @@ -10,6 +10,7 @@ from vstack.cli.init import InitCommand from vstack.cli.install import InstallCommand from vstack.cli.manifest import ManifestCommand +from vstack.cli.migrate import MigrateCommand from vstack.cli.status import StatusCommand from vstack.cli.uninstall import UninstallCommand from vstack.cli.validate import ValidateCommand @@ -53,6 +54,7 @@ class ManifestSubcommandConfig: "install", "init", "uninstall", + "migrate", ) @@ -125,6 +127,14 @@ class ManifestSubcommandConfig: scope_help="Uninstall from /.github/", only_help="Uninstall only these artifact types, e.g. --only skill agent", ), + "migrate": TopLevelCommandConfig( + command_factory=MigrateCommand, + help_text="Migrate docs artifact paths between major vstack versions", + requires_install_dir=False, + resolve_only_for_scope=False, + include_scope_group=False, + include_only_option=False, + ), } diff --git a/src/vstack/cli/helpers.py b/src/vstack/cli/helpers.py deleted file mode 100644 index 87526d5..0000000 --- a/src/vstack/cli/helpers.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Shared internal helpers for CLI command handlers.""" - -from __future__ import annotations - - -def normalize_targeted_names(names: list[str] | None) -> set[str]: - """Normalize targeted artifact names for force/adopt operations.""" - return {name.strip() for name in names or [] if name.strip()} diff --git a/src/vstack/cli/init.py b/src/vstack/cli/init.py index a7f2dd2..b3af947 100644 --- a/src/vstack/cli/init.py +++ b/src/vstack/cli/init.py @@ -9,7 +9,6 @@ from vstack.cli.base import BaseCommand, CommandContext from vstack.cli.constants import Colors -from vstack.cli.helpers import normalize_targeted_names from vstack.constants import VERSION from vstack.manifest import ( CURRENT_HASH_ALGORITHM, @@ -17,8 +16,6 @@ Manifest, content_hash, hash_with_algorithm, - preserve_existing_entry, - preserved_manifest_entries, ) if TYPE_CHECKING: @@ -231,9 +228,10 @@ def _load_existing_manifest( selected_manifest_keys = {gen.config.manifest_key for gen in gens} existing_entries = InitCommand._existing_entries_for_init(gens, existing_manifest) - new_entries = preserved_manifest_entries( - existing_manifest, - selected_manifest_keys, + new_entries = ( + existing_manifest.preserved_entries(selected_manifest_keys) + if existing_manifest is not None + else {} ) return manifest_file, existing_manifest, existing_entries, new_entries @@ -257,7 +255,7 @@ def _install_single_artifact( ) -> str: """Apply install decision flow for one rendered artifact and return the action taken.""" out_file = out_dir / gen.output_path(artifact.name) - new_version = (artifact.frontmatter or {}).get("version") or VERSION + new_version = str((artifact.frontmatter or {}).get("version") or VERSION) key = f"{gen.config.type_name}/{artifact.name}" existing_entry = existing_entries.get(key) existing_version = existing_entry.version if existing_entry is not None else None @@ -338,7 +336,7 @@ def _install_single_artifact( return action if existing_entry is not None: - preserve_existing_entry( + Manifest.preserve_existing_entry( new_entries=new_entries, manifest_key=gen.config.manifest_key, existing_entry=existing_entry, @@ -416,6 +414,31 @@ def _print_summary( "take ownership without overwriting" ) + @staticmethod + def _warn_unknown_workflow_roles( + *, + workflow_stages: list[dict[str, str]], + known_agent_names: set[str], + colors, + ) -> None: + """Emit a warning for any workflow stage that references an unknown agent. + + Unknown roles are not fatal — a project may define custom agents. + The warning is informational only and does not affect the exit code. + + :param workflow_stages: Parsed stage list from ``workflow.stages``. + :param known_agent_names: Agent names available in the current template root. + :param colors: CLI colours helper. + """ + for stage in workflow_stages: + role = stage.get("role", "") + if role and role not in known_agent_names: + print( + f" {colors.YELLOW}⚠{colors.RESET} workflow: unknown role " + f"{colors.BOLD}{role!r}{colors.RESET} — no matching agent template found", + file=sys.stderr, + ) + @staticmethod def execute( service: CommandService, @@ -442,8 +465,21 @@ def execute( checksum_algorithm = CURRENT_HASH_ALGORITHM gens = [g for g in service.generators if only is None or g.config.type_name in only] - targeted_force_names = normalize_targeted_names(force_names) - targeted_adopt_names = normalize_targeted_names(adopt_names) + targeted_force_names = InitCommand._normalize_targeted_names(force_names) + targeted_adopt_names = InitCommand._normalize_targeted_names(adopt_names) + + # Validate workflow stages against known agent names (warning only). + from vstack.agents.generator import AgentGenerator + + for gen in service.generators: + if isinstance(gen, AgentGenerator) and gen.workflow_stages: + known_names = {p.name for p in gen.find_templates()} + InitCommand._warn_unknown_workflow_roles( + workflow_stages=gen.workflow_stages, + known_agent_names=known_names, + colors=colors, + ) + break manifest_file, _, existing_entries, new_entries = InitCommand._load_existing_manifest( service=service, diff --git a/src/vstack/cli/interface.py b/src/vstack/cli/interface.py index ddf1bc7..704ec57 100644 --- a/src/vstack/cli/interface.py +++ b/src/vstack/cli/interface.py @@ -5,11 +5,10 @@ import argparse from pathlib import Path -from vstack.cli.base import CommandContext +from vstack.cli.base import BaseCommand, CommandContext from vstack.cli.catalog import COMMAND_CATALOG from vstack.cli.constants import GLOBAL_SUPPORTED_TYPE_NAMES, KNOWN_TYPE_NAMES from vstack.cli.parser import CommandLineParser -from vstack.cli.registry import build_command_registry from vstack.cli.service import CommandService from vstack.constants import ARTIFACTS_DOCS_ROOT from vstack.frontmatter import FrontmatterParser @@ -102,13 +101,6 @@ def _read_exclude( return frozenset(), {} parsed = FrontmatterParser.parse_yaml(config_path.read_text(encoding="utf-8")) raw_exclude = parsed.get("exclude", "") - # The minimal YAML parser stores nested mappings as raw indented strings. - # Re-parse by stripping the 2-space indent to access sub-keys. - if isinstance(raw_exclude, str) and raw_exclude.strip(): - dedented = "\n".join( - line[2:] if line.startswith(" ") else line for line in raw_exclude.split("\n") - ) - raw_exclude = FrontmatterParser.parse_yaml(dedented) or {} if not isinstance(raw_exclude, dict): return frozenset(), {} excluded_types: set[str] = set() @@ -146,13 +138,6 @@ def _read_artifacts_root(install_dir: Path | None) -> str: return ARTIFACTS_DOCS_ROOT parsed = FrontmatterParser.parse_yaml(config_path.read_text(encoding="utf-8")) artifacts = parsed.get("artifacts", "") - # The minimal YAML parser stores nested mappings as raw indented strings. - # Re-parse by stripping the 2-space indent to access sub-keys. - if isinstance(artifacts, str) and artifacts.strip(): - dedented = "\n".join( - line[2:] if line.startswith(" ") else line for line in artifacts.split("\n") - ) - artifacts = FrontmatterParser.parse_yaml(dedented) or {} if not isinstance(artifacts, dict): return ARTIFACTS_DOCS_ROOT value = artifacts.get("root", "") @@ -160,6 +145,99 @@ def _read_artifacts_root(install_dir: Path | None) -> str: return value.strip() return ARTIFACTS_DOCS_ROOT + @staticmethod + def _read_workflow_stages(install_dir: Path | None) -> list[dict]: + """Read ``workflow.stages`` from ``.vstack/config.yaml`` when available. + + Returns an empty list when *install_dir* is ``None``, when the config + file does not exist, or when the ``workflow:`` block is absent or + contains no valid stages. + + Each returned dict has at minimum a ``role`` key. Optional keys are + ``gate``, ``hitl``, and ``handoffs`` (a list of handoff dicts, each with + at minimum a ``prompt`` key and optionally ``agent`` and ``label`` + overrides). Unknown extra keys in the stage dict are silently ignored. + """ + if install_dir is None: + return [] + config_path = install_dir.parent / ".vstack" / "config.yaml" + if not config_path.exists(): + return [] + parsed = FrontmatterParser.parse_yaml(config_path.read_text(encoding="utf-8")) + workflow = parsed.get("workflow", "") + if not isinstance(workflow, dict): + return [] + stages_raw = workflow.get("stages", []) + if not isinstance(stages_raw, list): + return [] + result: list[dict] = [] + for item in stages_raw: + if isinstance(item, dict) and isinstance(item.get("role"), str): + handoffs = CommandLineInterface._parse_stage_handoffs(item) + stage: dict = { + "role": item["role"], + "gate": str(item.get("gate", "required")), + "handoffs": handoffs, + } + if "hitl" in item: + stage["hitl"] = str(item["hitl"]) + result.append(stage) + return result + + @staticmethod + def _parse_stage_handoffs(item: dict) -> list[dict[str, str]]: + """Parse the ``handoffs`` entry from a workflow stage dict. + + Supports the nested block form (``handoffs: {prompt: ...}``) and + the nested list form (``handoffs: [{prompt: ...}, ...]``). Falls + back to the legacy ``handoff_prompt`` flat key for backward + compatibility with pre-3.x configs. + + Each returned dict has ``prompt``, ``agent``, and ``label`` keys + (empty string when absent). + """ + raw = item.get("handoffs", "") + parsed_block: dict | list | None = None + + if isinstance(raw, (dict, list)): + parsed_block = raw + + if isinstance(parsed_block, dict): + # Single-handoff dict form: {prompt: ..., agent?: ..., label?: ...} + return [ + { + "prompt": str(parsed_block.get("prompt", "") or ""), + "agent": str(parsed_block.get("agent", "") or ""), + "label": str(parsed_block.get("label", "") or ""), + } + ] + if isinstance(parsed_block, list): + # Multi-handoff list form: [{prompt: ..., agent?: ..., label?: ...}, ...] + result = [] + for h in parsed_block: + if isinstance(h, dict): + result.append( + { + "prompt": str(h.get("prompt", "") or ""), + "agent": str(h.get("agent", "") or ""), + "label": str(h.get("label", "") or ""), + } + ) + return result + + # Backward compat: legacy flat ``handoff_prompt`` key. + legacy = str(item.get("handoff_prompt", "") or "") + if legacy: + return [{"prompt": legacy, "agent": "", "label": ""}] + return [] + + def _build_command_registry(self, service: CommandService) -> dict[str, BaseCommand]: + """Instantiate all catalog commands against *service*.""" + return { + command_name: config.command_factory(service) + for command_name, config in COMMAND_CATALOG.items() + } + def run(self) -> int: """Run one CLI invocation and return a process-style status code.""" cli_parser = self._parser_cls() @@ -173,10 +251,13 @@ def run(self) -> int: requires_install_dir=command_config.requires_install_dir, ) artifacts_root = self._read_artifacts_root(resolved_install_dir) + workflow_stages = self._read_workflow_stages(resolved_install_dir) service = self._service_cls( - templates_root=self._templates_root, artifacts_root=artifacts_root + templates_root=self._templates_root, + artifacts_root=artifacts_root, + workflow_stages=workflow_stages, ) - commands = build_command_registry(service) + commands = self._build_command_registry(service) effective_only = self._resolve_only_filter( args=args, resolve_only_for_scope=command_config.resolve_only_for_scope, diff --git a/src/vstack/cli/migrate.py b/src/vstack/cli/migrate.py new file mode 100644 index 0000000..b35525a --- /dev/null +++ b/src/vstack/cli/migrate.py @@ -0,0 +1,232 @@ +"""Migrate command — apply docs artifact path migrations between major versions.""" + +from __future__ import annotations + +import shutil +import sys +from pathlib import Path +from typing import TYPE_CHECKING + +import yaml + +from vstack.cli.base import BaseCommand, CommandContext +from vstack.cli.constants import Colors +from vstack.constants import ARTIFACTS_DOCS_ROOT, MIGRATIONS_ROOT, VERSION, VSTACK_DIR_NAME +from vstack.frontmatter import FrontmatterParser +from vstack.manifest.store import ManifestFile + +if TYPE_CHECKING: + from vstack.cli.service import CommandService + + +class MigrateCommand(BaseCommand): + """Apply docs artifact path migrations between major vstack versions. + + Reads migration records from the package ``_migrations/`` directory and + moves files at old docs paths to their new locations. Only files that + exist at the old path are moved; absent files are silently skipped. + """ + + def __init__(self, service: CommandService) -> None: + self._service = service + + @staticmethod + def _resolve_project_root(args: object) -> Path: + """Resolve the project root from ``--target`` or the current directory.""" + target = getattr(args, "target", None) + if target: + return Path(target).expanduser().resolve() + return Path.cwd() + + @staticmethod + def _detect_installed_major(project_root: Path) -> int | None: + """Read ``vstack_version`` from the manifest and return its major number. + + Returns ``None`` when the manifest is absent or the version cannot be + parsed as a semver-like string. + """ + manifest_path = project_root / VSTACK_DIR_NAME / "vstack.json" + if not manifest_path.exists(): + return None + manifest_file = ManifestFile(project_root / VSTACK_DIR_NAME) + try: + manifest = manifest_file.read() + except Exception: # noqa: BLE001 + return None + if manifest is None: + return None + version = manifest.vstack_version + if not version: + return None + try: + return int(version.split(".")[0]) + except (ValueError, IndexError): + return None + + @staticmethod + def _load_migration_record(from_major: int, to_major: int) -> list[dict] | None: + """Load moves for a single *from_major* → *to_major* step. + + Returns the ``moves`` list from the YAML record, or ``None`` when no + record file exists for this step. + """ + record_path = MIGRATIONS_ROOT / f"v{from_major}_to_v{to_major}.yaml" + if not record_path.exists(): + return None + raw = yaml.safe_load(record_path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + return None + moves = raw.get("moves", []) + if not isinstance(moves, list): + return None + return [m for m in moves if isinstance(m, dict)] + + @staticmethod + def _resolve_new_path(new_rel: str, artifacts_root: str) -> str: + """Substitute the default docs root in *new_rel* with *artifacts_root*. + + Migration records store new paths using the default ``docs/`` root. + When the project uses a custom root, the first path component is + replaced so the file lands under the configured directory. + """ + if artifacts_root == ARTIFACTS_DOCS_ROOT: + return new_rel + parts = Path(new_rel).parts + if parts and parts[0] == ARTIFACTS_DOCS_ROOT: + return str(Path(artifacts_root).joinpath(*parts[1:])) + return new_rel + + @staticmethod + def _read_artifacts_root(project_root: Path) -> str: + """Read ``artifacts.root`` from ``.vstack/config.yaml``. + + Returns :data:`~vstack.constants.ARTIFACTS_DOCS_ROOT` when the config + is absent or the key is not set. + """ + config_path = project_root / VSTACK_DIR_NAME / "config.yaml" + if not config_path.exists(): + return ARTIFACTS_DOCS_ROOT + parsed = FrontmatterParser.parse_yaml(config_path.read_text(encoding="utf-8")) + artifacts = parsed.get("artifacts", "") + if not isinstance(artifacts, dict): + return ARTIFACTS_DOCS_ROOT + value = artifacts.get("root", "") + if isinstance(value, str) and value.strip(): + return value.strip() + return ARTIFACTS_DOCS_ROOT + + @staticmethod + def _apply_moves( + *, + moves: list[dict], + project_root: Path, + artifacts_root: str, + dry_run: bool, + colors: type[Colors], + ) -> tuple[int, int]: + """Apply *moves* relative to *project_root*. + + Returns a ``(moved, skipped)`` count pair. + """ + moved = 0 + skipped = 0 + prefix = f"{colors.DIM}[dry-run]{colors.RESET} " if dry_run else "" + + for move in moves: + old_rel = move.get("old", "") + new_rel_raw = move.get("new", "") + if not old_rel or not new_rel_raw: + continue + + new_rel = MigrateCommand._resolve_new_path(new_rel_raw, artifacts_root) + old_path = project_root / old_rel + new_path = project_root / new_rel + + if not old_path.exists(): + print(f" {prefix}{colors.DIM}↷ {old_rel} — absent, skipping{colors.RESET}") + skipped += 1 + continue + + if new_path.exists(): + print( + f" {prefix}{colors.YELLOW}↷{colors.RESET} {old_rel}" + f" {colors.DIM}→ {new_rel}" + f" — destination exists, skipping{colors.RESET}" + ) + skipped += 1 + continue + + print( + f" {prefix}{colors.GREEN}→{colors.RESET} " + f"{colors.BOLD}{old_rel}{colors.RESET}" + f" {colors.DIM}→{colors.RESET} {new_rel}" + ) + if not dry_run: + new_path.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(old_path), str(new_path)) + moved += 1 + + return moved, skipped + + def run(self, *, context: CommandContext) -> int: + """Execute the migrate command and return a process-style status code.""" + args = context.args + colors = Colors + dry_run = getattr(args, "dry_run", False) + project_root = self._resolve_project_root(args) + from_major: int | None = getattr(args, "from_major", None) + to_major: int | None = getattr(args, "to_major", None) + + if from_major is None: + from_major = self._detect_installed_major(project_root) + if from_major is None: + print( + "ERROR: could not detect installed version from manifest. " + "Specify --from explicitly.", + file=sys.stderr, + ) + return 1 + + if to_major is None: + try: + to_major = int(VERSION.split(".")[0]) + except (ValueError, IndexError): + to_major = from_major + 1 + + if from_major >= to_major: + print(f"Nothing to migrate: already at v{to_major}.") + return 0 + + artifacts_root = self._read_artifacts_root(project_root) + + print(f"\n {colors.BOLD}Migrating docs paths v{from_major} → v{to_major}{colors.RESET}") + if dry_run: + print(f" {colors.DIM}(dry-run — no files will be moved){colors.RESET}") + + total_moved = 0 + total_skipped = 0 + + for step_from in range(from_major, to_major): + step_to = step_from + 1 + moves = self._load_migration_record(step_from, step_to) + if moves is None: + print( + f"\n {colors.DIM}v{step_from} → v{step_to}:" + f" no migration record, skipping{colors.RESET}" + ) + continue + print(f"\n {colors.DIM}v{step_from} → v{step_to}{colors.RESET}") + moved, skipped = self._apply_moves( + moves=moves, + project_root=project_root, + artifacts_root=artifacts_root, + dry_run=dry_run, + colors=colors, + ) + total_moved += moved + total_skipped += skipped + + print( + f"\n {colors.BOLD}Done.{colors.RESET} Moved: {total_moved} Skipped: {total_skipped}" + ) + return 0 diff --git a/src/vstack/cli/parser.py b/src/vstack/cli/parser.py index d48eedf..454b9cb 100644 --- a/src/vstack/cli/parser.py +++ b/src/vstack/cli/parser.py @@ -271,6 +271,36 @@ def _add_uninstall_command(self, sub: SubparserFactory) -> None: help="Force uninstall one named artifact without removing every modified file", ) + def _add_migrate_command(self, sub: SubparserFactory) -> None: + """Register the ``migrate`` subcommand.""" + migrate_config = COMMAND_CATALOG["migrate"] + parser = sub.add_parser("migrate", help=migrate_config.help_text) + parser.add_argument( + "--target", + metavar="", + help="Project root directory (default: current working directory)", + ) + parser.add_argument( + "--from", + dest="from_major", + type=int, + metavar="", + help="Source major version (e.g. 2). Detected from manifest when absent.", + ) + parser.add_argument( + "--to", + dest="to_major", + type=int, + metavar="", + help="Target major version (e.g. 3). Defaults to current vstack major.", + ) + parser.add_argument( + "--dry-run", + dest="dry_run", + action="store_true", + help="Show what would be moved without writing files", + ) + def vscode_user_dir(self) -> Path | None: """Return the first detected VS Code user data directory. @@ -339,6 +369,7 @@ def build(self) -> argparse.ArgumentParser: "install": self._add_install_command, "init": self._add_init_command, "uninstall": self._add_uninstall_command, + "migrate": self._add_migrate_command, } for command_name in TOP_LEVEL_COMMAND_ORDER: command_adders[command_name](sub) diff --git a/src/vstack/cli/registry.py b/src/vstack/cli/registry.py deleted file mode 100644 index a2e8070..0000000 --- a/src/vstack/cli/registry.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Command registry construction for CLI dispatch.""" - -from __future__ import annotations - -from vstack.cli.base import BaseCommand -from vstack.cli.catalog import COMMAND_CATALOG -from vstack.cli.service import CommandService - - -def build_command_registry(service: CommandService) -> dict[str, BaseCommand]: - """Build the map of top-level command names to command handlers.""" - return { - command_name: config.command_factory(service) - for command_name, config in COMMAND_CATALOG.items() - } diff --git a/src/vstack/cli/service.py b/src/vstack/cli/service.py index 42ca8d2..6a2147e 100644 --- a/src/vstack/cli/service.py +++ b/src/vstack/cli/service.py @@ -34,7 +34,13 @@ class CommandService: while keeping per-type behavior in ``ArtifactTypeConfig`` definitions. """ - def __init__(self, templates_root: Path, *, artifacts_root: str = ARTIFACTS_DOCS_ROOT) -> None: + def __init__( + self, + templates_root: Path, + *, + artifacts_root: str = ARTIFACTS_DOCS_ROOT, + workflow_stages: list[dict[str, str]] | None = None, + ) -> None: """Create generators for all known artifact families. Per-type generator subclasses are used when available so that @@ -48,10 +54,20 @@ def __init__(self, templates_root: Path, *, artifacts_root: str = ARTIFACTS_DOCS through to :class:`~vstack.agents.generator.AgentGenerator`. Defaults to :data:`~vstack.constants.ARTIFACTS_DOCS_ROOT`; override via ``artifacts.root`` in ``.vstack/config.yaml``. + workflow_stages: Ordered list of pipeline stage dicts read from + the ``workflow.stages`` block in ``.vstack/config.yaml``. + Each dict has ``role`` and ``gate`` string keys, a ``handoffs`` + key containing a list of dicts with ``prompt``, ``agent``, and + ``label`` string keys, and an optional ``hitl`` string key. + When ``None`` or empty the generator falls back to v3 behaviour. """ self.root = templates_root self.generators: list[GenericArtifactGenerator] = [ - AgentGenerator(templates_root, artifacts_root=artifacts_root) + AgentGenerator( + templates_root, + artifacts_root=artifacts_root, + workflow_stages=workflow_stages or [], + ) if tc is AGENT_TYPE else GenericArtifactGenerator(tc, templates_root) for tc in KNOWN_TYPES diff --git a/src/vstack/cli/uninstall.py b/src/vstack/cli/uninstall.py index 55db8a3..6e3c72a 100644 --- a/src/vstack/cli/uninstall.py +++ b/src/vstack/cli/uninstall.py @@ -9,9 +9,8 @@ from vstack.cli.base import BaseCommand, CommandContext from vstack.cli.constants import ArtifactState -from vstack.cli.helpers import normalize_targeted_names from vstack.constants import VERSION -from vstack.manifest import Manifest, preserve_existing_entry, preserved_manifest_entries +from vstack.manifest import Manifest if TYPE_CHECKING: from vstack.cli.service import CommandService @@ -67,7 +66,7 @@ def _remove_or_preserve_entry( return preserved.append(message) - preserve_existing_entry( + Manifest.preserve_existing_entry( new_entries=new_entries, manifest_key=gen.config.manifest_key, existing_entry=entry, @@ -125,7 +124,7 @@ def execute( manifest_file = service.manifest_for(install_dir) manifest_data = manifest_file.read() gens = [g for g in service.generators if only is None or g.config.type_name in only] - targeted_force_names = normalize_targeted_names(force_names) + targeted_force_names = UninstallCommand._normalize_targeted_names(force_names) if manifest_data is None: if manifest_file.read_error: @@ -135,10 +134,7 @@ def execute( return 0 selected_manifest_keys = {gen.config.manifest_key for gen in gens} - new_entries = preserved_manifest_entries( - manifest_data, - selected_manifest_keys, - ) + new_entries = manifest_data.preserved_entries(selected_manifest_keys) for gen in gens: out_dir = install_dir / gen.config.output_subdir diff --git a/src/vstack/constants.py b/src/vstack/constants.py index 75da934..08cb53a 100644 --- a/src/vstack/constants.py +++ b/src/vstack/constants.py @@ -13,6 +13,7 @@ _PACKAGE_ROOT = files("vstack") TEMPLATES_ROOT = Path(str(_PACKAGE_ROOT / "_templates")) +MIGRATIONS_ROOT = Path(str(_PACKAGE_ROOT / "_migrations")) # Default root directory for all role artifacts. Individual agent configs specify # only the subdirectory via ``artifacts.dir``; this root is applied at render time. diff --git a/src/vstack/frontmatter/parser.py b/src/vstack/frontmatter/parser.py index 2223518..7ec3dac 100644 --- a/src/vstack/frontmatter/parser.py +++ b/src/vstack/frontmatter/parser.py @@ -1,11 +1,8 @@ -"""YAML frontmatter parser — no external dependencies. +"""YAML frontmatter parser. -Supports: -- String scalars (quoted and unquoted) -- Inline lists ``[a, b, c]`` -- Block lists ``\n - item`` -- Block sequences of mappings (object-lists) ``\n - key: val\n key2: val2`` -- Block scalars ``|`` +Delegates YAML parsing to :func:`yaml.safe_load` (PyYAML). Supports the full +YAML 1.1 feature set used by vstack config files, including nested mappings, +block sequences, block scalars, and inline lists. """ from __future__ import annotations @@ -13,6 +10,8 @@ import re from dataclasses import dataclass, field +import yaml + _FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n(.*)", re.DOTALL) @@ -50,40 +49,7 @@ def __bool__(self) -> bool: class FrontmatterParser: - """Parse the repository's supported subset of YAML frontmatter.""" - - @staticmethod - def _is_current_object_list_item(meta: dict, current_key: str) -> bool: - """Return ``True`` when ``current_key`` points to the active object-list item.""" - return ( - bool(current_key) - and isinstance(meta.get(current_key), list) - and bool(meta[current_key]) - and isinstance(meta[current_key][-1], dict) - ) - - @staticmethod - def _flush_object_block_scalar( - *, - meta: dict, - current_key: str, - object_scalar_field: str, - object_block_lines: list[str], - ) -> None: - """Flush buffered block-scalar content into the active object-list item.""" - text = " ".join(b for b in object_block_lines if b).strip() - if FrontmatterParser._is_current_object_list_item(meta, current_key): - meta[current_key][-1][object_scalar_field] = text - - @staticmethod - def _flush_raw_block(*, meta: dict, current_key: str, raw_lines: list[str]) -> None: - """Flush buffered raw block content into the current top-level key.""" - meta[current_key] = "\n".join(raw_lines).rstrip() - - @staticmethod - def _flush_block_scalar(*, meta: dict, current_key: str, block_lines: list[str]) -> None: - """Flush a buffered top-level block scalar into the current key.""" - meta[current_key] = " ".join(b for b in block_lines if b).strip() + """Parse YAML frontmatter using :func:`yaml.safe_load`.""" @staticmethod def parse(content: str) -> FrontmatterContent: @@ -113,160 +79,20 @@ def parse_yaml(raw: str) -> dict: # ── Internal ────────────────────────────────────────────────────────────── - @staticmethod - def _parse_scalar(val: str) -> str: - """Strip surrounding quotes from a YAML scalar string.""" - return val.strip().strip("\"'") - @staticmethod def _parse_yaml_block(raw: str) -> dict: - """Parse a minimal YAML subset (no external dependencies). + """Delegate YAML parsing to :func:`yaml.safe_load`. - Supports: string values, inline lists ``[a, b]``, - block lists ``\n - item``, block scalars ``|``, - block sequences of mappings (object-lists): - ``\n - key: val\n key2: val2``, and - raw mapping blocks where the value is indented non-list YAML content: - ``\n server:\n type: local`` (used for ``mcp-servers``, ``hooks``, etc.). - """ - meta: dict = {} - current_key = "" - in_block_scalar = False - block_lines: list[str] = [] - in_raw_block = False - raw_lines: list[str] = [] - in_object_block_scalar = False - object_scalar_field = "" - object_block_lines: list[str] = [] - - for line in raw.split("\n"): - if line.strip().startswith("#"): - continue - - if in_object_block_scalar: - if line.startswith(" ") or line == "": - object_block_lines.append(line.strip()) - continue - else: - FrontmatterParser._flush_object_block_scalar( - meta=meta, - current_key=current_key, - object_scalar_field=object_scalar_field, - object_block_lines=object_block_lines, - ) - in_object_block_scalar = False - object_scalar_field = "" - object_block_lines = [] - - # ── Raw block accumulation ──────────────────────────────────────── - if in_raw_block: - if line == "" or line.startswith(" "): - raw_lines.append(line) - continue - else: - # Non-indented line closes the raw block; fall through to process it - FrontmatterParser._flush_raw_block( - meta=meta, - current_key=current_key, - raw_lines=raw_lines, - ) - in_raw_block = False - raw_lines = [] - - if in_block_scalar: - if line.startswith(" ") or line == "": - block_lines.append(line.strip()) - continue - else: - FrontmatterParser._flush_block_scalar( - meta=meta, - current_key=current_key, - block_lines=block_lines, - ) - in_block_scalar = False - block_lines = [] - - # 4-space key: continuation of an object-list item - obj_kv = re.match(r"^ ([a-zA-Z_-]+):\s*(.*)$", line) - if obj_kv and FrontmatterParser._is_current_object_list_item(meta, current_key): - obj_key = obj_kv.group(1) - obj_val = obj_kv.group(2).strip() - if obj_val in ("|", "|-", "|+", ">", ">-", ">+"): - in_object_block_scalar = True - object_scalar_field = obj_key - object_block_lines = [] - meta[current_key][-1][obj_key] = "" - else: - meta[current_key][-1][obj_key] = FrontmatterParser._parse_scalar(obj_val) - continue + Pre-processes ``- *`` (VS Code wildcard list items) into quoted form + so that PyYAML does not interpret the bare ``*`` as a YAML alias. - # Raw block trigger: 2-space non-list indented line when the current key - # has an empty provisional value (set by a bare ``key:`` with no value). - if ( - line.startswith(" ") - and not line.startswith(" - ") - and current_key - and meta.get(current_key) == [] - ): - in_raw_block = True - raw_lines = [line] - meta[current_key] = "" # clear empty-list placeholder - continue - - # 2-space list item - list_match = re.match(r"^ - (.+)$", line) - if list_match and current_key: - item_str = list_match.group(1).strip() - item_kv = re.match(r"^([a-zA-Z_-]+):\s*(.*)$", item_str) - if item_kv: - # Object-list item — first key bootstraps the dict - if not isinstance(meta.get(current_key), list): - meta[current_key] = [] - meta[current_key].append( - {item_kv.group(1): FrontmatterParser._parse_scalar(item_kv.group(2))} - ) - else: - if not isinstance(meta.get(current_key), list): - meta[current_key] = [] - meta[current_key].append(item_str.strip("\"'")) - continue - - kv = re.match(r"^([a-zA-Z_-]+):\s*(.*)$", line) - if kv: - current_key = kv.group(1) - val = kv.group(2).strip() - if val.startswith("[") and val.endswith("]"): - meta[current_key] = [ - v.strip().strip("\"'") for v in val[1:-1].split(",") if v.strip() - ] - elif val in ("|", "|-", "|+", ">", ">-", ">+"): - in_block_scalar = True - block_lines = [] - meta[current_key] = "" - elif val == "": - meta[current_key] = [] # provisional: may become a raw block - else: - meta[current_key] = val.strip("\"'") - - if in_object_block_scalar: - if object_scalar_field: - FrontmatterParser._flush_object_block_scalar( - meta=meta, - current_key=current_key, - object_scalar_field=object_scalar_field, - object_block_lines=object_block_lines, - ) - if in_raw_block and raw_lines: - FrontmatterParser._flush_raw_block( - meta=meta, - current_key=current_key, - raw_lines=raw_lines, - ) - if in_block_scalar and block_lines: - FrontmatterParser._flush_block_scalar( - meta=meta, - current_key=current_key, - block_lines=block_lines, - ) - - return meta + Returns an empty dict when *raw* is empty or parses to a non-mapping + value. + """ + # Replace bare wildcard items (`` - *``) with single-quoted form so that + # PyYAML does not treat the leading ``*`` as a YAML alias marker. + preprocessed = re.sub(r"^(\s*-\s)\*(\s*)$", r"\1'*'\2", raw, flags=re.MULTILINE) + result = yaml.safe_load(preprocessed) + if not isinstance(result, dict): + return {} + return result diff --git a/src/vstack/frontmatter/serializer.py b/src/vstack/frontmatter/serializer.py index a4ccb03..b2eb05c 100644 --- a/src/vstack/frontmatter/serializer.py +++ b/src/vstack/frontmatter/serializer.py @@ -13,8 +13,15 @@ import re import textwrap +import yaml + from vstack.frontmatter.schema import FieldSpec, FrontmatterSchema +# Characters that open a YAML alias (*), anchor (&), or tag (!) when they appear +# as the first character of a plain scalar. List items starting with these must +# be single-quoted so that PyYAML and VS Code parse them correctly. +_YAML_SPECIAL_LEADING = frozenset("*&!") + class FrontmatterSerializer: """Frontmatter serializer — converts metadata dict to YAML. @@ -23,6 +30,12 @@ class FrontmatterSerializer: No mutable instance state is retained between calls. """ + def _quote_list_item(self, item: str) -> str: + """Return *item* single-quoted when it starts with a YAML-special character.""" + if item and item[0] in _YAML_SPECIAL_LEADING: + return "'" + item.replace("'", "''") + "'" + return item + def _serialize_scalar(self, spec: FieldSpec, value: object) -> str: """Serialize a single ``"str"`` value according to *spec* options.""" text = str(value) @@ -90,7 +103,7 @@ def _serialize_object_field_pair( if spec.type == "list": if isinstance(value, list) and value: lines = [f"{spec.name}:"] - lines.extend(f" - {item_v}" for item_v in value) + lines.extend(f" - {self._quote_list_item(str(item_v))}" for item_v in value) return lines return [] if self._should_emit_multiline(value, preserve_multiline): @@ -138,7 +151,25 @@ def _append_object_list_items( lines.append(f"{prefix}{obj_line}") def _append_raw_field(self, lines: list[str], name: str, value: object) -> None: - """Append a raw YAML field value without additional serialization.""" + """Append a raw YAML field, properly indenting dict/list values. + + When *value* is a ``dict`` or ``list`` (as returned by PyYAML when + parsing nested structures), it is serialised with :func:`yaml.dump` and + each output line is indented by two spaces under the parent key. Plain + string values are treated as pre-formatted YAML and emitted as-is, + preserving the original behaviour for hand-authored raw blocks. + """ + if isinstance(value, (dict, list)): + yaml_str = yaml.dump( + value, + default_flow_style=False, + allow_unicode=True, + sort_keys=False, + ).rstrip("\n") + lines.append(f"{name}:") + for raw_line in yaml_str.splitlines(): + lines.append(f" {raw_line}") + return raw_str = str(value).strip() if value is not None else "" if raw_str: lines.append(f"{name}:") @@ -160,7 +191,7 @@ def _append_field_by_type( if isinstance(value, list) and value: lines.append(f"{spec.name}:") for item in value: - lines.append(f" - {item}") + lines.append(f" - {self._quote_list_item(str(item))}") return if spec.type == "object-list": if isinstance(value, list) and value: diff --git a/src/vstack/manifest/__init__.py b/src/vstack/manifest/__init__.py index c46b11c..2056599 100644 --- a/src/vstack/manifest/__init__.py +++ b/src/vstack/manifest/__init__.py @@ -1,16 +1,15 @@ """Manifest domain models and persistence helpers.""" +import warnings + from vstack.manifest.store import ( CURRENT_HASH_ALGORITHM, CURRENT_MANIFEST_VERSION, ArtifactEntry, Manifest, ManifestFile, - content_hash, - hash_with_algorithm, - preserve_existing_entry, - preserved_manifest_entries, ) +from vstack.utils import content_hash, hash_with_algorithm __all__ = [ "CURRENT_HASH_ALGORITHM", @@ -20,6 +19,53 @@ "ManifestFile", "content_hash", "hash_with_algorithm", + # Deprecated shims — removed from store.py in this release. Will be + # dropped in the next minor release. Use Manifest.preserved_entries() + # and Manifest.preserve_existing_entry() instead. "preserve_existing_entry", "preserved_manifest_entries", ] + + +def preserved_manifest_entries( + existing_manifest: "Manifest | None", + selected_manifest_keys: "set[str]", +) -> "dict[str, list[ArtifactEntry]]": + """Return artifact families not in *selected_manifest_keys*. + + .. deprecated:: + Use :meth:`Manifest.preserved_entries` instead. This module-level + wrapper will be removed in the next minor release. + """ + warnings.warn( + "preserved_manifest_entries() is deprecated; use Manifest.preserved_entries() instead.", + DeprecationWarning, + stacklevel=2, + ) + if existing_manifest is None: + return {} + return existing_manifest.preserved_entries(selected_manifest_keys) + + +def preserve_existing_entry( + *, + new_entries: "dict[str, list[ArtifactEntry]]", + manifest_key: str, + existing_entry: "ArtifactEntry", +) -> None: + """Carry forward one unchanged manifest entry into *new_entries*. + + .. deprecated:: + Use :meth:`Manifest.preserve_existing_entry` instead. This + module-level wrapper will be removed in the next minor release. + """ + warnings.warn( + "preserve_existing_entry() is deprecated; use Manifest.preserve_existing_entry() instead.", + DeprecationWarning, + stacklevel=2, + ) + Manifest.preserve_existing_entry( + new_entries=new_entries, + manifest_key=manifest_key, + existing_entry=existing_entry, + ) diff --git a/src/vstack/manifest/store.py b/src/vstack/manifest/store.py index bb53f3b..12dc914 100644 --- a/src/vstack/manifest/store.py +++ b/src/vstack/manifest/store.py @@ -2,7 +2,6 @@ from __future__ import annotations -import hashlib import json import os import re @@ -10,25 +9,13 @@ from pathlib import Path from vstack.constants import MANIFEST_FILENAME +from vstack.utils import content_hash, hash_with_algorithm CURRENT_MANIFEST_VERSION = 2 CURRENT_HASH_ALGORITHM = "sha256" _META_COMMENT_RE = re.compile(r"") - -def content_hash(content: str) -> str: - """Return a stable SHA-256 checksum for rendered artifact content.""" - return hashlib.sha256(content.encode("utf-8")).hexdigest() - - -def hash_with_algorithm(content: str, algorithm: str) -> str: - """Return a checksum for *content* using the requested algorithm.""" - normalized = algorithm.lower() - if normalized == "sha256": - return hashlib.sha256(content.encode("utf-8")).hexdigest() - if normalized == "md5": - return hashlib.md5(content.encode("utf-8"), usedforsecurity=False).hexdigest() - raise ValueError(f"Unsupported checksum algorithm: {algorithm}") +__all__ = ["content_hash", "hash_with_algorithm"] @dataclass @@ -240,6 +227,39 @@ def with_backfilled_checksums( skipped, ) + def preserved_entries( + self, + selected_manifest_keys: set[str], + ) -> dict[str, list[ArtifactEntry]]: + """Return artifact families not in *selected_manifest_keys*, preserving them intact. + + Used to carry forward unselected artifact types when only a subset + is targeted for the current install or uninstall operation. + + :param selected_manifest_keys: Manifest keys that the current operation manages. + :returns: A shallow copy of every artifact family that was not selected. + """ + return { + manifest_key: list(entries) + for manifest_key, entries in self.artifacts.items() + if manifest_key not in selected_manifest_keys + } + + @staticmethod + def preserve_existing_entry( + *, + new_entries: dict[str, list[ArtifactEntry]], + manifest_key: str, + existing_entry: ArtifactEntry, + ) -> None: + """Carry forward one unchanged manifest entry into *new_entries*. + + :param new_entries: The artifact dict being built for the updated manifest. + :param manifest_key: Manifest key (e.g. ``"agents"``) for the entry. + :param existing_entry: The entry to preserve as-is. + """ + new_entries.setdefault(manifest_key, []).append(existing_entry) + @classmethod def from_dict(cls, data: dict) -> Manifest: """Create a :class:`Manifest` from parsed JSON data.""" @@ -281,31 +301,6 @@ def from_dict(cls, data: dict) -> Manifest: ) -def preserved_manifest_entries( - existing_manifest: Manifest | None, - selected_manifest_keys: set[str], -) -> dict[str, list[ArtifactEntry]]: - """Preserve artifact families not selected for the current operation.""" - if existing_manifest is None: - return {} - - preserved: dict[str, list[ArtifactEntry]] = {} - for manifest_key, entries in existing_manifest.artifacts.items(): - if manifest_key not in selected_manifest_keys: - preserved[manifest_key] = list(entries) - return preserved - - -def preserve_existing_entry( - *, - new_entries: dict[str, list[ArtifactEntry]], - manifest_key: str, - existing_entry: ArtifactEntry, -) -> None: - """Carry forward one unchanged manifest entry for a manifest key.""" - new_entries.setdefault(manifest_key, []).append(existing_entry) - - class ManifestFile: """Read and write the ``vstack.json`` manifest inside an install root.""" diff --git a/src/vstack/skills/config.py b/src/vstack/skills/config.py index 10e1f8a..66e0d74 100644 --- a/src/vstack/skills/config.py +++ b/src/vstack/skills/config.py @@ -30,6 +30,8 @@ FieldSpec("compatibility", max_length=500, normalize_whitespace=True), # Agent Skills spec: arbitrary key/value mapping. We preserve this as raw YAML. FieldSpec("metadata", type="raw"), + # Agent Skills spec (experimental): space-separated pre-approved tool names. + FieldSpec("allowed-tools"), FieldSpec("argument-hint"), FieldSpec("user-invocable", type="bool"), FieldSpec("disable-model-invocation", type="bool"), diff --git a/src/vstack/utils.py b/src/vstack/utils.py new file mode 100644 index 0000000..e9805bf --- /dev/null +++ b/src/vstack/utils.py @@ -0,0 +1,20 @@ +"""Generic utility functions for vstack.""" + +from __future__ import annotations + +import hashlib + + +def content_hash(content: str) -> str: + """Return a stable SHA-256 checksum for rendered artifact content.""" + return hashlib.sha256(content.encode("utf-8")).hexdigest() + + +def hash_with_algorithm(content: str, algorithm: str) -> str: + """Return a checksum for *content* using the requested algorithm.""" + normalized = algorithm.lower() + if normalized == "sha256": + return hashlib.sha256(content.encode("utf-8")).hexdigest() + if normalized == "md5": + return hashlib.md5(content.encode("utf-8"), usedforsecurity=False).hexdigest() + raise ValueError(f"Unsupported checksum algorithm: {algorithm}") diff --git a/tests/vstack/agents/test_generation.py b/tests/vstack/agents/test_generation.py index 0a6cc05..cb93d29 100644 --- a/tests/vstack/agents/test_generation.py +++ b/tests/vstack/agents/test_generation.py @@ -13,6 +13,28 @@ class TestAgentGeneration: def test_architect_agent_includes_model_and_handoffs(self, tmp_path: Path) -> None: """Test that architect agent includes model and handoffs.""" + # Seed a minimal workflow config so handoffs include agent targets. + vstack_dir = tmp_path / ".vstack" + vstack_dir.mkdir(parents=True, exist_ok=True) + (vstack_dir / "config.yaml").write_text( + "workflow:\n" + " version: 1\n" + " stages:\n" + " - role: product\n" + " gate: required\n" + " handoffs:\n" + " prompt: Product done.\n" + " - role: architect\n" + " gate: required\n" + " handoffs:\n" + " prompt: Architecture done.\n" + " - role: designer\n" + " gate: optional\n" + " handoffs:\n" + " prompt: Design done.\n", + encoding="utf-8", + ) + result = run_vstack(["install", "--only", "agent", "--target", str(tmp_path)], timeout=60) assert result.returncode == 0, ( f"vstack install --only agent failed:\n{result.stdout}\n{result.stderr}" diff --git a/tests/vstack/agents/test_generator.py b/tests/vstack/agents/test_generator.py index 02b821c..e91f163 100644 --- a/tests/vstack/agents/test_generator.py +++ b/tests/vstack/agents/test_generator.py @@ -3,286 +3,564 @@ from __future__ import annotations from pathlib import Path +from typing import Any from vstack.agents.generator import AgentGenerator from vstack.constants import ARTIFACTS_DOCS_ROOT class TestAgentGenerator: - """Test cases for AgentGenerator.""" + """Tests for AgentGenerator and all its methods.""" def test_generator_uses_agent_type(self) -> None: """Test that generator uses agent type.""" gen = AgentGenerator() assert gen.config.type_name == "agent" - def test_template_partials_returns_four_keys(self, tmp_path: Path) -> None: - """Test that template_partials always returns all four placeholder keys.""" - tmpl_dir = tmp_path / "architect" - tmpl_dir.mkdir() - (tmpl_dir / "config.yaml").write_text("name: architect\n", encoding="utf-8") - - result = AgentGenerator().template_partials(tmpl_dir) - - assert set(result) == { - "AGENT_ARTIFACTS_INPUT", - "AGENT_ARTIFACTS_OUTPUT", - "AGENT_ARTIFACTS_INPUT_COMMENTS", - "AGENT_ARTIFACTS_OUTPUT_COMMENTS", - } - - def test_template_partials_empty_when_no_artifacts_block(self, tmp_path: Path) -> None: - """Test that all artifact placeholders are empty strings when config has no artifacts.""" - tmpl_dir = tmp_path / "plain" - tmpl_dir.mkdir() - (tmpl_dir / "config.yaml").write_text("name: plain\n", encoding="utf-8") - - result = AgentGenerator().template_partials(tmpl_dir) - - assert result["AGENT_ARTIFACTS_INPUT"] == "" - assert result["AGENT_ARTIFACTS_OUTPUT"] == "" - assert result["AGENT_ARTIFACTS_INPUT_COMMENTS"] == "" - assert result["AGENT_ARTIFACTS_OUTPUT_COMMENTS"] == "" - - def test_template_partials_input_prefixed_with_docs_root(self, tmp_path: Path) -> None: - """Test that input paths are prefixed with ARTIFACTS_DOCS_ROOT.""" - tmpl_dir = tmp_path / "architect" - tmpl_dir.mkdir() - (tmpl_dir / "config.yaml").write_text( - "name: architect\nartifacts:\n dir: architecture\n input:\n - product/**/*.md\n", - encoding="utf-8", - ) - - result = AgentGenerator().template_partials(tmpl_dir) - - assert f"`{ARTIFACTS_DOCS_ROOT}/product/**/*.md`" in result["AGENT_ARTIFACTS_INPUT"] - - def test_template_partials_output_prefixed_with_root_and_dir(self, tmp_path: Path) -> None: - """Test that output paths are prefixed with root/dir when dir is set.""" - tmpl_dir = tmp_path / "architect" - tmpl_dir.mkdir() - (tmpl_dir / "config.yaml").write_text( - "name: architect\nartifacts:\n dir: architecture\n output:\n - overview.md\n", - encoding="utf-8", - ) - - result = AgentGenerator().template_partials(tmpl_dir) - - assert ( - f"`{ARTIFACTS_DOCS_ROOT}/architecture/overview.md`" in result["AGENT_ARTIFACTS_OUTPUT"] - ) - - def test_template_partials_output_verbatim_when_no_dir(self, tmp_path: Path) -> None: - """Test that output paths are used verbatim when no dir is set.""" - tmpl_dir = tmp_path / "engineer" - tmpl_dir.mkdir() - (tmpl_dir / "config.yaml").write_text( - "name: engineer\nartifacts:\n output:\n - path: ./src/**/*\n", - encoding="utf-8", - ) - - result = AgentGenerator().template_partials(tmpl_dir) - - assert "`src/**/*`" in result["AGENT_ARTIFACTS_OUTPUT"] - assert "docs/" not in result["AGENT_ARTIFACTS_OUTPUT"] - - def test_template_partials_dotslash_strips_prefix_with_dir(self, tmp_path: Path) -> None: - """Test that ./path output items bypass dir prefix even when dir is set.""" - tmpl_dir = tmp_path / "tester" - tmpl_dir.mkdir() - (tmpl_dir / "config.yaml").write_text( - "name: tester\nartifacts:\n dir: reports\n output:\n - ./tests/**/*\n", - encoding="utf-8", - ) - - result = AgentGenerator().template_partials(tmpl_dir) - - assert "`tests/**/*`" in result["AGENT_ARTIFACTS_OUTPUT"] - assert "docs/reports/" not in result["AGENT_ARTIFACTS_OUTPUT"] - - def test_template_partials_input_comments_from_config(self, tmp_path: Path) -> None: - """Test that input_comments config field populates AGENT_ARTIFACTS_INPUT_COMMENTS.""" - tmpl_dir = tmp_path / "custom" - tmpl_dir.mkdir() - (tmpl_dir / "config.yaml").write_text( - "name: custom\nartifacts:\n input_comments: 'Read in order.'\n", - encoding="utf-8", - ) - - result = AgentGenerator().template_partials(tmpl_dir) - - assert result["AGENT_ARTIFACTS_INPUT_COMMENTS"] == "Read in order." - - def test_template_partials_output_comments_from_config(self, tmp_path: Path) -> None: - """Test that output_comments config field populates AGENT_ARTIFACTS_OUTPUT_COMMENTS.""" - tmpl_dir = tmp_path / "custom" - tmpl_dir.mkdir() - (tmpl_dir / "config.yaml").write_text( - "name: custom\nartifacts:\n output_comments: 'See ADR-001.'\n", - encoding="utf-8", - ) - - result = AgentGenerator().template_partials(tmpl_dir) - - assert result["AGENT_ARTIFACTS_OUTPUT_COMMENTS"] == "See ADR-001." - - def test_template_partials_handles_non_dict_artifacts_gracefully(self, tmp_path: Path) -> None: - """Test that template_partials handles malformed (non-dict) artifacts gracefully.""" - tmpl_dir = tmp_path / "broken" - tmpl_dir.mkdir() - # When artifacts is a list rather than a dict mapping, the generator must not crash. - (tmpl_dir / "config.yaml").write_text( - "name: broken\nartifacts:\n - foo\n - bar\n", encoding="utf-8" - ) - - result = AgentGenerator().template_partials(tmpl_dir) - - assert result["AGENT_ARTIFACTS_INPUT"] == "" - assert result["AGENT_ARTIFACTS_OUTPUT"] == "" - - def test_template_partials_handles_non_list_input_output_gracefully( - self, tmp_path: Path - ) -> None: - """Test that scalar input/output values are treated as empty lists.""" - tmpl_dir = tmp_path / "scalar" - tmpl_dir.mkdir() - # After re-parsing a nested raw block, input or output may be scalar strings - # if the indented content is malformed. Verify defensive guards hold. - (tmpl_dir / "config.yaml").write_text( - "name: scalar\nartifacts:\n dir: architecture\n input: not-a-list\n output: not-a-list\n", - encoding="utf-8", - ) - - result = AgentGenerator().template_partials(tmpl_dir) - - assert result["AGENT_ARTIFACTS_INPUT"] == "" - assert result["AGENT_ARTIFACTS_OUTPUT"] == "" - - def test_product_has_no_input_section(self, tmp_path: Path) -> None: - """Test that AGENT_ARTIFACTS_INPUT is empty for product (no input in config).""" - tmpl_dir = tmp_path / "product" - tmpl_dir.mkdir() - (tmpl_dir / "config.yaml").write_text( - "name: product\nartifacts:\n dir: product\n output:\n - vision.md\n", - encoding="utf-8", - ) - - result = AgentGenerator().template_partials(tmpl_dir) - - assert result["AGENT_ARTIFACTS_INPUT"] == "" - assert "### output" in result["AGENT_ARTIFACTS_OUTPUT"] - - def test_template_partials_uses_custom_artifacts_root(self, tmp_path: Path) -> None: - """Test that a custom artifacts_root replaces the default 'docs' prefix.""" - tmpl_dir = tmp_path / "architect" - tmpl_dir.mkdir() - (tmpl_dir / "config.yaml").write_text( - "name: architect\nartifacts:\n dir: architecture\n input:\n - product/**/*.md\n" - " output:\n - overview.md\n", - encoding="utf-8", - ) - - result = AgentGenerator(artifacts_root="documentation").template_partials(tmpl_dir) - - assert "`documentation/product/**/*.md`" in result["AGENT_ARTIFACTS_INPUT"] - assert "`documentation/architecture/overview.md`" in result["AGENT_ARTIFACTS_OUTPUT"] - assert "docs/" not in result["AGENT_ARTIFACTS_INPUT"] - assert "docs/" not in result["AGENT_ARTIFACTS_OUTPUT"] - - -class TestResolveOutputEntries: - """Unit tests for AgentGenerator._resolve_output_entries.""" - - def test_plain_string_with_dir_gets_root_and_dir_prefix(self) -> None: - """Test that plain string items are prefixed with root/dir when dir is set.""" - result = AgentGenerator._resolve_output_entries(["overview.md"], "docs", "architecture") - assert result == [{"path": "docs/architecture/overview.md", "notes": ""}] - - def test_plain_string_without_dir_is_verbatim(self) -> None: - """Test that plain string items are used verbatim when no dir is set.""" - result = AgentGenerator._resolve_output_entries(["src/**/*"], "docs", "") - assert result == [{"path": "src/**/*", "notes": ""}] - - def test_non_string_non_dict_item_is_skipped(self) -> None: - """Test that non-string, non-dict output items are silently skipped.""" - result = AgentGenerator._resolve_output_entries([42, None], "docs", "architecture") - assert result == [] - - def test_dotslash_prefix_strips_and_uses_verbatim(self) -> None: - """Test that ./path items strip the ./ and bypass dir prefix.""" - result = AgentGenerator._resolve_output_entries(["./tests/**/*"], "docs", "reports") - assert result == [{"path": "tests/**/*", "notes": ""}] - - def test_dict_item_with_notes_and_dir(self) -> None: - """Test that dict items with notes are resolved with dir prefix.""" - result = AgentGenerator._resolve_output_entries( - [{"path": "ux.md", "notes": "frontend only"}], "docs", "design" - ) - assert result == [{"path": "docs/design/ux.md", "notes": "frontend only"}] - - def test_dict_item_with_dotslash_path(self) -> None: - """Test that dict items with ./path bypass the dir prefix.""" - result = AgentGenerator._resolve_output_entries( - [{"path": "./issues/rca.md", "notes": "on issue"}], "docs", "reports" - ) - assert result == [{"path": "issues/rca.md", "notes": "on issue"}] - - def test_glob_with_slash_is_relative_to_dir(self) -> None: - """Test that glob paths like adr/*.md are prefixed with root/dir.""" - result = AgentGenerator._resolve_output_entries(["adr/*.md"], "docs", "architecture") - assert result == [{"path": "docs/architecture/adr/*.md", "notes": ""}] - - def test_double_glob_relative_to_dir(self) -> None: - """Test that **/*.md is prefixed with root/dir when dir is set.""" - result = AgentGenerator._resolve_output_entries(["**/*.md"], "docs", "reports") - assert result == [{"path": "docs/reports/**/*.md", "notes": ""}] - - -class TestBuildTable: - """Unit tests for AgentGenerator._build_table.""" - - def test_single_column_when_no_notes(self) -> None: - """Test that a single-column table is produced when no entry has notes.""" - table = AgentGenerator._build_table([{"path": "docs/foo.md", "notes": ""}]) - lines = table.splitlines() - assert lines[0].startswith("| Artifact") - assert lines[0].count("|") == 2 - assert "`docs/foo.md`" in lines[2] - - def test_two_column_when_any_entry_has_notes(self) -> None: - """Test that a two-column table is produced when at least one entry has notes.""" - entries = [ - {"path": "docs/foo.md", "notes": ""}, - {"path": "docs/bar.md", "notes": "important"}, - ] - table = AgentGenerator._build_table(entries) - assert "Notes" in table.splitlines()[0] - - def test_rows_have_equal_length(self) -> None: - """Test that all rows in the table have equal string length.""" - entries = [ - {"path": "docs/a.md", "notes": ""}, - {"path": "docs/b.md", "notes": "a very long note here"}, - ] - table = AgentGenerator._build_table(entries) - row_lengths = {len(line) for line in table.splitlines()} - assert len(row_lengths) == 1 - - -class TestBuildSection: - """Unit tests for AgentGenerator._build_section.""" - - def test_returns_empty_string_when_no_entries(self) -> None: - """Test that an empty string is returned when entries list is empty.""" - assert AgentGenerator._build_section("input", []) == "" - - def test_includes_heading_and_table(self) -> None: - """Test that the section includes the heading and table.""" - section = AgentGenerator._build_section("input", [{"path": "docs/a.md", "notes": ""}]) - assert section.startswith("### input") - assert "`docs/a.md`" in section - - def test_heading_matches_argument(self) -> None: - """Test that the section heading matches the heading argument.""" - section = AgentGenerator._build_section("output", [{"path": "docs/b.md", "notes": ""}]) - assert "### output" in section + class TestTemplatePartials: + """Tests for AgentGenerator.template_partials.""" + + def test_returns_five_keys(self, tmp_path: Path) -> None: + """template_partials always returns all five placeholder keys.""" + tmpl_dir = tmp_path / "architect" + tmpl_dir.mkdir() + (tmpl_dir / "config.yaml").write_text("name: architect\n", encoding="utf-8") + + result = AgentGenerator().template_partials(tmpl_dir) + + assert set(result) == { + "AGENT_ARTIFACTS_INPUT", + "AGENT_ARTIFACTS_OUTPUT", + "AGENT_ARTIFACTS_INPUT_COMMENTS", + "AGENT_ARTIFACTS_OUTPUT_COMMENTS", + "AGENT_ARTIFACTS_BASELINE", + } + + def test_empty_when_no_artifacts_block(self, tmp_path: Path) -> None: + """All artifact placeholders are empty strings when config has no artifacts.""" + tmpl_dir = tmp_path / "plain" + tmpl_dir.mkdir() + (tmpl_dir / "config.yaml").write_text("name: plain\n", encoding="utf-8") + + result = AgentGenerator().template_partials(tmpl_dir) + + assert result["AGENT_ARTIFACTS_INPUT"] == "" + assert result["AGENT_ARTIFACTS_OUTPUT"] == "" + assert result["AGENT_ARTIFACTS_INPUT_COMMENTS"] == "" + assert result["AGENT_ARTIFACTS_OUTPUT_COMMENTS"] == "" + + def test_input_prefixed_with_docs_root(self, tmp_path: Path) -> None: + """Input paths are prefixed with ARTIFACTS_DOCS_ROOT.""" + tmpl_dir = tmp_path / "architect" + tmpl_dir.mkdir() + (tmpl_dir / "config.yaml").write_text( + "name: architect\ndefaults:\n artifacts:\n dir: architecture\n input:\n - product/**/*.md\n", + encoding="utf-8", + ) + + result = AgentGenerator().template_partials(tmpl_dir) + + assert f"`{ARTIFACTS_DOCS_ROOT}/product/**/*.md`" in result["AGENT_ARTIFACTS_INPUT"] + + def test_output_prefixed_with_root_and_dir(self, tmp_path: Path) -> None: + """Output paths are prefixed with root/dir when dir is set.""" + tmpl_dir = tmp_path / "architect" + tmpl_dir.mkdir() + (tmpl_dir / "config.yaml").write_text( + "name: architect\ndefaults:\n artifacts:\n dir: architecture\n output:\n - overview.md\n", + encoding="utf-8", + ) + + result = AgentGenerator().template_partials(tmpl_dir) + + assert ( + f"`{ARTIFACTS_DOCS_ROOT}/architecture/overview.md`" + in result["AGENT_ARTIFACTS_OUTPUT"] + ) + + def test_output_verbatim_when_no_dir(self, tmp_path: Path) -> None: + """Output paths are used verbatim when no dir is set.""" + tmpl_dir = tmp_path / "engineer" + tmpl_dir.mkdir() + (tmpl_dir / "config.yaml").write_text( + "name: engineer\ndefaults:\n artifacts:\n output:\n - path: ./src/**/*\n", + encoding="utf-8", + ) + + result = AgentGenerator().template_partials(tmpl_dir) + + assert "`src/**/*`" in result["AGENT_ARTIFACTS_OUTPUT"] + assert "docs/" not in result["AGENT_ARTIFACTS_OUTPUT"] + + def test_dotslash_strips_prefix_with_dir(self, tmp_path: Path) -> None: + """./path output items bypass dir prefix even when dir is set.""" + tmpl_dir = tmp_path / "tester" + tmpl_dir.mkdir() + (tmpl_dir / "config.yaml").write_text( + "name: tester\ndefaults:\n artifacts:\n dir: reports\n output:\n - ./tests/**/*\n", + encoding="utf-8", + ) + + result = AgentGenerator().template_partials(tmpl_dir) + + assert "`tests/**/*`" in result["AGENT_ARTIFACTS_OUTPUT"] + assert "docs/reports/" not in result["AGENT_ARTIFACTS_OUTPUT"] + + def test_input_comments_from_config(self, tmp_path: Path) -> None: + """input_comments config field populates AGENT_ARTIFACTS_INPUT_COMMENTS.""" + tmpl_dir = tmp_path / "custom" + tmpl_dir.mkdir() + (tmpl_dir / "config.yaml").write_text( + "name: custom\ndefaults:\n artifacts:\n input_comments: 'Read in order.'\n", + encoding="utf-8", + ) + + result = AgentGenerator().template_partials(tmpl_dir) + + assert result["AGENT_ARTIFACTS_INPUT_COMMENTS"] == "Read in order." + + def test_output_comments_from_config(self, tmp_path: Path) -> None: + """output_comments config field populates AGENT_ARTIFACTS_OUTPUT_COMMENTS.""" + tmpl_dir = tmp_path / "custom" + tmpl_dir.mkdir() + (tmpl_dir / "config.yaml").write_text( + "name: custom\ndefaults:\n artifacts:\n output_comments: 'See ADR-001.'\n", + encoding="utf-8", + ) + + result = AgentGenerator().template_partials(tmpl_dir) + + assert result["AGENT_ARTIFACTS_OUTPUT_COMMENTS"] == "See ADR-001." + + def test_handles_non_dict_artifacts_gracefully(self, tmp_path: Path) -> None: + """template_partials handles malformed (non-dict) artifacts without crashing.""" + tmpl_dir = tmp_path / "broken" + tmpl_dir.mkdir() + (tmpl_dir / "config.yaml").write_text( + "name: broken\ndefaults:\n artifacts:\n - foo\n - bar\n", encoding="utf-8" + ) + + result = AgentGenerator().template_partials(tmpl_dir) + + assert result["AGENT_ARTIFACTS_INPUT"] == "" + assert result["AGENT_ARTIFACTS_OUTPUT"] == "" + + def test_handles_non_list_input_output_gracefully(self, tmp_path: Path) -> None: + """Scalar input/output values are treated as empty lists.""" + tmpl_dir = tmp_path / "scalar" + tmpl_dir.mkdir() + (tmpl_dir / "config.yaml").write_text( + "name: scalar\ndefaults:\n artifacts:\n dir: architecture\n input: not-a-list\n output: not-a-list\n", + encoding="utf-8", + ) + + result = AgentGenerator().template_partials(tmpl_dir) + + assert result["AGENT_ARTIFACTS_INPUT"] == "" + assert result["AGENT_ARTIFACTS_OUTPUT"] == "" + + def test_product_has_no_input_section(self, tmp_path: Path) -> None: + """AGENT_ARTIFACTS_INPUT is empty for product (no input in config).""" + tmpl_dir = tmp_path / "product" + tmpl_dir.mkdir() + (tmpl_dir / "config.yaml").write_text( + "name: product\ndefaults:\n artifacts:\n dir: product\n output:\n - vision.md\n", + encoding="utf-8", + ) + + result = AgentGenerator().template_partials(tmpl_dir) + + assert result["AGENT_ARTIFACTS_INPUT"] == "" + assert "### output" in result["AGENT_ARTIFACTS_OUTPUT"] + + def test_uses_custom_artifacts_root(self, tmp_path: Path) -> None: + """A custom artifacts_root replaces the default 'docs' prefix.""" + tmpl_dir = tmp_path / "architect" + tmpl_dir.mkdir() + (tmpl_dir / "config.yaml").write_text( + "name: architect\ndefaults:\n artifacts:\n dir: architecture\n input:\n - product/**/*.md\n" + " output:\n - overview.md\n", + encoding="utf-8", + ) + + result = AgentGenerator(artifacts_root="documentation").template_partials(tmpl_dir) + + assert "`documentation/product/**/*.md`" in result["AGENT_ARTIFACTS_INPUT"] + assert "`documentation/architecture/overview.md`" in result["AGENT_ARTIFACTS_OUTPUT"] + assert "docs/" not in result["AGENT_ARTIFACTS_INPUT"] + assert "docs/" not in result["AGENT_ARTIFACTS_OUTPUT"] + + class TestExtractDefaults: + """Tests for AgentGenerator._extract_defaults.""" + + def test_returns_empty_dict_when_no_defaults_key(self) -> None: + """Returns an empty dict when the config has no defaults key.""" + assert AgentGenerator()._extract_defaults({}) == {} + + def test_returns_empty_dict_when_defaults_is_none(self) -> None: + """Returns an empty dict when defaults is None.""" + assert AgentGenerator()._extract_defaults({"defaults": None}) == {} + + def test_returns_dict_as_is(self) -> None: + """Returns the dict unchanged when defaults is already a plain dict.""" + assert AgentGenerator()._extract_defaults({"defaults": {"artifacts": {}}}) == { + "artifacts": {} + } + + def test_returns_empty_dict_when_defaults_is_non_dict_non_string(self) -> None: + """Returns empty dict when defaults is neither a string nor a dict.""" + assert AgentGenerator()._extract_defaults({"defaults": 42}) == {} + + def test_returns_empty_dict_for_non_dict_string(self) -> None: + """Non-dict values (including strings) return an empty dict. + + PyYAML always produces a dict for a ``defaults:`` mapping block, so + a string value is not produced in normal operation. The method still + handles it defensively and returns ``{}``. + """ + assert ( + AgentGenerator()._extract_defaults({"defaults": " artifacts:\n dir: design\n"}) + == {} + ) + + class TestLoadArtifactConfig: + """Tests for AgentGenerator.load_artifact_config.""" + + def test_handoffs_block_as_list_yields_empty_prompt(self, tmp_path: Path) -> None: + """When handoffs parses as a list (not a dict), handoff_prompt falls back to empty.""" + tmpl_dir = tmp_path / "product" + tmpl_dir.mkdir() + (tmpl_dir / "config.yaml").write_text( + "defaults:\n handoffs:\n - prompt: foo\n", + encoding="utf-8", + ) + config = AgentGenerator().load_artifact_config(tmpl_dir) + assert "handoffs" not in config + + def test_handoffs_block_not_a_dict_or_string_yields_empty_prompt( + self, tmp_path: Path + ) -> None: + """When handoffs_block is neither a dict nor a str, handoff_prompt falls back to empty.""" + tmpl_dir = tmp_path / "product" + tmpl_dir.mkdir() + # Inject a stage that sets handoffs to a plain list value so the block-scalar + # path is triggered but is not a dict — a bare list under defaults.handoffs. + # Write config with an inline list under handoffs to hit the else branch. + (tmpl_dir / "config.yaml").write_text( + "name: product\ndefaults:\n handoffs: []\n", + encoding="utf-8", + ) + config = AgentGenerator().load_artifact_config(tmpl_dir) + assert "handoffs" not in config + + def test_handoffs_injected_when_workflow_resolves(self, tmp_path: Path) -> None: + """Resolved handoffs are injected into config when workflow stages are present.""" + tmpl_dir = tmp_path / "architect" + tmpl_dir.mkdir() + (tmpl_dir / "config.yaml").write_text( + "name: architect\n", + encoding="utf-8", + ) + stages: list[dict[str, Any]] = [ + { + "role": "architect", + "gate": "required", + "handoffs": [{"prompt": "Arch done.", "agent": "", "label": ""}], + }, + {"role": "designer", "gate": "optional", "handoffs": []}, + ] + gen = AgentGenerator(workflow_stages=stages) + config = gen.load_artifact_config(tmpl_dir) + assert "handoffs" in config + assert config["handoffs"][0]["agent"] == "designer" + + class TestResolveOutputEntries: + """Tests for AgentGenerator._resolve_output_entries.""" + + def test_plain_string_with_dir_gets_root_and_dir_prefix(self) -> None: + """Plain string items are prefixed with root/dir when dir is set.""" + result = AgentGenerator()._resolve_output_entries(["overview.md"], "architecture") + assert result == [ + {"path": "docs/architecture/overview.md", "notes": "", "baseline": False} + ] + + def test_plain_string_without_dir_is_verbatim(self) -> None: + """Plain string items are used verbatim when no dir is set.""" + result = AgentGenerator()._resolve_output_entries(["src/**/*"], "") + assert result == [{"path": "src/**/*", "notes": "", "baseline": False}] + + def test_non_string_non_dict_item_is_skipped(self) -> None: + """Non-string, non-dict output items are silently skipped.""" + result = AgentGenerator()._resolve_output_entries([42, None], "architecture") + assert result == [] + + def test_dotslash_prefix_strips_and_uses_verbatim(self) -> None: + """./path items strip the ./ and bypass dir prefix.""" + result = AgentGenerator()._resolve_output_entries(["./tests/**/*"], "reports") + assert result == [{"path": "tests/**/*", "notes": "", "baseline": False}] + + def test_dict_item_with_notes_and_dir(self) -> None: + """Dict items with notes are resolved with dir prefix.""" + result = AgentGenerator()._resolve_output_entries( + [{"path": "ux.md", "notes": "frontend only"}], "design" + ) + assert result == [ + {"path": "docs/design/ux.md", "notes": "frontend only", "baseline": False} + ] + + def test_dict_item_with_dotslash_path(self) -> None: + """Dict items with ./path bypass the dir prefix.""" + result = AgentGenerator()._resolve_output_entries( + [{"path": "./issues/rca.md", "notes": "on issue"}], "reports" + ) + assert result == [{"path": "issues/rca.md", "notes": "on issue", "baseline": False}] + + def test_glob_with_slash_is_relative_to_dir(self) -> None: + """Glob paths like adr/*.md are prefixed with root/dir.""" + result = AgentGenerator()._resolve_output_entries(["adr/*.md"], "architecture") + assert result == [ + {"path": "docs/architecture/adr/*.md", "notes": "", "baseline": False} + ] + + def test_double_glob_relative_to_dir(self) -> None: + """**/*.md is prefixed with root/dir when dir is set.""" + result = AgentGenerator()._resolve_output_entries(["**/*.md"], "reports") + assert result == [{"path": "docs/reports/**/*.md", "notes": "", "baseline": False}] + + class TestResolveHandoffs: + """Tests for AgentGenerator._resolve_handoffs.""" + + def test_returns_empty_when_no_workflow_and_no_prompt(self) -> None: + """Returns empty list when no workflow is configured and no prompt given.""" + assert AgentGenerator()._resolve_handoffs("architect", "") == [] + + def test_fallback_handoff_without_workflow(self) -> None: + """Returns a generic handoff (no agent:) when no workflow is configured but a prompt exists.""" + result = AgentGenerator()._resolve_handoffs("architect", "Do some work.") + assert len(result) == 1 + assert result[0]["prompt"] == "Do some work." + assert result[0]["label"] == "Continue to next stage" + assert "agent" not in result[0] + + def test_with_workflow_finds_next_role(self) -> None: + """Returns handoff with correct next agent when workflow is configured.""" + stages: list[dict[str, Any]] = [ + { + "role": "product", + "gate": "required", + "handoffs": [{"prompt": "Product done.", "agent": "", "label": ""}], + }, + { + "role": "architect", + "gate": "required", + "handoffs": [{"prompt": "Arch done.", "agent": "", "label": ""}], + }, + { + "role": "designer", + "gate": "optional", + "handoffs": [{"prompt": "Design done.", "agent": "", "label": ""}], + }, + ] + gen = AgentGenerator(workflow_stages=stages) + result = gen._resolve_handoffs("architect", "Arch done.") + assert len(result) == 1 + assert result[0]["agent"] == "designer" + assert result[0]["label"] == "Go to next stage: Designer" + + def test_last_stage_returns_empty(self) -> None: + """Returns empty list when the agent is the last stage in the workflow.""" + stages: list[dict[str, Any]] = [ + { + "role": "architect", + "gate": "required", + "handoffs": [{"prompt": "Arch done.", "agent": "", "label": ""}], + }, + {"role": "release", "gate": "required", "handoffs": []}, + ] + gen = AgentGenerator(workflow_stages=stages) + assert gen._resolve_handoffs("release", "") == [] + + def test_unknown_role_returns_empty(self) -> None: + """Returns empty list when the agent role is not found in workflow stages.""" + stages: list[dict[str, Any]] = [ + { + "role": "product", + "gate": "required", + "handoffs": [{"prompt": "Done.", "agent": "", "label": ""}], + } + ] + gen = AgentGenerator(workflow_stages=stages) + assert gen._resolve_handoffs("unknown", "Some prompt.") == [] + + def test_workflow_prompt_used_as_fallback(self) -> None: + """Uses the workflow handoffs[0].prompt when the agent's own prompt is empty.""" + stages: list[dict[str, Any]] = [ + { + "role": "engineer", + "gate": "required", + "handoffs": [{"prompt": "From workflow.", "agent": "", "label": ""}], + }, + {"role": "tester", "gate": "required", "handoffs": []}, + ] + gen = AgentGenerator(workflow_stages=stages) + result = gen._resolve_handoffs("engineer", "") + assert len(result) == 1 + assert "From workflow." in result[0]["prompt"] + + def test_multiple_handoffs_per_stage(self) -> None: + """All handoff entries are returned when a stage defines multiple.""" + stages: list[dict[str, Any]] = [ + { + "role": "architect", + "gate": "required", + "handoffs": [ + {"prompt": "Go to designer.", "agent": "", "label": ""}, + { + "prompt": "Skip to engineer.", + "agent": "engineer", + "label": "Skip design", + }, + ], + }, + {"role": "designer", "gate": "optional", "handoffs": []}, + {"role": "engineer", "gate": "required", "handoffs": []}, + ] + gen = AgentGenerator(workflow_stages=stages) + result = gen._resolve_handoffs("architect", "") + assert len(result) == 2 + assert result[0]["agent"] == "designer" + assert result[1]["agent"] == "engineer" + assert result[1]["label"] == "Skip design" + + def test_empty_workflow_prompt_returns_empty(self) -> None: + """Returns empty when both agent prompt and workflow handoffs prompts are empty.""" + stages: list[dict[str, Any]] = [ + { + "role": "engineer", + "gate": "required", + "handoffs": [{"prompt": "", "agent": "", "label": ""}], + }, + {"role": "tester", "gate": "required", "handoffs": []}, + ] + gen = AgentGenerator(workflow_stages=stages) + assert gen._resolve_handoffs("engineer", "") == [] + + def test_non_list_stage_handoffs_returns_empty(self) -> None: + """Returns empty list when stage handoffs value is not a list.""" + stages: list[dict[str, Any]] = [ + {"role": "engineer", "gate": "required", "handoffs": "bad-value"}, + {"role": "tester", "gate": "required", "handoffs": []}, + ] + gen = AgentGenerator(workflow_stages=stages) + assert gen._resolve_handoffs("engineer", "") == [] + + def test_non_dict_handoff_entry_is_skipped(self) -> None: + """Non-dict handoff entries are skipped.""" + stages: list[dict[str, Any]] = [ + { + "role": "engineer", + "gate": "required", + "handoffs": ["not-a-dict", {"prompt": "Go.", "agent": "", "label": ""}], + }, + {"role": "tester", "gate": "required", "handoffs": []}, + ] + gen = AgentGenerator(workflow_stages=stages) + result = gen._resolve_handoffs("engineer", "") + assert len(result) == 1 + assert result[0]["agent"] == "tester" + + class TestBuildHandoffs: + """Tests for AgentGenerator._build_handoffs (compatibility shim).""" + + def test_returns_empty_string_when_no_prompt(self) -> None: + """Returns empty string when no workflow and no prompt.""" + assert AgentGenerator()._build_handoffs("architect", "") == "" + + def test_returns_handoff_without_agent_when_no_workflow(self) -> None: + """Returns a handoff block without agent: key when no workflow is configured but prompt is set.""" + result = AgentGenerator()._build_handoffs("architect", "Work done.") + assert "handoffs:" in result + assert "Work done." in result + assert "agent:" not in result + + def test_returns_handoff_string_with_agent(self) -> None: + """Returns handoffs block with agent key when workflow is configured.""" + stages: list[dict[str, Any]] = [ + { + "role": "architect", + "gate": "required", + "handoffs": [{"prompt": "", "agent": "", "label": ""}], + }, + {"role": "designer", "gate": "optional", "handoffs": []}, + ] + gen = AgentGenerator(workflow_stages=stages) + result = gen._build_handoffs("architect", "Work done.") + assert "handoffs:" in result + assert "agent: designer" in result + + class TestBuildTable: + """Tests for AgentGenerator._build_table.""" + + def test_single_column_when_no_notes(self) -> None: + """A single-column Artifact table is produced when no entry has notes.""" + table = AgentGenerator()._build_table( + [{"path": "docs/foo.md", "notes": "", "baseline": False}] + ) + lines = table.splitlines() + assert lines[0].startswith("| Artifact") + assert lines[0].count("|") == 2 + assert "`docs/foo.md`" in lines[2] + + def test_two_column_when_any_entry_has_notes(self) -> None: + """A two-column Artifact | Notes table is produced when any entry has notes.""" + entries: list[dict[str, str | bool]] = [ + {"path": "docs/foo.md", "notes": "", "baseline": False}, + {"path": "docs/bar.md", "notes": "important", "baseline": False}, + ] + table = AgentGenerator()._build_table(entries) + assert "Notes" in table.splitlines()[0] + + def test_rows_have_equal_length(self) -> None: + """All rows in the table have equal string length.""" + entries: list[dict[str, str | bool]] = [ + {"path": "docs/a.md", "notes": "", "baseline": False}, + {"path": "docs/b.md", "notes": "a very long note here", "baseline": False}, + ] + table = AgentGenerator()._build_table(entries) + row_lengths = {len(line) for line in table.splitlines()} + assert len(row_lengths) == 1 + + class TestBuildSection: + """Tests for AgentGenerator._build_section and _build_baseline_section.""" + + def test_returns_empty_string_when_no_entries(self) -> None: + """An empty string is returned when entries list is empty.""" + assert AgentGenerator()._build_section("input", []) == "" + + def test_includes_heading_and_table(self) -> None: + """The section includes the heading and table.""" + section = AgentGenerator()._build_section( + "input", [{"path": "docs/a.md", "notes": "", "baseline": False}] + ) + assert section.startswith("### input") + assert "`docs/a.md`" in section + + def test_heading_matches_argument(self) -> None: + """The section heading matches the heading argument.""" + section = AgentGenerator()._build_section( + "output", [{"path": "docs/b.md", "notes": ""}] + ) + assert "### output" in section + + def test_baseline_section_with_entries_contains_heading(self) -> None: + """_build_baseline_section returns a section with the fixed heading when entries given.""" + entries: list[dict[str, str | bool]] = [ + {"path": "docs/architecture/overview.md", "notes": "", "baseline": True} + ] + section = AgentGenerator()._build_baseline_section(entries) + assert "### baseline docs you maintain" in section + assert "`docs/architecture/overview.md`" in section + + def test_baseline_section_empty_when_no_entries(self) -> None: + """_build_baseline_section returns an empty string when entries list is empty.""" + assert AgentGenerator()._build_baseline_section([]) == "" diff --git a/tests/vstack/agents/test_role_wiring.py b/tests/vstack/agents/test_role_wiring.py index eb74abd..eae808e 100644 --- a/tests/vstack/agents/test_role_wiring.py +++ b/tests/vstack/agents/test_role_wiring.py @@ -4,8 +4,6 @@ from pathlib import Path -from vstack.frontmatter import FrontmatterParser - TEMPLATES_ROOT = Path(__file__).resolve().parents[3] / "src" / "vstack" / "_templates" / "agents" @@ -50,26 +48,34 @@ def test_all_role_templates_reference_concise() -> None: def test_role_configs_follow_stage_handoff_policy() -> None: - """Non-release roles expose forward handoffs, release remains terminal.""" + """Non-release roles expose a handoff prompt under defaults.handoffs, release is terminal.""" roles_with_forward_handoff = ["product", "architect", "designer", "engineer", "tester"] for role in roles_with_forward_handoff: config = _read(f"{role}/config.yaml") + assert "defaults:" in config assert "handoffs:" in config - assert "label:" in config - assert "agent:" in config + assert "prompt:" in config release_config = _read("release/config.yaml") assert "handoffs:" not in release_config def test_all_role_handoff_targets_are_known_roles() -> None: - """Each handoff target should reference one of the known role agents.""" + """Handoff targets in generated agent files should reference known roles.""" + from vstack.frontmatter import FrontmatterParser + + github_dir = Path(__file__).parents[3] / ".github" / "agents" roles = ["product", "architect", "designer", "engineer", "tester", "release"] valid_targets = set(roles) for role in roles: - config = FrontmatterParser.parse_yaml(_read(f"{role}/config.yaml")) - handoffs = config.get("handoffs") or [] + agent_file = github_dir / f"{role}.agent.md" + if not agent_file.exists(): + continue + text = agent_file.read_text(encoding="utf-8") + parsed = FrontmatterParser.parse(text) + handoffs = parsed.metadata.get("handoffs") or [] for handoff in handoffs: target = handoff.get("agent") - assert target in valid_targets, f"{role} has unknown handoff target: {target!r}" + if target is not None: + assert target in valid_targets, f"{role} has unknown handoff target: {target!r}" diff --git a/tests/vstack/artifacts/test_generator.py b/tests/vstack/artifacts/test_generator.py index ae57576..c592346 100644 --- a/tests/vstack/artifacts/test_generator.py +++ b/tests/vstack/artifacts/test_generator.py @@ -196,7 +196,7 @@ def test_agent_verify_input_uses_schema_for_bool_field(self, tmp_path: Path) -> tmpl_dir.mkdir(parents=True) (tmpl_dir / "template.md").write_text("# writer\nbody\n", encoding="utf-8") (tmpl_dir / "config.yaml").write_text( - "name: writer\ndescription: A writer agent\nuser-invocable: yes\n", + "name: writer\ndescription: A writer agent\nuser-invocable: maybe\n", encoding="utf-8", ) gen = GenericArtifactGenerator(AGENT_TYPE, tmp_path / "templates") diff --git a/tests/vstack/cli/test_catalog.py b/tests/vstack/cli/test_catalog.py index 20b8bf7..fb6a064 100644 --- a/tests/vstack/cli/test_catalog.py +++ b/tests/vstack/cli/test_catalog.py @@ -28,6 +28,7 @@ def test_catalog_contains_expected_commands(self) -> None: "install", "init", "uninstall", + "migrate", } def test_command_order_matches_catalog_keys(self) -> None: diff --git a/tests/vstack/cli/test_helpers.py b/tests/vstack/cli/test_helpers.py index 749ccfe..524e2e9 100644 --- a/tests/vstack/cli/test_helpers.py +++ b/tests/vstack/cli/test_helpers.py @@ -2,7 +2,7 @@ from __future__ import annotations -from vstack.cli.helpers import normalize_targeted_names +from vstack.cli.base import BaseCommand class TestNormalizeTargetedNames: @@ -10,26 +10,32 @@ class TestNormalizeTargetedNames: def test_returns_empty_set_for_none(self) -> None: """None input produces an empty set.""" - assert normalize_targeted_names(None) == set() + assert BaseCommand._normalize_targeted_names(None) == set() def test_returns_empty_set_for_empty_list(self) -> None: """Empty list produces an empty set.""" - assert normalize_targeted_names([]) == set() + assert BaseCommand._normalize_targeted_names([]) == set() def test_strips_whitespace(self) -> None: """Names with surrounding whitespace are stripped.""" - assert normalize_targeted_names([" vision ", " debug "]) == {"vision", "debug"} + assert BaseCommand._normalize_targeted_names([" vision ", " debug "]) == { + "vision", + "debug", + } def test_deduplicates_names(self) -> None: """Duplicate names collapse into a single entry.""" - assert normalize_targeted_names(["vision", "vision", "debug"]) == {"vision", "debug"} + assert BaseCommand._normalize_targeted_names(["vision", "vision", "debug"]) == { + "vision", + "debug", + } def test_filters_blank_strings(self) -> None: """Blank strings (empty or whitespace-only) are excluded.""" - assert normalize_targeted_names([" ", "", "debug"]) == {"debug"} + assert BaseCommand._normalize_targeted_names([" ", "", "debug"]) == {"debug"} def test_returns_set_of_names(self) -> None: """Result is always a set.""" - result = normalize_targeted_names(["a", "b"]) + result = BaseCommand._normalize_targeted_names(["a", "b"]) assert isinstance(result, set) assert result == {"a", "b"} diff --git a/tests/vstack/cli/test_init.py b/tests/vstack/cli/test_init.py index acf1729..705ba48 100644 --- a/tests/vstack/cli/test_init.py +++ b/tests/vstack/cli/test_init.py @@ -641,3 +641,75 @@ def verify_input(): assert "k8s" in install_single_calls out = capsys.readouterr().out assert "excluded by config" in out + + +class TestWarnUnknownWorkflowRoles: + """Tests for InitCommand._warn_unknown_workflow_roles.""" + + def test_no_output_when_all_roles_known(self, capsys) -> None: + """No warning is emitted when all roles match known agent names.""" + from vstack.cli.constants import Colors + + stages = [{"role": "product"}, {"role": "architect"}] + known = {"product", "architect", "designer"} + InitCommand._warn_unknown_workflow_roles( + workflow_stages=stages, + known_agent_names=known, + colors=Colors, + ) + assert capsys.readouterr().err == "" + + def test_warning_emitted_for_unknown_role(self, capsys) -> None: + """A warning is printed to stderr for each unknown role in workflow stages.""" + from vstack.cli.constants import Colors + + stages = [{"role": "product"}, {"role": "custom-role"}] + known = {"product", "architect"} + InitCommand._warn_unknown_workflow_roles( + workflow_stages=stages, + known_agent_names=known, + colors=Colors, + ) + err = capsys.readouterr().err + assert "custom-role" in err + + def test_empty_role_is_skipped(self, capsys) -> None: + """Stages with empty or missing role key are silently skipped.""" + from vstack.cli.constants import Colors + + stages = [{"role": ""}, {"gate": "required"}] + known: set[str] = set() + InitCommand._warn_unknown_workflow_roles( + workflow_stages=stages, + known_agent_names=known, + colors=Colors, + ) + assert capsys.readouterr().err == "" + + def test_execute_calls_warn_for_unknown_workflow_role( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys + ) -> None: + """execute() emits a warning when a workflow stage references an unknown agent.""" + from vstack.agents.generator import AgentGenerator + + # Patch manifest loading so execute() terminates quickly. + monkeypatch.setattr( + "vstack.cli.init.InitCommand._load_existing_manifest", + staticmethod(lambda **_kwargs: (None, None, None, None)), + ) + + stages = [ + { + "role": "unknown-role", + "gate": "required", + "handoffs": [{"prompt": "x", "agent": "", "label": ""}], + } + ] + gen = AgentGenerator(workflow_stages=stages) + monkeypatch.setattr(AgentGenerator, "find_templates", lambda self: []) + + service = cast(Any, SimpleNamespace(generators=[gen])) + InitCommand.execute(service, tmp_path) + + err = capsys.readouterr().err + assert "unknown-role" in err diff --git a/tests/vstack/cli/test_interface.py b/tests/vstack/cli/test_interface.py index 2e3c2f2..d5650bb 100644 --- a/tests/vstack/cli/test_interface.py +++ b/tests/vstack/cli/test_interface.py @@ -56,9 +56,18 @@ def run(self, *, context: CommandContext): class _Service: """Service construction test double.""" - def __init__(self, *, templates_root, artifacts_root: str = "docs") -> None: + def __init__( + self, + *, + templates_root, + artifacts_root: str = "docs", + workflow_stages=None, + excluded_names=None, + ) -> None: self.templates_root = templates_root self.artifacts_root = artifacts_root + self.workflow_stages = workflow_stages + self.excluded_names = excluded_names class TestCommandLineInterface: @@ -71,8 +80,9 @@ def test_run_dispatches_validate_without_resolving_target(self, monkeypatch, tmp command = _Command(exit_code=5) monkeypatch.setattr( - "vstack.cli.interface.build_command_registry", - lambda service: {"validate": command}, + CommandLineInterface, + "_build_command_registry", + lambda self, service: {"validate": command}, ) interface = CommandLineInterface( @@ -94,8 +104,9 @@ def test_run_dispatches_install_with_global_default_types(self, monkeypatch, tmp command = _Command(exit_code=9) monkeypatch.setattr( - "vstack.cli.interface.build_command_registry", - lambda service: {"install": command}, + CommandLineInterface, + "_build_command_registry", + lambda self, service: {"install": command}, ) interface = CommandLineInterface( @@ -217,12 +228,20 @@ def test_run_passes_artifacts_root_from_config_to_service( captured: list[str] = [] class _CapturingService: - def __init__(self, *, templates_root, artifacts_root: str = "docs") -> None: + def __init__( + self, + *, + templates_root, + artifacts_root: str = "docs", + workflow_stages=None, + excluded_names=None, + ) -> None: captured.append(artifacts_root) monkeypatch.setattr( - "vstack.cli.interface.build_command_registry", - lambda service: {"install": command}, + CommandLineInterface, + "_build_command_registry", + lambda self, service: {"install": command}, ) interface = CommandLineInterface( @@ -235,6 +254,153 @@ def __init__(self, *, templates_root, artifacts_root: str = "docs") -> None: assert captured == ["custom"] +class TestReadWorkflowStages: + """Tests for CommandLineInterface._read_workflow_stages.""" + + def test_returns_empty_when_install_dir_is_none(self) -> None: + """Returns empty list when install_dir is None.""" + assert CommandLineInterface._read_workflow_stages(None) == [] + + def test_returns_empty_when_config_absent(self, tmp_path: Path) -> None: + """Returns empty list when .vstack/config.yaml does not exist.""" + assert CommandLineInterface._read_workflow_stages(tmp_path / ".github") == [] + + def test_returns_empty_when_workflow_block_absent(self, tmp_path: Path) -> None: + """Returns empty list when config.yaml has no workflow block.""" + vstack_dir = tmp_path / ".vstack" + vstack_dir.mkdir() + (vstack_dir / "config.yaml").write_text("exclude:\n prompts: all\n", encoding="utf-8") + assert CommandLineInterface._read_workflow_stages(tmp_path / ".github") == [] + + def test_returns_stages_from_config(self, tmp_path: Path) -> None: + """Returns parsed stage list from workflow.stages block.""" + vstack_dir = tmp_path / ".vstack" + vstack_dir.mkdir() + (vstack_dir / "config.yaml").write_text( + "workflow:\n" + " version: 1\n" + " stages:\n" + " - role: product\n" + " gate: required\n" + " handoffs:\n" + " prompt: Product done.\n" + " - role: architect\n" + " gate: optional\n" + " handoffs:\n" + " prompt: Arch done.\n", + encoding="utf-8", + ) + result = CommandLineInterface._read_workflow_stages(tmp_path / ".github") + assert result == [ + { + "role": "product", + "gate": "required", + "handoffs": [{"prompt": "Product done.", "agent": "", "label": ""}], + }, + { + "role": "architect", + "gate": "optional", + "handoffs": [{"prompt": "Arch done.", "agent": "", "label": ""}], + }, + ] + + def test_preserves_hitl_field(self, tmp_path: Path) -> None: + """hitl field is preserved in the parsed stage dict.""" + vstack_dir = tmp_path / ".vstack" + vstack_dir.mkdir() + (vstack_dir / "config.yaml").write_text( + "workflow:\n" + " version: 1\n" + " stages:\n" + " - role: engineer\n" + " gate: required\n" + " hitl: always\n" + " handoffs:\n" + " prompt: Impl done.\n" + " - role: designer\n" + " gate: optional\n" + " hitl: on-change\n" + " handoffs:\n" + " prompt: Design done.\n", + encoding="utf-8", + ) + result = CommandLineInterface._read_workflow_stages(tmp_path / ".github") + assert result == [ + { + "role": "engineer", + "gate": "required", + "hitl": "always", + "handoffs": [{"prompt": "Impl done.", "agent": "", "label": ""}], + }, + { + "role": "designer", + "gate": "optional", + "hitl": "on-change", + "handoffs": [{"prompt": "Design done.", "agent": "", "label": ""}], + }, + ] + + def test_skips_items_without_role(self, tmp_path: Path) -> None: + """Items lacking a string 'role' key are silently skipped.""" + vstack_dir = tmp_path / ".vstack" + vstack_dir.mkdir() + (vstack_dir / "config.yaml").write_text( + "workflow:\n stages:\n - gate: required\n - role: engineer\n", + encoding="utf-8", + ) + result = CommandLineInterface._read_workflow_stages(tmp_path / ".github") + assert result == [{"role": "engineer", "gate": "required", "handoffs": []}] + + def test_returns_empty_when_stages_not_a_list(self, tmp_path: Path) -> None: + """Returns empty list when workflow.stages is not a list.""" + vstack_dir = tmp_path / ".vstack" + vstack_dir.mkdir() + (vstack_dir / "config.yaml").write_text( + "workflow:\n stages: not-a-list\n", + encoding="utf-8", + ) + result = CommandLineInterface._read_workflow_stages(tmp_path / ".github") + assert result == [] + + +class TestParseStageHandoffs: + """Tests for CommandLineInterface._parse_stage_handoffs.""" + + def test_dict_handoffs_returns_single_entry(self) -> None: + """A dict handoffs value is returned as a single-entry list.""" + item = { + "role": "architect", + "handoffs": {"prompt": "Arch done.", "agent": "designer", "label": "Go"}, + } + result = CommandLineInterface._parse_stage_handoffs(item) + assert result == [{"prompt": "Arch done.", "agent": "designer", "label": "Go"}] + + def test_list_handoffs_returns_multiple_entries(self) -> None: + """A list handoffs value returns multiple normalized entries.""" + item = { + "role": "architect", + "handoffs": [ + {"prompt": "Go to designer.", "agent": "", "label": ""}, + {"prompt": "Skip to engineer.", "agent": "engineer", "label": "Skip design"}, + ], + } + result = CommandLineInterface._parse_stage_handoffs(item) + assert len(result) == 2 + assert result[0]["prompt"] == "Go to designer." + assert result[1]["agent"] == "engineer" + + def test_backward_compat_handoff_prompt_key(self) -> None: + """Legacy flat handoff_prompt key is wrapped as single-entry handoffs.""" + item = {"role": "architect", "handoff_prompt": "Legacy prompt."} + result = CommandLineInterface._parse_stage_handoffs(item) + assert result == [{"prompt": "Legacy prompt.", "agent": "", "label": ""}] + + def test_no_handoffs_returns_empty(self) -> None: + """Returns empty list when no handoffs or handoff_prompt key is present.""" + result = CommandLineInterface._parse_stage_handoffs({"role": "architect"}) + assert result == [] + + class TestReadExclude: """Tests for CommandLineInterface._read_exclude.""" @@ -321,8 +487,9 @@ def test_run_removes_excluded_type_from_only(self, monkeypatch, tmp_path: Path) command = _Command(exit_code=0) monkeypatch.setattr( - "vstack.cli.interface.build_command_registry", - lambda service: {"install": command}, + CommandLineInterface, + "_build_command_registry", + lambda self, service: {"install": command}, ) interface = CommandLineInterface( parser_cls=cast(Any, lambda: parser), @@ -350,8 +517,9 @@ def test_run_passes_excluded_names_to_context(self, monkeypatch, tmp_path: Path) command = _Command(exit_code=0) monkeypatch.setattr( - "vstack.cli.interface.build_command_registry", - lambda service: {"install": command}, + CommandLineInterface, + "_build_command_registry", + lambda self, service: {"install": command}, ) interface = CommandLineInterface( parser_cls=cast(Any, lambda: parser), diff --git a/tests/vstack/cli/test_migrate.py b/tests/vstack/cli/test_migrate.py new file mode 100644 index 0000000..59b0e46 --- /dev/null +++ b/tests/vstack/cli/test_migrate.py @@ -0,0 +1,559 @@ +"""Tests for MigrateCommand.""" + +from __future__ import annotations + +from argparse import Namespace +from pathlib import Path +from typing import Any, cast +from unittest.mock import MagicMock, patch + +import pytest +import yaml + +from vstack.cli.base import CommandContext +from vstack.cli.constants import Colors +from vstack.cli.migrate import MigrateCommand +from vstack.cli.service import CommandService +from vstack.constants import ARTIFACTS_DOCS_ROOT + + +def _service() -> CommandService: + return cast(CommandService, MagicMock(spec=CommandService)) + + +def _context(args: Namespace) -> CommandContext: + return CommandContext(args=args, install_dir=None, only=None) + + +class TestResolveProjectRoot: + """_resolve_project_root returns cwd or explicit --target.""" + + def test_returns_cwd_when_no_target( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """Uses cwd when --target is absent.""" + monkeypatch.chdir(tmp_path) + args = Namespace(target=None) + assert MigrateCommand._resolve_project_root(args) == tmp_path + + def test_returns_target_when_set(self, tmp_path: Path) -> None: + """Uses the explicit --target path.""" + args = Namespace(target=str(tmp_path)) + assert MigrateCommand._resolve_project_root(args) == tmp_path + + def test_expands_user_in_target(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Expands ~ in --target.""" + monkeypatch.setenv("HOME", str(tmp_path)) + args = Namespace(target="~") + assert MigrateCommand._resolve_project_root(args) == tmp_path + + +class TestDetectInstalledMajor: + """_detect_installed_major reads vstack_version from the manifest.""" + + def test_returns_major_from_manifest(self, tmp_path: Path) -> None: + """Parses major version from a valid semver string in the manifest.""" + vstack_dir = tmp_path / ".vstack" + vstack_dir.mkdir() + manifest = { + "manifest_version": 2, + "hash_algorithm": "sha256", + "vstack_version": "2.2.0", + "installed_at": "2026-01-01T00:00:00+00:00", + "artifacts": {}, + } + (vstack_dir / "vstack.json").write_text( + __import__("json").dumps(manifest), encoding="utf-8" + ) + assert MigrateCommand._detect_installed_major(tmp_path) == 2 + + def test_returns_none_when_manifest_absent(self, tmp_path: Path) -> None: + """Returns None when no manifest file exists.""" + assert MigrateCommand._detect_installed_major(tmp_path) is None + + def test_returns_none_when_version_unparseable(self, tmp_path: Path) -> None: + """Returns None when vstack_version cannot be split into a major int.""" + vstack_dir = tmp_path / ".vstack" + vstack_dir.mkdir() + manifest = { + "manifest_version": 2, + "hash_algorithm": "sha256", + "vstack_version": "not-a-version", + "installed_at": "2026-01-01T00:00:00+00:00", + "artifacts": {}, + } + (vstack_dir / "vstack.json").write_text( + __import__("json").dumps(manifest), encoding="utf-8" + ) + assert MigrateCommand._detect_installed_major(tmp_path) is None + + def test_returns_none_when_manifest_read_raises(self, tmp_path: Path) -> None: + """Returns None when ManifestFile.read() raises an unexpected exception.""" + vstack_dir = tmp_path / ".vstack" + vstack_dir.mkdir() + (vstack_dir / "vstack.json").write_text("{}", encoding="utf-8") + with patch("vstack.cli.migrate.ManifestFile") as mock_cls: + mock_cls.return_value.read.side_effect = RuntimeError("boom") + assert MigrateCommand._detect_installed_major(tmp_path) is None + + def test_returns_none_when_manifest_read_returns_none(self, tmp_path: Path) -> None: + """Returns None when ManifestFile.read() returns None (legacy schema).""" + vstack_dir = tmp_path / ".vstack" + vstack_dir.mkdir() + # manifest_version=1 triggers needs_upgrade() → read() returns None + manifest = { + "manifest_version": 1, + "hash_algorithm": "sha256", + "vstack_version": "1.3.6", + "installed_at": "2026-01-01T00:00:00+00:00", + "artifacts": {}, + } + (vstack_dir / "vstack.json").write_text( + __import__("json").dumps(manifest), encoding="utf-8" + ) + assert MigrateCommand._detect_installed_major(tmp_path) is None + + def test_returns_none_when_version_empty(self, tmp_path: Path) -> None: + """Returns None when vstack_version is an empty string.""" + vstack_dir = tmp_path / ".vstack" + vstack_dir.mkdir() + manifest = { + "manifest_version": 2, + "hash_algorithm": "sha256", + "vstack_version": "", + "installed_at": "2026-01-01T00:00:00+00:00", + "artifacts": {}, + } + (vstack_dir / "vstack.json").write_text( + __import__("json").dumps(manifest), encoding="utf-8" + ) + assert MigrateCommand._detect_installed_major(tmp_path) is None + + +class TestLoadMigrationRecord: + """_load_migration_record reads YAML files from the migrations directory.""" + + def test_returns_none_when_file_absent(self, tmp_path: Path) -> None: + """Returns None when no migration record exists for the given versions.""" + with patch("vstack.cli.migrate.MIGRATIONS_ROOT", tmp_path): + assert MigrateCommand._load_migration_record(9, 10) is None + + def test_returns_moves_from_yaml(self, tmp_path: Path) -> None: + """Returns the moves list from a valid YAML record.""" + record = { + "from_version": "1.x", + "to_version": "2.0", + "moves": [ + {"old": "docs/a.md", "new": "docs/b.md", "type": "docs"}, + ], + } + (tmp_path / "v1_to_v2.yaml").write_text(yaml.dump(record), encoding="utf-8") + with patch("vstack.cli.migrate.MIGRATIONS_ROOT", tmp_path): + result = MigrateCommand._load_migration_record(1, 2) + assert result == [{"old": "docs/a.md", "new": "docs/b.md", "type": "docs"}] + + def test_returns_empty_list_when_moves_absent(self, tmp_path: Path) -> None: + """Returns an empty list when the YAML record has no moves key.""" + record = {"from_version": "1.x", "to_version": "2.0"} + (tmp_path / "v1_to_v2.yaml").write_text(yaml.dump(record), encoding="utf-8") + with patch("vstack.cli.migrate.MIGRATIONS_ROOT", tmp_path): + result = MigrateCommand._load_migration_record(1, 2) + assert result == [] + + def test_filters_non_dict_moves(self, tmp_path: Path) -> None: + """Filters out non-dict entries from the moves list.""" + record = { + "from_version": "1.x", + "to_version": "2.0", + "moves": [ + {"old": "docs/a.md", "new": "docs/b.md", "type": "docs"}, + "not-a-dict", + 42, + ], + } + (tmp_path / "v1_to_v2.yaml").write_text(yaml.dump(record), encoding="utf-8") + with patch("vstack.cli.migrate.MIGRATIONS_ROOT", tmp_path): + result = MigrateCommand._load_migration_record(1, 2) + assert result == [{"old": "docs/a.md", "new": "docs/b.md", "type": "docs"}] + + def test_returns_none_when_moves_not_a_list(self, tmp_path: Path) -> None: + """Returns None when moves is not a list.""" + record = {"from_version": "1.x", "to_version": "2.0", "moves": "bad"} + (tmp_path / "v1_to_v2.yaml").write_text(yaml.dump(record), encoding="utf-8") + with patch("vstack.cli.migrate.MIGRATIONS_ROOT", tmp_path): + assert MigrateCommand._load_migration_record(1, 2) is None + + def test_returns_none_when_yaml_not_dict(self, tmp_path: Path) -> None: + """Returns None when the YAML root is not a mapping.""" + (tmp_path / "v1_to_v2.yaml").write_text("- item1\n- item2\n", encoding="utf-8") + with patch("vstack.cli.migrate.MIGRATIONS_ROOT", tmp_path): + assert MigrateCommand._load_migration_record(1, 2) is None + + +class TestResolveNewPath: + """_resolve_new_path substitutes the docs root when configured.""" + + def test_returns_path_unchanged_when_root_matches_default(self) -> None: + """Does not modify the path when the root equals the default.""" + result = MigrateCommand._resolve_new_path( + "docs/architecture/overview.md", ARTIFACTS_DOCS_ROOT + ) + assert result == "docs/architecture/overview.md" + + def test_substitutes_custom_root(self) -> None: + """Replaces the default docs/ prefix with the configured root.""" + result = MigrateCommand._resolve_new_path("docs/architecture/overview.md", "content") + assert result == "content/architecture/overview.md" + + def test_leaves_path_unchanged_when_prefix_does_not_match(self) -> None: + """Does not modify paths whose first component is not the default root.""" + result = MigrateCommand._resolve_new_path("other/architecture/overview.md", "content") + assert result == "other/architecture/overview.md" + + +class TestReadArtifactsRoot: + """_read_artifacts_root reads artifacts.root from config.yaml.""" + + def test_returns_default_when_config_absent(self, tmp_path: Path) -> None: + """Returns the default docs root when no config.yaml exists.""" + assert MigrateCommand._read_artifacts_root(tmp_path) == ARTIFACTS_DOCS_ROOT + + def test_returns_configured_root(self, tmp_path: Path) -> None: + """Returns the custom root from config.yaml.""" + vstack_dir = tmp_path / ".vstack" + vstack_dir.mkdir() + (vstack_dir / "config.yaml").write_text("artifacts:\n root: content\n", encoding="utf-8") + assert MigrateCommand._read_artifacts_root(tmp_path) == "content" + + def test_returns_default_when_artifacts_not_dict(self, tmp_path: Path) -> None: + """Returns default when artifacts key is not a mapping.""" + vstack_dir = tmp_path / ".vstack" + vstack_dir.mkdir() + (vstack_dir / "config.yaml").write_text("artifacts: all\n", encoding="utf-8") + assert MigrateCommand._read_artifacts_root(tmp_path) == ARTIFACTS_DOCS_ROOT + + def test_returns_default_when_root_blank(self, tmp_path: Path) -> None: + """Returns default when artifacts.root is blank.""" + vstack_dir = tmp_path / ".vstack" + vstack_dir.mkdir() + (vstack_dir / "config.yaml").write_text("artifacts:\n root: ''\n", encoding="utf-8") + assert MigrateCommand._read_artifacts_root(tmp_path) == ARTIFACTS_DOCS_ROOT + + +class _FakeColors(Colors): + DIM = "" + RESET = "" + GREEN = "" + YELLOW = "" + BOLD = "" + RED = "" + CYAN = "" + BLUE = "" + + +class TestApplyMoves: + """_apply_moves relocates files and reports results.""" + + def test_moves_file_to_new_path(self, tmp_path: Path) -> None: + """Moves an existing file from old to new path.""" + old = tmp_path / "docs" / "a.md" + old.parent.mkdir(parents=True) + old.write_text("content", encoding="utf-8") + + moves = [{"old": "docs/a.md", "new": "docs/sub/b.md"}] + moved, skipped = MigrateCommand._apply_moves( + moves=moves, + project_root=tmp_path, + artifacts_root="docs", + dry_run=False, + colors=_FakeColors, + ) + assert moved == 1 + assert skipped == 0 + assert not old.exists() + assert (tmp_path / "docs" / "sub" / "b.md").read_text(encoding="utf-8") == "content" + + def test_skips_absent_old_path(self, tmp_path: Path) -> None: + """Counts absent old paths as skipped without error.""" + moves = [{"old": "docs/missing.md", "new": "docs/new.md"}] + moved, skipped = MigrateCommand._apply_moves( + moves=moves, + project_root=tmp_path, + artifacts_root="docs", + dry_run=False, + colors=_FakeColors, + ) + assert moved == 0 + assert skipped == 1 + + def test_skips_when_destination_exists(self, tmp_path: Path) -> None: + """Counts existing destinations as skipped without overwriting.""" + old = tmp_path / "docs" / "a.md" + old.parent.mkdir(parents=True) + old.write_text("old content", encoding="utf-8") + new = tmp_path / "docs" / "b.md" + new.write_text("existing content", encoding="utf-8") + + moves = [{"old": "docs/a.md", "new": "docs/b.md"}] + moved, skipped = MigrateCommand._apply_moves( + moves=moves, + project_root=tmp_path, + artifacts_root="docs", + dry_run=False, + colors=_FakeColors, + ) + assert moved == 0 + assert skipped == 1 + assert new.read_text(encoding="utf-8") == "existing content" + + def test_dry_run_does_not_move_files(self, tmp_path: Path) -> None: + """Dry-run reports a move without actually moving the file.""" + old = tmp_path / "docs" / "a.md" + old.parent.mkdir(parents=True) + old.write_text("content", encoding="utf-8") + + moves = [{"old": "docs/a.md", "new": "docs/b.md"}] + moved, skipped = MigrateCommand._apply_moves( + moves=moves, + project_root=tmp_path, + artifacts_root="docs", + dry_run=True, + colors=_FakeColors, + ) + assert moved == 1 + assert skipped == 0 + assert old.exists() + assert not (tmp_path / "docs" / "b.md").exists() + + def test_skips_moves_with_empty_paths(self, tmp_path: Path) -> None: + """Ignores move entries that are missing old or new keys.""" + moves: list[dict[str, Any]] = [ + {"old": "", "new": "docs/b.md"}, + {"old": "docs/a.md", "new": ""}, + ] + moved, skipped = MigrateCommand._apply_moves( + moves=moves, + project_root=tmp_path, + artifacts_root="docs", + dry_run=False, + colors=_FakeColors, + ) + assert moved == 0 + assert skipped == 0 + + def test_creates_parent_directories(self, tmp_path: Path) -> None: + """Creates missing parent directories for the new path.""" + old = tmp_path / "docs" / "a.md" + old.parent.mkdir(parents=True) + old.write_text("content", encoding="utf-8") + + moves = [{"old": "docs/a.md", "new": "docs/deep/nested/b.md"}] + MigrateCommand._apply_moves( + moves=moves, + project_root=tmp_path, + artifacts_root="docs", + dry_run=False, + colors=_FakeColors, + ) + assert (tmp_path / "docs" / "deep" / "nested" / "b.md").exists() + + def test_substitutes_custom_artifacts_root_in_new_path(self, tmp_path: Path) -> None: + """Applies the custom artifacts root when resolving new paths.""" + old = tmp_path / "docs" / "a.md" + old.parent.mkdir(parents=True) + old.write_text("content", encoding="utf-8") + + moves = [{"old": "docs/a.md", "new": "docs/b.md"}] + MigrateCommand._apply_moves( + moves=moves, + project_root=tmp_path, + artifacts_root="content", + dry_run=False, + colors=_FakeColors, + ) + assert (tmp_path / "content" / "b.md").exists() + + +class TestMigrateCommandRun: + """MigrateCommand.run end-to-end scenarios.""" + + def test_detects_to_major_from_package_version(self, tmp_path: Path) -> None: + """Detects to_major from the current package VERSION when --to is absent.""" + record_dir = tmp_path / "_migrations" + record_dir.mkdir() + + cmd = MigrateCommand(_service()) + context = _context( + Namespace(dry_run=True, target=str(tmp_path), from_major=1, to_major=None) + ) + with patch("vstack.cli.migrate.MIGRATIONS_ROOT", record_dir): + # Should complete without error regardless of what VERSION resolves to + result = cmd.run(context=context) + assert result == 0 + + def test_to_major_falls_back_when_version_unparseable(self, tmp_path: Path) -> None: + """Falls back to from_major+1 when VERSION cannot be parsed.""" + record_dir = tmp_path / "_migrations" + record_dir.mkdir() + + cmd = MigrateCommand(_service()) + context = _context( + Namespace(dry_run=True, target=str(tmp_path), from_major=2, to_major=None) + ) + with ( + patch("vstack.cli.migrate.MIGRATIONS_ROOT", record_dir), + patch("vstack.cli.migrate.VERSION", "not-semver"), + ): + result = cmd.run(context=context) + assert result == 0 + + def test_returns_0_when_nothing_to_migrate( + self, tmp_path: Path, capsys: pytest.CaptureFixture + ) -> None: + """Returns 0 and prints a message when from >= to.""" + cmd = MigrateCommand(_service()) + context = _context(Namespace(dry_run=False, target=str(tmp_path), from_major=3, to_major=3)) + assert cmd.run(context=context) == 0 + assert "Nothing to migrate" in capsys.readouterr().out + + def test_returns_1_when_major_not_detectable(self, tmp_path: Path) -> None: + """Returns 1 and writes to stderr when from_major cannot be detected.""" + cmd = MigrateCommand(_service()) + context = _context( + Namespace(dry_run=False, target=str(tmp_path), from_major=None, to_major=3) + ) + assert cmd.run(context=context) == 1 + + def test_applies_moves_for_range(self, tmp_path: Path) -> None: + """Applies migration records across the full from→to range.""" + # Create old-style docs + (tmp_path / "docs").mkdir() + (tmp_path / "docs" / "architecture.md").write_text("arch", encoding="utf-8") + + # Fake migration record: v2→v3 + record_dir = tmp_path / "_migrations" + record_dir.mkdir() + record = { + "from_version": "2.x", + "to_version": "3.0", + "moves": [ + {"old": "docs/architecture.md", "new": "docs/overview.md", "type": "docs"}, + ], + } + (record_dir / "v2_to_v3.yaml").write_text(yaml.dump(record), encoding="utf-8") + + cmd = MigrateCommand(_service()) + context = _context(Namespace(dry_run=False, target=str(tmp_path), from_major=2, to_major=3)) + with patch("vstack.cli.migrate.MIGRATIONS_ROOT", record_dir): + result = cmd.run(context=context) + + assert result == 0 + assert not (tmp_path / "docs" / "architecture.md").exists() + assert (tmp_path / "docs" / "overview.md").read_text(encoding="utf-8") == "arch" + + def test_skips_steps_with_no_record( + self, tmp_path: Path, capsys: pytest.CaptureFixture + ) -> None: + """Skips version steps that have no migration record file.""" + record_dir = tmp_path / "_migrations" + record_dir.mkdir() # empty — no records + + cmd = MigrateCommand(_service()) + context = _context(Namespace(dry_run=False, target=str(tmp_path), from_major=1, to_major=3)) + with patch("vstack.cli.migrate.MIGRATIONS_ROOT", record_dir): + result = cmd.run(context=context) + + assert result == 0 + out = capsys.readouterr().out + assert "no migration record" in out + + def test_dry_run_does_not_move_files(self, tmp_path: Path) -> None: + """Dry-run leaves files in place while reporting counts.""" + (tmp_path / "docs").mkdir() + (tmp_path / "docs" / "architecture.md").write_text("arch", encoding="utf-8") + + record_dir = tmp_path / "_migrations" + record_dir.mkdir() + record = { + "from_version": "2.x", + "to_version": "3.0", + "moves": [ + {"old": "docs/architecture.md", "new": "docs/overview.md", "type": "docs"}, + ], + } + (record_dir / "v2_to_v3.yaml").write_text(yaml.dump(record), encoding="utf-8") + + cmd = MigrateCommand(_service()) + context = _context(Namespace(dry_run=True, target=str(tmp_path), from_major=2, to_major=3)) + with patch("vstack.cli.migrate.MIGRATIONS_ROOT", record_dir): + result = cmd.run(context=context) + + assert result == 0 + assert (tmp_path / "docs" / "architecture.md").exists() + assert not (tmp_path / "docs" / "overview.md").exists() + + def test_detects_from_major_from_manifest(self, tmp_path: Path) -> None: + """Detects from_major from the manifest when --from is absent.""" + vstack_dir = tmp_path / ".vstack" + vstack_dir.mkdir() + manifest = { + "manifest_version": 2, + "hash_algorithm": "sha256", + "vstack_version": "2.1.0", + "installed_at": "2026-01-01T00:00:00+00:00", + "artifacts": {}, + } + (vstack_dir / "vstack.json").write_text( + __import__("json").dumps(manifest), encoding="utf-8" + ) + + record_dir = tmp_path / "_migrations" + record_dir.mkdir() + + cmd = MigrateCommand(_service()) + context = _context( + Namespace(dry_run=True, target=str(tmp_path), from_major=None, to_major=3) + ) + with patch("vstack.cli.migrate.MIGRATIONS_ROOT", record_dir): + result = cmd.run(context=context) + + assert result == 0 + + def test_chains_multiple_migration_steps(self, tmp_path: Path) -> None: + """Applies v1→v2 and v2→v3 moves in sequence for a v1→v3 migration.""" + (tmp_path / "docs").mkdir() + (tmp_path / "docs" / "old_v1.md").write_text("v1", encoding="utf-8") + (tmp_path / "docs" / "old_v2.md").write_text("v2", encoding="utf-8") + + record_dir = tmp_path / "_migrations" + record_dir.mkdir() + + (record_dir / "v1_to_v2.yaml").write_text( + yaml.dump( + { + "from_version": "1.x", + "to_version": "2.0", + "moves": [{"old": "docs/old_v1.md", "new": "docs/new_v1.md", "type": "docs"}], + } + ), + encoding="utf-8", + ) + (record_dir / "v2_to_v3.yaml").write_text( + yaml.dump( + { + "from_version": "2.x", + "to_version": "3.0", + "moves": [{"old": "docs/old_v2.md", "new": "docs/new_v2.md", "type": "docs"}], + } + ), + encoding="utf-8", + ) + + cmd = MigrateCommand(_service()) + context = _context(Namespace(dry_run=False, target=str(tmp_path), from_major=1, to_major=3)) + with patch("vstack.cli.migrate.MIGRATIONS_ROOT", record_dir): + result = cmd.run(context=context) + + assert result == 0 + assert (tmp_path / "docs" / "new_v1.md").exists() + assert (tmp_path / "docs" / "new_v2.md").exists() diff --git a/tests/vstack/cli/test_parser.py b/tests/vstack/cli/test_parser.py index c24123b..f877cb2 100644 --- a/tests/vstack/cli/test_parser.py +++ b/tests/vstack/cli/test_parser.py @@ -90,6 +90,7 @@ def test_build_parser_has_expected_commands(self) -> None: "install", "init", "uninstall", + "migrate", } def test_verify_accepts_only_filter(self) -> None: diff --git a/tests/vstack/cli/test_registry.py b/tests/vstack/cli/test_registry.py index 07fde25..f32feff 100644 --- a/tests/vstack/cli/test_registry.py +++ b/tests/vstack/cli/test_registry.py @@ -1,4 +1,4 @@ -"""Tests for build_command_registry catalog factory dispatch.""" +"""Tests for CommandLineInterface command registry construction.""" from __future__ import annotations @@ -8,7 +8,7 @@ import pytest from vstack.cli.base import BaseCommand, CommandContext -from vstack.cli.registry import build_command_registry +from vstack.cli.interface import CommandLineInterface from vstack.cli.service import CommandService @@ -21,9 +21,9 @@ def run(self, *, context: CommandContext) -> int: class TestBuildCommandRegistry: - """Test cases for build_command_registry.""" + """Test cases for CommandLineInterface._build_command_registry.""" - def test_uses_catalog_factories(self, monkeypatch: pytest.MonkeyPatch) -> None: + def test_uses_catalog_factories(self, monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: """Registry construction delegates to command_factory for each catalog entry.""" created: list[str] = [] @@ -35,22 +35,24 @@ def _make(_service: object) -> _FakeCommand: return _make monkeypatch.setattr( - "vstack.cli.registry.COMMAND_CATALOG", + "vstack.cli.interface.COMMAND_CATALOG", { "alpha": SimpleNamespace(command_factory=_factory("alpha")), "beta": SimpleNamespace(command_factory=_factory("beta")), }, ) - registry = build_command_registry(service=cast(CommandService, object())) + cli = CommandLineInterface(templates_root=tmp_path) + registry = cli._build_command_registry(service=cast(CommandService, object())) assert set(registry.keys()) == {"alpha", "beta"} assert created == ["alpha", "beta"] - def test_returns_all_catalog_commands(self) -> None: + def test_returns_all_catalog_commands(self, tmp_path) -> None: """Default registry contains one entry per COMMAND_CATALOG key.""" from vstack.cli.catalog import COMMAND_CATALOG from vstack.constants import TEMPLATES_ROOT svc = CommandService(templates_root=TEMPLATES_ROOT) - registry = build_command_registry(service=svc) + cli = CommandLineInterface(templates_root=tmp_path) + registry = cli._build_command_registry(service=svc) assert set(registry.keys()) == set(COMMAND_CATALOG.keys()) diff --git a/tests/vstack/frontmatter/test_parser.py b/tests/vstack/frontmatter/test_parser.py index 634a20e..512da36 100644 --- a/tests/vstack/frontmatter/test_parser.py +++ b/tests/vstack/frontmatter/test_parser.py @@ -32,66 +32,74 @@ def test_bool(self) -> None: class TestFrontmatterParser: """Test cases for FrontmatterParser.""" + def setup_method(self) -> None: + """Create a shared parser instance for all tests.""" + self.parser = FrontmatterParser() + def test_parse_frontmatter_string_value(self) -> None: """Test that parse frontmatter string value.""" - result = FrontmatterParser.parse("---\nname: vision\nversion: 1.0.0\n---\nbody") + result = self.parser.parse("---\nname: vision\nversion: 1.0.0\n---\nbody") assert result.metadata["name"] == "vision" assert result.content == "body" def test_parse_frontmatter_inline_list(self) -> None: """Test that parse frontmatter inline list.""" - meta = FrontmatterParser.parse("---\naliases: [foo, bar]\n---\n").metadata + meta = self.parser.parse("---\naliases: [foo, bar]\n---\n").metadata assert meta["aliases"] == ["foo", "bar"] def test_parse_frontmatter_block_list(self) -> None: """Test that parse frontmatter block list.""" - meta = FrontmatterParser.parse("---\ntools:\n - read\n - edit\n---\n").metadata + meta = self.parser.parse("---\ntools:\n - read\n - edit\n---\n").metadata assert meta["tools"] == ["read", "edit"] def test_parse_frontmatter_block_scalar(self) -> None: """Test that parse frontmatter block scalar.""" - meta = FrontmatterParser.parse( - "---\ndescription: |\n line one\n line two\n---\n" - ).metadata + meta = self.parser.parse("---\ndescription: |\n line one\n line two\n---\n").metadata assert "line one" in meta["description"] def test_parse_no_frontmatter(self) -> None: """Test that parse no frontmatter.""" - result = FrontmatterParser.parse("body-only") + result = self.parser.parse("body-only") assert result.metadata == {} assert result.content == "body-only" + def test_parse_yaml_empty_input_returns_empty_dict(self) -> None: + """Empty input and non-mapping YAML values return an empty dict.""" + assert self.parser.parse_yaml("") == {} + assert self.parser.parse_yaml("just a scalar") == {} + assert self.parser.parse_yaml("- a\n- b\n") == {} + def test_parse_yaml_raw_block(self) -> None: - """Test that parse yaml raw block.""" - meta = FrontmatterParser.parse_yaml("mcp-servers:\n srv:\n command: cmd\n") - assert isinstance(meta["mcp-servers"], str) - assert "srv:" in meta["mcp-servers"] + """Raw block value is parsed as a nested dict by PyYAML.""" + meta = self.parser.parse_yaml("mcp-servers:\n srv:\n command: cmd\n") + assert isinstance(meta["mcp-servers"], dict) + assert meta["mcp-servers"]["srv"]["command"] == "cmd" def test_parse_yaml_ignores_comments_and_handles_object_list_continuation(self) -> None: """Test that parse yaml ignores comments and handles object list continuation.""" raw = "# comment\nhandoffs:\n - label: A\n prompt: hi\n" - meta = FrontmatterParser.parse_yaml(raw) + meta = self.parser.parse_yaml(raw) assert isinstance(meta["handoffs"], list) assert meta["handoffs"][0]["prompt"] == "hi" def test_parse_yaml_raw_block_closed_by_next_key(self) -> None: - """Test that parse yaml raw block closed by next key.""" + """Nested mapping value and subsequent sibling key are both parsed correctly.""" raw = "mcp-servers:\n srv:\n type: local\nname: x\n" - meta = FrontmatterParser.parse_yaml(raw) - assert "type: local" in meta["mcp-servers"] + meta = self.parser.parse_yaml(raw) + assert meta["mcp-servers"]["srv"]["type"] == "local" assert meta["name"] == "x" def test_parse_yaml_block_scalar_closed_by_next_key(self) -> None: """Test that parse yaml block scalar closed by next key.""" raw = "description: |\n line one\nname: tool\n" - meta = FrontmatterParser.parse_yaml(raw) + meta = self.parser.parse_yaml(raw) assert "line one" in meta["description"] assert meta["name"] == "tool" def test_parse_yaml_folded_scalar_closed_by_next_key(self) -> None: """Test that parse yaml folded scalar closed by next key.""" raw = "description: >\n line one\n line two\nname: tool\n" - meta = FrontmatterParser.parse_yaml(raw) + meta = self.parser.parse_yaml(raw) assert "line one" in meta["description"] assert "line two" in meta["description"] assert meta["name"] == "tool" @@ -106,22 +114,68 @@ def test_parse_yaml_object_list_block_scalar_value(self) -> None: " Line two\n" " send: false\n" ) - meta = FrontmatterParser.parse_yaml(raw) + meta = self.parser.parse_yaml(raw) assert isinstance(meta["handoffs"], list) assert "Line one" in meta["handoffs"][0]["prompt"] assert "Line two" in meta["handoffs"][0]["prompt"] - assert meta["handoffs"][0]["send"] == "false" - - def test_parse_yaml_object_list_coerces_from_scalar(self) -> None: - """Test that parse yaml object list coerces from scalar.""" - raw = "handoffs: value\n - label: A\n" - meta = FrontmatterParser.parse_yaml(raw) - assert isinstance(meta["handoffs"], list) - assert meta["handoffs"][0]["label"] == "A" - - def test_parse_yaml_string_list_coerces_from_scalar(self) -> None: - """Test that parse yaml string list coerces from scalar.""" - raw = "tools: value\n - read\n" - meta = FrontmatterParser.parse_yaml(raw) - assert isinstance(meta["tools"], list) - assert meta["tools"] == ["read"] + assert meta["handoffs"][0]["send"] is False + + def test_parse_yaml_wildcard_list_item(self) -> None: + """Bare ``*`` list items (VS Code wildcard) are pre-processed so PyYAML parses them as strings.""" + raw = "agents:\n - '*'\n - architect\n" + meta = self.parser.parse_yaml(raw) + assert meta["agents"] == ["*", "architect"] + + def test_parse_yaml_unquoted_wildcard_list_item(self) -> None: + """Unquoted ``- *`` in existing generated files is pre-processed before PyYAML.""" + raw = "agents:\n - *\n - architect\n" + meta = self.parser.parse_yaml(raw) + assert meta["agents"] == ["*", "architect"] + + def test_parse_yaml_object_list_nested_block_dict(self) -> None: + """Nested dict block inside an object-list item is parsed as a dict by PyYAML.""" + raw = ( + "stages:\n" + " - role: architect\n" + " gate: required\n" + " handoffs:\n" + " prompt: Architecture done.\n" + " agent: designer\n" + ) + meta = self.parser.parse_yaml(raw) + assert isinstance(meta["stages"], list) + stage = meta["stages"][0] + assert stage["role"] == "architect" + assert stage["gate"] == "required" + # PyYAML parses the nested mapping directly as a dict + assert isinstance(stage["handoffs"], dict) + assert stage["handoffs"]["prompt"] == "Architecture done." + assert stage["handoffs"]["agent"] == "designer" + + def test_parse_yaml_object_list_nested_block_with_block_scalar(self) -> None: + """Nested block inside an object-list item: folded scalar prompt is parsed directly.""" + raw = ( + "stages:\n" + " - role: architect\n" + " gate: required\n" + " handoffs:\n" + " prompt: >\n" + " Line one\n" + " Line two\n" + " other: value\n" + ) + meta = self.parser.parse_yaml(raw) + stage = meta["stages"][0] + # PyYAML parses the nested mapping directly as a dict with the folded scalar resolved + assert isinstance(stage["handoffs"], dict) + assert "Line one" in stage["handoffs"]["prompt"] + assert "Line two" in stage["handoffs"]["prompt"] + # The sibling key is parsed correctly + assert stage["other"] == "value" + + def test_parse_yaml_object_list_empty_nested_block_is_none(self) -> None: + """An empty-value nested key in an object-list item is None (PyYAML null).""" + raw = "stages:\n - role: release\n gate: required\n handoffs:\n" + meta = self.parser.parse_yaml(raw) + stage = meta["stages"][0] + assert stage["handoffs"] is None diff --git a/tests/vstack/frontmatter/test_serializer.py b/tests/vstack/frontmatter/test_serializer.py index b1ec4f0..a28a9eb 100644 --- a/tests/vstack/frontmatter/test_serializer.py +++ b/tests/vstack/frontmatter/test_serializer.py @@ -29,6 +29,35 @@ def test_serialize_raw_field_empty_skipped(self) -> None: output = FrontmatterSerializer().serialize({"name": "agent", "mcp-servers": ""}, schema) assert "mcp-servers" not in output + def test_serialize_raw_field_dict_value(self) -> None: + """Dict value from PyYAML parsing is emitted as properly indented YAML.""" + schema = FrontmatterSchema( + [FieldSpec("name", quoted=False), FieldSpec("metadata", type="raw")] + ) + output = FrontmatterSerializer().serialize( + {"name": "agent", "metadata": {"owner": "team-a", "tier": "backend"}}, + schema, + ) + assert "metadata:" in output + assert " owner: team-a" in output + assert " tier: backend" in output + # Must not contain a Python dict repr + assert "{'owner'" not in output + + def test_serialize_raw_field_list_value(self) -> None: + """List value from PyYAML parsing is emitted as properly indented YAML.""" + schema = FrontmatterSchema( + [FieldSpec("name", quoted=False), FieldSpec("hooks", type="raw")] + ) + output = FrontmatterSerializer().serialize( + {"name": "agent", "hooks": [{"event": "onSave", "command": "lint"}]}, + schema, + ) + assert "hooks:" in output + assert " - command: lint" in output or " - event: onSave" in output + # Must not contain a Python list repr + assert "[{" not in output + def test_serialize_frontmatter_required_fields(self) -> None: """Test that serialize includes required frontmatter fields.""" output = FrontmatterSerializer().serialize( diff --git a/tests/vstack/manifest/test_store.py b/tests/vstack/manifest/test_store.py index 10ecb2c..0449d2f 100644 --- a/tests/vstack/manifest/test_store.py +++ b/tests/vstack/manifest/test_store.py @@ -16,6 +16,8 @@ Manifest, ManifestFile, hash_with_algorithm, + preserve_existing_entry, + preserved_manifest_entries, ) @@ -597,3 +599,39 @@ def test_read_none_when_manifest_entry_missing_required_file_key(self, tmp_path) encoding="utf-8", ) assert mf.read() is None + + +class TestDeprecatedModuleLevelHelpers: + """Deprecated module-level helpers re-export Manifest methods and warn.""" + + def test_preserved_manifest_entries_warns_and_delegates(self) -> None: + """preserved_manifest_entries() emits DeprecationWarning and returns correct result.""" + entry = ArtifactEntry(name="vision", file="docs/vision.md", checksum="abc") + manifest = Manifest( + vstack_version="0.1.0", + installed_at="2026-01-01T00:00:00Z", + artifacts={"skills": [entry], "agents": []}, + ) + with pytest.warns( + DeprecationWarning, match="preserved_manifest_entries\\(\\) is deprecated" + ): + result = preserved_manifest_entries(manifest, {"agents"}) + assert result == {"skills": [entry]} + + def test_preserved_manifest_entries_none_returns_empty(self) -> None: + """preserved_manifest_entries() returns {} when existing_manifest is None.""" + with pytest.warns(DeprecationWarning): + result = preserved_manifest_entries(None, {"agents"}) + assert result == {} + + def test_preserve_existing_entry_warns_and_delegates(self) -> None: + """preserve_existing_entry() emits DeprecationWarning and appends the entry.""" + entry = ArtifactEntry(name="vision", file="docs/vision.md", checksum="abc") + new_entries: dict = {} + with pytest.warns(DeprecationWarning, match="preserve_existing_entry\\(\\) is deprecated"): + preserve_existing_entry( + new_entries=new_entries, + manifest_key="skills", + existing_entry=entry, + ) + assert new_entries == {"skills": [entry]}