diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 7b901c9..e72a83c 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codex-sdlc", - "version": "0.6.0", + "version": "1.0.0", "description": "Run evidence-backed software delivery across one or more repositories with Codex.", "author": { "name": "vimihi", diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a33793..75e43bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to codex-sdlc are documented here. +## 1.0.0 - 2026-09-25 + +- Add opt-in, assessed Compact runs with a frozen revisioned graph, one canonical BA specification, generated compatibility views, and one independent integrated QC record. Full remains the default; saved model settings and existing runs retain their meaning. +- Bind Compact review to exact facts/specification bytes and require every acceptance criterion's declared evidence, current QC checks, PM-reviewed completion, and final human acceptance. Higher-risk work requires Full. +- Add a matched scripted framework benchmark with identical permission, pagination, and mapping defects; report its limits separately from actual agent speed. +- Add local readiness preflight, generated task assignments and handoffs, recorded activation, ordered command collection, and lifecycle timing. +- Add explicit archived repair cycles for completed implementations and rejected handoffs, downstream gate invalidation, stale-authority rejection, and interruption recovery. +- Fix ownership for configured application roots and unify portable path handling across assignments, command working directories, and permission patterns. +- Allow multiple technical capabilities to reference one business requirement while preserving exact capability/report mapping. +- Keep independent review, QC, human acceptance, existing model presets, and unrepaired legacy runs. Parallel scheduling and evidence caching remain deferred. + ## 0.6.0 - 2026-09-24 - Add the `sdlc` entry skill for `/sdlc --save-my-token` and `/sdlc --normal` chat shorthand, with explicit `$sdlc` invocation support. diff --git a/README.md b/README.md index a730c0f..48d658c 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,8 @@ Eight Codex skills and a repository-local CLI keep tasks, evidence, blockers, an ## Get started +**New in 1.0.0:** Runtime-assisted delivery reduces manual task bookkeeping, and eligible bounded features can use opt-in Compact mode. See the [release notes](docs/releases/1.0.0.md) for changes and upgrade guidance. + You need **Codex**, **Node.js `>=24.16.0 <25`**, **npm 11**, and an existing application repository. Setup configures your application directories; it does not scaffold application code. ### 1. Install the plugin @@ -83,7 +85,7 @@ Start a codex-sdlc feature delivery for: . To continue later, ask Codex to resume the existing run. Its manifest records the current tasks, completed work, and remaining decisions. -**Version note:** Project model modes require both the **0.6.0 plugin and runtime**. Per-role routing and AI Product Owner review remain available from 0.5.0. Check your installed plugin version; updating the plugin does not upgrade an existing project's runtime. See the [upgrade guide](docs/getting-started.md#upgrade-an-existing-project). +**Version note:** Compact mode and the new task helpers require both the **1.0.0 plugin and runtime**. Check your installed plugin version; updating the plugin does not upgrade an existing project's runtime. See the [upgrade guide](docs/getting-started.md#upgrade-an-existing-project).
Prefer the CLI? Preview a Next.js setup @@ -91,7 +93,7 @@ To continue later, ask Codex to resume the existing run. Its manifest records th Run this against an existing web repository: ```sh -npx --yes codex-sdlc@0.6.0 init \ +npx --yes codex-sdlc@1.0.0 init \ --root /absolute/path/to/web --name example-web \ --applications web --web-root . --web-preset nextjs \ --dry-run @@ -106,12 +108,36 @@ node .sdlc/runtime.cjs doctor node .sdlc/runtime.cjs validate-config ``` -For a global CLI installation, use `npm install --global codex-sdlc@0.6.0`. +For a global CLI installation, use `npm install --global codex-sdlc@1.0.0`.
## Your repositories. Your stack. +### Less delivery bookkeeping in 1.0.0 + +The runtime can now prepare task assignments, record agent activation, collect declared checks, and generate handoff reports from real files and execution evidence. PM still reviews the result, and QC still verifies acceptance independently. + +- `preflight` detects local application/root, fixture-file, and command problems before implementation. Live services and populated data remain explicit checks. +- `prepare-task`, `activate-task`, `check-task`, and `handoff-task` replace manual IDs, hashes, inventories, and report metadata for eligible backend/frontend work. +- `repair-task` preserves earlier cycles and invalidates affected verification after a QC defect or rejected implementation review. `recover-repair` handles an interrupted file transaction. +- `timing` reports recorded lifecycle intervals and collector durations, with overlapping work and unknown model time clearly distinguished. +- Configured roots such as `apps/api` and Next.js route groups/dynamic segments work consistently. Several technical capabilities can reference one business requirement. + +See [runtime-assisted delivery](skills/sdlc-pm/references/task-operations.md) and [1.0.0 release notes](docs/releases/1.0.0.md). Full remains the default; eligible new runs can now opt into Compact. Parallel scheduling, evidence caching, and model-default changes remain deferred. Automatic handoff verifies the caller-declared task-owned subset of actual uncommitted Git changes; it does not independently infer ownership of omitted files or support deleted product paths. + +### Compact delivery for bounded features + +```text +$sdlc --compact +``` + +Compact uses one specification and one independent QC record, with compatibility views and metadata generated by the runtime. It combines requirements/API review and integrated QC into fewer handoffs, while preserving affected implementation roles, permission boundaries, repair history, and final human acceptance. + +Only use it when scope and existing patterns are understood. Migrations, breaking APIs, authorization changes, new sensitive-data exposure, or unresolved risk require Full. The profile is frozen per run; `--save-my-token` and `--normal` continue to configure models separately. See the [Compact guide](skills/sdlc-pm/references/compact-workflow.md). + +A [reproducible scripted benchmark](docs/workflow-benchmark.md) compares identical acceptance checks and seeded defects. Its timings measure local framework work, not real agent implementation time or token savings. + Start with a web-only, mobile-only, backend-only, or combined project. Use multi-repository mode when the applications live in separate Git checkouts. | Layer | Built-in preset | @@ -166,7 +192,7 @@ Selections must be available on your Codex host. New runs snapshot the settings; ### Project model modes -Version 0.6.0 adds these chat shortcuts: +These chat shortcuts, introduced in 0.6.0, remain available: ```text /sdlc --save-my-token @@ -200,7 +226,7 @@ codex-sdlc does not operate a hosted service or send repository content to a cod Preview a project upgrade before applying it: ```sh -npx --yes codex-sdlc@0.6.0 upgrade \ +npx --yes codex-sdlc@1.0.0 upgrade \ --root /absolute/path/to/coordinator --dry-run ``` @@ -214,10 +240,10 @@ Applied upgrades create backups. Rollback checks managed-file integrity before r | --- | --- | | [Getting started](docs/getting-started.md) | [CLI setup and lifecycle reference](docs/cli-reference.md) | | [Multi-repository workspaces](docs/multi-repository.md) | [Role models and dispatch records](docs/agent-models.md) | -| [What's new in 0.6.0](docs/releases/0.6.0.md) | [Release history](https://github.com/prime-vimihi/codex-sdlc/releases) | +| [What's new in 1.0.0](docs/releases/1.0.0.md) | [Release history](https://github.com/prime-vimihi/codex-sdlc/releases) | | [Support](SUPPORT.md) | [Contributing](CONTRIBUTING.md) | -Current verification includes automated runtime tests, plugin and skill validation, and independent built-CLI checks. Native Linux/Windows qualification and a complete delivery test spanning multiple models remain pending. See the [0.6.0 validation notes](docs/releases/0.6.0.md#validation). +Current verification includes automated runtime tests, plugin and skill validation, and independent built-CLI checks. Native Linux/Windows qualification and a matched real-agent performance benchmark remain pending. See the [1.0.0 validation notes](docs/releases/1.0.0.md#validation). ## Build with us diff --git a/assets/framework.yaml b/assets/framework.yaml index b7ea56d..0807bcb 100644 --- a/assets/framework.yaml +++ b/assets/framework.yaml @@ -1,7 +1,7 @@ schema_version: 1 framework: name: codex-sdlc - version: 0.6.0 + version: 1.0.0 run_schema_version: 1 project_schema_version: 2 installation: diff --git a/assets/schemas/compact-qc.schema.json b/assets/schemas/compact-qc.schema.json new file mode 100644 index 0000000..0516287 --- /dev/null +++ b/assets/schemas/compact-qc.schema.json @@ -0,0 +1,217 @@ +{ + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "run_id", + "task_id", + "producer", + "revision", + "reviewed_specification", + "results", + "defects", + "recommendation", + "collector_evidence" + ], + "properties": { + "schema_version": { + "const": 1 + }, + "run_id": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9]*-[0-9]+$" + }, + "task_id": { + "const": "QC-001" + }, + "producer": { + "const": "qc" + }, + "revision": { + "type": "integer", + "minimum": 1 + }, + "reviewed_specification": { + "type": "object", + "additionalProperties": false, + "required": [ + "specification_revision", + "specification_sha256", + "facts_sha256", + "semantic_claims_sha256", + "acceptance_criteria_sha256" + ], + "properties": { + "specification_revision": { + "type": "integer", + "minimum": 1 + }, + "specification_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "facts_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "semantic_claims_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "acceptance_criteria_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + } + }, + "results": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "ac_id", + "status", + "evidence", + "notes" + ], + "properties": { + "ac_id": { + "type": "string", + "pattern": "^AC-[0-9]+$" + }, + "status": { + "enum": [ + "passed", + "failed", + "blocked", + "not_tested" + ] + }, + "evidence": { + "type": "array", + "minItems": 0, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "reference", + "sha256" + ], + "properties": { + "type": { + "enum": [ + "command", + "api", + "ui", + "live_database", + "observation" + ] + }, + "reference": { + "type": "string", + "format": "portable-repository-path" + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + } + } + }, + "notes": { + "type": "string", + "pattern": "\\S" + } + } + } + }, + "defects": { + "type": "array", + "minItems": 0, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "ac_ids", + "status", + "severity", + "summary" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^DEF-[A-Z0-9-]+$" + }, + "ac_ids": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "pattern": "^AC-[0-9]+$" + }, + "uniqueItems": true + }, + "status": { + "enum": [ + "open", + "resolved" + ] + }, + "severity": { + "enum": [ + "critical", + "high", + "medium", + "low" + ] + }, + "summary": { + "type": "string", + "pattern": "\\S" + } + } + } + }, + "recommendation": { + "enum": [ + "ready", + "changes_requested" + ] + }, + "collector_evidence": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "reference", + "sha256", + "status" + ], + "properties": { + "reference": { + "type": "string", + "pattern": "^evidence/commands/EVD-[0-9a-f]{16}(?:-(?:[2-9]|[1-9][0-9]+))?/evidence\\.json$" + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "status": { + "enum": [ + "passed", + "failed" + ] + } + } + } + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://codex-sdlc.local/schemas/compact-qc.schema.json", + "title": "Compact independent verification" +} diff --git a/assets/schemas/compact-specification.schema.json b/assets/schemas/compact-specification.schema.json new file mode 100644 index 0000000..8f3988f --- /dev/null +++ b/assets/schemas/compact-specification.schema.json @@ -0,0 +1,330 @@ +{ + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "run_id", + "task_id", + "producer", + "revision", + "facts_sha256", + "scope", + "requirements", + "acceptance_criteria", + "api_change" + ], + "properties": { + "schema_version": { + "const": 1 + }, + "run_id": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9]*-[0-9]+$" + }, + "task_id": { + "const": "BA-001" + }, + "producer": { + "const": "ba" + }, + "revision": { + "type": "integer", + "minimum": 1 + }, + "facts_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "scope": { + "type": "object", + "additionalProperties": false, + "required": [ + "summary", + "in_scope", + "out_of_scope" + ], + "properties": { + "summary": { + "type": "string", + "pattern": "\\S" + }, + "in_scope": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "pattern": "\\S" + }, + "uniqueItems": true + }, + "out_of_scope": { + "type": "array", + "minItems": 0, + "items": { + "type": "string", + "pattern": "\\S" + }, + "uniqueItems": true + } + } + }, + "requirements": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "description", + "fact_ids" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^REQ-[0-9]+$" + }, + "description": { + "type": "string", + "pattern": "\\S" + }, + "fact_ids": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "pattern": "^FACT-[0-9]+$" + }, + "uniqueItems": true + } + } + } + }, + "acceptance_criteria": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "requirement_id", + "given", + "when", + "then", + "evidence_types" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^AC-[0-9]+$" + }, + "requirement_id": { + "type": "string", + "pattern": "^REQ-[0-9]+$" + }, + "given": { + "type": "string", + "pattern": "\\S" + }, + "when": { + "type": "string", + "pattern": "\\S" + }, + "then": { + "type": "string", + "pattern": "\\S" + }, + "evidence_types": { + "type": "array", + "minItems": 1, + "items": { + "enum": [ + "command", + "api", + "ui", + "live_database", + "observation" + ] + }, + "uniqueItems": true + } + } + } + }, + "api_change": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "description" + ], + "properties": { + "kind": { + "enum": [ + "unchanged", + "additive" + ] + }, + "description": { + "type": "string", + "pattern": "\\S" + } + } + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://codex-sdlc.local/schemas/compact-specification.schema.json", + "title": "Compact business specification", + "$defs": { + "input": { + "type": "object", + "additionalProperties": false, + "required": [ + "scope", + "requirements", + "acceptance_criteria", + "api_change" + ], + "properties": { + "scope": { + "type": "object", + "additionalProperties": false, + "required": [ + "summary", + "in_scope", + "out_of_scope" + ], + "properties": { + "summary": { + "type": "string", + "pattern": "\\S" + }, + "in_scope": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "pattern": "\\S" + }, + "uniqueItems": true + }, + "out_of_scope": { + "type": "array", + "minItems": 0, + "items": { + "type": "string", + "pattern": "\\S" + }, + "uniqueItems": true + } + } + }, + "requirements": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "description", + "fact_ids" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^REQ-[0-9]+$" + }, + "description": { + "type": "string", + "pattern": "\\S" + }, + "fact_ids": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "pattern": "^FACT-[0-9]+$" + }, + "uniqueItems": true + } + } + } + }, + "acceptance_criteria": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "requirement_id", + "given", + "when", + "then", + "evidence_types" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^AC-[0-9]+$" + }, + "requirement_id": { + "type": "string", + "pattern": "^REQ-[0-9]+$" + }, + "given": { + "type": "string", + "pattern": "\\S" + }, + "when": { + "type": "string", + "pattern": "\\S" + }, + "then": { + "type": "string", + "pattern": "\\S" + }, + "evidence_types": { + "type": "array", + "minItems": 1, + "items": { + "enum": [ + "command", + "api", + "ui", + "live_database", + "observation" + ] + }, + "uniqueItems": true + } + } + } + }, + "api_change": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "description" + ], + "properties": { + "kind": { + "enum": [ + "unchanged", + "additive" + ] + }, + "description": { + "type": "string", + "pattern": "\\S" + } + } + } + } + } + } +} diff --git a/assets/schemas/delivery-assignment.schema.json b/assets/schemas/delivery-assignment.schema.json index f04ae6b..bc07a51 100644 --- a/assets/schemas/delivery-assignment.schema.json +++ b/assets/schemas/delivery-assignment.schema.json @@ -155,7 +155,7 @@ "repositoryPath": { "type": "string", "minLength": 1, - "pattern": "^(?:\\.|(?![A-Za-z]:)(?!/)(?!.*\\\\)(?!.*(?:^|/)\\.\\.?(?:/|$))[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)*)$" + "format": "portable-repository-path" }, "repositoryId": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, "taskStatus": { diff --git a/assets/schemas/project.schema.json b/assets/schemas/project.schema.json index e4cb40f..ea13e71 100644 --- a/assets/schemas/project.schema.json +++ b/assets/schemas/project.schema.json @@ -246,7 +246,7 @@ "args": { "type": "array", "items": { "type": "string" } }, "cwd": { "type": "string", - "pattern": "^(?:\\./?|(?!\\.{1,2}(?:/|$))(?!(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\\.|/|$))[a-z0-9._-]*[a-z0-9_-](?:/(?!\\.{1,2}(?:/|$))(?!(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\\.|/|$))[a-z0-9._-]*[a-z0-9_-])*(?:/\\.)?)$" + "format": "portable-command-cwd" }, "network": { "enum": ["disabled", "restricted", "required"] }, "mutates": { "type": "boolean" }, @@ -267,7 +267,7 @@ "args": { "type": "array", "items": { "type": "string" } }, "cwd": { "type": "string", - "pattern": "^(?:\\./?|(?!\\.{1,2}(?:/|$))(?!(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\\.|/|$))[a-z0-9._-]*[a-z0-9_-](?:/(?!\\.{1,2}(?:/|$))(?!(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\\.|/|$))[a-z0-9._-]*[a-z0-9_-])*(?:/\\.)?)$" + "format": "portable-command-cwd" } } } diff --git a/assets/schemas/run.schema.json b/assets/schemas/run.schema.json index 8869077..5cfd800 100644 --- a/assets/schemas/run.schema.json +++ b/assets/schemas/run.schema.json @@ -6,6 +6,9 @@ "additionalProperties": false, "required": ["schema_version", "run", "request", "scope", "affected_applications", "design", "assumptions", "open_questions", "decisions", "blockers", "tasks", "quality_gates", "defects", "product_owner_review", "final_result"], "properties": { + "workflow_profile": {"type": "object", "additionalProperties": false, "required": ["name", "revision", "assessment"], "properties": {"name": {"const": "compact"}, "revision": {"const": 1}, "assessment": {"type": "object", "additionalProperties": false, "required": ["bounded_scope", "existing_patterns", "migrations", "breaking_api", "authorization_changes", "sensitive_data_exposure", "cross_system_uncertainty", "rationale"], "properties": {"bounded_scope": {"const": true}, "existing_patterns": {"const": true}, "migrations": {"const": false}, "breaking_api": {"const": false}, "authorization_changes": {"const": false}, "sensitive_data_exposure": {"const": false}, "cross_system_uncertainty": {"const": false}, "rationale": {"type": "string", "pattern": "\\S"}}}}}, + "compact_review": {"type": "object", "additionalProperties": false, "required": ["specification_revision", "specification_sha256", "facts_sha256", "semantic_claims_sha256", "acceptance_criteria_sha256"], "properties": {"specification_revision": {"type": "integer", "minimum": 1}, "specification_sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, "facts_sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, "semantic_claims_sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, "acceptance_criteria_sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"}}}, + "repair_history": { "type": "array", "items": { "$ref": "#/$defs/repair" } }, "schema_version": { "const": 1 }, "agent_policy": { "$ref": "agent-policy.schema.json" }, "run": { "$ref": "#/$defs/run" }, @@ -102,6 +105,94 @@ } }, "$defs": { + "repair": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "task_id", + "defect_id", + "reason", + "actor", + "at", + "affected_task_ids", + "tasks", + "quality_gates", + "archives" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^RPR-[0-9]+$" + }, + "task_id": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9]*-[0-9]+$" + }, + "defect_id": { + "type": "string", + "pattern": "^DEF-[A-Z0-9-]+$" + }, + "reason": { + "type": "string", + "minLength": 1 + }, + "actor": { + "const": "pm" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "affected_task_ids": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "tasks": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "task.schema.json" + } + }, + "quality_gates": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/qualityGate" + } + }, + "archives": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "archive_path", + "sha256" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "archive_path": { + "type": "string", + "minLength": 1 + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + } + } + } + } + }, "run": { "type": "object", "additionalProperties": false, @@ -111,11 +202,11 @@ "title": { "type": "string", "minLength": 1 }, "workflow": { "const": "feature-development" }, "status": { "enum": ["draft", "intake", "requirements", "awaiting_approval", "technical_design", "implementation", "integration", "qc", "product_owner_review", "completed", "blocked", "failed", "cancelled"] }, - "current_stage": { "enum": ["intake", "requirements", "requirements_review", "api_contract", "api_contract_review", "backend_implementation", "web_implementation", "mobile_implementation", "integration", "qc", "product_owner_review"] }, + "current_stage": { "enum": ["intake", "requirements", "requirements_review", "api_contract", "api_contract_review", "backend_implementation", "web_implementation", "mobile_implementation", "integration", "qc", "product_owner_advisory", "product_owner_review"] }, "created_at": { "type": ["string", "null"], "format": "date-time" }, "updated_at": { "type": ["string", "null"], "format": "date-time" }, "created_by": { "type": "string", "minLength": 1 }, - "current_owner": { "enum": ["product-owner", "pm", "ba", "backend", "frontend", "qc"] }, + "current_owner": { "enum": ["product-owner", "pm", "ba", "backend", "frontend", "qc", "po"] }, "branch": { "type": ["string", "null"] }, "framework_version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } diff --git a/assets/schemas/task.schema.json b/assets/schemas/task.schema.json index 57f1e4a..ce144c9 100644 --- a/assets/schemas/task.schema.json +++ b/assets/schemas/task.schema.json @@ -6,6 +6,7 @@ "additionalProperties": false, "required": ["id", "title", "stage", "role", "target", "status", "dependencies", "required_inputs", "required_outputs", "outputs", "evidence", "transitions", "started_at", "completed_at", "commit", "blocker_reason", "failure_reason"], "properties": { + "activation_offset": { "type": "integer", "minimum": 0 }, "agent_dispatches": { "type": "array", "items": { "$ref": "agent-dispatch.schema.json" } }, "id": { "type": "string", "pattern": "^[A-Z][A-Z0-9]*-[0-9]+$" }, "title": { "type": "string", "minLength": 1 }, diff --git a/docs/cli-reference.md b/docs/cli-reference.md index b8a07d3..f6dcf97 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -4,6 +4,8 @@ For the first-run walkthrough, read [Getting started](getting-started.md). Retur ## Build and test +Version 1.0.0 is in development. Release-pinned 1.0.0 commands below apply after publication; use the local tarball procedure while testing this branch. + Requirements: Node.js `>=24.16.0 <25` and npm 11. ```sh @@ -15,11 +17,46 @@ npm pack Install the published CLI with: ```sh -npm install --global codex-sdlc@0.6.0 +npm install --global codex-sdlc@1.0.0 ``` ## Project setup modes +### Opt-in Compact runs + +Full remains the default. To start a new Compact run, provide a complete project-local JSON risk assessment as described in the [Compact guide](../skills/sdlc-pm/references/compact-workflow.md): + +```sh +node .sdlc/runtime.cjs start --id CHANGE-001 --title "Requested change" \ + --request .sdlc/requests/change.md --applications web \ + --profile compact --assessment .sdlc/requests/change-assessment.json --json +node .sdlc/runtime.cjs compact-spec CHANGE-001 --json < specification-input.json +node .sdlc/runtime.cjs compact-qc CHANGE-001 --json < qc-input.json +``` + +`compact-spec` runs only while BA-001 is running, and `compact-qc` only while QC-001 is running. Both consume strict JSON semantic inputs, derive metadata/views, support `--dry-run` and `--expected-version`, and leave transitions/gates for the appropriate reviewer. The commands shown above occur at different workflow stages, not consecutively without the intervening implementation and reviews. + +An assessment is valid only with `--profile compact`. It must confirm bounded scope and existing patterns and explicitly exclude migrations, breaking APIs, authorization changes, new sensitive-data exposure, and unresolved cross-system risk. Unknown risk fails selection. A saved profile and reviewed specification binding are immutable; changed scope needs a new run. Existing runs and model presets are unchanged. + +Compact requires both integration and QC gates, with integration performed inside QC. Approved or handed-off verification cannot be rerun silently: reopen an uncompleted QC review and reset both gates before executing further checks, then regenerate the verification record. Product fixes use the existing repair cycle. See [benchmark methodology](workflow-benchmark.md) for reproducible framework-only measurements. + +### Runtime-assisted delivery (1.0.0) + +Run these from the initialized coordinator. The workflow, permitted paths, configured commands, actual model dispatch, and independent review remain authoritative. + +| Command | Input and effect | +| --- | --- | +| `preflight --applications backend,web --expect-root web=apps/platform --json` | Read-only local readiness inspection; supports `--root`, repeated `--expect-root`, `--require-file`, and `--command`. Does not test live services/data. | +| `prepare-task RUN-001 WEB-001 --json` | JSON stdin: `{controls, commandIds}`. Generates the canonical assignment and task packet; does not activate. Supports `--dry-run` and `--expected-version`. | +| `activate-task RUN-001 WEB-001 --reason "Reviewed assignment and real dispatch" --json` | JSON stdin: existing `{plan, agent_id, actual_model, actual_reasoning_effort, observation_source}`. Records host dispatch, then starts a ready task. Exact retry is idempotent. | +| `check-task RUN-001 WEB-001 --json` | Runs assigned required commands in order and stops at failure. Optional repeated `--command` must be assigned. Other task roles need explicit command IDs. Does not approve a gate. | +| `handoff-task RUN-001 WEB-001 --json` | JSON stdin: `{requirementOutcomes, changedFiles}`. Outcomes identify both `requirement_id` and `capability`. Generates metadata and requests review; never completes. Supports preview/version check. | +| `repair-task RUN-001 WEB-001 --defect DEF-001 --actor pm --reason "Acceptance failure" --json` | Archives a completed or review-rejected implementation and invalidates dependent tasks. Supports `--dry-run`. New assignment/dispatch/evidence is required. | +| `recover-repair RUN-001 --actor pm --json` | Completes or rolls back an interrupted repair only if journal, manifest, file bytes, and authority version still match. | +| `timing RUN-001 --json` | Reports lifecycle state intervals and recorded collector execution, including archived repair cycles. | + +Use the [task-operation guide](../skills/sdlc-pm/references/task-operations.md) for complete JSON examples and boundaries. Lower-level lifecycle and publication commands remain available for existing/manual workflows and exceptional approval/scaffolding cases. Repaired runs retain their history and need a 1.0.0-capable runtime; unrepaired legacy runs remain supported. Existing model presets keep their original meaning. + ### Project model modes After initialization, the 0.6.0 runtime accepts: @@ -112,9 +149,9 @@ Use the `generic` application preset or `none` database preset when a listed pre Install the generated tarball, preview the bounded changes, and then initialize. During local testing, pin the generated repository launcher to the tarball: ```sh -npm install --global ./codex-sdlc-0.6.0.tgz -codex-sdlc init --root /path/to/project --name example --applications web --web-root . --web-preset nextjs --runtime-spec file:/absolute/path/codex-sdlc-0.6.0.tgz --dry-run -codex-sdlc init --root /path/to/project --name example --applications web --web-root . --web-preset nextjs --runtime-spec file:/absolute/path/codex-sdlc-0.6.0.tgz +npm install --global ./codex-sdlc-1.0.0.tgz +codex-sdlc init --root /path/to/project --name example --applications web --web-root . --web-preset nextjs --runtime-spec file:/absolute/path/codex-sdlc-1.0.0.tgz --dry-run +codex-sdlc init --root /path/to/project --name example --applications web --web-root . --web-preset nextjs --runtime-spec file:/absolute/path/codex-sdlc-1.0.0.tgz cd /path/to/project node .sdlc/runtime.cjs restore ``` @@ -133,8 +170,8 @@ The installer preserves existing `AGENTS.md` and `.gitignore` content, refuses c Preview and apply an upgrade with the new runtime package pinned into the repository: ```sh -codex-sdlc upgrade --root /path/to/project --runtime-spec file:/absolute/path/codex-sdlc-0.6.0.tgz --dry-run -codex-sdlc upgrade --root /path/to/project --runtime-spec file:/absolute/path/codex-sdlc-0.6.0.tgz +codex-sdlc upgrade --root /path/to/project --runtime-spec file:/absolute/path/codex-sdlc-1.0.0.tgz --dry-run +codex-sdlc upgrade --root /path/to/project --runtime-spec file:/absolute/path/codex-sdlc-1.0.0.tgz cd /path/to/project node .sdlc/runtime.cjs restore node .sdlc/runtime.cjs doctor diff --git a/docs/getting-started.md b/docs/getting-started.md index cea3c3d..bb55bb5 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -4,6 +4,8 @@ codex-sdlc combines a Codex skills plugin with a deterministic npm CLI. The plug ## Requirements +Version 1.0.0 includes opt-in Compact delivery and runtime-assisted task operations. Update both the plugin and project runtime to use them; existing Full-mode runs and model presets are preserved. + - Codex with the public **codex-sdlc** plugin installed. - Node.js `>=24.16.0 <25` and npm 11. - One or more existing application directories. The initializer configures applications; it does not scaffold product code. @@ -14,13 +16,15 @@ codex-sdlc combines a Codex skills plugin with a deterministic npm CLI. The plug Open the repository that should own `.sdlc` and start a new Codex task: ```text -Initialize codex-sdlc for this existing repository. Explain the setup choices and show the dry run before applying changes. If the CLI is unavailable, use codex-sdlc@0.6.0 through npx. +Initialize codex-sdlc for this existing repository. Explain the setup choices and show the dry run before applying changes. If the CLI is unavailable, use codex-sdlc@1.0.0 through npx. ``` Tell Codex which applications already exist and where they live. Supported presets are Go for backend, Next.js for web, Flutter for mobile, PostgreSQL for the primary database, and Redis for cache. Codex will inspect the repository, show the proposed files, apply the same command without `--dry-run`, restore the pinned project runtime, and run diagnostics. ## Common project shapes +For a new bounded feature using established patterns, ask `$sdlc --compact `. Codex assesses eligibility before selecting Compact. Full remains the default and is required for migrations, breaking APIs, permission changes, new sensitive-data exposure, or unresolved risk. Compact is per run and does not change the project's model preset. See the [Compact workflow](../skills/sdlc-pm/references/compact-workflow.md). + | Shape | Example request | | --- | --- | | Web only | `Initialize this existing Next.js repository as a web-only codex-sdlc project.` | @@ -34,7 +38,7 @@ Tell Codex which applications already exist and where they live. Supported prese Preview first: ```sh -npx --yes codex-sdlc@0.6.0 init --root /absolute/path/to/web --name example-web \ +npx --yes codex-sdlc@1.0.0 init --root /absolute/path/to/web --name example-web \ --applications web --web-root . --web-preset nextjs --dry-run ``` @@ -54,7 +58,7 @@ One checkout is the coordinator and owns `.sdlc`. The reserved repository ID `co This example uses the frontend checkout as the coordinator and maps a backend elsewhere: ```sh -npx --yes codex-sdlc@0.6.0 init \ +npx --yes codex-sdlc@1.0.0 init \ --root /absolute/path/to/frontend \ --name example-platform \ --workspace-mode multi-repository \ @@ -75,9 +79,9 @@ node .sdlc/runtime.cjs doctor node .sdlc/runtime.cjs validate-config ``` -Only the coordinator receives `.sdlc`. The plugin skills remain in each user's Codex plugin cache. Another contributor binds their local checkout paths with `npx --yes codex-sdlc@0.6.0 configure --root --repo =` and reruns `doctor`. +Only the coordinator receives `.sdlc`. The plugin skills remain in each user's Codex plugin cache. Another contributor binds their local checkout paths with `npx --yes codex-sdlc@1.0.0 configure --root --repo =` and reruns `doctor`. -Choose single-repository or multi-repository mode before starting delivery. Version 0.6.0 does not automatically convert an initialized single-repository project to multi-repository mode. Preserve existing runs before changing topology. +Choose single-repository or multi-repository mode before starting delivery. Version 1.0.0 does not automatically convert an initialized single-repository project to multi-repository mode. Preserve existing runs before changing topology. ## Choose agent models (optional) @@ -90,7 +94,7 @@ You can also ask: `Enable an advisory AI Product Owner review using Sol.` The re After installing the new plugin version, preview the project runtime upgrade: ```sh -npx --yes codex-sdlc@0.6.0 upgrade --root /absolute/path/to/coordinator --dry-run +npx --yes codex-sdlc@1.0.0 upgrade --root /absolute/path/to/coordinator --dry-run ``` Run the same command without `--dry-run`, then run `node .sdlc/runtime.cjs restore`, `doctor`, and `validate-config` from the coordinator. The plugin update supplies skills; the project upgrade supplies commands and schemas. diff --git a/docs/marketplace-submission.md b/docs/marketplace-submission.md index 8bd9ddf..ce57a3c 100644 --- a/docs/marketplace-submission.md +++ b/docs/marketplace-submission.md @@ -14,7 +14,7 @@ Long description: -Set up and coordinate resumable software delivery across one repository or multiple Git checkouts, with configurable models by role, independent quality control, and optional advisory AI Product Owner review. Final acceptance remains human. codex-sdlc records tasks, approvals, evidence, defects, and release decisions in a coordinator repository so work can be validated and resumed. The public plugin supplies Codex skills; its pinned npm CLI creates and operates the repository-local `.sdlc` framework. +Set up and coordinate resumable software delivery across one repository or multiple Git checkouts, with configurable models by role, independent quality control, and optional advisory AI Product Owner review. Choose Full delivery or opt-in Compact for bounded features using established patterns. Runtime helpers prepare assignments, collect checks, generate handoffs, and preserve repair history. Final acceptance remains human. codex-sdlc records tasks, approvals, evidence, defects, and release decisions in a coordinator repository so work can be validated and resumed. The public plugin supplies Codex skills; its pinned npm CLI creates and operates the repository-local `.sdlc` framework. ## Starter prompts @@ -72,7 +72,14 @@ Set up and coordinate resumable software delivery across one repository or multi - Expected behavior: Preview and save inherited PM, `gpt-6-sol/high` for BA, and `gpt-6-luna/xhigh` for backend, web/mobile frontend, and QC in `.sdlc/project.yaml`. Preserve PO settings. A mode-only request does not start a feature run; every subsequently started run snapshots the preset. - User prompt: `$sdlc --normal` in the same project. - Expected behavior: Remove model, effort, and fallback overrides for the five delivery roles. Preserve existing run snapshots and optional PO settings. New runs inherit their delivery-role models. -- Fixture: A disposable initialized project using plugin/runtime 0.6.0. The host must advertise the requested models and efforts to exercise actual dispatch; saving and previewing configuration need no model dispatch. +- Fixture: A disposable initialized project using plugin/runtime 1.0.0. The host must advertise the requested models and efforts to exercise actual dispatch; saving and previewing configuration need no model dispatch. + +### 8. Deliver a bounded feature with Compact + +- User prompt: `$sdlc --compact Add a display-name length hint beside the existing profile input, using the current validation rule and UI pattern.` +- Expected behavior: The PM assesses bounded scope, established patterns, and the absence of migration, breaking API, authorization, sensitive-data, or unresolved cross-system changes. It starts a new Compact run, reviews the BA specification, delegates affected implementation, and requires independent QC with current evidence and both integration and QC gates. +- Expected result: The run saves its Compact profile and immutable review binding; generated claims and acceptance views match the canonical specification. Final human acceptance stays pending. Existing runs and saved model presets are unchanged. +- Fixture: A disposable initialized project using plugin/runtime 1.0.0, an existing profile form and validation rule, runnable declared checks, and a host that can enforce the required evidence isolation. ## Negative tests @@ -109,9 +116,19 @@ Set up and coordinate resumable software delivery across one repository or multi - User prompt: `$sdlc --save-my-token --normal`. - Expected behavior: Explain that the flags conflict and leave project configuration unchanged. The equivalent runtime command fails before writing. +### 7. Reject ineligible Compact work + +- User prompt: `$sdlc --compact Replace the authorization model and migrate existing permissions.` +- Expected behavior: Explain that authorization changes and migrations require Full delivery. Do not start a Compact run or weaken its eligibility assessment. + +### 8. Reject stale or failed Compact verification + +- Scenario: QC evidence predates the current repair cycle, belongs to another task, is future-dated, or has been superseded by a failed check. +- Expected behavior: Reject the evidence for readiness and require current independent verification. Do not reuse a prior passing result to hide a later failure. + ## Release notes -Version 0.6.0 adds the sdlc entry skill and persistent project model presets. `/sdlc --save-my-token` inherits PM, selects Sol/high for BA, and Luna/extra-high for backend, web/mobile frontend, and QC. `/sdlc --normal` restores inherited models for those roles. Explicit `$sdlc` invocation is supported when a client reserves slash commands. Settings apply to every new run while preserving existing run policies, optional AI Product Owner settings, and other projects. Both CLI presets support dry-run previews and reject conflicting settings. Final acceptance remains human. +Version 1.0.0 adds opt-in Compact delivery for bounded features, with one canonical BA specification, generated supporting views, and independent integrated QC. Full remains the default. New runtime helpers handle task preparation, activation, checks, handoffs, repair history, preflight, and timing. Configured application roots and Next.js routes are handled consistently, and multiple technical capabilities can share a business requirement. Existing project model presets remain available. PM review, current verification evidence, and final human acceptance are preserved. A scripted fixture measured about 22% lower framework execution time for Compact versus the new Full workflow; real-agent delivery time and token savings are not yet measured. ## Portal prerequisites @@ -130,12 +147,12 @@ Version 0.6.0 adds the sdlc entry skill and persistent project model presets. `/ Submit the skills-only plugin from the release source tree. The bundle root contains `plugin.json`, `skills/`, `.codex-plugin/plugin.json`, `README.md`, `docs/getting-started.md`, `docs/agent-models.md`, and the referenced brand assets. It requires no MCP server, authentication configuration, demo credentials, or network allowlist. -Build the upload ZIP and npm tarball from the reviewed 0.6.0 source. The prepared artifacts are under `build/public-submission/0.6.0/`; verify their hashes using that directory’s `SHA256SUMS` before upload. +Build the upload ZIP and npm tarball from the reviewed 1.0.0 source. The prepared artifacts are under `build/public-submission/1.0.0/`; verify their hashes using that directory’s `SHA256SUMS` before upload. ## Release order -1. Tag the reviewed source revision as `v0.6.0` and publish the GitHub release using `docs/releases/0.6.0.md`. -2. Wait for `.github/workflows/publish.yml` to publish `codex-sdlc@0.6.0` to npm with provenance, then verify that the public registry serves 0.6.0. Do not submit the plugin update until npm serves 0.6.0. +1. Tag the reviewed source revision as `v1.0.0` and publish the GitHub release using `docs/releases/1.0.0.md`. +2. Wait for `.github/workflows/publish.yml` to publish `codex-sdlc@1.0.0` to npm with provenance, then verify that the public registry serves 1.0.0. Do not submit the plugin update until npm serves 1.0.0. 3. Open the existing codex-sdlc listing in the OpenAI plugin submission portal, create a version update, keep the type **Skills only**, and upload the prepared skills bundle and listing logo. 4. Copy the listing, prompts, tests, availability, and release notes from this document; complete publisher identity and policy attestations. 5. Submit for review. After approval, explicitly publish the approved version to the universal Plugins Directory. diff --git a/docs/releases/1.0.0.md b/docs/releases/1.0.0.md new file mode 100644 index 0000000..298ec81 --- /dev/null +++ b/docs/releases/1.0.0.md @@ -0,0 +1,53 @@ +# codex-sdlc 1.0.0 + +Release date: 2026-09-25. + +## Runtime-assisted delivery + +The runtime handles assignment metadata, dispatch recording, ordered verification, and file-backed handoff reports. Agents supply substantive controls, implementation outcomes, and an explicit task-owned subset of Git changes. PM review and independent QC remain required. + +- `preflight` inspects selected application roots, local fixture paths, command declarations and executable availability without starting services or executing tests. It explicitly leaves service connectivity, populated data, and actual host model availability unchecked. +- `prepare-task` generates assignment identity, revisions, file hashes, permission roots, input/output inventory, and available evidence from repository authority. +- `activate-task` records a real host dispatch and starts the ready task. It does not launch an LLM or invent actual model observations. +- `check-task` executes assigned checks in order, preserves real failures, and leaves quality-gate approval to the reviewer. +- `handoff-task` validates actual selected Git changes, stable source reads, verification after source modification, artifact integrity, and semantic report reconciliation. It publishes a deterministic receipt and requests review. An exact retry is safe; changed source, outcomes, or evidence requires rework. +- `repair-task` archives a completed implementation or rejected `awaiting_review` handoff, preserves historical artifacts and task records, and resets affected downstream verification. New revisions, dispatch activations, and evidence are required. `recover-repair` protects recovery with file/manifest hashes and version checks; pending recovery blocks normal mutations and evidence execution. +- `timing` distinguishes recorded lifecycle intervals, recoverable failure/retry time, and collector execution. Overlapping task totals are not elapsed wall time or inferred model computation. + +## Opt-in Compact workflow + +New bounded features can select `start --profile compact --assessment ` or the `$sdlc --compact ` skill route. Full remains the default, and no saved run is converted. A complete assessment must establish existing patterns and exclude migrations, breaking APIs, changed authorization, new sensitive-data exposure, and unresolved cross-system risk. + +Compact preserves BA, PM review, affected implementation, independent QC, optional AI Product Owner review, and human acceptance. `compact-spec` derives claims and acceptance views from one canonical specification; `compact-qc` derives a summary and readiness from explicit per-criterion evidence. Integration is verified within QC and its gate remains required. Reviewed facts/specification hashes are immutable, and repairs invalidate both integration and QC. Latest failed checks, superseded proofs, missing observations, and future timestamps cannot satisfy readiness. + +See the [Compact guide](../../skills/sdlc-pm/references/compact-workflow.md) and [benchmark methodology](../workflow-benchmark.md). Model presets are unchanged. Scripted fixture timings are not a measured end-to-end agent speedup. + +## Compatibility fixes + +Configured roots such as `apps/api` and `apps/platform` are checked against project-derived authority rather than naming conventions. Next.js route groups, dynamic segments, and other portable literal path components are accepted consistently; traversal, reserved names, symlink escapes, cross-target writes, and wrong repositories remain rejected. + +Capabilities retain unique technical identities, while several capabilities may reference one `REQ-*`. Existing v1 assignment/report shapes remain supported. Agents no longer need to split business requirements merely to fill technical slots. + +## Upgrade and limits + +Update both plugin and repository runtime to 1.0.0. The Plugins Directory update is published separately from npm and GitHub; check the installed plugin version before using Compact or the new helpers. Preview the project runtime upgrade: + +```sh +npx --yes codex-sdlc@1.0.0 upgrade --root /absolute/path/to/coordinator --dry-run +``` + +Run the same command without `--dry-run`, then run `node .sdlc/runtime.cjs restore`, `doctor`, and `validate-config` from the coordinator. New helper commands require this runtime; existing lower-level commands remain available. + +Unrepaired legacy runs retain their existing shape. An explicitly repaired run adds optional repair-history/activation-lineage fields and requires a 1.0.0-capable reader; do not downgrade that runtime while retaining the repaired run. Model policy snapshots and `--save-my-token`/`--normal` semantics are unchanged. + +Automatic handoff supports existing changed regular files, including new untracked files. It verifies the caller-declared task-owned subset of actual uncommitted Git changes, not completeness of ownership attribution. Deleted product paths and already-committed-only changes are not supported by that automatic path. Ineligible approval, dependency, or scaffolding cases retain the lower-level planning/hold workflow; no helper can bypass a prohibition. + +This release preserves Full as the default delivery graph and adds opt-in Compact revision 1 for new runs. Parallel scheduling, evidence reuse, broad application registries, and different model defaults are deferred. No end-to-end model latency improvement is claimed without a matched real-agent benchmark. + +## Validation + +All 182 tests across 20 test files passed, along with TypeScript checks, the build, all eight skill validations, and plugin validation. Automated coverage includes real-layout report reconciliation, repeated requirement mappings, Full and Compact lifecycle/repair cycles, Compact eligibility and immutable review bindings, stale/failed/future evidence rejection, interrupted repair recovery, preflight, timing, and legacy installation/run behavior. + +A fresh installation of the built npm tarball passed the Compact CLI lifecycle through specification, implementation handoff, independent QC records, both verification gates, and finalization with human acceptance still pending. The earlier Full-package checks covered launcher restore, doctor, preflight, model presets, run creation, and timing. An independent CLI test now builds its own temporary CLI so a clean checkout does not depend on an existing `dist` folder. No production service or user project was used as a writable test fixture. + +In three scripted repetitions per profile, the same acceptance checks rejected the same permission, pagination, and mapping defects. Candidate Full used 7 tasks/24 required outputs and a median 4.43s fixture wall time; Compact used 5 tasks/11 required outputs and 3.44s (about 22% lower). Those are synthetic framework measurements only. Actual model execution speed, token savings, and an ordinary-prompt comparison remain unmeasured. diff --git a/docs/workflow-benchmark.md b/docs/workflow-benchmark.md new file mode 100644 index 0000000..0a677f7 --- /dev/null +++ b/docs/workflow-benchmark.md @@ -0,0 +1,56 @@ +# Full and Compact workflow benchmark + +This is a reproducible local framework benchmark, not a measurement of real agent implementation time, human review, model reasoning, or token savings. + +## Run it + +From a complete repository checkout with its dependencies installed: + +```sh +node scripts/benchmark-workflows.mjs --repetitions 3 +``` + +The script captures baseline commit `17bde3f88bb3b1633c2eb1a8ea9e5b565449076f`, builds it in an isolated ignored directory, builds the candidate, and compares baseline Full, candidate Full, and candidate Compact. The baseline commit must be available in local Git history. `--baseline-only` records just that baseline. Repetitions are bounded from 1 to 20. + +Results are written to `build/benchmarks/baseline.json` or `build/benchmarks/comparison.json`. The script creates and removes disposable fixtures; it does not use a customer project, actual database, external service, or live agent. It checks the candidate source/assets fingerprint before and after the comparison and refuses a completed result if they changed during measurement. + +## Comparable workload + +Every fixture starts from the same small order-list implementation and uses the same source, acceptance test code, and data seed. Five checks cover authentication, tenant permission, one-based pagination, invalid/empty-page behavior, and safe output mapping. Three deliberately broken variants exercise permission leakage, incorrect page offset, and incorrect mapping. Each must fail its check and be refused by the handoff operation; the corrected implementation must pass. + +The script performs the prescribed runtime lifecycle with clearly synthetic role and review records. Full uses fixed documentary fixture content; Compact uses a canonical specification and QC input with generated views. No real PM, BA, implementation agent, or independent human reviewer is simulated as a measured person. Final human acceptance remains pending in every successful fixture. + +Candidate Full must retain the same graph as baseline Full. Source/test hashes and broken-variant outcomes must agree across all suites. Compact combines integration and QC, so repeated invocations of the same acceptance suite differ by design even though the acceptance criteria and test source are identical. + +## What the report means + +| Metric | Meaning | +| --- | --- | +| Task count | Tasks required by the selected graph for this web-only fixture | +| Required outputs | Distinct required workflow output paths | +| Fixture-authored required outputs | Required files populated from fixed fixture content, not measured human/LLM writing | +| Generated required outputs | Required files generated by the runtime | +| Semantic inputs | Separate request, assessment, specification, controls, outcomes, and QC input categories | +| Auxiliary generated artifacts | Assignment, receipt, changed-file inventory, and collector JSON/stdout/stderr files | +| Runtime operation time | Scripted lifecycle/helper work, including validation and I/O | +| Collector wrapper time | Process execution plus evidence publication overhead | +| Collector command time | Duration recorded inside collector evidence; excludes wrappers and agent work | +| Fixture wall time | Entire scripted fixture, including setup and Git/file operations | + +Task and output counts for this fixture are Full **7 tasks / 24 required outputs** and Compact **5 tasks / 11 required outputs**. This demonstrates a smaller prescribed workflow, not an equivalent percentage reduction in real feature delivery time. Compact adds structured inputs and generated views, so total generated file count can increase even while the required documentary workload decreases. + +Use medians from the completed report, and retain the raw samples. A few local repetitions are sensitive to caches, system load, and process startup. They do not establish a production speedup or justify applying the percentage to a previous multi-hour agent run. A future real-agent pilot should use matched starting code, requirements, environment, model policy, and independent acceptance evaluation, with blocked and failed runs retained in the results. + +## Recorded development result + +The completed comparison on Node.js 24.19.0, macOS arm64 used three repetitions per suite. Canonical fact/claim mapping, implementation/test source hashes, all three defect outcomes, and pending human acceptance were checked across all nine runs. + +| Metric | Baseline Full (`17bde3f`) | Candidate Full | Compact | +| --- | ---: | ---: | ---: | +| Tasks | 7 | 7 | 5 | +| Distinct required outputs | 24 | 24 | 11 | +| Runtime operation calls | 42 | 42 | 37 | +| Median scripted fixture wall time | 4.55s | 4.43s | 3.44s | +| Total generated required + auxiliary artifacts | 27 | 27 | 29 | + +Compact's fixture wall time was about 22% lower than candidate Full in this experiment. The reduction in required outputs does not mean every file count or all writing decreased: Compact adds structured semantic inputs and generated views. The small Full-to-Full timing difference is not treated as a demonstrated optimization. Earlier incomplete measurements with noncanonical Full claim fixtures are superseded by the corrected completed comparison. diff --git a/package-lock.json b/package-lock.json index aa82961..fbbe565 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-sdlc", - "version": "0.6.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-sdlc", - "version": "0.6.0", + "version": "1.0.0", "license": "Apache-2.0", "dependencies": { "ajv": "8.20.0", diff --git a/package.json b/package.json index 99ea7a7..67dd9d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-sdlc", - "version": "0.6.0", + "version": "1.0.0", "description": "Evidence-backed SDLC runtime for one or more repositories with Codex", "type": "module", "license": "Apache-2.0", diff --git a/plugin.json b/plugin.json index 375c69f..07e7d55 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "codex-sdlc", - "version": "0.6.0", + "version": "1.0.0", "description": "Run evidence-backed software delivery across one or more repositories with Codex.", "author": { "name": "vimihi", diff --git a/scripts/benchmark-workflows.mjs b/scripts/benchmark-workflows.mjs new file mode 100644 index 0000000..ebc9b0f --- /dev/null +++ b/scripts/benchmark-workflows.mjs @@ -0,0 +1,173 @@ +#!/usr/bin/env node +/** Reproducible synthetic runtime benchmark. Never runs against a user's project. */ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { access, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, resolve } from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { pathToFileURL, fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { parse, stringify } from 'yaml'; +const exec = promisify(execFile); +const repository = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const output = resolve(repository, 'build/benchmarks'); +const baselineRef = '17bde3f88bb3b1633c2eb1a8ea9e5b565449076f'; +const baselineTree = resolve(output, 'baseline-17bde3f'); +const repetitionsIndex = process.argv.indexOf('--repetitions'); +const repetitions = repetitionsIndex < 0 ? 3 : Number(process.argv[repetitionsIndex + 1]); +assert(Number.isInteger(repetitions) && repetitions >= 1 && repetitions <= 20, 'repetitions must be 1..20'); +const baselineOnly = process.argv.includes('--baseline-only'); +const runId = 'BENCH-001'; +const request = 'REQ-001: Add a bounded paginated order view using the existing tenant permission policy and API mapping. AC-001 permissions; AC-002 pagination; AC-003 mapping. No authorization policy, database, or public contract changes.\n'; +const facts = { schema_version: 1, run_id: runId, producer: 'pm', revision: 1, facts: [{ id: 'FACT-001', subject: 'order.view', relation: 'equals', value: 'tenant-scoped paginated public order rows', status: 'approved' }] }; +const claims = { schema_version: 1, run_id: runId, task_id: 'BA-001', producer: 'ba', revision: 1, claims: [{ id: 'CLAIM-001', source_fact_id: 'FACT-001', requirement_ids: ['REQ-001'], question_ids: [], subject: 'order.view', relation: 'equals', value: facts.facts[0].value, status: 'approved' }] }; +const markdown = `---\nrevision: 1\n---\n# Synthetic benchmark specification\n\n## REQ-001\nUse existing tenant permissions, paginate records, and map safe public rows.\n\n## AC-001\nrequirement_id: REQ-001\nUnauthenticated access fails and records from other tenants are excluded.\n\n## AC-002\nrequirement_id: REQ-001\nOne-based pages return the correct slice and reject invalid bounds.\n\n## AC-003\nrequirement_id: REQ-001\nMap state labels without exposing internal fields.\n\nThis is programmatically authored fixture content, not a human or LLM review.\n`; +const goodSource = `exports.listOrders=(user,q,records)=>{if(!user)throw new Error('forbidden');if(!Number.isInteger(q.page)||q.page<1||!Number.isInteger(q.size)||q.size<1||q.size>3)throw new Error('invalid');const allowed=records.filter(r=>r.tenant===user.tenant);const start=(q.page-1)*q.size;return{total:allowed.length,rows:allowed.slice(start,start+q.size).map(r=>({id:r.id,title:r.name,status:r.state==='P'?'Pending':'Done'}))}};\n`; +const variants = { + permission: goodSource.replace('records.filter(r=>r.tenant===user.tenant)', 'records'), + pagination: goodSource.replace('(q.page-1)*q.size', 'q.page*q.size'), + mapping: goodSource.replace("r.state==='P'?'Pending':'Done'", 'r.state'), +}; +const acceptanceSource = `const{test}=require('node:test');const a=require('node:assert/strict');const{listOrders}=require('./feature.cjs');const rows=Array.from({length:7},(_,i)=>({id:i+1,tenant:i===1?'other':'team',name:'Order '+(i+1),state:i%2?'D':'P',secret:'internal-'+i}));const user={tenant:'team'};test('AC-001 authentication',()=>a.throws(()=>listOrders(null,{page:1,size:2},rows),/forbidden/));test('AC-001 tenant permission',()=>{const x=listOrders(user,{page:1,size:3},rows);a.equal(x.total,6);a.deepEqual(x.rows.map(r=>r.id),[1,3,4]);});test('AC-002 one-based pagination',()=>a.deepEqual(listOrders(user,{page:2,size:2},rows).rows.map(r=>r.id),[4,5]));test('AC-002 bounds and empty pages',()=>{for(const q of [{page:0,size:2},{page:1,size:0},{page:1,size:4},{page:1.5,size:2}])a.throws(()=>listOrders(user,q,rows),/invalid/);a.deepEqual(listOrders(user,{page:9,size:2},rows).rows,[]);});test('AC-003 safe response mapping',()=>a.deepEqual(listOrders(user,{page:1,size:1},rows).rows,[{id:1,title:'Order 1',status:'Pending'}]));\n`; +const controls = { api_contract_status: 'approved', gaps: [], questions: [], requirements: ['permission', 'pagination', 'mapping'].map(capability => ({ requirement_id: 'REQ-001', capability, required: true, parameters: {}, source_references: [{ kind: 'fact', reference: 'FACT-001' }] })) }; +const handoffInput = { requirementOutcomes: controls.requirements.map(({ requirement_id, capability }) => ({ requirement_id, capability, status: 'implemented' })), changedFiles: [{ path: 'web/feature.cjs', type: 'source' }] }; +const assessment = { bounded_scope: true, existing_patterns: true, migrations: false, breaking_api: false, authorization_changes: false, sensitive_data_exposure: false, cross_system_uncertainty: false, rationale: 'Add one bounded view using existing tenant access and response mapping conventions; fixture changes neither permission policy nor persistence.' }; +async function exists(path) { try { await access(path); return true; } catch { return false; } } +async function captureBaseline() { + await mkdir(output, { recursive: true }); + // Recreate the owned baseline directory so a previous benchmark cannot contaminate it. + await rm(baselineTree, { recursive: true, force: true }); + await mkdir(baselineTree, { recursive: true }); + const archive = resolve(output, 'baseline.tar'); + await exec('git', ['archive', '--format=tar', `--output=${archive}`, baselineRef], { cwd: repository }); + await exec('tar', ['-xf', archive, '-C', baselineTree]); await rm(archive); + if (!await exists(resolve(baselineTree, 'node_modules'))) await symlink(resolve(repository, 'node_modules'), resolve(baselineTree, 'node_modules')); + await exec(resolve(repository, 'node_modules/.bin/tsc'), ['-p', resolve(baselineTree, 'tsconfig.json')], { cwd: baselineTree }); +} +async function candidateFingerprint() { + const digest=createHash('sha256'); + async function visit(path, label) { + for(const entry of (await readdir(path,{withFileTypes:true})).sort((a,b)=>a.name.localeCompare(b.name))){ + const next=resolve(path,entry.name), identity=`${label}/${entry.name}`; + if(entry.isDirectory())await visit(next,identity); + else if(entry.isFile()){digest.update(identity);digest.update(await readFile(next));} + } + } + for(const directory of ['src','assets'])await visit(resolve(repository,directory),directory); + for(const file of ['package.json','package-lock.json','tsconfig.json']){digest.update(file);digest.update(await readFile(resolve(repository,file)));} + return digest.digest('hex'); +} +async function modules(tree) { + const load = (name) => import(pathToFileURL(resolve(tree, `dist/${name}.js`)).href); + return Object.assign({}, ...await Promise.all(['install', 'runs', 'transitions', 'manifest-transaction', 'task-operations', 'task-activation', 'agents', 'evidence', 'lifecycle', 'finalize', 'quality-gates', 'schemas'].map(load)), await exists(resolve(tree,'dist/compact-artifacts.js')) ? await load('compact-artifacts') : {}); +} +async function scenario(tree, profile, repetition) { + const api = await modules(tree); + const root = await mkdtemp(resolve(tmpdir(), 'codex-sdlc-workflow-benchmark-')); + const totals = { runtime_operations_ms: 0, validation_ms: 0, collector_wrapper_ms: 0, collector_command_execution_ms: 0 }; + const calls = {}; let claimLinks = []; const evidenceRecords = []; const authored = new Set(); const authoredInputs = new Set(); const checks = []; + const wallStart = performance.now(); + const timed = async (group, name, fn) => { const start=performance.now(); try { return await fn(); } finally { totals[group]+=performance.now()-start; calls[name]=(calls[name]??0)+1; } }; + const operation = (name, fn) => timed('runtime_operations_ms',name,fn); + const put = async (path, content, manual=true) => { await mkdir(dirname(resolve(root,path)),{recursive:true});await writeFile(resolve(root,path),content);if(manual&&path.startsWith(`.sdlc/runs/${runId}/`)) authored.add(path.slice(`.sdlc/runs/${runId}/`.length)); }; + const transition = (taskId,to,actor='pm') => operation('transition',()=>api.mutateRunManifest(root,runId,async manifest=>{const request={taskId,to,actor,reason:'Synthetic benchmark lifecycle action',at:new Date().toISOString()};Object.assign(manifest,api.transitionTask(manifest,request,await api.prepareTransitionContext(root,runId,manifest,request)));})); + const collect = async (taskId,command='sdlc_test') => { + const previous=process.env.CODEX_SDLC_NETWORK_POLICY;process.env.CODEX_SDLC_NETWORK_POLICY='disabled'; + try {const record=await timed('collector_wrapper_ms','evidence',()=>api.executeConfiguredCommand(root,runId,taskId,command));totals.collector_command_execution_ms+=Date.parse(record.completed_at)-Date.parse(record.started_at);evidenceRecords.push(record);return record;} + finally{if(previous===undefined)delete process.env.CODEX_SDLC_NETWORK_POLICY;else process.env.CODEX_SDLC_NETWORK_POLICY=previous;} + }; + try { + await put('web/feature.cjs',"exports.listOrders=(user,q,records)=>{if(!user)throw new Error('forbidden');const allowed=records.filter(r=>r.tenant===user.tenant);return{total:allowed.length,rows:allowed}};\n",false);await put('web/acceptance.test.cjs',acceptanceSource,false); + await put('web/requirements-check.cjs',`const fs=require('node:fs');const a=require('node:assert/strict');const facts=JSON.parse(fs.readFileSync('../.sdlc/runs/${runId}/facts.yaml','utf8'));a.equal(facts.facts[0].status,'approved');const request=fs.readFileSync('../.sdlc/runs/${runId}/request.md','utf8');for(const id of ['REQ-001','AC-001','AC-002','AC-003'])a.ok(request.includes(id));\n`,false); + await exec('git',['init','-b','main'],{cwd:root});await exec('git',['add','.'],{cwd:root});await exec('git',['-c','user.name=Benchmark','-c','user.email=benchmark@example.invalid','commit','-m','Synthetic fixture baseline'],{cwd:root}); + await operation('initialize',()=>api.initializeProject({root,projectName:'Synthetic compact comparison',applications:['web'],webRoot:'web',dryRun:false})); + const projectPath=resolve(root,'.sdlc/project.yaml');const project=parse(await readFile(projectPath,'utf8'));project.applications.web.lifecycle='scaffolded'; + project.commands.sdlc_test={executable:process.execPath,args:['--test','acceptance.test.cjs'],cwd:'web',network:'disabled',mutates:false}; + project.commands.sdlc_typecheck={executable:process.execPath,args:['requirements-check.cjs'],cwd:'web',network:'disabled',mutates:false};await writeFile(projectPath,stringify(project)); + await put('.sdlc/requests/feature.md',request,false);authoredInputs.add('feature request');if(profile==='compact')authoredInputs.add('low-risk assessment'); + await operation('start',()=>api.startRun(root,{id:runId,title:'Tenant-scoped paginated order view',requestFile:'.sdlc/requests/feature.md',affectedApplications:{web:true,backend:false,mobile:false,database:false,sharedPackages:false},now:new Date().toISOString(),...(profile==='compact'?{profile,assessment}:{})})); + let manifest=await api.loadRun(root,runId);assert.equal(manifest.workflow_profile?.name??'full',profile);const initialTasks=manifest.tasks.map(task=>({id:task.id,role:task.role,stage:task.stage,required_outputs:task.required_outputs})); + for (const original of initialTasks) { + manifest=await api.loadRun(root,runId);const task=manifest.tasks.find(task=>task.id===original.id);assert.equal(task.status,'ready',`${profile} ${task.id} must be ready`); + if(task.role==='frontend') { + authoredInputs.add('implementation semantic controls'); + await operation('prepare-task',()=>api.prepareTask(root,runId,task.id,{controls,commandIds:['sdlc_test']})); + const capabilities={source:'synthetic benchmark host adapter; no live LLM',model_selection:true,reasoning_selection:true,models:[]}; + const plan=await operation('agent-plan',()=>api.planAgent(root,runId,task.id,capabilities)); + await operation('activate-task',()=>api.activateTask(root,runId,task.id,{plan,agent_id:'synthetic-benchmark-agent',actual_model:null,actual_reasoning_effort:null,observation_source:null},'Synthetic host adapter activation')); + for(const path of task.required_outputs.filter(path=>!path.endsWith('-delivery-report.yaml')))await put(`.sdlc/runs/${runId}/${path}`,markdown); + for(const [variant,source] of Object.entries(variants)) { + await put('web/feature.cjs',source,false);const evidence=await collect(task.id);assert.equal(evidence.result_status,'failed',`${variant} must fail acceptance`); + let refused=false;try{await operation('rejected-handoff',()=>api.handoffTask(root,runId,task.id,handoffInput));}catch(error){assert.match(String(error),/missing passed collector evidence/);refused=true;}assert(refused,`${variant} must not hand off`);checks.push({variant,collector:'failed',handoff:'rejected'}); + } + await put('web/feature.cjs',goodSource,false);const passed=await collect(task.id);assert.equal(passed.result_status,'passed');checks.push({variant:'correct',collector:'passed'}); + authoredInputs.add('implementation requirement outcomes and task-owned file inventory'); + await operation('handoff-task',()=>api.handoffTask(root,runId,task.id,handoffInput)); + await operation('review-gate',()=>api.recordQualityGate(root,runId,'web','passed',[passed.evidence_path],'pm','Synthetic fixed-fixture review; no human acceptance',new Date().toISOString())); + } else { + await transition(task.id,'running',task.role); + if(profile==='compact'&&task.role==='ba') { + assert.equal(typeof api.publishCompactSpecification,'function','Compact specification helper unavailable'); + authoredInputs.add('compact specification semantic input'); + await operation('compact-specification',()=>api.publishCompactSpecification(root,runId,{scope:{summary:request,in_scope:['Tenant-scoped paginated order view'],out_of_scope:['Policy, schema, service or public contract changes']},requirements:[{id:'REQ-001',description:'Apply existing tenant isolation, pagination and safe public row mapping.',fact_ids:['FACT-001']}],acceptance_criteria:['permission','pagination','mapping'].map((label,i)=>({id:`AC-00${i+1}`,requirement_id:'REQ-001',given:'The fixed seeded order records and existing tenant policy',when:`The ${label} acceptance case executes`,then:'Its fixed Node assertions pass',evidence_types:['command']})),api_change:{kind:'unchanged',description:'Uses existing response conventions; no public contract change'}})); + } else if(profile==='compact'&&task.role==='qc') { + assert.equal(typeof api.publishCompactQc,'function','Compact QC helper unavailable'); + const passed=await collect(task.id);assert.equal(passed.result_status,'passed');authoredInputs.add('compact QC acceptance outcomes'); + await operation('compact-qc',()=>api.publishCompactQc(root,runId,{results:[1,2,3].map(i=>({ac_id:`AC-00${i}`,status:'passed',evidence:[{type:'command',reference:passed.evidence_path}],notes:'Executed fixed seed Node acceptance cases; no external service claim.'})),defects:[]})); + for(const gate of ['integration','qc'])await operation('review-gate',()=>api.recordQualityGate(root,runId,gate,'passed',[passed.evidence_path],'pm','Synthetic independent acceptance fixture',new Date().toISOString())); + } else { + for(const path of task.required_outputs.filter(path=>!['request.md','manifest.yaml'].includes(path)))await put(`.sdlc/runs/${runId}/${path}`,path==='facts.yaml'?JSON.stringify(facts,null,2):path.endsWith('semantic-claims.yaml')?stringify(claims):markdown); + } + if(task.role==='ba') { + await timed('validation_ms','validate-semantic-claims',async()=>{ + const packageDocument=parse(await readFile(resolve(root,`.sdlc/runs/${runId}/artifacts/ba/semantic-claims.yaml`),'utf8')); + const validation=api.validateDocument('semanticClaims',packageDocument); + assert.equal(validation.valid,true,validation.diagnostics.join('; ')); + claimLinks=packageDocument.claims.map(claim=>({fact_id:claim.source_fact_id,requirement_ids:claim.requirement_ids,question_ids:claim.question_ids,subject:claim.subject,relation:claim.relation,value:claim.value,status:claim.status})); + assert.deepEqual(claimLinks,[{fact_id:'FACT-001',requirement_ids:['REQ-001'],question_ids:[],subject:facts.facts[0].subject,relation:facts.facts[0].relation,value:facts.facts[0].value,status:'approved'}],'both profiles must preserve the same approved fact and requirement mapping'); + }); + const record=await collect(task.id,'sdlc_typecheck');assert.equal(record.result_status,'passed'); + if(profile==='compact')await transition(task.id,'awaiting_review',task.role); + await operation('review-gate',()=>api.recordQualityGate(root,runId,'requirements','passed',profile==='compact'?['artifacts/ba/specification.yaml']:[record.evidence_path],'pm','Synthetic requirements traceability check',new Date().toISOString())); + } + if(profile!=='compact'&&['integration','qc'].includes(task.stage)) { + const record=await collect(task.id);assert.equal(record.result_status,'passed'); + await operation('review-gate',()=>api.recordQualityGate(root,runId,task.stage,'passed',[record.evidence_path],'pm','Synthetic fixed acceptance review',new Date().toISOString())); + } + if(!(profile==='compact'&&task.role==='ba'))await transition(task.id,'awaiting_review',task.role); + } + await transition(task.id,'completed','pm'); + } + await timed('validation_ms','validate-run',async()=>{const result=await api.validateRun(root,runId);assert.equal(result.valid,true,result.diagnostics.join('; '));}); + const final=await operation('finalize',()=>api.finalizeRun(root,runId,'pm',new Date().toISOString())); + assert.equal(final.product_owner_review.decision,null);assert.equal(final.final_result.status,'ready');assert.equal(final.run.status,'product_owner_review'); + const required=[...new Set(initialTasks.flatMap(task=>task.required_outputs))]; + return {profile,repetition,semantic_claim_links:claimLinks,graph_sha256:createHash('sha256').update(JSON.stringify(initialTasks)).digest('hex'),fixture_program_artifact_count:3,task_count:initialTasks.length,task_ids:initialTasks.map(task=>task.id),required_artifact_count:required.length,fixture_authored_required_artifact_count:required.filter(path=>authored.has(path)).length,runtime_generated_required_artifact_count:required.filter(path=>!authored.has(path)).length,fixture_authored_required_artifact_paths:required.filter(path=>authored.has(path)),runtime_generated_required_artifact_paths:required.filter(path=>!authored.has(path)),runtime_generated_auxiliary_artifact_count:3+evidenceRecords.length*3,runtime_generated_total_artifact_count:required.filter(path=>!authored.has(path)).length+3+evidenceRecords.length*3,collector_invocation_count:evidenceRecords.length,runtime_generated_auxiliary_artifact_categories:{assignment:1,handoff_receipt:1,changed_file_inventory:1,collector_json:evidenceRecords.length,collector_stdout:evidenceRecords.length,collector_stderr:evidenceRecords.length},semantic_input_count:authoredInputs.size,authored_semantic_inputs:[...authoredInputs],runtime_call_count:Object.values(calls).reduce((a,b)=>a+b,0),runtime_calls:calls,...Object.fromEntries(Object.entries(totals).map(([key,value])=>[key,Math.round(value*100)/100])),fixture_wall_ms:Math.round((performance.now()-wallStart)*100)/100,acceptance_case_count:5,negative_variants:checks,final_status:final.run.status,human_acceptance:'pending',source_sha256:createHash('sha256').update(goodSource).digest('hex'),acceptance_sha256:createHash('sha256').update(acceptanceSource).digest('hex')}; + } finally {await rm(root,{recursive:true,force:true});} +} +function summarize(samples) { + const median=(key)=>{const values=samples.map(sample=>sample[key]).sort((a,b)=>a-b);const mid=Math.floor(values.length/2);return values.length%2?values[mid]:(values[mid-1]+values[mid])/2;}; + return {samples:samples.length,task_count:samples[0].task_count,required_artifact_count:samples[0].required_artifact_count,fixture_authored_required_artifact_count:samples[0].fixture_authored_required_artifact_count,runtime_generated_required_artifact_count:samples[0].runtime_generated_required_artifact_count,median_runtime_operations_ms:median('runtime_operations_ms'),median_validation_ms:median('validation_ms'),median_collector_wrapper_ms:median('collector_wrapper_ms'),median_collector_command_execution_ms:median('collector_command_execution_ms'),median_fixture_wall_ms:median('fixture_wall_ms')}; +} +await captureBaseline(); +const candidateHash=baselineOnly?null:await candidateFingerprint(); +if (!baselineOnly) await exec(resolve(repository, 'node_modules/.bin/tsc'), ['-p', resolve(repository, 'tsconfig.json')], { cwd: repository }); +const suites=[{name:'baseline_full',tree:baselineTree,profile:'full'},...baselineOnly?[]:[{name:'candidate_full',tree:repository,profile:'full'},{name:'candidate_compact',tree:repository,profile:'compact'}]]; +const report={schema_version:1,completed:false,candidate_source_sha256:candidateHash,baseline_commit:baselineRef,benchmark_script_sha256:createHash('sha256').update(await readFile(fileURLToPath(import.meta.url))).digest('hex'),recorded_at:new Date().toISOString(),environment:{node:process.version,platform:process.platform,arch:process.arch,dependency_lock_sha256:createHash('sha256').update(await readFile(resolve(repository,'package-lock.json'))).digest('hex')},repetitions,seed:'fixed-seven-orders-v1',limits:['Synthetic local fixture; no real agents, humans, external services, database, or customer data.','Runtime operations include helper/validation I/O; collector wrapper time includes subprocess execution and evidence publication.','Collector execution time comes only from collector timestamps; it excludes LLM and human work.','Fixture wall time includes scripted file creation and Git setup; it is not real delivery lead time.','Authored artifact counts identify fixed fixture content supplied by this script, not actual human typing or LLM output. Required artifacts count distinct workflow required outputs; extra semantic inputs are counted separately.', +'Auxiliary generated artifacts count assignment/receipt/change inventory and collector JSON/stdout/stderr; they exclude locks, installation assets, and internal authority-version metadata.','Negative variants and acceptance source are identical across profiles; Compact combines integration/QC into one collector pass, so repeated verification invocation counts differ by design. Synthetic role/review fixtures do not replace independent real delivery review.'],suites:{}}; +for(const suite of suites){const samples=[];for(let repetition=1;repetition<=repetitions;repetition++){samples.push(await scenario(suite.tree,suite.profile,repetition));process.stdout.write(`${suite.name} repetition ${repetition}: ${samples.at(-1).fixture_wall_ms}ms\n`);}report.suites[suite.name]={summary:summarize(samples),samples};await writeFile(resolve(output,baselineOnly?'baseline.json':'comparison.json'),JSON.stringify(report,null,2)+'\n');} +const baselineSample=report.suites.baseline_full.samples[0]; +for(const suite of Object.values(report.suites))for(const sample of suite.samples){ + assert.equal(sample.source_sha256,baselineSample.source_sha256,'feature source differs across measured suites'); + assert.equal(sample.acceptance_sha256,baselineSample.acceptance_sha256,'acceptance test source differs across measured suites'); + assert.deepEqual(sample.negative_variants,baselineSample.negative_variants,'bug rejection parity changed'); + assert.equal(sample.human_acceptance,'pending'); + assert.deepEqual(sample.semantic_claim_links,baselineSample.semantic_claim_links,'fact-to-requirement mapping differs across profiles'); + if(sample.profile==='full')assert.equal(sample.graph_sha256,baselineSample.graph_sha256,'candidate Full changed the baseline task/output graph'); +} +if(!baselineOnly)assert.equal(await candidateFingerprint(),candidateHash,'Candidate runtime/assets changed during measurement; rerun against stable source'); +report.completed=true; +await writeFile(resolve(output,baselineOnly?'baseline.json':'comparison.json'),JSON.stringify(report,null,2)+'\n'); +process.stdout.write(JSON.stringify(Object.fromEntries(Object.entries(report.suites).map(([name,value])=>[name,value.summary])),null,2)+'\n'); diff --git a/skills/sdlc-ba/SKILL.md b/skills/sdlc-ba/SKILL.md index 997c595..805fe47 100644 --- a/skills/sdlc-ba/SKILL.md +++ b/skills/sdlc-ba/SKILL.md @@ -5,6 +5,10 @@ description: Use when an active SDLC run needs feature requirements, typed fact- # SDLC Business Analysis +For a saved Compact run, follow the [Compact specification path](../sdlc-pm/references/compact-workflow.md): author one semantic specification through `compact-spec`, and let the runtime derive fact claims and acceptance views. The seven-document envelope below applies to Full/legacy evaluator work. Compact still requires approved facts, complete acceptance coverage, and PM review; never silently omit unresolved facts. + +Keep business requirements tied to real user outcomes. Runtime 1.0.0 allows several distinct technical capabilities to reference the same `REQ-*`; do not split or renumber requirements merely to satisfy the five backend capability slots. Fact/claim reconciliation and acceptance traceability remain required. + Create reviewable requirements from the run's typed facts; Product Owner and PM approval remain separate decisions. 1. Read the active run manifest, assigned BA task, immutable request, PM intake artifacts including `facts.yaml`, `.sdlc/project.yaml`, applicable `AGENTS.md`, policies, workflow, and templates. If project documentation is configured in another repository, resolve `resources.documentation` through `.sdlc/local.yaml`; cite it as source material while keeping authoritative BA run artifacts in the coordinator repository. diff --git a/skills/sdlc-backend/SKILL.md b/skills/sdlc-backend/SKILL.md index b26a041..2731964 100644 --- a/skills/sdlc-backend/SKILL.md +++ b/skills/sdlc-backend/SKILL.md @@ -5,6 +5,8 @@ description: Use when a PM delivery assignment delegates an API contract, backen # SDLC backend +For a normal 1.0.0 runtime assignment, use the prepared task packet and [runtime-assisted delivery](../sdlc-pm/references/task-operations.md). Implement only its authorized scope, produce substantive artifacts, collect assigned checks with `check-task`, and give actual requirement/capability outcomes and product changes to `handoff-task`. Return the saved report path and status; the runtime generates hashes and metadata. Never approve your own gate or complete the task. The strict envelope rules below apply to an explicitly supplied legacy evaluator stimulus or manual delivery contract. + Read these references before responding: - `references/role-contract.md` diff --git a/skills/sdlc-frontend/SKILL.md b/skills/sdlc-frontend/SKILL.md index de4f7e5..a784a7d 100644 --- a/skills/sdlc-frontend/SKILL.md +++ b/skills/sdlc-frontend/SKILL.md @@ -5,6 +5,8 @@ description: Use when a PM delivery assignment delegates a web or mobile impleme # SDLC frontend +For a normal 1.0.0 runtime assignment, use the prepared task packet and [runtime-assisted delivery](../sdlc-pm/references/task-operations.md). Implement only its selected web/mobile scope, produce substantive artifacts, collect assigned checks with `check-task`, and give actual requirement/capability outcomes and product changes to `handoff-task`. Return the saved report path and status; the runtime generates hashes and metadata. Never approve your own gate or complete the task. The strict envelope rules below apply to an explicitly supplied legacy evaluator stimulus or manual delivery contract. + Read these references before responding: - `references/role-contract.md` diff --git a/skills/sdlc-pm/SKILL.md b/skills/sdlc-pm/SKILL.md index 7f9d3ca..5700c56 100644 --- a/skills/sdlc-pm/SKILL.md +++ b/skills/sdlc-pm/SKILL.md @@ -5,6 +5,10 @@ description: Use when starting, coordinating, resuming, reviewing, or finalizing # SDLC Project Orchestrator +Select the saved run profile first. For an explicitly requested new Compact run or a saved `workflow_profile.name: compact`, follow the [Compact workflow](references/compact-workflow.md) instead of the Full graph and artifact inventory below. Full is the default; profiles never change in place. Compact retains BA, PM review, independent integrated QC, optional PO advice, and human acceptance. + +For ordinary work on runtime 1.0.0, use [runtime-assisted delivery](references/task-operations.md) as the primary execution path. It prepares assignments, records activation, collects checks, and generates handoff metadata. Keep substantive requirements/API review and independent QC. Use the lower-level contract below for legacy runtimes, explicitly supplied evaluator stimuli, and exceptional dependency/scaffolding/approval planning that the helper correctly refuses; do not duplicate successful helper operations with manual metadata construction and redundant validation calls. + For `/sdlc --save-my-token` or `/sdlc --normal` (including `$sdlc` invocation), first use the [sdlc entry point](../sdlc/SKILL.md) to apply the requested project model preset. A mode-only request does not start a run. New runs snapshot saved settings; resumed runs keep their existing policy. Make repository state—not an agent claim—the source of delivery truth. Read [role contract](references/role-contract.md), [orchestration contract](references/orchestration-contract.md), [approval contract](references/approval-contract.md), and [final review contract](references/final-review-contract.md). @@ -15,18 +19,18 @@ For a delegated assignment with `execution_mode: task-only`, perform only the na 1. Read `AGENTS.md`, applicable nested instructions, `.sdlc/project.yaml`, selected workflow, all policies, and `.sdlc/runs//manifest.yaml`. If `workspace.mode` is `multi-repository`, also resolve `.sdlc/local.yaml` and stop on any missing or remote-mismatched mapping. The coordinator repository owns `.sdlc` authority; application and resource paths resolve inside the repository IDs declared in project configuration. For a new request, save the request then create the run with `node .sdlc/runtime.cjs start --id --title --request <request-path> --applications <backend,web,mobile,database,shared-packages>`. 2. Run `node .sdlc/runtime.cjs ready <run-id>` and `node .sdlc/runtime.cjs validate-run <run-id>` before planning or resuming. Treat the manifest task statuses, dependencies, affected applications, required inputs/outputs, evidence, gates, defects, blockers, and decisions as authoritative. -3. Build the graph from the selected workflow and manifest: intake → BA → PM requirements review → backend API contract → PM API review → affected backend/web/mobile implementation → PM integration → QC → optional AI Product Owner advisory review → PM delivery package → human Product Owner decision. Never omit BA; never dispatch frontend before the reviewed API contract. +3. Build the graph from the selected workflow and manifest: intake → BA → PM requirements review → backend API contract → PM API review → affected backend/web/mobile implementation → PM integration → QC → optional AI Product Owner advisory review → PM delivery package → human Product Owner decision. Never omit BA. When backend is affected, require API-contract review before frontend. For frontend-only runs, review applicable existing API assumptions during requirements review; do not invent omitted backend contract tasks. 4. During intake, normalize explicit Product Owner statements into `.sdlc/runs/<run-id>/facts.yaml`. Keep inferred or ambiguous material behavior `proposed` or `unresolved`; never promote it to `approved` without a recorded decision. During requirements review, validate all seven BA artifacts, their schemas, one-to-one fact/claim reconciliation, and cross-artifact claim links before passing the requirements gate. ## Dispatch and verify -When the run has `agent_policy`, read [agent model routing](references/agent-model-routing.md) before dispatch. Use actual model-selecting spawn parameters, record successful agent launches through the runtime, and preserve unreported actual models as unknown. Configured PM tasks run in task-only PM children; the parent coordinates and relays their results. Configured non-PM tasks also require the spawn/record/activate sequence in that reference before execution. Use the legacy sequence below only for roles without a configured model. +When the run has `agent_policy`, read [agent model routing](references/agent-model-routing.md) before dispatch. Use actual model-selecting spawn parameters, record successful agent launches through the runtime, and preserve unreported actual models as unknown. Configured PM tasks run in task-only PM children; the parent coordinates and relays their results. Configured non-PM tasks also require the spawn/record/activate sequence in that reference before execution. Model inheritance does not disable 1.0.0 helpers. Use the lower-level sequence below when the runtime lacks those helpers or an exceptional planning/hold case requires it. For each ready non-PM task, first verify that dependencies are completed and every required input exists. For backend or frontend work, create the current schema-valid `.sdlc/runs/<run-id>/tasks/<task-id>.assignment.yaml`, bind its revision and hash to the authority snapshot, then transition `ready -> running`. The pre-start assignment authorizes execution: set `task_status: running` and `transition_policy` to `current_status: running`, `allowed_request: awaiting_review`, and `unmet_disposition: refuse`; do not copy the temporary manifest `ready` state into those fields. Send the matching `$sdlc-ba`, `$sdlc-backend`, `$sdlc-frontend`, or `$sdlc-qc` assignment using every field in the orchestration contract. Required outputs, collector evidence, and quality gates are not prerequisites for `ready -> running`; they are produced or verified later. Assign one frontend target at a time. PM performs intake, requirements/API reviews, integration report, and final package only in PM-permitted paths, through task-only PM children when a PM model is configured. Dispatch `PO-001` with `$sdlc-po` when the run includes it; the AI review is advisory. Follow the runtime state sequence exactly. Backend/frontend roles return only their task-specific typed delivery report and never request `completed`. Before any handoff transition, PM validates the assignment, authority, report, artifact integrity, exact changed-file manifest, collector evidence, target ownership, and normative disposition with production reconciliation. The role hands work off through `running -> awaiting_review` only after all required outputs exist and, for tasks whose runtime gate requires it, passed collector evidence exists. PM or the designated reviewer uses `awaiting_review -> completed` only after reviewing the handoff, confirming required outputs and evidence, and confirming the relevant quality gate is passed. Never propose or use `ready -> completed` or `running -> completed`; neither edge exists. Use `node .sdlc/runtime.cjs transition <run-id> <task-id> <status> --actor pm --reason <reason>` from the current legal state, validate after each transition, and show ordered commands when planning more than one transition. -Use `node .sdlc/runtime.cjs evidence <run-id> <task-id> <command-id>` for configured command IDs only (`sdlc_test`, `sdlc_typecheck`, `sdlc_validate`); inspect the resulting evidence record, then use `quality-gate` to record the audited gate outcome with that evidence. Never turn a summary, code inspection, screenshot claim, or unavailable application into passing evidence. +Use `check-task` on 1.0.0 or `node .sdlc/runtime.cjs evidence <run-id> <task-id> <command-id>` for actual configured command IDs, including applicable application build/test commands; inspect the resulting evidence record, then use `quality-gate` to record the audited gate outcome with that evidence. Never turn a summary, code inspection, screenshot claim, or unavailable application into passing evidence. In a multi-repository workspace, set an assignment's `repository` to the target application repository; for `api_contract`, use the configured `resources.api_contracts.repository` when present. Product changed-file entries and ownership records must carry that repository ID, while coordinator `.sdlc` artifact paths remain portable strings. @@ -34,7 +38,7 @@ In a multi-repository workspace, set an assignment's `repository` to the target Pause only for a declared material Product Owner decision or a genuine repository blocker. For approval, transition `running -> awaiting_approval`, create an `approval-request` bound to the exact later `running` or `completed` action, record `approval-decision`, then pass its ID with `transition --decision`; a pending, rejected, incomplete, differently bound, or already consumed decision does not authorize the edge. Each approval transition consumes its decision exactly once; a later cycle requires a new `DEC-*` ID. Use `ready -> blocked` or another source edge actually supported by the runtime to create a blocker; keep an ineligible pending task pending. Recover only through `blocked -> ready` after every open task blocker is resolved, passing the concrete blocker ID with `--resolve-blocker`, then use a separate `ready -> running` transition. A task may enter `failed` only from a runtime-supported source and only with failure evidence. Recover through `failed -> ready` with `--retry-reason`, then use a separate `ready -> running` transition. Rework from review uses the legal `awaiting_review -> running` edge. -Route a QC defect to its responsible role. Require that role's corrected artifact and collector evidence, then rerun PM integration review and independent `$sdlc-qc` retest; do not reuse the original recommendation. +Route a QC defect to its responsible role. On runtime 1.0.0 use `repair-task` for a completed implementation or rejected `awaiting_review` handoff, then prepare/activate its new cycle. Require corrected artifacts and fresh collector evidence, then rerun affected PM integration review and independent `$sdlc-qc` retest; do not reuse the original recommendation or bypass terminal task transitions. Do not deploy, use production credentials, perform destructive database/Git work, or override a hard prohibition. Product Owner approval requests are decisions, not authorization to bypass those limits. diff --git a/skills/sdlc-pm/references/agent-model-routing.md b/skills/sdlc-pm/references/agent-model-routing.md index b7f23b2..4341d8f 100644 --- a/skills/sdlc-pm/references/agent-model-routing.md +++ b/skills/sdlc-pm/references/agent-model-routing.md @@ -1,5 +1,7 @@ # Role model dispatch +Model routing is independent of workflow profile. Compact uses the same saved role policy and actual host dispatch records; it does not imply cheaper models or lower reasoning effort. + Read this when `manifest.agent_policy` exists. It is the run's frozen policy; project settings apply only to new runs. Roles without settings retain host inheritance. Configuring `frontend` covers both web and mobile. A `po` role or advisory review setting adds `PO-001` between QC and the PM delivery package. ## Host preflight @@ -23,6 +25,8 @@ This is a shape example. Populate it from the actual host. Describe omitted-effo ## Launch and record +On runtime 1.0.0, prepare eligible tasks through [runtime-assisted delivery](task-operations.md), then use `activate-task` with the real dispatch JSON to combine steps 4 and 5 below. This works for configured and inherited roles. The child still waits until the helper succeeds, and host launch/model observations remain unchanged. Use the lower-level sequence for existing manual/exceptional flows and older runtimes. + 1. Check the task is ready, dependencies are complete, and required inputs exist. Prepare any typed backend/frontend assignment through the existing publisher. Obtain the agent plan after those inputs are ready. 2. Delegate this bounded task with the returned `selected.model` and optional `selected.reasoning_effort` as actual tool parameters. If `selected` is null, omit model/effort parameters. Pass the plan’s selected effort when present, including an explicitly resolved host default. When absent, omission follows the host’s declared default/inheritance behavior; the planner checks inherited effort compatibility. Unknown behavior requires a configured effort or an advertised model default. With `collaboration.spawn_agent`, pass `fork_turns: "none"` and a self-contained assignment. Never combine overrides with a full-history fork. Do not select a named custom agent whose TOML overrides the intended model or effort. 3. The initial child message must tell it to wait for an activation message before producing artifacts. Include coordinator root, run/task identity, matching skill path, allowed scope, required inputs/outputs, configured command IDs, and the selected model plan. Shared repository state remains authoritative. diff --git a/skills/sdlc-pm/references/compact-workflow.md b/skills/sdlc-pm/references/compact-workflow.md new file mode 100644 index 0000000..c6c55f4 --- /dev/null +++ b/skills/sdlc-pm/references/compact-workflow.md @@ -0,0 +1,105 @@ +# Opt-in Compact workflow + +Use Compact only for a new, bounded feature using established implementation and authorization patterns. Full remains the default. A saved run's profile and reviewed requirements cannot be changed in place. Workflow selection is independent of `--save-my-token` and `--normal`, which continue to configure role models. + +## Eligibility and start + +Resolve the selected application and preflight its commands, services, and acceptance fixtures. Assess scope from the request and repository; do not fill false risk flags merely to select a shorter workflow. Unknown scope, migrations, breaking APIs, authorization changes, new sensitive-data exposure, or substantial cross-system uncertainty require Full. An additive endpoint can qualify when it follows established permissions and does not break an existing contract. + +`$sdlc --compact <feature request>` routes to this workflow. `/sdlc` remains chat shorthand rather than a registered native slash command. Without `--compact`, start Full. A bare Compact request without a feature does not create a run or change project defaults. + +Save a strict JSON assessment inside the coordinator, for example `.sdlc/requests/change-assessment.json`: + +```json +{ + "bounded_scope": true, + "existing_patterns": true, + "migrations": false, + "breaking_api": false, + "authorization_changes": false, + "sensitive_data_exposure": false, + "cross_system_uncertainty": false, + "rationale": "A bounded change using existing application, permission, and data patterns." +} +``` + +Every value is an assessment to establish, not a default to assume. Start only after confirming the example applies: + +```sh +node .sdlc/runtime.cjs start --id CHANGE-001 --title "Requested change" --request .sdlc/requests/change.md --applications web --profile compact --assessment .sdlc/requests/change-assessment.json --json +``` + +The runtime freezes Compact revision 1 and the assessment into this run. Missing or higher-risk assessments fail without creating a run. If new information makes Compact unsuitable, preserve this history and start a new Full run with the original request, new findings, and references to reusable work; never remove gates or rewrite the saved profile to force progress. + +## Graph and ownership + +`PM-001 → BA-001 → affected BE-002 / WEB-001 / MOB-001 → QC-001 → optional PO-001 → PM-004 → human decision`. + +PM records request facts and reviews the BA handoff directly; separate PM requirements/API-review tasks and a separate backend contract task are omitted. The reviewed specification contains the applicable API change. QC independently verifies the integrated result, so both integration and QC gates remain required, even though a separate INT-001 task is omitted. Implementation still uses the existing target boundaries and sequential scheduling. Only PM completes tasks; roles cannot approve their own completion. + +## One BA specification + +PM-001 produces approved `facts.yaml`; request and manifest are created by the runtime. Resolve open facts before Compact specification review. BA-001 supplies the following semantic input to `compact-spec`, using actual fact/requirement IDs: + +```json +{ + "scope": { + "summary": "Update the existing status display", + "in_scope": ["Existing status output"], + "out_of_scope": ["New permissions, data collection, and API contracts"] + }, + "requirements": [{"id":"REQ-001","description":"Show the approved status","fact_ids":["FACT-001"]}], + "acceptance_criteria": [{"id":"AC-001","requirement_id":"REQ-001","given":"The existing feature","when":"The status is read","then":"The approved status is returned","evidence_types":["command"]}], + "api_change": {"kind":"unchanged","description":"The existing API shape is preserved"} +} +``` + +```sh +node .sdlc/runtime.cjs compact-spec CHANGE-001 --json < specification-input.json +``` + +BA must be running. The helper writes `artifacts/ba/specification.yaml` and derives `semantic-claims.yaml` and `acceptance-criteria.md` from approved facts. Do not separately author seven Full-mode documents or manually edit generated views. Every requirement needs acceptance criteria and factual support; unresolved/proposed facts, missing references, duplicate IDs, and unsupported API changes are rejected. + +Select required evidence types up front: `command`, `api`, `ui`, `live_database`, or `observation`. A UI fixture cannot replace required database evidence. Do not downgrade evidence after discovering an unavailable environment. + +BA requests `awaiting_review`. PM reviews scope, facts, acceptance tests, API assumptions, and eligibility, then records: + +```sh +node .sdlc/runtime.cjs quality-gate CHANGE-001 requirements passed --actor pm --reason "Reviewed specification and applicable API behavior" --evidence artifacts/ba/specification.yaml +node .sdlc/runtime.cjs transition CHANGE-001 BA-001 completed --actor pm --reason "Specification approved for implementation" +``` + +The review binds exact specification/fact/generated-view hashes. Later edits invalidate the review rather than silently changing approved scope. A dummy configuration command is not a substitute for this review. + +## Implementation + +Use [runtime-assisted delivery](task-operations.md): prepare the affected assignments from reviewed controls, launch actual role agents, record activation, collect checks, and hand off. Compact does not alter model selection, file ownership, evidence freshness, or reviewer approval. Backend controls with migration impact are rejected even if the original assessment claimed none. Keep unresolved gaps blocked. + +## One independent QC record + +QC-001 integrates the delivered work and executes its own checks while running. Use `check-task` with actual configured command IDs. Record direct observations under `artifacts/qc/evidence/` as YAML with matching run/task/producer, positive revision, evidence type, observation time, environment, procedure, actual result, and passed/failed status. The full shape is `CompactObservation` in the runtime; recorded commands are referenced directly through their canonical evidence JSON paths. + +Supply one result per acceptance criterion to `compact-qc`: + +```json +{ + "results": [{"ac_id":"AC-001","status":"passed","evidence":[{"type":"command","reference":"evidence/commands/EVD-0123456789abcdef/evidence.json"}],"notes":"Independent execution confirmed the approved behavior."}], + "defects": [] +} +``` + +The reference above is illustrative; use only evidence returned by actual execution. Allowed result statuses are `passed`, `failed`, `blocked`, and `not_tested`. Defects declare their ID, affected `ac_ids`, open/resolved status, severity, and summary. The runtime derives readiness, hashes, and `artifacts/qc/summary.md`; do not claim readiness yourself or author six Full-mode QC documents. + +```sh +node .sdlc/runtime.cjs compact-qc CHANGE-001 --json < qc-input.json +``` + +Both authoring helpers support `--dry-run` and `--expected-version`. Publishing does not pass a gate or complete a task. Readiness requires every AC to pass with every declared evidence type, all latest QC checks to pass, and no open defects. Developer evidence, superseded command results, future timestamps, missing observations, and request/spec/self citations cannot satisfy independent QC. + +Once ready, QC hands off to `awaiting_review`; PM reviews the integrated result and records both integration and QC gates using current passed QC collector references, then completes QC. Optional AI PO advice and final human acceptance follow the existing flow. `finalize` only prepares the human review. + +## Rework + +For product changes, use `repair-task` on the affected implementation. It preserves the profile and reviewed specification, archives prior implementation/QC artifacts, and invalidates both integration and QC gates. Collect fresh evidence and rebuild the QC record after repair. + +Extra QC checks are refused before execution after handoff or gate approval. For a still-uncompleted QC review, return QC to running and reset both integration/QC gates to pending before collecting more evidence; republish verification afterward. Do not run checks and review transitions concurrently. Completed deliveries require the existing repair/new-run rules. A failed or blocked result remains explicit; elapsed time never authorizes skipping it. diff --git a/skills/sdlc-pm/references/final-review-contract.md b/skills/sdlc-pm/references/final-review-contract.md index 1883e72..f455f5f 100644 --- a/skills/sdlc-pm/references/final-review-contract.md +++ b/skills/sdlc-pm/references/final-review-contract.md @@ -1,5 +1,7 @@ # Final Product Owner review contract +For Compact runs, the requirements gate binds the reviewed canonical specification and its generated views; it does not need a substitute command result. Integration and QC both require the current independent QC collector evidence and complete per-criterion verification. Follow the saved profile's required tasks and outputs rather than adding omitted Full-mode tasks. + Before finalization, confirm from the manifest that every mandatory task is completed, every affected quality gate is `passed` with collector evidence, all required outputs exist, Product Owner review/final result remain pending, and no open blocker or blocker/critical defect remains. Render `final-report.md` from the repository template. The Product Owner package includes delivered scope and REQ coverage, affected applications, commits, command and quality-gate evidence, defects and disposition, known limitations/deferred items with `DEC-*` references, material decisions, and the next Product Owner action. diff --git a/skills/sdlc-pm/references/orchestration-contract.md b/skills/sdlc-pm/references/orchestration-contract.md index 9906e12..0a5a47b 100644 --- a/skills/sdlc-pm/references/orchestration-contract.md +++ b/skills/sdlc-pm/references/orchestration-contract.md @@ -1,10 +1,12 @@ # Orchestration contract +The graph and artifact inventory below describe Full/legacy delivery. For a saved Compact run, use the [frozen Compact workflow](compact-workflow.md); do not insert omitted Full stages or convert the profile during resume. + ## Dependency graph `PM-001 → BA-001 → PM-002 → BE-001 → PM-003 → {BE-002, WEB-001, MOBILE-001 as affected} → INT-001 → QC-001 → PM-004`. -Use the active manifest rather than these example IDs when a workflow supplies different IDs. `WEB-001` and `MOBILE-001` both require completed API-contract review; each is a separate frontend assignment. Integration waits for every affected implementation task. +Use the active manifest rather than these example IDs when a workflow supplies different IDs. `WEB-001` and `MOBILE-001` require completed API-contract review when backend work is affected; frontend-only graphs depend on requirements review; each is a separate frontend assignment. Integration waits for every affected implementation task. ## Assignment shape @@ -51,7 +53,7 @@ production_deployment: prohibited role_reports_may_request_completed: false ``` -Scenario rules are state-derived: approval request precedes decision and its bound transition; intake never skips BA; backend API contract and PM review precede frontend; configured evidence precedes a passed gate and review completion; integration precedes independent QC; finalization prepares Product Owner review and never deploys. +Scenario rules are state-derived: approval request precedes decision and its bound transition; intake never skips BA; backend API contract and PM review precede frontend when backend is affected; configured evidence precedes a passed gate and review completion; integration precedes independent QC; finalization prepares Product Owner review and never deploys. ## Transition sequence diff --git a/skills/sdlc-pm/references/task-operations.md b/skills/sdlc-pm/references/task-operations.md new file mode 100644 index 0000000..5ce0ba8 --- /dev/null +++ b/skills/sdlc-pm/references/task-operations.md @@ -0,0 +1,105 @@ +# Runtime-assisted delivery (1.0.0) + +This helper flow supports both Full and Compact implementations. Select the saved profile first; [Compact](compact-workflow.md) has a different BA/QC artifact inventory and combines integration with independent QC. The assignment scenario `full_task` names an implementation contract and does not select the Full workflow profile. + +Use this path for ordinary delivery on a 1.0.0 runtime. It preserves the existing task graph and independent review, while the runtime constructs mechanical authority metadata. Helpers serve eligible concrete tasks. Use the existing lower-level planning/hold workflow for missing dependencies, application scaffolding, or material approvals that preparation refuses; a refused helper call never authorizes a pending action. Legacy evaluator stimuli keep their specified response envelopes; do not use an evaluator envelope as the normal user-facing workflow. + +## Readiness and scope + +Read the selected coordinator's instructions and project configuration. Before starting implementation, identify the actual application (for example, customer web versus admin portal), services, and acceptance fixtures. Inspect the local selection: + +```sh +node .sdlc/runtime.cjs preflight --applications backend,web --expect-root web=apps/platform --json +``` + +Replace application/root values with the user's actual selected project. Add `--require-file <path>` for an agreed local fixture, or `repository:path` for a mapped checkout. This operation never starts services or executes tests, and a ready result proves only its listed local checks. Live services, database connectivity, populated data, and host model availability remain explicitly unchecked. Establish their evidence before relying on them. Never seed persistent data silently or treat a fixture file as proof of a live database result. + +Start/resume the run normally. Run IDs use uppercase `PROJECT-001` form. Retain approved facts, all required BA artifacts, requirements/API review, and target-scoped assignments. Do not create extra business requirements to populate technical capability slots: several capabilities may cite the same real `REQ-*`. + +## Prepare the assignment + +Provide semantic controls and verification command IDs as a strict JSON object on standard input. The runtime fills identity, revisions, hashes, dependencies, input/output inventories, permitted roots, and available evidence. Example frontend input (replace facts, requirements, and capabilities with actual reviewed ones): + +```json +{ + "controls": { + "api_contract_status": "approved", + "gaps": [], + "questions": [], + "requirements": [ + {"requirement_id":"REQ-001","capability":"requested_behavior","required":true,"parameters":{},"source_references":[{"kind":"fact","reference":"FACT-001"}]} + ] + }, + "commandIds": ["sdlc_test", "sdlc_typecheck", "web_build"] +} +``` + +Backend controls retain `mode`, the five API capabilities, storage roles, and migration impact/approval fields from the backend contract. Capability names remain unique; requirement IDs may repeat across different capabilities. Supply actual semantic decisions, not guessed approval states. Select checks that prove the task's obligations, including its build where required; configuration validation alone does not prove application behavior. + +```sh +node .sdlc/runtime.cjs prepare-task RUN-001 WEB-001 --dry-run --json < task-input.json +node .sdlc/runtime.cjs prepare-task RUN-001 WEB-001 --json < task-input.json +``` + +The packet includes assignment and authoritative metadata plus canonical paths. Preparation does not activate a task or launch an agent. Treat `authorityVersion` as an optimistic concurrency token; `--expected-version` rejects a stale caller. Do not read runtime source code to regenerate this packet. + +## Dispatch and execute + +Obtain `agent-plan` from actual host capabilities. Launch a bounded child with that plan, initially waiting. Then pass the real returned agent ID and unchanged plan in the usual dispatch JSON to: + +```sh +node .sdlc/runtime.cjs activate-task RUN-001 WEB-001 --reason "Reviewed inputs and successful host dispatch" --json < dispatch.json +``` + +This records the dispatch and performs the legal ready-to-running transition. Only after success send the child its activation message. It never launches an LLM or verifies a model through self-identification. Unreported actual model/effort remains null. An exact retry is idempotent. If activation fails after recording dispatch, keep the child waiting, fix the reported prerequisite, and retry; do not assume it started. Already-running task replacement uses the existing documented resume/agent-dispatch flow. + +While implementing, produce the required substantive artifacts with their canonical metadata. Keep product changes uncommitted through automatic handoff so the runtime can compare the explicit task-owned file list with Git changes. Unrelated dirty files stay outside the assignment. + +```sh +node .sdlc/runtime.cjs check-task RUN-001 WEB-001 --json +``` + +This runs the assignment's required commands in order, under the existing execution/network-policy requirements. It stops on failure and reports remaining checks as not run. It records execution evidence, not gate approval. Non-delivery tasks select declared checks explicitly with repeatable `--command`. Rework product changes before re-running applicable checks; do not bypass network-policy enforcement by setting an attestation without the harness enforcing it. + +## Hand off without retyping metadata + +Provide a strict JSON object with substantive requirement outcomes and the actual task-owned Git changes: + +```json +{ + "requirementOutcomes": [{"requirement_id":"REQ-001","capability":"requested_behavior","status":"implemented"}], + "changedFiles": [{"path":"apps/platform/src/app/(portal)/example/page.tsx","type":"source"}] +} +``` + +Every assigned requirement/capability pair needs its own outcome. Use `documented` for contract work and `implemented` only for implemented behavior. In a multi-repository workspace include the assigned `repository` on each product change. Declare real source/test/generated changes; artifacts and their metadata are collected by the runtime. + +```sh +node .sdlc/runtime.cjs handoff-task RUN-001 WEB-001 --json < outcomes.json +``` + +The runtime validates existing artifacts, current passed collector evidence, ownership, actual selected Git changes, and report reconciliation; publishes the report, changed-file inventory, and a deterministic handoff receipt; and requests `awaiting_review`. It never requests `completed`. A retry must match the saved report, outcomes, and source fingerprints. `--dry-run` validates without publication or transition. + +The initial automatic path supports existing changed regular files, including new untracked files. Deleted files and already-committed-only changes are not silently accepted; report the limitation and preserve accurate changed-file/evidence records. Do not invent files, omit task changes, or claim a manual fallback verifies cases the runtime cannot represent. + +Return the saved report path, task status, and any diagnostics rather than echoing the entire metadata envelope. PM independently reviews the substantive result and appropriate evidence, records the quality gate, then uses the ordinary `awaiting_review -> completed` transition. A successful `validate-config` is not evidence of requirements correctness or a product build. + +## Repair and resume + +For a QC defect affecting a completed implementation task, or a reviewer rejecting an implementation still in `awaiting_review`: + +```sh +node .sdlc/runtime.cjs repair-task RUN-001 WEB-001 --defect DEF-001 --actor pm --reason "QC reproduced the acceptance failure" --dry-run --json +node .sdlc/runtime.cjs repair-task RUN-001 WEB-001 --defect DEF-001 --actor pm --reason "QC reproduced the acceptance failure" --json +``` + +For helper-managed review rework, call `repair-task` before the manual `awaiting_review -> running` edge; the old report/receipt must be archived before replacing the handoff. The operation preserves archived task cycles, gates, assignments, reports, and output bytes; reopens that implementation; and invalidates dependent integration/QC/final-review work. It leaves unrelated implementation and product files intact. Resolve external blockers and pending approvals first; repair does not waive them. Human-reviewed or terminal deliveries require a new run. + +Prepare a new assignment, dispatch and activate the responsible role, collect new checks, hand off, and independently retest. Archived assignment revisions, dispatches, and evidence cannot authorize the new cycle. Do not patch old completed-task metadata or the installed runtime to force progress. An interrupted file transaction reports recovery explicitly: + +```sh +node .sdlc/runtime.cjs recover-repair RUN-001 --actor pm --json +node .sdlc/runtime.cjs timing RUN-001 --json +``` + +Recovery acts only when the journal and manifest still match; drift is reported. Timing separates recorded task states and collector execution, includes prior repair cycles, and never equates running-state time with LLM computation. A blocked check remains blocked even when the user requests a draft PR. diff --git a/skills/sdlc-qc/SKILL.md b/skills/sdlc-qc/SKILL.md index 998c9c0..cf2a498 100644 --- a/skills/sdlc-qc/SKILL.md +++ b/skills/sdlc-qc/SKILL.md @@ -5,6 +5,10 @@ description: Use when an integrated feature needs independent acceptance, API, w # SDLC Quality Assurance +For a saved Compact run, follow [Compact independent verification](../sdlc-pm/references/compact-workflow.md): integrate and independently check the result, then publish one `compact-qc` record and generated summary instead of the six Full-mode documents below. Both integration and QC gates still require complete current evidence. Failed, blocked, and untested criteria never become passes merely because the workflow is Compact. + +Identify required live services and test data during intake planning so environment gaps are visible before implementation. Keep mock, service-level, and live-database evidence distinct. For a defect after implementation completion, ask PM to open a runtime 1.0.0 `repair-task` cycle; use the fresh repaired result for independent retest. Do not reopen completed tasks by editing manifests. Use `timing` for recorded durations without treating state time as pure model execution. + For a multi-repository workspace, resolve each application, command, and changed-file entry through its declared repository ID in `.sdlc/project.yaml` and `.sdlc/local.yaml`. Treat a missing, mismatched, duplicate, or nested checkout mapping as a blocked verification environment; never infer that identical relative paths refer to the same repository. Independently verify approved requirements. Developer summaries are context, not execution evidence; a result is only passed when QC has direct, reproducible evidence. diff --git a/skills/sdlc-setup/SKILL.md b/skills/sdlc-setup/SKILL.md index 3a42753..35d9a73 100644 --- a/skills/sdlc-setup/SKILL.md +++ b/skills/sdlc-setup/SKILL.md @@ -11,7 +11,7 @@ Resolve the repository the user selected before running a command. Read its `AGE For a new setup, explain briefly that the installed plugin supplies Codex skills while the npm CLI creates and operates `.sdlc`. Plugin skills stay in Codex's plugin cache; initialization does not create `.agents/skills` in the project. -Confirm Node.js satisfies the version declared by codex-sdlc. Run `codex-sdlc --version` when the executable is available and use it only when it reports 0.6.0. If it is unavailable or differs, run the release-pinned CLI through `npx --yes codex-sdlc@0.6.0`; do not require a global installation. Let the environment request approval if downloading the package requires network access. +Confirm Node.js satisfies the version declared by codex-sdlc. Run `codex-sdlc --version` when the executable is available and use it only when it reports 1.0.0. If it is unavailable or differs, run the release-pinned CLI through `npx --yes codex-sdlc@1.0.0`; do not require a global installation. Let the environment request approval if downloading the package requires network access. Infer the project name, application roots, technologies, and workspace shape from the selected repository. Ask only for missing information that changes repository topology or application ownership. State which checkout will own `.sdlc` before previewing a multi-repository installation. @@ -49,6 +49,8 @@ Settings are stored in coordinator `.sdlc/project.yaml` and copied into new run ## Diagnose and lifecycle operations +Runtime 1.0.0 adds `preflight` for selected application roots, required local fixture files, and configured command availability. Use it before an eligible feature delivery; it never starts services or proves live database readiness. See [runtime-assisted delivery](../sdlc-pm/references/task-operations.md). Upgrading preserves unrepaired legacy run shapes. Once a 1.0.0 repair adds archived cycles and activation offsets, the run requires a 1.0.0-capable runtime; do not downgrade its runtime or hand-edit away the new history. + `doctor` is read-only. Report every diagnostic with the concrete file or command the user must fix. Do not start a feature run while required project checks are generated failing placeholders. For upgrade, rollback, or uninstall, always run the matching `--dry-run` first and inspect its bounded file list and backup ID. `upgrade` preserves `.sdlc/project.yaml` except the required framework version, refreshes managed assets and permissions, and records a rollback snapshot under `.sdlc/backups/`. After an applied upgrade or rollback, run `node .sdlc/runtime.cjs restore`, then `doctor` and `validate-config`. diff --git a/skills/sdlc/SKILL.md b/skills/sdlc/SKILL.md index 2496af0..e956328 100644 --- a/skills/sdlc/SKILL.md +++ b/skills/sdlc/SKILL.md @@ -1,6 +1,6 @@ --- name: sdlc -description: Handle /sdlc or $sdlc requests, including --save-my-token and --normal to persist delivery-role model presets for new runs in the selected project, or route a feature request to sdlc-pm. +description: Handle /sdlc or $sdlc feature requests, including opt-in --compact delivery and --save-my-token or --normal project model presets, and route work to sdlc-pm. --- # SDLC entry point @@ -39,4 +39,6 @@ Treat `/sdlc --save-my-token` and `/sdlc --normal` as chat shorthand for this sk ## Delivery requests +For `$sdlc --compact <feature>` or `/sdlc --compact <feature>`, follow the [Compact workflow](../sdlc-pm/references/compact-workflow.md). Compact applies only to that new run and requires a complete low-risk assessment. Full remains the default. An existing run retains its saved profile; do not convert it during resume. If Compact is unsuitable, explain why Full is required before starting a new Full run. A standalone `--compact` without a feature does not start work or change project configuration. If an explicitly requested model preset accompanies the feature, save it first and then apply the independently selected workflow profile. + For a feature request or explicit resume without either mode flag, use [sdlc-pm](../sdlc-pm/SKILL.md) and preserve the saved project mode. A bare `/sdlc` or `$sdlc` explains the two mode commands and asks what feature to start or run to resume; it does not reset models or start unspecified work. diff --git a/src/agents.ts b/src/agents.ts index f83ca00..847a394 100644 --- a/src/agents.ts +++ b/src/agents.ts @@ -256,7 +256,7 @@ function assertObservedSelection(record: AgentDispatch): void { } } -function activation(task: Task): number { return task.transitions.filter((entry) => entry.to === "ready").length; } +function activation(task: Task): number { return (task.activation_offset ?? 0) + task.transitions.filter((entry) => entry.to === "ready").length; } function primarySelection(settings: RoleModel): ModelSelection { return { model: settings.model, ...(settings.reasoning_effort === undefined ? {} : { reasoning_effort: settings.reasoning_effort }) }; } diff --git a/src/changed-files-authority.ts b/src/changed-files-authority.ts index a782ef1..c09a094 100644 --- a/src/changed-files-authority.ts +++ b/src/changed-files-authority.ts @@ -224,7 +224,7 @@ function isPortableGlobPattern(value: unknown): value is string { return value.split("/").every((segment) => segment.length > 0 && segment !== "." && segment !== ".." - && /^(?:\*\*|[A-Za-z0-9._-]*\*?[A-Za-z0-9._-]*)$/u.test(segment)); + && (segment === "**" || ((segment.match(/\*/g)?.length ?? 0) <= 1 && isPortableRepositoryPath(segment.replace("*", "wildcard"))))); } function portableGlobMatches(pattern: string, path: string): boolean { diff --git a/src/cli-grammar.ts b/src/cli-grammar.ts index 9cf2b2a..f21ac94 100644 --- a/src/cli-grammar.ts +++ b/src/cli-grammar.ts @@ -21,11 +21,21 @@ export const cliGrammar: Readonly<Record<string, CommandGrammar>> = { "agent-plan": { positionals: 2, requiredOptions: [] }, "agent-dispatch": { positionals: 2, requiredOptions: [] }, doctor: { positionals: 0, requiredOptions: [], optionalOptions: ["--root"] }, + preflight: { positionals: 0, requiredOptions: [], optionalOptions: ["--root", "--applications"], repeatableOptions: ["--expect-root", "--require-file", "--command"] }, + "prepare-task": { positionals: 2, requiredOptions: [], optionalOptions: ["--expected-version"], flagOptions: ["--dry-run"] }, + "activate-task": { positionals: 2, requiredOptions: ["--reason"] }, + "check-task": { positionals: 2, requiredOptions: [], repeatableOptions: ["--command"] }, + "handoff-task": { positionals: 2, requiredOptions: [], optionalOptions: ["--expected-version"], flagOptions: ["--dry-run"] }, + "repair-task": { positionals: 2, requiredOptions: ["--defect", "--actor", "--reason"], flagOptions: ["--dry-run"] }, + "recover-repair": { positionals: 1, requiredOptions: ["--actor"] }, + timing: { positionals: 1, requiredOptions: [] }, upgrade: { positionals: 0, requiredOptions: [], optionalOptions: ["--root", "--runtime-spec"], flagOptions: ["--dry-run"] }, rollback: { positionals: 0, requiredOptions: [], optionalOptions: ["--root", "--backup"], flagOptions: ["--dry-run"] }, uninstall: { positionals: 0, requiredOptions: [], optionalOptions: ["--root"], flagOptions: ["--dry-run"] }, "validate-config": { positionals: 0, requiredOptions: [] }, - start: { positionals: 0, requiredOptions: ["--id", "--title", "--request", "--applications"] }, + start: { positionals: 0, requiredOptions: ["--id", "--title", "--request", "--applications"], optionalOptions: ["--profile", "--assessment"] }, + "compact-spec": { positionals: 1, requiredOptions: [], optionalOptions: ["--expected-version"], flagOptions: ["--dry-run"] }, + "compact-qc": { positionals: 1, requiredOptions: [], optionalOptions: ["--expected-version"], flagOptions: ["--dry-run"] }, ready: { positionals: 1, requiredOptions: [] }, transition: { positionals: 3, requiredOptions: ["--actor", "--reason"], optionalOptions: ["--resolve-blocker", "--retry-reason", "--decision"] }, "validate-run": { positionals: 1, requiredOptions: [] }, diff --git a/src/cli.ts b/src/cli.ts index 6883f4b..df09371 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -20,6 +20,14 @@ import { parseStrictYamlDocument } from "./semantic-contracts.js"; import { prepareTransitionContext, SdlcTransitionError, SdlcTransitionUsageError, transitionTask } from "./transitions.js"; import { SdlcValidationError, type TaskStatus } from "./types.js"; import { resolveWorkspace } from "./workspace.js"; +import { preflightProject } from "./preflight.js"; +import { getRunTiming } from "./run-timing.js"; +import { prepareTask, handoffTask, type PrepareTaskInput, type HandoffTaskInput } from "./task-operations.js"; +import { activateTask } from "./task-activation.js"; +import { collectTaskChecks } from "./task-checks.js"; +import { repairTask, recoverRepair } from "./repairs.js"; +import { resolveWorkflowProfile, type CompactAssessment } from "./workflow-profile.js"; +import { publishCompactSpecification, publishCompactQc, type CompactSpecificationInput, type CompactQcInput } from "./compact-artifacts.js"; const exitCodes = { success: 0, @@ -52,6 +60,8 @@ interface StartOptions { title: string; request: string; applications: string; + profile?: "full" | "compact"; + assessment?: string; } interface AgentOptions { @@ -283,6 +293,111 @@ export async function main(rawArguments = process.argv.slice(2)): Promise<number emit("doctor", result, result.ready ? "installation is ready" : "installation needs attention", diagnostics, result.ready ? exitCodes.success : exitCodes.failure); }); + program.command("preflight") + .description("inspect local readiness without starting services or executing application checks") + .option("--root <path>", "coordinator root", ".") + .option("--applications <csv>", "affected backend, web, or mobile applications") + .option("--expect-root <role=path>", "verify the intended application root; repeatable", collectOption, []) + .option("--require-file <path>", "required local fixture or file, optionally repository:path", collectOption, []) + .option("--command <id>", "inspect a configured command without executing it", collectOption, []) + .action(async (options: { root: string; applications?: string; expectRoot: string[]; requireFile: string[]; command: string[] }) => { + executed = true; activeCommand = "preflight"; + if (options.expectRoot.some((entry) => !/^(backend|web|mobile)=.+$/.test(entry))) throw new CliFailure(exitCodes.usage, { code: "USAGE", message: "--expect-root requires backend=path, web=path, or mobile=path" }); + const result = await preflightProject(options.root, { + applications: options.applications === undefined ? undefined : parseConfiguredApplications(options.applications), + expectedRoots: parseRepositoryMappings(options.expectRoot), requiredFiles: options.requireFile, + commands: options.command.length ? options.command : undefined, + }); + emit("preflight", result, result.ready ? "local preflight ready; live services and acceptance data remain unchecked" : "local preflight blocked", result.diagnostics.map((message) => ({ code: "PREFLIGHT", message })), result.ready ? exitCodes.success : exitCodes.failure); + }); + + program.command("compact-spec <run-id>") + .description("publish one Compact specification and derive claim/acceptance views from JSON stdin") + .option("--dry-run", "validate and preview without writing") + .option("--expected-version <version>", "require the observed run authority version") + .action(async (runId: string, options: { dryRun?: boolean; expectedVersion?: string }) => { + executed = true; activeCommand = "compact-spec"; + const result = await publishCompactSpecification(root, runId, await readJsonInput() as CompactSpecificationInput, { dryRun: options.dryRun, expectedVersion: options.expectedVersion === undefined ? undefined : parseAuthorityVersion(options.expectedVersion) }); + emit("compact-spec", result, `${result.dryRun ? "previewed" : "saved"} Compact specification and generated views; PM review remains required`); + }); + + program.command("compact-qc <run-id>") + .description("publish independent Compact verification and its generated summary from JSON stdin") + .option("--dry-run", "validate and preview without writing") + .option("--expected-version <version>", "require the observed run authority version") + .action(async (runId: string, options: { dryRun?: boolean; expectedVersion?: string }) => { + executed = true; activeCommand = "compact-qc"; + const result = await publishCompactQc(root, runId, await readJsonInput() as CompactQcInput, { dryRun: options.dryRun, expectedVersion: options.expectedVersion === undefined ? undefined : parseAuthorityVersion(options.expectedVersion) }); + emit("compact-qc", result, `${result.dryRun ? "previewed" : "saved"} Compact verification; inspect coverage before recording gates and PM review`); + }); + + program.command("prepare-task <run-id> <task-id>") + .description("derive and publish an assignment from semantic controls supplied as JSON on stdin") + .option("--dry-run", "derive the task packet without writing") + .option("--expected-version <version>", "require the observed run authority version") + .action(async (runId: string, taskId: string, options: { dryRun?: boolean; expectedVersion?: string }) => { + executed = true; activeCommand = "prepare-task"; + const result = await prepareTask(root, runId, taskId, await readJsonInput() as PrepareTaskInput, { dryRun: options.dryRun, expectedVersion: options.expectedVersion === undefined ? undefined : parseAuthorityVersion(options.expectedVersion), now }); + emit("prepare-task", result, `${result.dryRun ? "previewed" : "prepared"} ${taskId}; actual agent dispatch and activation remain separate`); + }); + + program.command("activate-task <run-id> <task-id>") + .description("record a successful host dispatch from JSON stdin, then start the ready task") + .requiredOption("--reason <reason>") + .action(async (runId: string, taskId: string, options: { reason: string }) => { + executed = true; activeCommand = "activate-task"; + const result = await activateTask(root, runId, taskId, await readJsonInput() as AgentDispatchInput, options.reason, now); + emit("activate-task", result, `${taskId} is running; host adapter may now activate the recorded agent`); + }); + + program.command("check-task <run-id> <task-id>") + .description("collect assigned command evidence in order; never approve a quality gate") + .option("--command <id>", "select a declared check; required for tasks without an implementation assignment", collectOption, []) + .action(async (runId: string, taskId: string, options: { command: string[] }) => { + executed = true; activeCommand = "check-task"; + const result = await collectTaskChecks(root, runId, taskId, options.command.length ? options.command : undefined); + emit("check-task", result, result.passed ? `${taskId} command checks passed; review remains pending` : `${taskId} command failed; remaining checks were not run`, result.passed ? [] : [{ code: "COMMAND_FAILED", message: "configured command failed" }], result.passed ? exitCodes.success : exitCodes.failure); + }); + + program.command("handoff-task <run-id> <task-id>") + .description("assemble a verified report from JSON outcomes and actual task-owned Git changes") + .option("--dry-run", "validate and preview without publishing") + .option("--expected-version <version>", "require the observed run authority version") + .action(async (runId: string, taskId: string, options: { dryRun?: boolean; expectedVersion?: string }) => { + executed = true; activeCommand = "handoff-task"; + const result = await handoffTask(root, runId, taskId, await readJsonInput() as HandoffTaskInput, { dryRun: options.dryRun, expectedVersion: options.expectedVersion === undefined ? undefined : parseAuthorityVersion(options.expectedVersion), now }); + emit("handoff-task", result, `${result.dryRun ? "previewed handoff for" : "handed off"} ${taskId}; independent review and gate approval remain required`); + }); + + program.command("repair-task <run-id> <task-id>") + .description("archive a completed or review-rejected implementation cycle and invalidate downstream verification") + .requiredOption("--defect <id>") + .requiredOption("--actor <actor>") + .requiredOption("--reason <reason>") + .option("--dry-run", "show affected tasks without writing") + .action(async (runId: string, taskId: string, options: { defect: string; actor: string; reason: string; dryRun?: boolean }) => { + executed = true; activeCommand = "repair-task"; + const result = await repairTask(root, runId, taskId, { defectId: options.defect, actor: options.actor, reason: options.reason, dryRun: options.dryRun, now }); + emit("repair-task", result, `${result.dry_run ? "planned" : "opened"} ${result.repair.id}; fresh implementation and independent retest required`); + }); + + program.command("recover-repair <run-id>") + .description("recover interrupted repair files only when the journal still matches the manifest") + .requiredOption("--actor <actor>") + .action(async (runId: string, options: { actor: string }) => { + executed = true; activeCommand = "recover-repair"; + const result = await recoverRepair(root, runId, options.actor); + emit("recover-repair", result, result.recovered ? `recovered ${result.repair_id}: ${result.outcome}` : "no interrupted repair"); + }); + + program.command("timing <run-id>") + .description("summarize recorded lifecycle intervals and collector durations, including repair cycles") + .action(async (runId: string) => { + executed = true; activeCommand = "timing"; + const result = await getRunTiming(root, runId, { now }); + emit("timing", result, `recorded wall time ${result.wall_ms === null ? "unavailable" : `${(result.wall_ms / 60000).toFixed(1)} minutes`}; collector execution ${(result.collector_command_ms / 1000).toFixed(1)} seconds; use --json for intervals and limitations`); + }); + program.command("upgrade") .option("--root <path>", "repository root", ".") .option("--runtime-spec <npm-spec>", "exact npm dependency spec for the upgraded project runtime") @@ -355,9 +470,23 @@ export async function main(rawArguments = process.argv.slice(2)): Promise<number .requiredOption("--title <title>") .requiredOption("--request <path>") .requiredOption("--applications <applications>") + .option("--profile <profile>", "full (default) or opt-in compact", "full") + .option("--assessment <path>", "project-local JSON eligibility assessment required for compact") .action(async (options: StartOptions) => { executed = true; activeCommand = "start"; + let assessment: CompactAssessment | undefined; + if (options.assessment !== undefined) { + const path = await resolvePathInsideRoot(root, options.assessment, { mustExist: true }); + const source = await readFile(path, "utf8"); + try { + const strict = parseStrictYamlDocument(source); + const json = JSON.parse(source); + if (JSON.stringify(strict) !== JSON.stringify(json)) throw new Error("ambiguous input"); + assessment = json as CompactAssessment; + } catch { throw new CliFailure(exitCodes.usage, { code: "USAGE", message: "compact assessment must be strict JSON without duplicate keys" }); } + } + resolveWorkflowProfile(options.profile, assessment); const inspection = await inspectProject(root); if (!inspection.ready) { throw new SdlcValidationError(["project is not ready", ...inspection.diagnostics]); @@ -374,10 +503,12 @@ export async function main(rawArguments = process.argv.slice(2)): Promise<number title: options.title, requestFile: options.request, affectedApplications, + profile: options.profile, + assessment, now, }); const manifestPath = relative(root, `${runDirectory}/manifest.yaml`).replaceAll("\\", "/"); - emit("start", { run_id: options.id, manifest_path: manifestPath }, `created ${manifestPath}`); + emit("start", { run_id: options.id, manifest_path: manifestPath, profile: options.profile ?? "full" }, `created ${manifestPath} (${options.profile ?? "full"} workflow)`); }); program.command("ready <run-id>").action(async (runId: string) => { diff --git a/src/compact-artifacts.ts b/src/compact-artifacts.ts new file mode 100644 index 0000000..fa81283 --- /dev/null +++ b/src/compact-artifacts.ts @@ -0,0 +1,349 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { isDeepStrictEqual } from "node:util"; +import { stringify } from "yaml"; + +import { loadProject } from "./config.js"; +import { readEvidenceReference } from "./evidence-validation.js"; +import { publishRunAuthority, readAuthorityVersion } from "./manifest-transaction.js"; +import { isPortableRepositoryPath, resolvePathInsideRoot } from "./paths.js"; +import { acquireRunAuthorityLock, releaseRunAuthorityLock } from "./run-authority-lock.js"; +import { loadRunSnapshotUnderLock } from "./runs.js"; +import { validateDocument, type SchemaName } from "./schemas.js"; +import { parseStrictYamlDocument, type SemanticClaim, type SemanticFact } from "./semantic-contracts.js"; +import type { RunManifest, Task } from "./types.js"; + +export const compactPaths = { + specification: "artifacts/ba/specification.yaml", + semanticClaims: "artifacts/ba/semantic-claims.yaml", + acceptanceCriteria: "artifacts/ba/acceptance-criteria.md", + verification: "artifacts/qc/verification.yaml", + summary: "artifacts/qc/summary.md", +} as const; + +export type CompactEvidenceType = "command" | "api" | "ui" | "live_database" | "observation"; +export interface CompactSpecificationInput { + scope: { summary: string; in_scope: string[]; out_of_scope: string[] }; + requirements: Array<{ id: string; description: string; fact_ids: string[] }>; + acceptance_criteria: Array<{ id: string; requirement_id: string; given: string; when: string; then: string; evidence_types: CompactEvidenceType[] }>; + api_change: { kind: "unchanged" | "additive"; description: string }; +} +export interface CompactSpecification extends CompactSpecificationInput { + schema_version: 1; + run_id: string; + task_id: "BA-001"; + producer: "ba"; + revision: number; + facts_sha256: string; +} +export interface CompactReview { + specification_revision: number; + specification_sha256: string; + facts_sha256: string; + semantic_claims_sha256: string; + acceptance_criteria_sha256: string; +} +export interface CompactEvidenceReference { type: CompactEvidenceType; reference: string } +export interface CompactQcInput { + results: Array<{ ac_id: string; status: "passed" | "failed" | "blocked" | "not_tested"; evidence: CompactEvidenceReference[]; notes: string }>; + defects: Array<{ id: string; ac_ids: string[]; status: "open" | "resolved"; severity: "critical" | "high" | "medium" | "low"; summary: string }>; +} +export interface CompactQcVerification extends Omit<CompactQcInput, "results"> { + schema_version: 1; + run_id: string; + task_id: "QC-001"; + producer: "qc"; + revision: number; + reviewed_specification: CompactReview; + collector_evidence: Array<{ reference: string; sha256: string; status: "passed" | "failed" }>; + results: Array<Omit<CompactQcInput["results"][number], "evidence"> & { evidence: Array<CompactEvidenceReference & { sha256: string }> }>; + recommendation: "ready" | "changes_requested"; +} +/** Independently authored direct observation. It is evidence content, never a request/spec citation. */ +export interface CompactObservation { + schema_version: 1; + run_id: string; + task_id: "QC-001"; + producer: "qc"; + revision: number; + evidence_type: Exclude<CompactEvidenceType, "command">; + observed_at: string; + environment: string; + procedure: string; + actual_result: string; + status: "passed" | "failed"; +} +export interface CompactArtifactOptions { dryRun?: boolean; expectedVersion?: number } +export interface CompactSpecificationPublication { dryRun: boolean; authorityVersion: number; paths: string[]; document: CompactSpecification } +export interface CompactQcPublication { dryRun: boolean; authorityVersion: number; paths: string[]; document: CompactQcVerification } +interface FactsDocument { schema_version: 1; run_id: string; producer: "pm"; revision: number; facts: SemanticFact[] } +type CompactManifest = RunManifest & { workflow_profile?: { name: string; revision: number; assessment: Record<string, unknown> }; compact_review?: CompactReview }; +interface Context { manifest: RunManifest; version: number } + +/** Publish one authored BA specification and two deterministic compatibility views. Never passes a gate. */ +export async function publishCompactSpecification(root: string, runId: string, input: CompactSpecificationInput, options: CompactArtifactOptions = {}): Promise<CompactSpecificationPublication> { + closed(input, ["scope", "requirements", "acceptance_criteria", "api_change"], "compact specification input"); + const context = await capture(root, runId, options); + requireRunning(context.manifest, "BA-001"); + if ((context.manifest as CompactManifest).compact_review !== undefined) throw new Error("reviewed compact specification is frozen; start a new run to change approved scope"); + const factsSource = await source(root, runId, "facts.yaml"); + const previousSource = await optionalSource(root, runId, compactPaths.specification); + const previous = previousSource === undefined ? undefined : parseStrictYamlDocument(previousSource) as CompactSpecification; + if (previous !== undefined) assertSchema("compactSpecification", previous); + const document: CompactSpecification = { ...structuredClone(input), schema_version: 1, run_id: runId, task_id: "BA-001", producer: "ba", revision: previous?.revision ?? 1, facts_sha256: sha256(factsSource) }; + if (previous !== undefined && !isDeepStrictEqual(document, previous)) document.revision = previous.revision + 1; + const generated = specificationViews(context.manifest, document, factsSource); + const publications = [{ path: compactPaths.specification, source: yaml(document) }, { path: compactPaths.semanticClaims, source: generated.claims }, { path: compactPaths.acceptanceCriteria, source: generated.criteria }]; + let authorityVersion = context.version; + if (!options.dryRun) { + const publication = await publishRunAuthority(root, runId, publications, { + expectedVersion: context.version, + beforeRelease: async () => { if (sha256(await source(root, runId, "facts.yaml")) !== document.facts_sha256) throw new Error("facts changed during compact specification publication"); }, + }); + authorityVersion = publication.authorityVersion; + } + return { dryRun: options.dryRun ?? false, authorityVersion, paths: publications.map((entry) => entry.path), document }; +} + +/** Validate bytes and deterministic views before PM records their exact review binding. */ +export async function reviewCompactSpecification(root: string, runId: string, manifest: RunManifest): Promise<CompactReview> { + return (await readCompactSpecification(root, runId, manifest)).review; +} + +export async function readCompactSpecification(root: string, runId: string, manifest: RunManifest): Promise<{ document: CompactSpecification; review: CompactReview }> { + assertCompact(manifest, runId); + const [specificationSource, factsSource, claimsSource, criteriaSource] = await Promise.all([ + source(root, runId, compactPaths.specification), source(root, runId, "facts.yaml"), + source(root, runId, compactPaths.semanticClaims), source(root, runId, compactPaths.acceptanceCriteria), + ]); + const document = parseStrictYamlDocument(specificationSource) as CompactSpecification; + const expected = specificationViews(manifest, document, factsSource); + if (claimsSource !== expected.claims || criteriaSource !== expected.criteria) throw new Error("compact generated views differ from the canonical specification/facts; regenerate before PM review"); + return { document, review: { specification_revision: document.revision, specification_sha256: sha256(specificationSource), facts_sha256: sha256(factsSource), semantic_claims_sha256: sha256(claimsSource), acceptance_criteria_sha256: sha256(criteriaSource) } }; +} + +/** Uses the supplied locked manifest; does not recursively acquire the run lock. */ +export async function assertCompactReview(root: string, runId: string, manifest: RunManifest): Promise<void> { + const actual = await reviewCompactSpecification(root, runId, manifest); + const reviewed = (manifest as CompactManifest).compact_review; + if (reviewed === undefined || !isDeepStrictEqual(actual, reviewed)) throw new Error("compact specification/facts/views do not match PM's frozen review"); +} + +/** Publish honest independent QC results, including incomplete/blocked results, without approving readiness. */ +export async function publishCompactQc(root: string, runId: string, input: CompactQcInput, options: CompactArtifactOptions = {}): Promise<CompactQcPublication> { + closed(input, ["results", "defects"], "compact QC input"); + if (!Array.isArray(input.results) || !Array.isArray(input.defects)) throw new Error("compact QC results and defects must be arrays"); + for (const result of input.results) { + closed(result, ["ac_id", "status", "evidence", "notes"], "compact QC result"); + if (!Array.isArray(result.evidence)) throw new Error("compact QC evidence must be an array"); + for (const evidence of result.evidence) closed(evidence, ["type", "reference"], "compact QC evidence reference"); + } + const context = await capture(root, runId, options); + requireRunning(context.manifest, "QC-001"); + await assertCompactReview(root, runId, context.manifest); + const { document: specification, review } = await readCompactSpecification(root, runId, context.manifest); + const previousSource = await optionalSource(root, runId, compactPaths.verification); + const previous = previousSource === undefined ? undefined : parseStrictYamlDocument(previousSource) as CompactQcVerification; + if (previous !== undefined) assertSchema("compactQc", previous); + const results: CompactQcVerification["results"] = []; + for (const result of input.results) { + const evidence: CompactQcVerification["results"][number]["evidence"] = []; + for (const reference of result.evidence) { + const proof = await readProof(root, runId, context.manifest, reference); + evidence.push({ ...reference, sha256: proof.sha256 }); + } + results.push({ ...structuredClone(result), evidence }); + } + const document: CompactQcVerification = { schema_version: 1, run_id: runId, task_id: "QC-001", producer: "qc", revision: previous?.revision ?? 1, reviewed_specification: review, collector_evidence: await qcCollectors(root, runId, context.manifest), results, defects: structuredClone(input.defects), recommendation: "changes_requested" }; + document.recommendation = readyRecommendation(document, specification); + if (previous !== undefined && !isDeepStrictEqual(previous, document)) document.revision = previous.revision + 1; + await validateVerification(root, runId, context.manifest, document, specification, review); + const publications = [{ path: compactPaths.verification, source: yaml(document) }, { path: compactPaths.summary, source: verificationSummary(document) }]; + let authorityVersion = context.version; + if (!options.dryRun) { + const published = await publishRunAuthority(root, runId, publications, { expectedVersion: context.version, beforeRelease: async () => { + await assertCompactReview(root, runId, context.manifest); + await validateVerification(root, runId, context.manifest, document, specification, review); + } }); + authorityVersion = published.authorityVersion; + } + return { dryRun: options.dryRun ?? false, authorityVersion, paths: publications.map((entry) => entry.path), document }; +} + +export async function readCompactVerification(root: string, runId: string, manifest: RunManifest): Promise<CompactQcVerification> { + await assertCompactReview(root, runId, manifest); + const { document: specification, review } = await readCompactSpecification(root, runId, manifest); + const document = parseStrictYamlDocument(await source(root, runId, compactPaths.verification)) as CompactQcVerification; + await validateVerification(root, runId, manifest, document, specification, review); + if (await source(root, runId, compactPaths.summary) !== verificationSummary(document)) throw new Error("compact QC summary differs from canonical verification"); + return document; +} + +export async function assertCompactVerification(root: string, runId: string, manifest: RunManifest): Promise<void> { + const document = await readCompactVerification(root, runId, manifest); + if (document.recommendation !== "ready") throw new Error("compact QC is not ready: every AC needs passed direct evidence and no open defects"); +} + +function specificationViews(manifest: RunManifest, document: CompactSpecification, factsSource: string): { claims: string; criteria: string } { + assertCompact(manifest, document.run_id); + assertSchema("compactSpecification", document); + const facts = parseStrictYamlDocument(factsSource) as FactsDocument; + assertSchema("facts", facts); + if (facts.run_id !== document.run_id || document.facts_sha256 !== sha256(factsSource)) throw new Error("compact specification facts binding is stale or belongs to another run"); + uniqueIds(facts.facts, "id", "facts"); + uniqueIds(document.requirements, "id", "requirements"); + uniqueIds(document.acceptance_criteria, "id", "acceptance criteria"); + if (document.scope.in_scope.some((item) => document.scope.out_of_scope.includes(item))) throw new Error("compact scope cannot include and exclude the same item"); + if (document.api_change.kind === "additive" && !manifest.affected_applications?.backend) throw new Error("additive API change requires an affected backend application"); + if (facts.facts.some((fact) => fact.status === "unresolved" || fact.status === "proposed")) throw new Error("Compact specification cannot omit unresolved or proposed feature facts; resolve or explicitly mark them out_of_scope first"); + const approved = facts.facts.filter((fact) => fact.status === "approved"); + if (approved.length === 0) throw new Error("compact specification requires approved facts"); + const references = new Map<string, string[]>(); + for (const requirement of document.requirements) { + for (const factId of requirement.fact_ids) { + const fact = facts.facts.find((candidate) => candidate.id === factId); + if (fact?.status !== "approved") throw new Error(`requirement ${requirement.id} references missing or unresolved/unapproved fact ${factId}`); + references.set(factId, [...(references.get(factId) ?? []), requirement.id]); + } + if (!document.acceptance_criteria.some((criterion) => criterion.requirement_id === requirement.id)) throw new Error(`requirement ${requirement.id} has no acceptance criterion`); + } + for (const fact of approved) if (!references.has(fact.id)) throw new Error(`approved fact ${fact.id} has no requirement link`); + for (const criterion of document.acceptance_criteria) if (!document.requirements.some((requirement) => requirement.id === criterion.requirement_id)) throw new Error(`acceptance criterion ${criterion.id} references missing requirement ${criterion.requirement_id}`); + const claims: SemanticClaim[] = approved.map((fact) => ({ ...structuredClone(fact), id: fact.id.replace("FACT-", "CLAIM-") as SemanticClaim["id"], source_fact_id: fact.id, requirement_ids: references.get(fact.id)! as SemanticClaim["requirement_ids"], question_ids: [] })); + const claimsDocument = { schema_version: 1, run_id: document.run_id, task_id: "BA-001", producer: "ba", revision: document.revision, claims }; + assertSchema("semanticClaims", claimsDocument); + const criteria = document.acceptance_criteria.map((criterion) => { + const claimIds = claims.filter((claim) => claim.requirement_ids.includes(criterion.requirement_id as `REQ-${string}`)).map((claim) => claim.id); + return `## ${criterion.id}\n\nrequirement_id: ${criterion.requirement_id}\nclaim_ids: [${claimIds.join(", ")}]\n\nGiven ${oneLine(criterion.given)}\nWhen ${oneLine(criterion.when)}\nThen ${oneLine(criterion.then)}\n\nevidence_expected: ${criterion.evidence_types.join(", ")}\n`; + }); + return { claims: yaml(claimsDocument), criteria: `---\nrun_id: ${document.run_id}\ntask_id: BA-001\nproducer: ba\nstatus: draft\nrevision: ${document.revision}\n---\n\n# Acceptance criteria\n\n${criteria.join("\n")}` }; +} + +async function validateVerification(root: string, runId: string, manifest: RunManifest, document: CompactQcVerification, specification: CompactSpecification, review: CompactReview): Promise<void> { + assertSchema("compactQc", document); + if (document.run_id !== runId || !isDeepStrictEqual(document.reviewed_specification, review)) throw new Error("compact QC belongs to another run or reviewed specification"); + if (!isDeepStrictEqual(document.collector_evidence, await qcCollectors(root, runId, manifest))) throw new Error("compact QC collector evidence changed or does not belong to current independent QC"); + uniqueIds(document.results, "ac_id", "QC results"); + uniqueIds(document.defects, "id", "QC defects"); + const expected = new Set(specification.acceptance_criteria.map((criterion) => criterion.id)); + if (document.results.length !== expected.size || document.results.some((result) => !expected.has(result.ac_id))) throw new Error("compact QC must cover every canonical AC exactly once"); + for (const result of document.results) { + const criterion = specification.acceptance_criteria.find((entry) => entry.id === result.ac_id)!; + const seen = new Set<string>(); + for (const reference of result.evidence) { + if (!criterion.evidence_types.includes(reference.type)) throw new Error(`${result.ac_id} cites undeclared evidence type ${reference.type}`); + if (seen.has(reference.reference)) throw new Error(`${result.ac_id} duplicates evidence reference ${reference.reference}`); + seen.add(reference.reference); + if (reference.type === "command" && !document.collector_evidence.some((entry) => entry.reference === reference.reference)) throw new Error(`${result.ac_id} command evidence is superseded by a newer QC check`); + const actual = await readProof(root, runId, manifest, reference); + if (actual.sha256 !== reference.sha256) throw new Error(`compact QC evidence changed: ${reference.reference}`); + if (result.status === "passed" && actual.status !== "passed") throw new Error(`${result.ac_id} cannot pass with failed direct evidence`); + } + if (result.status === "passed" && criterion.evidence_types.some((type) => !result.evidence.some((entry) => entry.type === type))) throw new Error(`${result.ac_id} passed without every required evidence type`); + } + for (const defect of document.defects) { + if (defect.ac_ids.some((id) => !expected.has(id))) throw new Error(`defect ${defect.id} references an unknown acceptance criterion`); + if (defect.status === "resolved" && defect.ac_ids.some((id) => document.results.find((result) => result.ac_id === id)?.status !== "passed")) throw new Error(`resolved defect ${defect.id} requires passed retest coverage`); + } + if (document.recommendation !== readyRecommendation(document, specification)) throw new Error("compact QC recommendation does not match direct coverage and open defects"); +} + +function readyRecommendation(document: Pick<CompactQcVerification, "results" | "defects" | "collector_evidence">, specification: CompactSpecification): "ready" | "changes_requested" { + return document.collector_evidence.length > 0 + && document.collector_evidence.every((entry) => entry.status === "passed") + && document.results.length === specification.acceptance_criteria.length + && document.results.every((result) => result.status === "passed") + && document.defects.every((defect) => defect.status !== "open") ? "ready" : "changes_requested"; +} + +async function readProof(root: string, runId: string, manifest: RunManifest, reference: CompactEvidenceReference): Promise<{ sha256: string; status: "passed" | "failed" }> { + closed(reference, Object.hasOwn(reference, "sha256") ? ["type", "reference", "sha256"] : ["type", "reference"], "QC evidence"); + const qc = manifest.tasks.find((task) => task.id === "QC-001"); + if (qc?.role !== "qc" || qc.started_at === null) throw new Error("direct QC evidence requires an activated independent QC task"); + if (!isPortableRepositoryPath(reference.reference) || reference.reference === ".") throw new Error("QC evidence reference must be a portable run-relative path"); + if (reference.type === "command") { + if (!qc.evidence.includes(reference.reference)) throw new Error("compact command evidence must be recorded by QC-001, not an implementation task"); + const evidence = await readEvidenceReference({ root, runId, manifest, reference: reference.reference, project: await loadProject(root), owner: "qc", expectedTask: qc, expectedStage: "qc" }); + if (Date.parse(evidence.record.started_at) < Date.parse(qc.started_at)) throw new Error("compact command evidence predates the independent QC activation"); + if (Date.parse(evidence.record.started_at) > Date.now() || Date.parse(evidence.record.completed_at) > Date.now() || Date.parse(evidence.record.completed_at) < Date.parse(evidence.record.started_at)) throw new Error("compact command evidence has future or reversed timestamps"); + return { sha256: sha256(evidence.source), status: evidence.record.result_status }; + } + if (!["api", "ui", "live_database", "observation"].includes(reference.type) + || !reference.reference.startsWith("artifacts/qc/evidence/") || !/\.ya?ml$/u.test(reference.reference)) throw new Error("direct observations must be QC evidence YAML artifacts, never request/specification/self citations"); + const observationSource = await source(root, runId, reference.reference); + const observation = parseStrictYamlDocument(observationSource) as CompactObservation; + closed(observation, ["schema_version", "run_id", "task_id", "producer", "revision", "evidence_type", "observed_at", "environment", "procedure", "actual_result", "status"], "direct QC observation"); + if (observation.schema_version !== 1 || observation.run_id !== runId || observation.task_id !== "QC-001" || observation.producer !== "qc" + || !Number.isInteger(observation.revision) || observation.revision < 1 || observation.evidence_type !== reference.type + || !["passed", "failed"].includes(observation.status) + || !Number.isFinite(Date.parse(observation.observed_at)) || Date.parse(observation.observed_at) < Date.parse(qc.started_at) || Date.parse(observation.observed_at) > Date.now() + || [observation.environment, observation.procedure, observation.actual_result].some((value) => typeof value !== "string" || !value.trim())) throw new Error("direct QC observation lacks matching identity, type, time, environment, procedure, or actual result"); + const repair = manifest.repair_history?.findLast((record) => record.affected_task_ids.includes("QC-001")); + if (repair !== undefined && Date.parse(observation.observed_at) < Date.parse(repair.at)) throw new Error("direct QC observation predates the current repair cycle"); + return { sha256: sha256(observationSource), status: observation.status }; +} + +async function qcCollectors(root: string, runId: string, manifest: RunManifest): Promise<Array<{ reference: string; sha256: string; status: "passed" | "failed" }>> { + const qc = manifest.tasks.find((task) => task.id === "QC-001"); + if (qc?.role !== "qc" || qc.started_at === null) throw new Error("compact verification requires an activated independent QC task"); + const project = await loadProject(root); + const latest = new Map<string, { reference: string; sha256: string; status: "passed" | "failed" }>(); + for (const reference of qc.evidence) { + const evidence = await readEvidenceReference({ root, runId, manifest, reference, project, owner: "qc", expectedTask: qc, expectedStage: "qc" }); + if (Date.parse(evidence.record.started_at) < Date.parse(qc.started_at)) throw new Error("QC collector evidence predates its current activation"); + if (Date.parse(evidence.record.started_at) > Date.now() || Date.parse(evidence.record.completed_at) > Date.now() || Date.parse(evidence.record.completed_at) < Date.parse(evidence.record.started_at)) throw new Error("QC collector evidence has future or reversed timestamps"); + latest.set(evidence.record.command_id, { reference, sha256: sha256(evidence.source), status: evidence.record.result_status }); + } + return [...latest.values()]; +} + +function verificationSummary(document: CompactQcVerification): string { + return `---\nrun_id: ${document.run_id}\ntask_id: QC-001\nproducer: qc\nrevision: ${document.revision}\n---\n\n# Independent verification\n\nRecommendation: ${document.recommendation}\n\n${document.results.map((result) => `- ${result.ac_id}: ${result.status}. ${oneLine(result.notes)}`).join("\n")}\n\nOpen defects: ${document.defects.filter((defect) => defect.status === "open").map((defect) => defect.id).join(", ") || "none"}\n`; +} + +async function capture(root: string, runId: string, options: CompactArtifactOptions): Promise<Context> { + if (!/^[A-Z][A-Z0-9]*-[0-9]+$/u.test(runId)) throw new Error("compact artifacts require a canonical run ID"); + const lock = await acquireRunAuthorityLock(root, runId); + try { + const { manifest } = await loadRunSnapshotUnderLock(root, runId, false, lock); + assertCompact(manifest, runId); + const version = await readAuthorityVersion(root, runId); + if (options.expectedVersion !== undefined && options.expectedVersion !== version) throw new Error(`stale authority version: expected ${options.expectedVersion} but actual ${version}`); + return { manifest, version }; + } finally { await releaseRunAuthorityLock(lock); } +} +function assertCompact(manifest: RunManifest, runId: string): void { + const profile = (manifest as CompactManifest).workflow_profile; + const assessment = profile?.assessment; + if (manifest.run.id !== runId || profile?.name !== "compact" || profile.revision !== 1 || !assessment + || assessment.bounded_scope !== true || assessment.existing_patterns !== true + || ["migrations", "breaking_api", "authorization_changes", "sensitive_data_exposure", "cross_system_uncertainty"].some((key) => assessment[key] !== false) + || typeof assessment.rationale !== "string" || !assessment.rationale.trim()) throw new Error("compact artifacts require an eligible bounded Compact workflow assessment"); +} +function requireRunning(manifest: RunManifest, taskId: string): Task { + const task = manifest.tasks.find((entry) => entry.id === taskId); + if (task?.status !== "running" || task.dependencies.some((id) => manifest.tasks.find((entry) => entry.id === id)?.status !== "completed")) throw new Error(`${taskId} must be running with completed dependencies to author compact artifacts`); + return task; +} +function uniqueIds<T extends object>(items: T[], key: keyof T, label: string): void { + const ids = items.map((item) => item[key]); + if (new Set(ids).size !== ids.length) throw new Error(`${label} contain duplicate ${String(key)} values`); +} +function assertSchema(name: SchemaName, value: unknown): void { + const validation = validateDocument(name, value); + if (!validation.valid) throw new Error(`invalid ${name}: ${validation.diagnostics.join("; ")}`); +} +function closed(value: unknown, keys: string[], label: string): void { + if (value === null || typeof value !== "object" || Array.isArray(value) || Object.keys(value).sort().join(",") !== [...keys].sort().join(",")) throw new Error(`${label} must contain exactly ${keys.join(", ")}`); +} +async function source(root: string, runId: string, path: string): Promise<string> { + if (!isPortableRepositoryPath(path) || path === ".") throw new Error("invalid compact artifact path"); + return readFile(await resolvePathInsideRoot(root, `.sdlc/runs/${runId}/${path}`, { mustExist: true }), "utf8"); +} +async function optionalSource(root: string, runId: string, path: string): Promise<string | undefined> { + try { return await source(root, runId, path); } + catch (error) { if (error instanceof Error && /path does not exist inside repository root/.test(error.message)) return undefined; throw error; } +} +function yaml(value: unknown): string { return stringify(value, { aliasDuplicateObjects: false }); } +function sha256(value: string): string { return createHash("sha256").update(value).digest("hex"); } +function oneLine(value: string): string { return value.replace(/[\r\n]+/gu, " "); } diff --git a/src/constants.ts b/src/constants.ts index edfc9e4..8e217b9 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,4 +1,4 @@ export const FRAMEWORK_NAME = "codex-sdlc" as const; -export const FRAMEWORK_VERSION = "0.6.0" as const; +export const FRAMEWORK_VERSION = "1.0.0" as const; export const SCHEMA_VERSION = 1 as const; export const PROJECT_SCHEMA_VERSION = 2 as const; diff --git a/src/delivery-authority-resolver.ts b/src/delivery-authority-resolver.ts index e2ac7b8..7317bf5 100644 --- a/src/delivery-authority-resolver.ts +++ b/src/delivery-authority-resolver.ts @@ -41,6 +41,7 @@ import { type RunAuthorityLockOptions, } from "./run-authority-lock.js"; import type { EvidenceRecord, ProjectConfig, RunManifest, SdlcDecision, Task, TaskRole, ValidationResult } from "./types.js"; +import { assertCompactDeliveryControls } from "./workflow-profile.js"; const assignmentPathPattern = /^\.sdlc\/runs\/([A-Z][A-Z0-9]*-[0-9]+)\/tasks\/([A-Z][A-Z0-9]*-[0-9]+)\.assignment\.yaml$/; const changedFilesPath = "evidence/diffs/changed-files.json"; @@ -111,10 +112,15 @@ async function resolveRepositoryDeliveryAuthorityUnderLock( } assertSchema("run", manifestValue, `${runId} run manifest`); const manifest = manifestValue as RunManifest; + assertCompactDeliveryControls(manifest, assignment.controls); if (manifest.run.id !== runId) throw new Error(`delivery assignment path run ${runId} does not match manifest run ${manifest.run.id}`); const task = manifest.tasks.find((candidate) => candidate.id === taskId); if (task === undefined) throw new Error(`delivery assignment task ${taskId} does not exist in run ${runId}`); assertDeliveryTask(task); + const archived = await readArchivedAssignment(root, runId, taskId, manifest); + if (archived !== undefined && (assignment.revision <= archived.revision || assignment.assignment_id !== archived.assignment_id)) { + throw new Error(`${taskId} assignment must retain its identity and advance beyond archived repair revision ${archived.revision}`); + } const factsPath = `.sdlc/runs/${runId}/facts.yaml`; const factsSource = await readRepositoryFile(root, factsPath); @@ -279,7 +285,25 @@ export async function validateRepositoryStructuredDelivery( return { valid: diagnostics.length === 0, diagnostics: [...new Set(diagnostics)] }; } -async function resolveRequiredInputs(root: string, runId: string, task: Task): Promise<DeliveryAuthorityInput[]> { +export async function readArchivedAssignment(root: string, runId: string, taskId: string, manifest: RunManifest): Promise<DeliveryAssignment | undefined> { + let latest: DeliveryAssignment | undefined; + for (const repair of manifest.repair_history ?? []) { + const archive = repair.archives.find((entry) => entry.path === `tasks/${taskId}.assignment.yaml`); + if (archive === undefined) continue; + if (archive.archive_path !== `repairs/${repair.id}/${archive.path}`) throw new Error("invalid archived assignment path"); + const source = await readRepositoryFile(root, `.sdlc/runs/${runId}/${archive.archive_path}`); + if (sha256(source) !== archive.sha256) throw new Error("archived assignment integrity changed"); + const value = parseStrictYamlDocument(source); + assertSchema("deliveryAssignment", value, "archived assignment"); + const assignment = value as DeliveryAssignment; + if (assignment.run_id !== runId || assignment.task_id !== taskId) throw new Error("archived assignment identity mismatch"); + if (latest !== undefined && (assignment.assignment_id !== latest.assignment_id || assignment.revision <= latest.revision)) throw new Error("archived assignment revisions must advance with stable identity"); + latest = assignment; + } + return latest; +} + +export async function resolveRequiredInputs(root: string, runId: string, task: Task): Promise<DeliveryAuthorityInput[]> { const ownAssignment = `tasks/${task.id}.assignment.yaml`; return Promise.all(task.required_inputs.filter((path) => path !== ownAssignment).map(async (runPath) => { const repositoryPath = `.sdlc/runs/${runId}/${runPath}`; @@ -341,7 +365,7 @@ async function resolveChangedFiles( return { path: changedFilesPath, sha256: sha256(source), files }; } -async function resolvePermissionRoots( +export async function resolvePermissionRoots( root: string, runId: string, task: Task, @@ -394,7 +418,7 @@ function isSharedContractRoot(path: string, project: ProjectConfig): boolean { : path === configured; } -function resolveApprovalDecisions(manifest: RunManifest, task: Task): DeliveryAuthorityApproval[] { +export function resolveApprovalDecisions(manifest: RunManifest, task: Task): DeliveryAuthorityApproval[] { return (manifest.decisions ?? []) .filter((decision) => ( decision.action === "destructive_migration" @@ -485,7 +509,7 @@ function approvalAuthority(runId: string, taskId: string, decision: SdlcDecision : { ...base, status: "approved", complete: true, approved_by: "product-owner", consumed: false, consumed_at: null }; } -function workflowOutputs(runId: string, task: Task): RequiredDeliveryOutput[] { +export function workflowOutputs(runId: string, task: Task): RequiredDeliveryOutput[] { return task.required_outputs .filter((path) => !path.endsWith("-delivery-report.yaml")) .map((path) => ({ @@ -496,7 +520,7 @@ function workflowOutputs(runId: string, task: Task): RequiredDeliveryOutput[] { })); } -async function resolveArtifactIntegrity(root: string, report: DeliveryReport): Promise<DeliveryArtifactIntegrity[]> { +export async function resolveArtifactIntegrity(root: string, report: DeliveryReport): Promise<DeliveryArtifactIntegrity[]> { return Promise.all(report.artifacts.filter(isProducedArtifact).map(async (artifact) => { const source = await readRepositoryFile(root, artifact.path); return { @@ -586,7 +610,7 @@ function artifactKind(path: string): ArtifactKind { throw new Error(`workflow output has no structured delivery artifact kind: ${path}`); } -function producerForRunPath(path: string): Exclude<TaskRole, never> { +export function producerForRunPath(path: string): Exclude<TaskRole, never> { if (path.startsWith("artifacts/ba/")) return "ba"; if (path.startsWith("artifacts/backend/")) return "backend"; if (path.startsWith("artifacts/web/") || path.startsWith("artifacts/mobile/")) return "frontend"; @@ -605,7 +629,7 @@ function isTargetPermissionRoot(task: Task, path: string, project: ProjectConfig return path === project.applications.mobile?.root || path === artifactRoot; } -function repositoryForTask(project: ProjectConfig, task: Task): string { +export function repositoryForTask(project: ProjectConfig, task: Task): string { if (task.stage === "api_contract" && project.resources?.api_contracts !== undefined) { return project.resources.api_contracts.repository; } @@ -615,7 +639,7 @@ function repositoryForTask(project: ProjectConfig, task: Task): string { return project.workspace?.coordinator ?? "coordinator"; } -function documentRevision(source: string, path: string): number { +export function documentRevision(source: string, path: string): number { let value: unknown; try { if (path.endsWith(".yaml") || path.endsWith(".yml")) value = parseStrictYamlDocument(source); diff --git a/src/delivery-report-reconciliation.ts b/src/delivery-report-reconciliation.ts index 6a80dfe..13f1e78 100644 --- a/src/delivery-report-reconciliation.ts +++ b/src/delivery-report-reconciliation.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import { isDeepStrictEqual } from "node:util"; -import { portablePathContains, portableRepositoryPathKey } from "./paths.js"; +import { portablePathContains } from "./paths.js"; import { parseStrictYamlDocument, @@ -209,7 +209,7 @@ function reconcileWrites( if (!assignment.allowed_write_roots.some((root) => containsPath(root, write.path))) { diagnostics.push(`write path ${write.path} is outside allowed roots`); } - if (!isSelectedTargetPath(assignment, write.path)) { + if (!isSelectedTargetPath(assignment, authority, write.path)) { diagnostics.push(`write path ${write.path} is not owned by target ${assignment.target}`); } } @@ -302,11 +302,11 @@ function reconcileRequirementResults( expected: NormativeDeliveryState, diagnostics: string[], ): void { - compareKeyed(requirements, results, "requirement_id", "observations.requirement_results", diagnostics); + compareKeyed(requirements, results, "capability", "observations.requirement_results", diagnostics); for (const requirement of requirements) { - const result = results.find((candidate) => candidate.requirement_id === requirement.requirement_id); + const result = results.find((candidate) => candidate.capability === requirement.capability); if (result === undefined) continue; - compareScalar(result.capability, requirement.capability, `observations.requirement_results.${requirement.requirement_id}.capability`, diagnostics); + compareScalar(result.requirement_id, requirement.requirement_id, `observations.requirement_results.${requirement.capability}.requirement_id`, diagnostics); compareScalar(result.required, requirement.required, `observations.requirement_results.${requirement.requirement_id}.required`, diagnostics); if (requirement.required && result.status === "not_applicable") diagnostics.push(`required result ${requirement.requirement_id} cannot be not_applicable`); const legalStatuses: DeliveryResultStatus[] = expected.barrier !== "eligible" @@ -412,9 +412,9 @@ function requiredResultsSatisfied(assignment: DeliveryAssignment, report: Delive const requirements = assignment.role === "backend" ? assignment.controls.api_requirements : assignment.controls.requirements; const results = report.role === "backend" ? report.observations.requirement_results : report.observations.requirement_results; return requirements.filter((requirement) => requirement.required).every((requirement) => { - const result = results.find((candidate) => candidate.requirement_id === requirement.requirement_id); + const result = results.find((candidate) => candidate.capability === requirement.capability); const allowed = assignment.stage === "api_contract" ? ["documented", "implemented"] : ["implemented"]; - return result !== undefined && allowed.includes(result.status); + return result !== undefined && result.requirement_id === requirement.requirement_id && allowed.includes(result.status); }); } @@ -455,22 +455,11 @@ function containsPath(root: string, path: string): boolean { } } -function isSelectedTargetPath(assignment: DeliveryAssignment, path: string): boolean { - const targetFolder = assignment.target === "backend" ? "backend" : assignment.target; - let pathKey: string; - try { pathKey = portableRepositoryPathKey(path); } catch { return false; } - if (pathKey === "apps" || pathKey.startsWith("apps/")) { - return assignment.allowed_write_roots.some((root) => { - let rootKey: string; - try { rootKey = portableRepositoryPathKey(root); } catch { return false; } - const segments = rootKey.split("/"); - const application = segments[0] === "apps" ? segments[1] : undefined; - const ownsTarget = application === targetFolder || application?.endsWith(`-${targetFolder}`) === true; - return ownsTarget && containsPath(root, path); - }); - } +/** Permission roots come from the project-backed authority resolver, not folder names. */ +function isSelectedTargetPath(assignment: DeliveryAssignment, authority: DeliveryAuthoritySnapshot, path: string): boolean { + if (!authority.workflow.permission_roots.some((root) => containsPath(root, path))) return false; const artifactRoot = `.sdlc/runs/${assignment.run_id}/artifacts`; - if (containsPath(artifactRoot, path)) return containsPath(`${artifactRoot}/${targetFolder}`, path); + if (containsPath(artifactRoot, path)) return containsPath(`${artifactRoot}/${authority.task.target}`, path); return true; } diff --git a/src/evidence-validation.ts b/src/evidence-validation.ts index 799edc2..8206809 100644 --- a/src/evidence-validation.ts +++ b/src/evidence-validation.ts @@ -52,6 +52,11 @@ export async function readEvidenceReference(input: EvidenceReferenceValidationIn if (task === undefined) { throw new Error(`evidence task ID does not exist in the active run: ${input.reference}`); } + const repair = input.manifest.repair_history?.findLast((entry) => entry.affected_task_ids.includes(task.id)); + const archivedReference = input.manifest.repair_history?.some((entry) => entry.tasks.some((prior) => prior.id === task.id && prior.evidence.includes(input.reference))); + if (archivedReference || (repair !== undefined && Date.parse(evidence.started_at) < Date.parse(repair.at))) { + throw new Error(`evidence predates the current repair cycle for ${task.id}: ${input.reference}`); + } try { taskStageContract(task); } catch (error) { diff --git a/src/evidence.ts b/src/evidence.ts index a814352..c631b4e 100644 --- a/src/evidence.ts +++ b/src/evidence.ts @@ -6,13 +6,14 @@ import { canonicalizeCommandDeclaration, type CanonicalCommandProvenance } from import { assertNetworkPolicyAttested, executeCommand } from "./commands.js"; import { loadProject } from "./config.js"; import { createEvidenceId, evidencePathsForId } from "./evidence-identifiers.js"; -import { mutateRunManifest } from "./manifest-transaction.js"; +import { assertNoPendingRepair, mutateRunManifest } from "./manifest-transaction.js"; import { SdlcPathError, resolvePathInsideRoot } from "./paths.js"; import { SdlcPolicyError } from "./policy.js"; import { configuredSecretValues, redactText } from "./redaction.js"; import { loadRun } from "./runs.js"; import { validateDocument } from "./schemas.js"; import type { EvidenceRecord } from "./types.js"; +import { isCompactRun } from "./workflow-profile.js"; export async function executeConfiguredCommand( root: string, @@ -21,6 +22,7 @@ export async function executeConfiguredCommand( commandId: string, timing: string | { clock?: () => string } = {}, ): Promise<EvidenceRecord> { + await assertNoPendingRepair(root, runId); const project = await loadProject(root); const secrets = configuredSecretValues(project.security.secret_environment_variables); try { @@ -32,9 +34,16 @@ export async function executeConfiguredCommand( const canonicalCommand = await canonicalizeCommandDeclaration(root, command, secrets, project); const manifest = await loadRun(root, runId); - if (!manifest.tasks.some((task) => task.id === taskId)) { + const task = manifest.tasks.find((task) => task.id === taskId); + if (task === undefined) { throw new Error(`task does not exist in run ${runId}: ${taskId}`); } + if (isCompactRun(manifest)) { + if (task.status !== "running") throw new Error("Compact evidence requires a running task; reopen review or repair before collecting new evidence"); + if (task.id === "QC-001" && [manifest.quality_gates.integration?.status, manifest.quality_gates.qc?.status].includes("passed")) { + throw new Error("Compact QC verification is already approved; reopen review and reset integration/QC gates before rerunning checks"); + } + } const runRoot = await resolvePathInsideRoot(root, `.sdlc/runs/${runId}`, { mustExist: true }); const commandsRelative = `.sdlc/runs/${runId}/evidence/commands`; const stagingDirectory = await mkdtemp(join(dirname(runRoot), `${runId}.evidence-staging-`)); diff --git a/src/finalize.ts b/src/finalize.ts index 2328580..07f4132 100644 --- a/src/finalize.ts +++ b/src/finalize.ts @@ -4,7 +4,9 @@ import { createHash } from "node:crypto"; import { loadProject } from "./config.js"; import { assertEvidenceReference, resolveRunReference } from "./evidence-validation.js"; import { mutateRunManifest } from "./manifest-transaction.js"; -import { qualityGateTaskStages } from "./quality-gates.js"; +import { qualityGateStage } from "./quality-gates.js"; +import { assertCompactReview, assertCompactVerification } from "./compact-artifacts.js"; +import { isCompactRun } from "./workflow-profile.js"; import type { RunManifest } from "./types.js"; const alwaysRequiredGates = ["requirements", "integration", "qc"] as const; @@ -13,6 +15,10 @@ export async function finalizeRun(root: string, runId: string, actor: string, no const transaction = await mutateRunManifest(root, runId, async (manifest) => { normalizeLegacyInapplicableGates(manifest); assertReadyForProductOwnerReview(manifest); + if (isCompactRun(manifest)) { + await assertCompactReview(root, runId, manifest); + await assertCompactVerification(root, runId, manifest); + } if (manifest.agent_policy?.product_owner_review === "advisory") await assertProductOwnerAdvisory(root, runId, manifest); await assertReferencedFilesExist(root, runId, manifest); await assertEvidencePassed(root, runId, manifest); @@ -78,7 +84,7 @@ function assertReadyForProductOwnerReview(manifest: RunManifest): void { } const requiredGates = new Set<string>(alwaysRequiredGates); if (manifest.affected_applications?.backend) { - requiredGates.add("api_contract"); + if (!isCompactRun(manifest)) requiredGates.add("api_contract"); requiredGates.add("backend"); } if (manifest.affected_applications?.web) requiredGates.add("web"); @@ -95,7 +101,7 @@ function assertReadyForProductOwnerReview(manifest: RunManifest): void { throw new Error(`${gateId} quality gate must be not_applicable when ${application} is unaffected`); } } - if (!manifest.affected_applications?.backend && manifest.quality_gates.api_contract?.status !== "not_applicable") { + if ((!manifest.affected_applications?.backend || isCompactRun(manifest)) && manifest.quality_gates.api_contract?.status !== "not_applicable") { throw new Error("api_contract quality gate must be not_applicable when backend is unaffected"); } const blocker = manifest.blockers.find((candidate) => candidate.status === "open"); @@ -154,7 +160,7 @@ async function assertEvidencePassed(root: string, runId: string, manifest: RunMa } } for (const [gateId, gate] of Object.entries(manifest.quality_gates)) { - const expectedStage = qualityGateTaskStages[gateId as keyof typeof qualityGateTaskStages]; + const expectedStage = qualityGateStage(gateId, manifest); if (expectedStage === undefined) continue; for (const reference of gate.evidence) { const evidence = await assertEvidenceReference({ diff --git a/src/index.ts b/src/index.ts index 436d4a3..f256099 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,15 @@ export { configureAgents, updateAgentPolicy, planAgent, resolveAgentPlan, recordAgentDispatch } from "./agents.js"; export type { AgentPolicy, AgentCapabilities, AgentPlan, AgentDispatch, RoleModel, ReasoningEffort } from "./agents.js"; export { main } from "./cli.js"; +export { prepareTask, handoffTask } from "./task-operations.js"; +export { activateTask } from "./task-activation.js"; +export { collectTaskChecks } from "./task-checks.js"; +export { preflightProject } from "./preflight.js"; +export { getRunTiming, summarizeRunTiming } from "./run-timing.js"; +export { repairTask, recoverRepair } from "./repairs.js"; +export { publishCompactSpecification, publishCompactQc } from "./compact-artifacts.js"; +export type { CompactSpecificationInput, CompactQcInput } from "./compact-artifacts.js"; +export type { CompactAssessment, WorkflowProfile } from "./workflow-profile.js"; export { configureRepositories, initializeProject, inspectProject } from "./install.js"; export { rollbackProject, uninstallProject, upgradeProject } from "./installation-lifecycle.js"; export { loadFramework, loadLocal, loadProject, loadWorkflow } from "./config.js"; diff --git a/src/lifecycle.ts b/src/lifecycle.ts index c45d95b..23af95a 100644 --- a/src/lifecycle.ts +++ b/src/lifecycle.ts @@ -3,7 +3,9 @@ import { createHash } from "node:crypto"; import { loadProject } from "./config.js"; import { assertEvidenceReference, resolveRunReference } from "./evidence-validation.js"; import { mutateRunManifest } from "./manifest-transaction.js"; -import { qualityGateTaskStages } from "./quality-gates.js"; +import { qualityGateStage } from "./quality-gates.js"; +import { assertCompactReview, assertCompactVerification, reviewCompactSpecification } from "./compact-artifacts.js"; +import { isCompactRun } from "./workflow-profile.js"; import type { QualityGate, RunManifest, SdlcDecision, TaskStatus } from "./types.js"; type ApprovalTarget = Extract<TaskStatus, "running" | "completed">; @@ -28,7 +30,18 @@ export async function recordQualityGate( if (gate === undefined) throw new Error(`quality gate does not exist: ${gateId}`); if (gate.status === "not_applicable") throw new Error(`quality gate is not applicable: ${gateId}`); if (status === "passed" && references.length === 0) throw new Error(`passed quality gate requires evidence: ${gateId}`); - const expectedStage = qualityGateTaskStages[gateId as keyof typeof qualityGateTaskStages]; + if (isCompactRun(manifest)) { + if (gateId === "requirements" && status === "passed") { + if (actor !== "pm") throw new Error("Compact requirements gate must be reviewed by pm"); + if (!["awaiting_review", "completed"].includes(manifest.tasks.find((task) => task.id === "BA-001")?.status ?? "")) throw new Error("Compact specification must be handed off before PM review"); + if (!references.includes("artifacts/ba/specification.yaml") || references.some((reference) => !["artifacts/ba/specification.yaml", "artifacts/ba/semantic-claims.yaml", "artifacts/ba/acceptance-criteria.md"].includes(reference))) throw new Error("Compact requirements gate evidence must include the specification and only its derived views"); + manifest.compact_review = await reviewCompactSpecification(root, runId, manifest); + } else if (status === "passed") { + await assertCompactReview(root, runId, manifest); + if (gateId === "integration" || gateId === "qc") await assertCompactVerification(root, runId, manifest); + } + } + const expectedStage = qualityGateStage(gateId, manifest); for (const reference of references) { await resolveRunReference(root, runId, reference); if (expectedStage === undefined) continue; diff --git a/src/manifest-transaction.ts b/src/manifest-transaction.ts index 3124e05..ad2472d 100644 --- a/src/manifest-transaction.ts +++ b/src/manifest-transaction.ts @@ -17,8 +17,11 @@ import { loadRunSnapshotUnderLock, validateRunSnapshotUnderLock } from "./runs.j import { parseStrictYamlDocument, structuredDocumentRevision } from "./semantic-contracts.js"; import { validateDocument } from "./schemas.js"; import type { RunManifest } from "./types.js"; +import { assertFrozenWorkflowProfile } from "./workflow-profile.js"; export interface ManifestTransactionOptions extends RunAuthorityLockOptions { + /** Internal compound operations restore their staged files before rollback validation, under the run lock. */ + rollbackChanges?: () => Promise<void>; /** Test-only hook for deterministic cleanup coverage after the temp write. */ beforeReplace?: (manifestPath: string, temporaryPath: string) => Promise<void> | void; /** Test-only hook after publication and before the final repository validation. */ @@ -79,10 +82,12 @@ async function mutateRunManifestInternal<T>( await resolvePathInsideRoot(root, `.sdlc/runs/${runId}`, { mustExist: true }); const lock = await acquireRunAuthorityLock(root, runId, options); try { + await assertNoPendingRepair(root, runId); const authorityVersion = await readAuthorityVersion(root, runId); const snapshot = await loadRunSnapshotUnderLock(root, runId, true, lock); const manifest = structuredClone(snapshot.manifest); const value = await mutate(manifest); + assertFrozenWorkflowProfile(snapshot.manifest, manifest); const validation = validateDocument("run", manifest); if (!validation.valid) { throw new Error(`updated manifest is invalid: ${validation.diagnostics.join("; ")}`); @@ -110,6 +115,7 @@ async function mutateRunManifestInternal<T>( await assertAuthorityVersion(root, runId, authorityVersion); await writeAuthorityVersion(root, runId, authorityVersion + 1); }, async () => { + await options.rollbackChanges?.(); const rollbackSnapshot = await loadRunSnapshotUnderLock(root, runId, true, lock); if (rollbackSnapshot.manifestSource !== snapshot.manifestSource) { throw new Error("rollback manifest identity does not match the prior manifest"); @@ -120,6 +126,11 @@ async function mutateRunManifestInternal<T>( } }); return { manifest, value, authorityVersion: authorityVersion + 1 }; + } catch (error) { + try { await options.rollbackChanges?.(); } catch (rollbackError) { + throw new Error(`manifest mutation failed (${errorMessage(error)}); file rollback failed (${errorMessage(rollbackError)})`); + } + throw error; } finally { await releaseRunAuthorityLock(lock); } @@ -143,6 +154,7 @@ export async function publishRunAuthority( const lock = await acquireRunAuthorityLock(root, runId, options); let stagingDirectory: string | undefined; try { + await assertNoPendingRepair(root, runId); const authorityVersion = await readAuthorityVersion(root, runId); if (options.expectedVersion !== authorityVersion) { throw new Error(`stale authority version: expected ${options.expectedVersion} but actual ${authorityVersion}`); @@ -446,7 +458,13 @@ async function rollbackPublications(states: readonly PublicationState[]): Promis const authorityVersionFile = ".sdlc-authority-version.json"; -async function readAuthorityVersion(root: string, runId: string): Promise<number> { +export async function assertNoPendingRepair(root: string, runId: string): Promise<void> { + const path = await resolvePathInsideRoot(root, `.sdlc/runs/${runId}/.repair-transaction.json`); + try { await lstat(path); } catch (error) { if (isMissing(error)) return; throw error; } + throw new Error("an interrupted repair needs recover-repair before another mutation or evidence collection"); +} + +export async function readAuthorityVersion(root: string, runId: string): Promise<number> { const path = await resolvePathInsideRoot(root, `.sdlc/runs/${runId}/${authorityVersionFile}`); let source: string; try { source = await readFile(path, "utf8"); } catch (error) { @@ -470,7 +488,7 @@ async function assertAuthorityVersion(root: string, runId: string, expected: num if (actual !== expected) throw new Error(`run authority version changed during transaction: expected ${expected} but actual ${actual}`); } -async function writeAuthorityVersion(root: string, runId: string, version: number): Promise<void> { +export async function writeAuthorityVersion(root: string, runId: string, version: number): Promise<void> { const path = await resolvePathInsideRoot(root, `.sdlc/runs/${runId}/${authorityVersionFile}`); await replaceAtomically(path, `${JSON.stringify({ schema_version: 1, version })}\n`); } diff --git a/src/preflight.ts b/src/preflight.ts new file mode 100644 index 0000000..f362f79 --- /dev/null +++ b/src/preflight.ts @@ -0,0 +1,171 @@ +import { constants } from "node:fs"; +import { access, readFile, stat } from "node:fs/promises"; +import { basename, delimiter, extname, isAbsolute, resolve } from "node:path"; + +import { loadFramework, loadProject } from "./config.js"; +import { isPortableRepositoryPath, portablePathsOverlap, resolvePathInsideRoot } from "./paths.js"; +import { configuredSecretValues, redactText } from "./redaction.js"; +import { repositoryForApplication, resolveWorkspace, resolveWorkspacePath } from "./workspace.js"; +import type { ApplicationKind } from "./install.js"; +import type { CommandStep, ProjectConfig } from "./types.js"; + +export interface PreflightOptions { + applications?: string[]; + expectedRoots?: Record<string, string>; + /** Coordinator-relative paths, or repository:path in a mapped workspace. */ + requiredFiles?: string[]; + /** Inspect only these declarations when supplied; never execute them. */ + commands?: string[]; +} +export interface PreflightCheck { + id: string; + status: "passed" | "blocked" | "not_checked"; + message: string; + next_step?: string; +} +export interface PreflightReport { + root: string; + ready: boolean; + status: "ready" | "blocked"; + checks: PreflightCheck[]; + diagnostics: string[]; +} + +/** Targeted local inspection only. No application commands, startup, or service probes. */ +export async function preflightProject(rootInput: string, options: PreflightOptions = {}): Promise<PreflightReport> { + const root = resolve(rootInput); + const checks: PreflightCheck[] = []; + let secrets: string[] = []; + const add = (id: string, status: PreflightCheck["status"], message: string, next_step?: string) => { + checks.push({ id, status, message: redactText(message, secrets), ...(next_step ? { next_step } : {}) }); + }; + const finish = (): PreflightReport => { + add("services", "not_checked", "Live services, database connectivity, and populated test data have not been checked.", "Record separate service and test-data evidence before checks that depend on them."); + add("execution", "not_checked", "No builds, tests, application startup, installs, or seed commands were executed."); + add("model-capabilities", "not_checked", "Configured model policy is validated locally; current host model availability is checked at dispatch."); + const diagnostics = checks.filter((check) => check.status === "blocked").map((check) => check.message); + return { root, ready: diagnostics.length === 0, status: diagnostics.length === 0 ? "ready" : "blocked", checks, diagnostics }; + }; + let project: ProjectConfig; + try { + project = await loadProject(root); + secrets = configuredSecretValues(project.security.secret_environment_variables); + await loadFramework(root); + const runtime = await resolvePathInsideRoot(root, ".sdlc/runtime.cjs", { mustExist: true }); + if (!(await stat(runtime)).isFile()) throw new Error(".sdlc/runtime.cjs is not a file"); + add("configuration", "passed", "Project and framework schemas/versions match this runtime; local runtime launcher exists."); + } catch (error) { + add("configuration", "blocked", message(error), "Repair or upgrade the project configuration and runtime installation."); + return finish(); + } + let workspace; + try { + workspace = await resolveWorkspace(root, project); + add("workspace", "passed", "Declared repository mappings resolve to the expected local checkouts."); + } catch (error) { + add("workspace", "blocked", message(error), "Correct the repository declarations and local mappings."); + return finish(); + } + const applications = [...new Set(options.applications ?? Object.keys(project.applications))]; + if (!applications.length) add("applications", "blocked", "No affected applications selected.", "Select at least one declared application."); + for (const role of Object.keys(options.expectedRoots ?? {})) { + if (!applications.includes(role)) add(`expected-root:${role}`, "blocked", `Expected root for ${role} is outside the selected applications.`, "Include that application in the preflight selection."); + } + for (const name of applications) { + const application = Object.hasOwn(project.applications, name) ? project.applications[name] : undefined; + if (!["backend", "web", "mobile"].includes(name) || !application) { + add(`application:${name}`, "blocked", `Requested application ${name} is not declared.`, "Declare the requested application and its repository/root before delivery."); + continue; + } + const expected = options.expectedRoots?.[name]; + if (expected !== undefined && (!isPortableRepositoryPath(expected) || expected !== application.root)) { + add(`expected-root:${name}`, "blocked", `${name} root mismatch: expected ${expected}; configured ${application.root}.`, "Correct the selected application root; do not substitute an admin or customer application."); + } + try { + const path = await resolveWorkspacePath(workspace, repositoryForApplication(project, name as ApplicationKind), application.root, { mustExist: true }); + if (!(await stat(path)).isDirectory()) throw new Error(`${name} root is not a directory`); + add(`application:${name}`, "passed", `${name} directory exists at ${application.root}; lifecycle is ${application.lifecycle}.`); + } catch (error) { + add(`application:${name}`, "blocked", message(error), "Provide the declared application directory or correct its root."); + } + } + for (const file of options.requiredFiles ?? []) { + try { + const separator = file.indexOf(":"); + const repository = separator < 0 ? workspace.coordinator : file.slice(0, separator); + const path = separator < 0 ? file : file.slice(separator + 1); + const absolute = await resolveWorkspacePath(workspace, repository, path, { mustExist: true }); + if (!(await stat(absolute)).isFile()) throw new Error(`Required fixture is not a file: ${file}`); + add(`file:${file}`, "passed", `Required local file exists: ${file}. Its data content and live use are not verified.`); + } catch (error) { + add(`file:${file}`, "blocked", message(error), "Supply the required fixture file in the declared repository."); + } + } + const commandIds = options.commands ?? applications.flatMap((application) => [ + Object.hasOwn(project.commands, `${application}_test`) ? `${application}_test` : "sdlc_test", + Object.hasOwn(project.commands, `${application}_typecheck`) ? `${application}_typecheck` : "sdlc_typecheck", + ]); + for (const id of new Set(commandIds)) { + const command = Object.hasOwn(project.commands, id) ? project.commands[id] : undefined; + if (!command) { + add(`command:${id}`, "blocked", `Required command ${id} is not declared.`, "Configure the required command in project.yaml."); + continue; + } + let declarations = command.steps ?? [command]; + if (options.commands === undefined && command.steps) { + declarations = declarations.filter((step) => applications.some((name) => { + const application = Object.hasOwn(project.applications, name) ? project.applications[name] : undefined; + return application && (step.repository ?? command.repository ?? workspace.coordinator) === (application.repository ?? workspace.coordinator) + && portablePathsOverlap(step.cwd, application.root); + })); + } + if (!declarations.length) add(`command:${id}`, "blocked", `Command ${id} has no steps covering selected applications.`, "Declare checks for the selected application roots."); + for (const [index, step] of declarations.entries()) { + try { + if (step.args.some((arg) => arg.includes(`Configure commands.${id}`))) throw new Error(`Command ${id} is unconfigured`); + const cwd = await resolveWorkspacePath(workspace, step.repository ?? command.repository, step.cwd, { mustExist: true }); + if (!(await stat(cwd)).isDirectory()) throw new Error(`Command ${id} working directory is not a directory`); + if (!await executableAvailable(step.executable, cwd)) throw new Error(`Command ${id} executable is unavailable: ${step.executable}`); + await inspectScript(step, cwd, workspace.repositories[step.repository ?? command.repository ?? workspace.coordinator]!.root); + add(`command:${id}:${index}`, "passed", `Command ${id} declaration, working directory, and executable are locally available; command was not executed.`); + } catch (error) { + add(`command:${id}:${index}`, "blocked", message(error), "Correct the command declaration or provide the missing local tool/script."); + } + } + } + return finish(); +} + +async function executableAvailable(executable: string, cwd: string): Promise<boolean> { + const hasPath = isAbsolute(executable) || /[/\\]/.test(executable); + const directories = hasPath ? [cwd] : (process.env.PATH ?? "").split(delimiter).map((path) => resolve(cwd, path)); + const suffixes = process.platform === "win32" && !extname(executable) ? ["", ...(process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";")] : [""]; + for (const directory of directories) for (const suffix of suffixes) { + const candidate = resolve(directory, `${executable}${suffix}`); + try { + if (!(await stat(candidate)).isFile()) continue; + await access(candidate, process.platform === "win32" ? constants.F_OK : constants.X_OK); + return true; + } catch { /* Inspect other PATH candidates without executing anything. */ } + } + return false; +} + +async function inspectScript(step: CommandStep, cwd: string, repositoryRoot: string): Promise<void> { + const executable = basename(step.executable).replace(/\.(exe|cmd|bat)$/i, ""); + if (executable === "npm") { + const script = step.args[0] === "run" || step.args[0] === "run-script" ? step.args[1] : step.args[0] === "test" ? "test" : undefined; + if (script) { + const path = await resolvePathInsideRoot(cwd, "package.json", { mustExist: true }); + let packageJson: { scripts?: Record<string, unknown> }; + try { packageJson = JSON.parse(await readFile(path, "utf8")); } + catch { throw new Error("The configured working directory has invalid package.json metadata"); } + if (typeof packageJson.scripts?.[script] !== "string" || !packageJson.scripts[script].trim()) throw new Error(`npm script ${script} is missing from the configured working directory`); + } + } + if (executable === "node" && step.args[0] && !step.args[0].startsWith("-")) { + const path = await resolvePathInsideRoot(repositoryRoot, resolve(cwd, step.args[0]), { mustExist: true }); + if (!(await stat(path)).isFile()) throw new Error("Configured Node script is not a file"); + } +} +function message(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/src/quality-gates.ts b/src/quality-gates.ts index 421bba2..c36c96e 100644 --- a/src/quality-gates.ts +++ b/src/quality-gates.ts @@ -1,4 +1,5 @@ -import type { Task, TaskRole, TaskStage, TaskTarget } from "./types.js"; +import type { RunManifest, Task, TaskRole, TaskStage, TaskTarget } from "./types.js"; +import { isCompactRun } from "./workflow-profile.js"; interface TaskStageContract { role: TaskRole; @@ -44,6 +45,20 @@ export function qualityGateForTask(task: Task): string | undefined { return taskStageContract(task).qualityGate; } -export function requiresCollectorEvidence(task: Task): boolean { - return taskStageContract(task).collectorEvidence; +export function qualityGatesForTask(task: Task, manifest?: RunManifest): string[] { + if (manifest !== undefined && isCompactRun(manifest) && task.id === "QC-001") return ["integration", "qc"]; + const gate = qualityGateForTask(task); + return gate === undefined ? [] : [gate]; +} + +export function qualityGateStage(gateId: string, manifest?: RunManifest): TaskStage | undefined { + if (manifest !== undefined && isCompactRun(manifest)) { + if (gateId === "requirements") return undefined; + if (gateId === "integration") return "qc"; + } + return qualityGateTaskStages[gateId as keyof typeof qualityGateTaskStages]; +} + +export function requiresCollectorEvidence(task: Task, manifest?: RunManifest): boolean { + return taskStageContract(task).collectorEvidence || (manifest !== undefined && isCompactRun(manifest) && task.id === "QC-001"); } diff --git a/src/repair-validation.ts b/src/repair-validation.ts new file mode 100644 index 0000000..4518014 --- /dev/null +++ b/src/repair-validation.ts @@ -0,0 +1,47 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { isPortableRepositoryPath, resolvePathInsideRoot } from "./paths.js"; +import { validateDocument } from "./schemas.js"; +import type { RunManifest } from "./types.js"; + +/** Archived cycles remain durable evidence; current activation counters cannot erase their lineage. */ +export async function repairHistoryDiagnostics(root: string, runId: string, manifest: RunManifest): Promise<string[]> { + const diagnostics: string[] = []; + const offsets = new Map<string, number>(); + let previousAt = -Infinity; + for (const [index, record] of (manifest.repair_history ?? []).entries()) { + if (record.id !== `RPR-${String(index + 1).padStart(3, "0")}`) diagnostics.push("repair history IDs must be sequential"); + if (Date.parse(record.at) < previousAt) diagnostics.push(`${record.id} predates the previous repair`); + previousAt = Date.parse(record.at); + const ids = record.tasks.map((task) => task.id); + if (new Set(ids).size !== ids.length || JSON.stringify(ids) !== JSON.stringify(record.affected_task_ids) || !ids.includes(record.task_id)) diagnostics.push(`${record.id} archived tasks do not match its affected tasks`); + const primary = record.tasks.find((task) => task.id === record.task_id); + if (primary === undefined || !["completed", "awaiting_review"].includes(primary.status) || !primary.stage.endsWith("_implementation")) diagnostics.push(`${record.id} must archive a completed implementation task or implementation awaiting review`); + for (const task of record.tasks) { + diagnostics.push(...validateDocument("task", task).diagnostics.map((message) => `${record.id}/${task.id}: ${message}`)); + const current = manifest.tasks.find((candidate) => candidate.id === task.id); + if (current === undefined || current.role !== task.role || current.stage !== task.stage || current.target !== task.target + || JSON.stringify(current.dependencies) !== JSON.stringify(task.dependencies) + || JSON.stringify(current.required_outputs) !== JSON.stringify(task.required_outputs)) diagnostics.push(`${record.id} changed the canonical identity of ${task.id}`); + if ((task.activation_offset ?? 0) !== (offsets.get(task.id) ?? 0)) diagnostics.push(`${record.id}/${task.id} has inconsistent activation history`); + if (task.transitions.some((transition) => Date.parse(transition.at) > Date.parse(record.at))) diagnostics.push(`${record.id}/${task.id} contains transitions after its archive time`); + offsets.set(task.id, (offsets.get(task.id) ?? 0) + task.transitions.filter((entry) => entry.to === "ready").length); + } + const seen = new Set<string>(); + for (const archive of record.archives) { + if (!isPortableRepositoryPath(archive.path) || archive.path === "." || archive.archive_path !== `repairs/${record.id}/${archive.path}` || seen.has(archive.path)) { + diagnostics.push(`${record.id} has an invalid archive path`); continue; + } + seen.add(archive.path); + try { + const path = await resolvePathInsideRoot(root, `.sdlc/runs/${runId}/${archive.archive_path}`, { mustExist: true }); + const digest = createHash("sha256").update(await readFile(path)).digest("hex"); + if (digest !== archive.sha256) diagnostics.push(`${record.id} archive integrity changed: ${archive.path}`); + } catch (error) { diagnostics.push(`${record.id} archive unavailable: ${archive.path}; ${error instanceof Error ? error.message : String(error)}`); } + } + } + for (const task of manifest.tasks) { + if ((task.activation_offset ?? 0) !== (offsets.get(task.id) ?? 0)) diagnostics.push(`${task.id} activation offset does not match archived repair cycles`); + } + return diagnostics; +} diff --git a/src/repairs.ts b/src/repairs.ts new file mode 100644 index 0000000..890a648 --- /dev/null +++ b/src/repairs.ts @@ -0,0 +1,216 @@ +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { stringify } from "yaml"; + +import { mutateRunManifest, readAuthorityVersion, writeAuthorityVersion, type ManifestTransactionOptions } from "./manifest-transaction.js"; +import { isPortableRepositoryPath, resolvePathInsideRoot } from "./paths.js"; +import { qualityGatesForTask } from "./quality-gates.js"; +import { acquireRunAuthorityLock, releaseRunAuthorityLock } from "./run-authority-lock.js"; +import { loadRun, parseManifest } from "./runs.js"; +import { synchronizeRunState } from "./transitions.js"; +import type { RepairRecord, RunManifest, Task } from "./types.js"; + +const journalName = ".repair-transaction.json"; +const diffPath = "evidence/diffs/changed-files.json"; +const implementationStages = new Set(["backend_implementation", "web_implementation", "mobile_implementation"]); + +export interface RepairOptions { + defectId: string; + actor: string; + reason: string; + dryRun?: boolean; + now?: string; +} + +interface FileChange { path: string; before: string | null; after: string | null } +interface RepairJournal { + schema_version: 1; + run_id: string; + repair_id: string; + before_manifest: string; + after_manifest: string; + before_version: number; + after_version: number; + changes: FileChange[]; +} + +/** Explicit repair starts a new cycle while retaining prior task, gate, and artifact history. */ +export async function repairTask(root: string, runId: string, taskId: string, options: RepairOptions, transactionOptions: ManifestTransactionOptions = {}) { + if (options.actor !== "pm") throw new Error("only pm may open a repair cycle"); + if (!/^DEF-[A-Z0-9-]+$/.test(options.defectId)) throw new Error("repair requires a canonical DEF-* defect ID"); + if (!options.reason.trim()) throw new Error("repair requires a reason"); + const at = options.now ?? new Date().toISOString(); + if (!Number.isFinite(Date.parse(at))) throw new Error("repair time is invalid"); + const journalPath = await runPath(root, runId, journalName); + if (await optionalFile(journalPath) !== null) throw new Error("an interrupted repair needs recover-repair before continuing"); + const current = await loadRun(root, runId); + const latest = current.repair_history?.at(-1); + if (latest?.task_id === taskId && latest.defect_id === options.defectId && !["completed", "awaiting_review"].includes(current.tasks.find((task) => task.id === taskId)?.status ?? "")) { + return { dry_run: options.dryRun ?? false, already_open: true, repair: latest, status: current.run.status }; + } + const preview = planRepair(current, taskId, options, at); + if (options.dryRun) return { dry_run: true, already_open: false, repair: preview.record, status: preview.manifest.run.status }; + + let journal: RepairJournal | undefined; + const rollbackChanges = async () => { + if (journal === undefined) return; + await applyChanges(root, runId, journal.changes, "before"); + await rm(journalPath, { force: true }); + }; + const transaction = await mutateRunManifest(root, runId, async (manifest) => { + const planned = planRepair(manifest, taskId, options, at); + const changes: FileChange[] = []; + for (const path of repairPaths(planned.record.tasks)) { + const before = await optionalFile(await runPath(root, runId, path)); + if (before === null) continue; + const archivePath = `repairs/${planned.record.id}/${path}`; + if (await optionalFile(await runPath(root, runId, archivePath)) !== null) throw new Error(`repair archive already exists: ${archivePath}`); + planned.record.archives.push({ path, archive_path: archivePath, sha256: hash(Buffer.from(before, "base64")) }); + changes.push({ path: archivePath, before: null, after: before }); + changes.push({ path, before, after: null }); + } + const beforeDiff = await optionalFile(await runPath(root, runId, diffPath)); + if (beforeDiff !== null) { + const document = JSON.parse(Buffer.from(beforeDiff, "base64").toString("utf8")) as { files: Array<string | { repository: string; path: string }>; ownership?: Array<{ task_id: string; path: string; repository?: string }> }; + const affected = new Set(planned.record.affected_task_ids); + const removed = new Set((document.ownership ?? []).filter((entry) => affected.has(entry.task_id)).map(pathIdentity)); + const removedArtifacts = new Set(planned.record.archives.map((entry) => `.sdlc/runs/${runId}/${entry.path}`)); + document.files = document.files.filter((entry) => !removed.has(pathIdentity(entry)) && !(typeof entry === "string" && removedArtifacts.has(entry))); + document.ownership = (document.ownership ?? []).filter((entry) => !affected.has(entry.task_id)); + const archivePath = `repairs/${planned.record.id}/${diffPath}`; + planned.record.archives.push({ path: diffPath, archive_path: archivePath, sha256: hash(Buffer.from(beforeDiff, "base64")) }); + changes.push({ path: archivePath, before: null, after: beforeDiff }); + changes.push({ path: diffPath, before: beforeDiff, after: Buffer.from(JSON.stringify(document, null, 2) + "\n").toString("base64") }); + } + planned.manifest.repair_history = [...(manifest.repair_history ?? []), planned.record]; + const originalSource = await readFile(await runPath(root, runId, "manifest.yaml")); + Object.assign(manifest, planned.manifest); + journal = { + schema_version: 1, run_id: runId, repair_id: planned.record.id, + before_manifest: hash(originalSource), + after_manifest: hash(Buffer.from(stringify(manifest, { aliasDuplicateObjects: false }))), + before_version: await readAuthorityVersion(root, runId), + after_version: (await readAuthorityVersion(root, runId)) + 1, + changes, + }; + await writeFile(journalPath, JSON.stringify(journal), { flag: "wx" }); + await applyChanges(root, runId, changes, "after"); + return planned.record; + }, { ...transactionOptions, rollbackChanges }); + await rm(journalPath, { force: true }); + return { dry_run: false, already_open: false, repair: transaction.value, status: transaction.manifest.run.status }; +} + +export function planRepair(current: RunManifest, taskId: string, options: Pick<RepairOptions, "defectId" | "reason" | "actor">, at: string): { record: RepairRecord; manifest: RunManifest } { + if (options.actor !== "pm" || !options.reason.trim() || !/^DEF-[A-Z0-9-]+$/.test(options.defectId)) throw new Error("repair requires pm, a defect ID, and reason"); + if (["completed", "cancelled"].includes(current.run.status) || ["failed", "cancelled"].includes(current.final_result?.status ?? "") || current.product_owner_review?.decision != null) throw new Error("a terminal or human-reviewed run cannot be repaired; start a new run"); + if (!Number.isFinite(Date.parse(at)) || Date.parse(at) < Date.parse(current.run.updated_at ?? current.run.created_at ?? at)) throw new Error("repair time predates run state"); + const selected = current.tasks.find((task) => task.id === taskId); + if (selected === undefined || !implementationStages.has(selected.stage) || !["completed", "awaiting_review"].includes(selected.status)) throw new Error("repair requires a completed implementation task or an implementation awaiting review"); + const affected = new Set([taskId]); + for (let changed = true; changed;) { + changed = false; + for (const task of current.tasks) if (!affected.has(task.id) && task.dependencies.some((id) => affected.has(id))) { affected.add(task.id); changed = true; } + } + if (current.tasks.some((task) => affected.has(task.id) && task.status === "awaiting_approval")) throw new Error("resolve pending approvals before opening a repair"); + if (current.blockers.some((blocker) => blocker.status === "open" && affected.has(blocker.task_id))) throw new Error("resolve affected external blockers before opening a repair; repair does not waive them"); + if (current.decisions?.some((decision) => decision.action?.startsWith("transition:") && decision.affected_tasks.some((id) => affected.has(id)) && decision.status === "approved" && decision.consumed_at === null)) throw new Error("an unconsumed affected approval must be resolved before repair"); + const manifest = structuredClone(current); + const record: RepairRecord = { + id: `RPR-${String((current.repair_history?.length ?? 0) + 1).padStart(3, "0")}`, + task_id: taskId, defect_id: options.defectId, reason: options.reason, actor: "pm", at, + affected_task_ids: current.tasks.filter((task) => affected.has(task.id)).map((task) => task.id), + tasks: structuredClone(current.tasks.filter((task) => affected.has(task.id))), quality_gates: {}, archives: [], + }; + for (const task of manifest.tasks.filter((task) => affected.has(task.id))) { + for (const gateId of qualityGatesForTask(task, manifest)) if (manifest.quality_gates[gateId] !== undefined) { + record.quality_gates[gateId] = structuredClone(manifest.quality_gates[gateId]); + const gate = manifest.quality_gates[gateId]; + gate.status = "pending"; gate.evidence = []; + gate.history = [...(gate.history ?? []), { status: "pending", evidence: [], actor: "pm", reason: `Invalidated by ${record.id}: ${options.reason}`, at }]; + } + task.activation_offset = (task.activation_offset ?? 0) + task.transitions.filter((transition) => transition.to === "ready").length; + task.status = task.id === taskId ? "ready" : "pending"; + task.transitions = task.status === "ready" ? [{ from: "pending", to: "ready", actor: "pm", reason: `${record.id}: ${options.reason}`, at }] : []; + task.started_at = null; task.completed_at = null; task.commit = null; + task.outputs = []; task.evidence = []; task.agent_dispatches = []; + task.blocker_reason = null; task.failure_reason = null; + } + if (manifest.product_owner_review) manifest.product_owner_review = { status: "pending", decision: null, comments: null, decided_at: null }; + if (manifest.final_result) manifest.final_result = { ...manifest.final_result, status: "pending", completed_at: null }; + manifest.repair_history = [...(manifest.repair_history ?? []), record]; + manifest.run.updated_at = at; + synchronizeRunState(manifest); + return { record, manifest }; +} + +/** A journal permits recovery after process interruption without treating partial writes as valid authority. */ +export async function recoverRepair(root: string, runId: string, actor: string) { + if (actor !== "pm") throw new Error("only pm may recover a repair transaction"); + const lock = await acquireRunAuthorityLock(root, runId); + try { + const path = await runPath(root, runId, journalName); + const source = await optionalFile(path); + if (source === null) return { recovered: false }; + const journal = JSON.parse(Buffer.from(source, "base64").toString("utf8")) as RepairJournal; + const manifestSource = await readFile(await runPath(root, runId, "manifest.yaml")); + const manifest = parseManifest(manifestSource.toString("utf8")); + const allowed = new Set([...repairPaths(manifest.tasks), diffPath]); + if (journal.schema_version !== 1 || journal.run_id !== runId || !/^RPR-[0-9]+$/.test(journal.repair_id) || !Array.isArray(journal.changes) + || !Number.isSafeInteger(journal.before_version) || journal.before_version < 0 || journal.after_version !== journal.before_version + 1) throw new Error("invalid repair recovery journal"); + const seen = new Set<string>(); + for (const change of journal.changes) { + const original = change.path?.startsWith(`repairs/${journal.repair_id}/`) ? change.path.slice(`repairs/${journal.repair_id}/`.length) : change.path; + if (!isPortableRepositoryPath(change.path) || !allowed.has(original) || seen.has(change.path) || ![change.before, change.after].every((value) => value === null || typeof value === "string")) throw new Error("repair journal contains an unsafe file change"); + seen.add(change.path); + } + const manifestHash = hash(manifestSource); + const direction = manifestHash === journal.before_manifest ? "before" : manifestHash === journal.after_manifest ? "after" : null; + if (direction === null) throw new Error("run changed after interrupted repair; refusing automatic recovery"); + const version = await readAuthorityVersion(root, runId); + if (version !== journal.before_version && version !== journal.after_version) throw new Error("run authority version changed after interrupted repair; refusing recovery"); + await applyChanges(root, runId, journal.changes, direction); + await writeAuthorityVersion(root, runId, direction === "after" ? journal.after_version : journal.before_version); + await rm(path); + return { recovered: true, repair_id: journal.repair_id, outcome: direction === "before" ? "rolled_back" : "committed" }; + } finally { await releaseRunAuthorityLock(lock); } +} + +function repairPaths(tasks: Task[]): string[] { + return [...new Set(tasks.flatMap((task) => [ + ...task.required_outputs, ...task.outputs, + ...(task.role === "backend" || task.role === "frontend" ? [`tasks/${task.id}.assignment.yaml`, `tasks/${task.id}.handoff.json`] : []), + ]))].filter((path) => !["request.md", "manifest.yaml", "facts.yaml"].includes(path) && !path.startsWith("evidence/commands/")); +} + +async function applyChanges(root: string, runId: string, changes: FileChange[], direction: "before" | "after") { + const ordered = direction === "before" ? [...changes].reverse() : changes; + for (const change of ordered) { + const path = await runPath(root, runId, change.path); + const existing = await optionalFile(path); + if (existing !== change.before && existing !== change.after) throw new Error(`file changed outside repair transaction: ${change.path}`); + const next = change[direction]; + if (existing === next) continue; + if (next === null) await rm(path, { force: true }); + else { + await mkdir(dirname(path), { recursive: true }); + const temporary = `${path}.${randomUUID()}.tmp`; + try { await writeFile(temporary, Buffer.from(next, "base64"), { flag: "wx" }); await rename(temporary, path); } + finally { await rm(temporary, { force: true }); } + } + } +} + +function pathIdentity(value: string | { repository?: string; path: string }): string { + return typeof value === "string" ? `coordinator:${value}` : `${value.repository ?? "coordinator"}:${value.path}`; +} +async function runPath(root: string, runId: string, path: string): Promise<string> { + if (!/^[A-Z][A-Z0-9]*-[0-9]+$/.test(runId) || !isPortableRepositoryPath(path) || path === ".") throw new Error("invalid repair path"); + return resolvePathInsideRoot(root, `.sdlc/runs/${runId}/${path}`); +} +async function optionalFile(path: string): Promise<string | null> { + try { return (await readFile(path)).toString("base64"); } + catch (error) { if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return null; throw error; } +} +function hash(source: Uint8Array): string { return createHash("sha256").update(source).digest("hex"); } diff --git a/src/run-timing.ts b/src/run-timing.ts new file mode 100644 index 0000000..fe99615 --- /dev/null +++ b/src/run-timing.ts @@ -0,0 +1,144 @@ +import { readFile } from "node:fs/promises"; + +import { evidenceIdFromReference } from "./evidence-identifiers.js"; +import { resolvePathInsideRoot } from "./paths.js"; +import { loadRun } from "./runs.js"; +import { validateDocument } from "./schemas.js"; +import type { EvidenceRecord, RunManifest, Task, TaskStatus } from "./types.js"; + +export interface ArchivedTimingCycle { id: string; tasks: Task[]; ended_at: string } +export interface RunTimingOptions { now?: string; evidence?: EvidenceRecord[]; archivedCycles?: ArchivedTimingCycle[] } +export interface TimingInterval { status: TaskStatus; started_at: string; ended_at: string; duration_ms: number; ongoing: boolean } +export interface TaskTiming { + task_id: string; + cycle: string; + intervals: TimingInterval[]; + status_ms: Partial<Record<TaskStatus, number>>; +} +export interface RunTimingReport { + run_id: string; + as_of: string; + wall_ms: number | null; + wall_started_at: string | null; + wall_ended_at: string | null; + tasks: TaskTiming[]; + /** Task-state totals can overlap; these are not elapsed run wall time. */ + task_status_ms: Partial<Record<TaskStatus, number>>; + collector_command_ms: number; + collector_commands: Array<{ evidence_path: string; task_id: string; command_id: string; duration_ms: number }>; + diagnostics: string[]; + limitations: string[]; +} + +const terminal = new Set<TaskStatus>(["completed", "cancelled"]); + +/** Derive time from durable lifecycle events; never infer LLM compute from task state. */ +export function summarizeRunTiming(manifest: RunManifest, options: RunTimingOptions = {}): RunTimingReport { + const now = options.now ?? new Date().toISOString(); + const diagnostics: string[] = []; + const currentTime = timestamp(now); + if (currentTime === null) throw new Error("Timing as-of timestamp is invalid"); + const started = timestamp(manifest.run.created_at); + const review = manifest.product_owner_review; + const humanDecisionFinal = review?.status === "completed" && review.decision !== null && review.decision !== "deferred"; + const runEnded = ["completed", "cancelled"].includes(manifest.run.status) + || ["completed", "failed", "cancelled"].includes(manifest.final_result?.status ?? "") || humanDecisionFinal; + const end = runEnded + ? timestamp(manifest.final_result?.completed_at ?? (humanDecisionFinal ? review?.decided_at : null) ?? manifest.run.updated_at) + : currentTime; + let wall: number | null = null; + if (started !== null && end !== null && end >= started && end <= currentTime) wall = end - started; + else diagnostics.push("Run wall time is unavailable because its timestamps are absent, invalid, reversed, or later than the as-of timestamp."); + const archived = options.archivedCycles ?? repairCycles(manifest); + const cycles = [...archived, { id: "current", tasks: manifest.tasks, ended_at: end === null ? now : new Date(end).toISOString() }]; + const tasks: TaskTiming[] = []; + const totals: Partial<Record<TaskStatus, number>> = {}; + for (const cycle of cycles) { + const cutoff = timestamp(cycle.ended_at); + for (const task of cycle.tasks) { + const result: TaskTiming = { task_id: task.id, cycle: cycle.id, intervals: [], status_ms: {} }; + tasks.push(result); + const times = task.transitions.map((transition) => timestamp(transition.at)); + if (cutoff === null || cutoff > currentTime || times.some((time, index) => time === null || time > cutoff || (index > 0 && (times[index - 1] === null || time < times[index - 1]!)))) { + diagnostics.push(`${cycle.id}/${task.id}: invalid or nonchronological lifecycle timestamps; intervals were not counted.`); + continue; + } + for (const [index, transition] of task.transitions.entries()) { + if (terminal.has(transition.to)) continue; + const next = times[index + 1]; + const intervalEnd = next ?? cutoff; + const intervalStart = times[index]!; + const duration = intervalEnd - intervalStart; + result.intervals.push({ status: transition.to, started_at: transition.at, ended_at: new Date(intervalEnd).toISOString(), duration_ms: duration, ongoing: next === undefined && cycle.id === "current" && !runEnded }); + result.status_ms[transition.to] = (result.status_ms[transition.to] ?? 0) + duration; + totals[transition.to] = (totals[transition.to] ?? 0) + duration; + } + if (!task.transitions.length && !["pending", "ready"].includes(task.status)) diagnostics.push(`${cycle.id}/${task.id}: no lifecycle transitions; task time was not inferred from status.`); + } + } + const collectorCommands: RunTimingReport["collector_commands"] = []; + const seen = new Set<string>(); + const knownTasks = new Set(cycles.flatMap((cycle) => cycle.tasks.map((task) => task.id))); + for (const evidence of options.evidence ?? []) { + if (seen.has(evidence.evidence_path)) continue; + seen.add(evidence.evidence_path); + const commandStart = timestamp(evidence.started_at); + const commandEnd = timestamp(evidence.completed_at); + if (evidence.run_id !== manifest.run.id || evidenceIdFromReference(evidence.evidence_path) !== evidence.id || !knownTasks.has(evidence.task_id) || commandStart === null || commandEnd === null || commandEnd < commandStart || commandEnd > currentTime) { + diagnostics.push(`Collector evidence ${evidence.id}: invalid identity or timestamps; duration was not counted.`); + continue; + } + collectorCommands.push({ evidence_path: evidence.evidence_path, task_id: evidence.task_id, command_id: evidence.command_id, duration_ms: commandEnd - commandStart }); + } + return { + run_id: manifest.run.id, as_of: now, wall_ms: wall, + wall_started_at: started === null ? null : new Date(started).toISOString(), + wall_ended_at: end === null ? null : new Date(end).toISOString(), + tasks, task_status_ms: totals, + collector_command_ms: collectorCommands.reduce((sum, command) => sum + command.duration_ms, 0), + collector_commands: collectorCommands, diagnostics, + limitations: [ + "Wall time is one elapsed run span, not a sum of overlapping task durations.", + "Task-state durations include orchestration and waiting; running time is not LLM compute time.", + "Collector command totals count only referenced collector evidence and may overlap; LLM execution and builds/tests outside the collector are excluded.", + "Unrecorded activity and time before the first recorded transition cannot be reconstructed.", + ], + }; +} + +/** Reads evidence JSON only, never stdout/stderr logs or external telemetry. */ +export async function getRunTiming(root: string, runId: string, options: { now?: string } = {}): Promise<RunTimingReport> { + const manifest = await loadRun(root, runId); + const archivedCycles = repairCycles(manifest); + const references = new Set([...manifest.tasks, ...archivedCycles.flatMap((cycle) => cycle.tasks)].flatMap((task) => task.evidence)); + const evidence: EvidenceRecord[] = []; + const diagnostics: string[] = []; + for (const reference of references) { + if (!evidenceIdFromReference(reference)) continue; + try { + const path = await resolvePathInsideRoot(root, `.sdlc/runs/${runId}/${reference}`, { mustExist: true }); + const record: unknown = JSON.parse(await readFile(path, "utf8")); + const validation = validateDocument("evidence", record); + if (!validation.valid) throw new Error("invalid collector evidence schema"); + const typed = record as EvidenceRecord; + if (typed.evidence_path !== reference || typed.id !== evidenceIdFromReference(reference)) throw new Error("collector evidence identity differs from reference"); + evidence.push(typed); + } catch { + // Do not echo file contents, JSON parse fragments, command args, or output logs. + diagnostics.push(`Unable to read valid collector timing metadata at ${reference}.`); + } + } + const report = summarizeRunTiming(manifest, { ...options, evidence, archivedCycles }); + report.diagnostics.push(...diagnostics); + return report; +} + +function repairCycles(manifest: RunManifest): ArchivedTimingCycle[] { + const history = (manifest as RunManifest & { repair_history?: Array<{ id: string; tasks: Task[]; at: string }> }).repair_history ?? []; + return history.map((repair) => ({ id: repair.id, tasks: repair.tasks, ended_at: repair.at })); +} +function timestamp(value: string | null | undefined): number | null { + if (!value || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:Z|[+-]\d\d:\d\d)$/.test(value)) return null; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; +} diff --git a/src/runs.ts b/src/runs.ts index fc4ef59..85d3d23 100644 --- a/src/runs.ts +++ b/src/runs.ts @@ -7,6 +7,7 @@ import { parseDocument, stringify } from "yaml"; import { agentPolicyDiagnostics, assertTaskAgentDispatch, type AgentPolicy } from "./agents.js"; import { assertProductOwnerAdvisory } from "./product-owner.js"; import { assertAcyclic } from "./graph.js"; +import { repairHistoryDiagnostics } from "./repair-validation.js"; import { loadProject, loadWorkflow } from "./config.js"; import { FRAMEWORK_VERSION } from "./constants.js"; import { resolvePathInsideRoot, SdlcPathError } from "./paths.js"; @@ -21,10 +22,15 @@ import { } from "./run-authority-lock.js"; import type { RunManifest, Task, TaskRole, TaskStage, TaskStatus, TaskTarget, WorkflowConfig } from "./types.js"; import { resolveWorkspace } from "./workspace.js"; +import { assertCompactReview, readCompactVerification } from "./compact-artifacts.js"; +import { assertWorkflowProfile, compactOutputs, isCompactRun, resolveWorkflowProfile, type CompactAssessment } from "./workflow-profile.js"; +import { qualityGatesForTask } from "./quality-gates.js"; const runIdPattern = /^[A-Z][A-Z0-9]*-[0-9]+$/; export interface StartRunInput { + profile?: "full" | "compact"; + assessment?: CompactAssessment; id: string; title: string; requestFile: string; @@ -51,6 +57,7 @@ export interface StartRunOptions { } export async function startRun(root: string, input: StartRunInput, options: StartRunOptions = {}): Promise<string> { + resolveWorkflowProfile(input.profile, input.assessment); if (!runIdPattern.test(input.id)) { throw new Error(`invalid run ID: ${input.id}`); } @@ -81,7 +88,7 @@ export async function startRun(root: string, input: StartRunInput, options: Star const manifestPath = join(temporaryDirectory, "manifest.yaml"); const requestDestination = join(temporaryDirectory, "request.md"); const template = await readFile(templatePath, "utf8"); - const manifest = renderManifest(template, input, await loadWorkflow(root), project.agents); + const manifest = renderManifest(template, input, input.profile === "compact" ? undefined : await loadWorkflow(root), project.agents); const validation = await validateManifest(manifest, root, input.id); if (!validation.valid) { throw new Error(`rendered run manifest is invalid:\n${validation.diagnostics.join("\n")}`); @@ -204,7 +211,7 @@ export async function validateRunSnapshotUnderLock( return { valid: diagnostics.length === 0, diagnostics }; } -function renderManifest(template: string, input: StartRunInput, workflow: WorkflowConfig, agents?: AgentPolicy): RunManifest { +function renderManifest(template: string, input: StartRunInput, workflow: WorkflowConfig | undefined, agents?: AgentPolicy): RunManifest { const tasks = createTasks(input, workflow, agents); const replacements: Record<string, string> = { run_id: input.id, @@ -218,7 +225,7 @@ function renderManifest(template: string, input: StartRunInput, workflow: Workfl affected_database: String(input.affectedApplications.database), affected_shared_packages: String(input.affectedApplications.sharedPackages), tasks_yaml: indent(stringify(tasks).trimEnd(), 2), - api_contract_quality_gate_status: input.affectedApplications.backend ? "pending" : "not_applicable", + api_contract_quality_gate_status: input.profile !== "compact" && input.affectedApplications.backend ? "pending" : "not_applicable", backend_quality_gate_status: input.affectedApplications.backend ? "pending" : "not_applicable", web_quality_gate_status: input.affectedApplications.web ? "pending" : "not_applicable", mobile_quality_gate_status: input.affectedApplications.mobile ? "pending" : "not_applicable", @@ -230,10 +237,14 @@ function renderManifest(template: string, input: StartRunInput, workflow: Workfl } const manifest = parseManifest(rendered); if (agents !== undefined) manifest.agent_policy = structuredClone(agents); + const profile = resolveWorkflowProfile(input.profile, input.assessment); + if (profile !== undefined) manifest.workflow_profile = profile; return manifest; } -function createTasks(input: StartRunInput, workflow: WorkflowConfig, agents?: AgentPolicy): Task[] { +function createTasks(input: StartRunInput, workflow: WorkflowConfig | undefined, agents?: AgentPolicy): Task[] { + if (input.profile === "compact") return createCompactTasks(input, agents); + if (workflow === undefined) throw new Error("Full workflow configuration is required"); const outputsByStage = new Map(workflow.stages.map((stage) => [stage.id, stage.required_outputs])); const tasks: Task[] = [ createTask("PM-001", "Intake and discovery", "intake", "pm", null, [], outputsByStage, input.now, "ready"), @@ -284,6 +295,32 @@ function createTasks(input: StartRunInput, workflow: WorkflowConfig, agents?: Ag return tasks; } +function createCompactTasks(input: StartRunInput, agents?: AgentPolicy): Task[] { + const outputs = new Map(Object.entries(compactOutputs).map(([stage, paths]) => [stage as TaskStage, [...paths]])); + const tasks = [ + createTask("PM-001", "Compact intake", "intake", "pm", null, [], outputs, input.now, "ready"), + createTask("BA-001", "Compact specification and review", "requirements", "ba", null, ["PM-001"], outputs, input.now), + ]; + const implementationIds: string[] = []; + for (const [application, id, stage, role] of [["backend", "BE-002", "backend_implementation", "backend"], ["web", "WEB-001", "web_implementation", "frontend"], ["mobile", "MOB-001", "mobile_implementation", "frontend"]] as const) { + if (!input.affectedApplications[application]) continue; + tasks.push(createTask(id, `${application} implementation`, stage, role, application, ["BA-001"], outputs, input.now)); + implementationIds.push(id); + } + tasks.push(createTask("QC-001", "Independent integration and verification", "qc", "qc", "qc", implementationIds, outputs, input.now)); + if (agents?.product_owner_review === "advisory") tasks.push(createTask("PO-001", "AI Product Owner advisory review", "product_owner_advisory", "po", null, ["QC-001"], outputs, input.now)); + tasks.push(createTask("PM-004", "Product Owner delivery package", "product_owner_review", "pm", null, [agents?.product_owner_review === "advisory" ? "PO-001" : "QC-001"], outputs, input.now)); + const byId = new Map(tasks.map((task) => [task.id, task])); + for (const task of tasks) { + task.required_inputs = [...new Set(task.dependencies.flatMap((id) => byId.get(id)?.required_outputs ?? []))]; + if (task.role === "backend" || task.role === "frontend") task.required_inputs.push("facts.yaml", `tasks/${task.id}.assignment.yaml`); + if (task.id === "QC-001" || task.id === "PO-001" || task.id === "PM-004") { + task.required_inputs = [...new Set([...task.required_inputs, "request.md", "facts.yaml", ...byId.get("BA-001")!.required_outputs, ...byId.get("QC-001")!.required_outputs.filter(() => task.id !== "QC-001")])]; + } + } + return tasks; +} + function createTask( id: string, title: string, @@ -331,7 +368,11 @@ async function validateManifest(manifest: unknown, root: string, runId: string): return { valid: false, diagnostics: [...diagnostics, ...(await manifestPathDiagnostics(manifest, root, runId))] }; } if (manifest.agent_policy !== undefined) diagnostics.push(...agentPolicyDiagnostics(manifest.agent_policy)); + if (manifest.workflow_profile !== undefined) { + try { assertWorkflowProfile(manifest.workflow_profile); } catch (error) { diagnostics.push(errorMessage(error)); } + } else if (manifest.compact_review !== undefined) diagnostics.push("Full runs cannot contain Compact review bindings"); const tasks = manifest.tasks; + if (diagnostics.length === 0) diagnostics.push(...await repairHistoryDiagnostics(root, runId, manifest as unknown as RunManifest)); if (diagnostics.length === 0) { for (const task of tasks) { if (["running", "awaiting_review", "awaiting_approval", "completed"].includes(task.status)) { @@ -361,11 +402,50 @@ async function validateManifest(manifest: unknown, root: string, runId: string): diagnostics.push(errorMessage(error)); } diagnostics.push(...dependencyStateDiagnostics(tasks)); - diagnostics.push(...canonicalGraphDiagnostics(manifest, tasks, await loadWorkflow(root))); + diagnostics.push(...canonicalGraphDiagnostics(manifest, tasks, isCompactRun(manifest as unknown as RunManifest) ? undefined : await loadWorkflow(root))); diagnostics.push(...(await manifestPathDiagnostics(manifest, root, runId))); + if (diagnostics.length === 0 && isCompactRun(manifest as unknown as RunManifest)) diagnostics.push(...await compactStateDiagnostics(root, runId, manifest as unknown as RunManifest)); return { valid: diagnostics.length === 0, diagnostics }; } +async function compactStateDiagnostics(root: string, runId: string, manifest: RunManifest): Promise<string[]> { + const diagnostics: string[] = []; + if (manifest.quality_gates.api_contract?.status !== "not_applicable") diagnostics.push("Compact api_contract gate must be not_applicable; the reviewed specification owns the combined contract"); + for (const gate of ["requirements", "integration", "qc"]) if (manifest.quality_gates[gate]?.status === "not_applicable") diagnostics.push(`Compact ${gate} gate is required`); + for (const task of manifest.tasks) { + if (task.transitions.some((entry) => entry.to === "completed" && entry.actor !== "pm") + || (task.status === "completed" && !task.transitions.some((entry) => entry.to === "completed" && entry.actor === "pm"))) diagnostics.push(`Compact task ${task.id} requires completion reviewed by pm`); + if (task.status === "completed") for (const gate of qualityGatesForTask(task, manifest)) if (manifest.quality_gates[gate]?.status !== "passed") diagnostics.push(`Compact completed task ${task.id} requires passed ${gate} gate`); + } + const downstreamActive = manifest.tasks.some((task) => !["PM-001", "BA-001"].includes(task.id) && task.status !== "pending" && task.status !== "cancelled"); + if (downstreamActive && manifest.quality_gates.requirements?.status !== "passed") diagnostics.push("Compact implementation and verification require a passed requirements review"); + const requirementsGate = manifest.quality_gates.requirements; + if (requirementsGate?.status === "passed") { + const review = requirementsGate.history?.at(-1); + if (review?.status !== "passed" || review.actor !== "pm" || !sameStrings(review.evidence, requirementsGate.evidence) + || !requirementsGate.evidence.includes("artifacts/ba/specification.yaml")) diagnostics.push("Compact requirements gate requires the recorded PM specification review"); + } + const reviewed = manifest.compact_review !== undefined || manifest.quality_gates.requirements?.status === "passed" + || manifest.tasks.find((task) => task.id === "BA-001")?.status === "completed" || downstreamActive; + if (reviewed) { + try { await assertCompactReview(root, runId, manifest); } catch (error) { diagnostics.push(errorMessage(error)); } + } + const qc = manifest.tasks.find((task) => task.id === "QC-001"); + if (qc !== undefined && (["awaiting_review", "completed"].includes(qc.status) || manifest.quality_gates.integration?.status === "passed" || manifest.quality_gates.qc?.status === "passed")) { + try { + const verification = await readCompactVerification(root, runId, manifest); + if (verification.recommendation !== "ready") throw new Error("Compact QC handoff requires complete passed acceptance coverage and no open defects"); + const currentCollectorReferences = new Set(verification.collector_evidence.filter((entry) => entry.status === "passed").map((entry) => entry.reference)); + for (const gateId of ["integration", "qc"]) { + const gate = manifest.quality_gates[gateId]; + if (gate?.status === "passed" && (gate.evidence.length === 0 || gate.evidence.some((reference) => !currentCollectorReferences.has(reference)))) diagnostics.push(`Compact ${gateId} gate requires current independent QC collector evidence`); + } + } catch (error) { diagnostics.push(errorMessage(error)); } + } + if (qc?.status === "completed" && (manifest.quality_gates.integration?.status !== "passed" || manifest.quality_gates.qc?.status !== "passed")) diagnostics.push("Compact QC completion requires passed integration and qc gates"); + return diagnostics; +} + async function manifestPathDiagnostics(manifest: Record<string, unknown>, root: string, runId: string): Promise<string[]> { const diagnostics: string[] = []; const request = isRecord(manifest.request) ? manifest.request : undefined; @@ -421,7 +501,7 @@ function dependencyStateDiagnostics(tasks: Task[]): string[] { }); } -function canonicalGraphDiagnostics(manifest: Record<string, unknown>, tasks: Task[], workflow: WorkflowConfig): string[] { +function canonicalGraphDiagnostics(manifest: Record<string, unknown>, tasks: Task[], workflow: WorkflowConfig | undefined): string[] { const affected = affectedApplicationsFromManifest(manifest); if (affected === undefined) { return []; @@ -435,6 +515,7 @@ function canonicalGraphDiagnostics(manifest: Record<string, unknown>, tasks: Tas requestFile: "request.md", affectedApplications: affected, now: "1970-01-01T00:00:00.000Z", + ...(isRecord(manifest.workflow_profile) && manifest.workflow_profile.name === "compact" ? { profile: "compact" as const } : {}), }, workflow, manifest.agent_policy as AgentPolicy | undefined); const expectedById = new Map(expectedTasks.map((task) => [task.id, task])); const actualById = new Map(tasks.map((task) => [task.id, task])); @@ -581,7 +662,7 @@ async function writeManifest(path: string, manifest: RunManifest): Promise<void> await writeFile(path, source, { encoding: "utf8", flag: "wx" }); } -function parseManifest(source: string): RunManifest { +export function parseManifest(source: string): RunManifest { const document = parseDocument(source); if (document.errors.length > 0) { throw new Error(document.errors.map((error) => error.message).join("\n")); diff --git a/src/schemas.ts b/src/schemas.ts index 3415703..89116ec 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -6,10 +6,13 @@ import { resolve } from "node:path"; import type { ValidateFunction } from "ajv"; import type { Ajv2020 as Ajv2020Instance } from "ajv/dist/2020.js"; +import { isPortableRepositoryPath } from "./paths.js"; import { evidencePathsForId } from "./evidence-identifiers.js"; import type { ValidationResult } from "./types.js"; const schemaFileNames = { + compactSpecification: "compact-specification.schema.json", + compactQc: "compact-qc.schema.json", agentPolicy: "agent-policy.schema.json", agentCapabilities: "agent-capabilities.schema.json", agentDispatch: "agent-dispatch.schema.json", @@ -72,6 +75,8 @@ function validators(): Map<SchemaName, ValidateFunction> { const schemasDirectory = fileURLToPath(new URL("../assets/schemas/", import.meta.url)); const ajv = new Ajv2020({ allErrors: true, strict: true }); addFormats(ajv); + ajv.addFormat("portable-repository-path", { type: "string", validate: isPortableRepositoryPath }); + ajv.addFormat("portable-command-cwd", { type: "string", validate: (value: string) => isPortableRepositoryPath(value === "./" ? "." : value.endsWith("/.") ? value.slice(0, -2) : value) }); for (const fileName of readdirSync(schemasDirectory)) { if (fileName.endsWith(".schema.json")) { ajv.addSchema(JSON.parse(readFileSync(resolve(schemasDirectory, fileName), "utf8")) as object); @@ -110,7 +115,6 @@ function assignmentSemanticDiagnostics(value: Record<string, unknown>): string[] ...duplicateKeyDiagnostics(value.available_evidence, "command_key", "available_evidence"), ...evidenceReferenceDiagnostics(value.available_evidence, "available_evidence"), ...duplicateKeyDiagnostics(value.evidence_requirements, "command_key", "evidence_requirements"), - ...duplicateKeyDiagnostics(controls.api_requirements ?? controls.requirements, "requirement_id", "controls.requirements"), ...duplicateKeyDiagnostics(controls.api_requirements ?? controls.requirements, "capability", "controls.requirements"), ...sourceReferenceDiagnostics(controls.api_requirements ?? controls.requirements, "controls.requirements"), ...gapQuestionDiagnostics(controls, "controls"), @@ -136,7 +140,6 @@ function reportSemanticDiagnostics(value: Record<string, unknown>): string[] { ...duplicateKeyDiagnostics(value.evidence, "evidence_id", "evidence"), ...duplicateKeyDiagnostics(value.evidence, "command_key", "evidence"), ...evidenceReferenceDiagnostics(value.evidence, "evidence"), - ...duplicateKeyDiagnostics(observations.requirement_results, "requirement_id", "observations.requirement_results"), ...duplicateKeyDiagnostics(observations.requirement_results, "capability", "observations.requirement_results"), ...gapQuestionDiagnostics(observations, "observations"), ]; diff --git a/src/task-activation.ts b/src/task-activation.ts new file mode 100644 index 0000000..2c98d33 --- /dev/null +++ b/src/task-activation.ts @@ -0,0 +1,28 @@ +import { assertTaskAgentDispatch, recordAgentDispatch, type AgentDispatchInput } from "./agents.js"; +import { mutateRunManifest } from "./manifest-transaction.js"; +import { loadRun } from "./runs.js"; +import { prepareTransitionContext, transitionTask } from "./transitions.js"; + +/** Bridges a successful host launch to the existing audited dispatch/start sequence. Never spawns an agent. */ +export async function activateTask(root: string, runId: string, taskId: string, input: AgentDispatchInput, reason: string, at = new Date().toISOString()) { + if (!reason.trim()) throw new Error("activation requires a reason"); + const manifest = await loadRun(root, runId); + const task = manifest.tasks.find((entry) => entry.id === taskId); + if (task === undefined) throw new Error(`unknown task: ${taskId}`); + const last = task.agent_dispatches?.at(-1); + if (task.status === "running" && last?.agent_id === input.agent_id && JSON.stringify(last.plan) === JSON.stringify(input.plan) + && last.actual_model === input.actual_model && last.actual_reasoning_effort === input.actual_reasoning_effort && last.observation_source === input.observation_source) { + assertTaskAgentDispatch(manifest, task); + return { task_id: taskId, status: task.status, agent_id: last.agent_id, already_active: true }; + } + if (task.status !== "ready") throw new Error("activate-task requires a ready task; use the existing resume dispatch flow for an already running task"); + if (input.plan.task_id !== taskId || input.plan.run_id !== runId) throw new Error("dispatch plan identity does not match activation"); + const dispatch = await recordAgentDispatch(root, runId, taskId, input, at); + const transaction = await mutateRunManifest(root, runId, async (current) => { + const active = current.tasks.find((entry) => entry.id === taskId); + if (active?.agent_dispatches?.at(-1)?.agent_id !== dispatch.agent_id) throw new Error("dispatch changed before activation"); + const request = { taskId, to: "running" as const, actor: "pm", reason, at }; + Object.assign(current, transitionTask(current, request, await prepareTransitionContext(root, runId, current, request))); + }); + return { task_id: taskId, status: transaction.manifest.tasks.find((entry) => entry.id === taskId)!.status, agent_id: dispatch.agent_id, already_active: false }; +} diff --git a/src/task-checks.ts b/src/task-checks.ts new file mode 100644 index 0000000..e65d088 --- /dev/null +++ b/src/task-checks.ts @@ -0,0 +1,37 @@ +import { readFile } from "node:fs/promises"; +import { executeConfiguredCommand } from "./evidence.js"; +import { resolvePathInsideRoot } from "./paths.js"; +import { loadRun } from "./runs.js"; +import { parseStrictYamlDocument, type DeliveryAssignment } from "./semantic-contracts.js"; +import { validateDocument } from "./schemas.js"; +import type { EvidenceRecord } from "./types.js"; + +/** Execute declared checks once in order, stopping at a real failure. Gate approval stays separate. */ +export async function collectTaskChecks(root: string, runId: string, taskId: string, commandIds?: string[]) { + const manifest = await loadRun(root, runId); + const task = manifest.tasks.find((entry) => entry.id === taskId); + if (task?.status !== "running") throw new Error("check-task requires a running task"); + let commands = commandIds; + if (task.role === "backend" || task.role === "frontend") { + const path = await resolvePathInsideRoot(root, `.sdlc/runs/${runId}/tasks/${taskId}.assignment.yaml`, { mustExist: true }); + const value = parseStrictYamlDocument(await readFile(path, "utf8")); + const valid = validateDocument("deliveryAssignment", value); + if (!valid.valid) throw new Error(`invalid task assignment: ${valid.diagnostics.join("; ")}`); + const assignment = value as DeliveryAssignment; + if (assignment.task_id !== taskId || assignment.run_id !== runId) throw new Error("check assignment identity does not match task"); + const declared = assignment.evidence_requirements.map((requirement) => requirement.command_key); + if (commands?.some((command) => !declared.includes(command))) throw new Error("check-task commands must be declared by the task assignment"); + commands ??= assignment.evidence_requirements.filter((requirement) => requirement.required).map((requirement) => requirement.command_key); + } + if (!commands?.length) throw new Error("check-task requires at least one declared command; use --command for non-delivery tasks"); + if (new Set(commands).size !== commands.length) throw new Error("check-task command IDs must be unique"); + const evidence: EvidenceRecord[] = []; + for (const [index, command] of commands.entries()) { + const current = await loadRun(root, runId); + if (current.tasks.find((entry) => entry.id === taskId)?.status !== "running") throw new Error("task state changed while collecting checks"); + const result = await executeConfiguredCommand(root, runId, taskId, command); + evidence.push(result); + if (result.result_status !== "passed") return { task_id: taskId, passed: false, evidence, remaining_commands: commands.slice(index + 1) }; + } + return { task_id: taskId, passed: true, evidence, remaining_commands: [] as string[] }; +} diff --git a/src/task-operations.ts b/src/task-operations.ts new file mode 100644 index 0000000..0d19728 --- /dev/null +++ b/src/task-operations.ts @@ -0,0 +1,456 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { lstat, readFile } from "node:fs/promises"; +import { promisify } from "node:util"; +import { isDeepStrictEqual } from "node:util"; + +import { stringify } from "yaml"; + +import { changedFilesForTask, validateChangedFilesAuthorityDocument, type ChangedFilesAuthorityDocument } from "./changed-files-authority.js"; +import { loadProject } from "./config.js"; +import { documentRevision, readArchivedAssignment, repositoryForTask, resolveApprovalDecisions, resolveArtifactIntegrity, resolvePermissionRoots, resolveRepositoryDeliveryAuthority, resolveRepositoryDeliveryReportPackage, resolveRequiredInputs, workflowOutputs, type RepositoryRunSnapshot, type ResolvedDeliveryAuthority } from "./delivery-authority-resolver.js"; +import { reconcileDeliveryReportPackage, type DeliveryArtifactIntegrity } from "./delivery-report-reconciliation.js"; +import { readEvidenceReference } from "./evidence-validation.js"; +import { mutateRunManifest, publishRunAuthority, readAuthorityVersion } from "./manifest-transaction.js"; +import { isPortableRepositoryPath, portablePathContains, resolvePathInsideRoot } from "./paths.js"; +import { requiresCollectorEvidence, taskStageContract } from "./quality-gates.js"; +import { acquireRunAuthorityLock, releaseRunAuthorityLock } from "./run-authority-lock.js"; +import { loadRunSnapshotUnderLock } from "./runs.js"; +import { validateDocument } from "./schemas.js"; +import { parseStrictYamlDocument, reconcileDeliveryAssignmentAuthority, repositoryPathIdentity, type BackendControls, type CollectorEvidence, type DeliveryAssignment, type DeliveryAuthoritySnapshot, type DeliveryReport, type DeliveryResultStatus, type DeliveryWrite, type FrontendControls, type PortableDeliveryPath } from "./semantic-contracts.js"; +import { prepareTransitionContext, transitionTask } from "./transitions.js"; +import type { EvidenceRecord, ProjectConfig, RunManifest, Task } from "./types.js"; +import { resolveWorkspace, resolveWorkspacePath } from "./workspace.js"; +import { assertCompactDeliveryControls } from "./workflow-profile.js"; + +export interface PrepareTaskInput { + /** Substantive requirements and decisions, supplied by PM; no mechanical assignment metadata. */ + controls: BackendControls | FrontendControls<string>; + commandIds: string[]; +} + +export interface HandoffTaskInput { + /** Outcomes are the delivery role's assertions, never inferred from the presence of a file. */ + requirementOutcomes: Array<{ requirement_id: string; capability: string; status: DeliveryResultStatus }>; + /** Explicit task-owned subset of actual Git changes. Unrelated working-tree changes are excluded. */ + changedFiles: Array<{ path: string; repository?: string; type: "source" | "test" | "generated" }>; +} + +export interface TaskOperationOptions { + dryRun?: boolean; + expectedVersion?: number; + now?: string; +} + +export interface TaskPacket extends ResolvedDeliveryAuthority { + runId: string; + taskId: string; + authorityVersion: number; + dryRun: boolean; + taskStatus: Task["status"]; + paths: { assignment: string; report: string; receipt: string; changedFiles: string }; + /** Preparation never dispatches or activates the task. */ + executionAuthorized: boolean; +} + +export interface TaskHandoffResult extends TaskPacket { + report: DeliveryReport; + artifactIntegrity: DeliveryArtifactIntegrity[]; + alreadyHandedOff: boolean; +} + +interface Context { + root: string; + runId: string; + task: Task; + project: ProjectConfig; + snapshot: RepositoryRunSnapshot; + version: number; +} +interface ProductFingerprint { path: string; repository: string; type: "source" | "test" | "generated"; sha256: string } +interface HandoffReceipt { + schema_version: 1; + run_id: string; + task_id: string; + assignment_revision: number; + assignment_sha256: string; + report_sha256: string; + files: ProductFingerprint[]; +} +const execFileAsync = promisify(execFile); +const changedFilesRelative = "evidence/diffs/changed-files.json"; + +/** Build execution authority; dispatch and ready -> running remain separate, audited operations. */ +export async function prepareTask(root: string, runId: string, taskId: string, input: PrepareTaskInput, options: TaskOperationOptions = {}): Promise<TaskPacket> { + assertClosed(input, ["controls", "commandIds"], "task preparation input"); + const context = await captureContext(root, runId, taskId, options); + if (!["ready", "running"].includes(context.task.status)) throw new Error(`cannot prepare ${taskId} while ${context.task.status}`); + const paths = taskPaths(context); + if (await optionalSource(root, paths.report) !== undefined) throw new Error("a current delivery report exists; use handoff retry or the repair workflow before revising this assignment"); + const previous = await readAssignment(context); + const assignment = await buildAssignment(context, input, previous); + const changed = await readChangedFiles(context); + const resolved = await buildAuthority(context, assignment, changed.document, changed.source); + assertValid(reconcileDeliveryAssignmentAuthority(resolved.assignment, resolved.authoritySnapshot, resolved.authorityIntegrity, { approvalLifecycle: "repository_runtime" }), "prepared assignment"); + if (!options.dryRun && !isDeepStrictEqual(previous, assignment)) { + const published = await publishRunAuthority(root, runId, [{ path: `tasks/${taskId}.assignment.yaml`, source: assignmentSource(assignment) }], { expectedVersion: context.version }); + context.version = published.authorityVersion; + return packet(context, await resolveRepositoryDeliveryAuthority(root, paths.assignment), false); + } + return packet(context, options.dryRun || previous === undefined ? resolved : await resolveRepositoryDeliveryAuthority(root, paths.assignment), options.dryRun ?? false); +} + +/** Publish a file-backed handoff and request review. Never approves or completes a task. */ +export async function handoffTask(root: string, runId: string, taskId: string, input: HandoffTaskInput, options: TaskOperationOptions = {}): Promise<TaskHandoffResult> { + assertClosed(input, ["requirementOutcomes", "changedFiles"], "task handoff input"); + const context = await captureContext(root, runId, taskId, options); + if (!["running", "awaiting_review"].includes(context.task.status)) throw new Error(`cannot hand off ${taskId} while ${context.task.status}`); + const paths = taskPaths(context); + const existing = await resolveRepositoryDeliveryAuthority(root, paths.assignment); + assertEligible(context, existing.assignment.controls); + const products = await inspectProductWrites(context, existing.assignment, input.changedFiles); + const existingReport = await optionalSource(root, paths.report); + if (existingReport !== undefined) { + const resolved = await resolveRepositoryDeliveryReportPackage(root, paths.assignment); + assertReportPackage(resolved); + const currentEvidence = (await collectorEvidence(context, resolved.assignment.evidence_requirements.map((entry) => entry.command_key))).map((entry) => entry.authority); + if (!isDeepStrictEqual(currentEvidence, resolved.assignment.available_evidence)) throw new Error("collector evidence changed since publication; repair the task before handing off again"); + assertOutcomes(resolved.assignment, input.requirementOutcomes, resolved.report); + const receiptSource = await optionalSource(root, paths.receipt); + if (receiptSource === undefined) throw new Error("handoff retry requires its runtime receipt; use the manual review flow for a manually published report"); + const receipt = JSON.parse(receiptSource) as HandoffReceipt; + const expected = receiptFor(context, resolved.assignment, existingReport, products.fingerprints); + if (!isDeepStrictEqual(receipt, expected)) throw new Error("handoff files or report changed since publication; repair the task before handing off again"); + if (!options.dryRun && context.task.status === "running") await requestReview(context, receipt, options); + const current = options.dryRun ? resolved : await resolveRepositoryDeliveryReportPackage(root, paths.assignment); + return { ...packet(context, current, options.dryRun ?? false), report: current.report, artifactIntegrity: current.artifactIntegrity, alreadyHandedOff: true }; + } + if (context.task.status === "awaiting_review") throw new Error("awaiting_review task is missing its current handoff report"); + const assignment = await buildAssignment(context, { controls: existing.assignment.controls, commandIds: existing.assignment.evidence_requirements.map((entry) => entry.command_key) }, existing.assignment); + await assertFreshPassedEvidence(context, assignment, products.latestModified); + const artifacts = await Promise.all(assignment.required_outputs.map(async (output) => { + const source = await readSource(root, output.path); + return { path: output.path, artifact_kind: output.artifact_kind, producer: output.producer, status: "produced" as const, revision: documentRevision(source, output.path), sha256: sha256(source) }; + })); + const writes: DeliveryWrite[] = [...products.writes, ...artifacts.map((artifact) => ({ path: artifact.path, type: "artifact" as const }))]; + for (const write of writes) assertWriteScope(context, assignment, write); + const priorChanged = await readChangedFiles(context); + const document = mergeChangedFiles(context, assignment, priorChanged.document, writes); + await validateChangedFilesAuthorityDocument(root, document, context.snapshot.manifest, context.project); + const changedSource = JSON.stringify(document, null, 2) + "\n"; + const resolved = await buildAuthority(context, assignment, document, changedSource); + const outcomes = deriveOutcomes(assignment, input.requirementOutcomes); + const observations = assignment.role === "backend" + ? { mode: assignment.controls.mode, storage: assignment.controls.storage, migration: assignment.controls.migration, requirement_results: outcomes } + : { api_contract_status: assignment.controls.api_contract_status, gaps: assignment.controls.gaps, questions: assignment.controls.questions, requirement_results: outcomes }; + const report = { + schema_version: 1, kind: "delivery_report", assignment_id: assignment.assignment_id, assignment_revision: assignment.revision, + run_id: runId, task_id: taskId, ...(assignment.repository === undefined ? {} : { repository: assignment.repository }), + revision: assignment.revision, role: assignment.role, producer: assignment.role, target: assignment.target, stage: assignment.stage, scenario: assignment.scenario, + disposition: "proceed", transition_request: { from: "running", to: "awaiting_review" }, artifacts, writes, + evidence: assignment.available_evidence.map((entry) => entry.status === "passed" || entry.status === "failed" ? entry : { ...entry, reference: null, document_sha256: null }), observations, + } as DeliveryReport; + const artifactIntegrity = await resolveArtifactIntegrity(root, report); + assertReportPackage({ ...resolved, report, artifactIntegrity }); + const reportSource = stringify(report, { aliasDuplicateObjects: false }); + const receipt = receiptFor(context, assignment, reportSource, products.fingerprints); + assertReportScope(context, assignment); + if (!options.dryRun) { + const publication = await publishRunAuthority(root, runId, [ + { path: `tasks/${taskId}.assignment.yaml`, source: assignmentSource(assignment) }, + { path: changedFilesRelative, source: changedSource }, + { path: `artifacts/${context.task.target}/${taskId}-delivery-report.yaml`, source: reportSource }, + { path: `tasks/${taskId}.handoff.json`, source: JSON.stringify(receipt, null, 2) + "\n" }, + ], { expectedVersion: context.version, beforeRelease: () => assertFingerprintsUnchanged(context, assignment, receipt.files) }); + context.version = publication.authorityVersion; + await requestReview(context, receipt, options); + } + const current = options.dryRun ? { ...resolved, report, artifactIntegrity } : await resolveRepositoryDeliveryReportPackage(root, paths.assignment); + return { ...packet(context, current, options.dryRun ?? false), report: current.report, artifactIntegrity: current.artifactIntegrity, alreadyHandedOff: false }; +} + +async function captureContext(root: string, runId: string, taskId: string, options: TaskOperationOptions): Promise<Context> { + if (!/^[A-Z][A-Z0-9]*-[0-9]+$/u.test(runId) || !/^[A-Z][A-Z0-9]*-[0-9]+$/u.test(taskId)) throw new Error("task operations require canonical run and task IDs"); + const lock = await acquireRunAuthorityLock(root, runId); + try { + const version = await readAuthorityVersion(root, runId); + if (options.expectedVersion !== undefined && options.expectedVersion !== version) throw new Error(`stale authority version: expected ${options.expectedVersion} but actual ${version}`); + const snapshot = await loadRunSnapshotUnderLock(root, runId, false, lock); + const task = snapshot.manifest.tasks.find((entry) => entry.id === taskId); + if (task === undefined) throw new Error(`unknown task ${taskId}`); + taskStageContract(task); + if (task.role !== "backend" && task.role !== "frontend") throw new Error("automatic task preparation/handoff supports backend and frontend tasks only"); + const project = await loadProject(root); + return { root, runId, task, project, snapshot, version }; + } finally { await releaseRunAuthorityLock(lock); } +} + +async function buildAssignment(context: Context, input: PrepareTaskInput, previous?: DeliveryAssignment): Promise<DeliveryAssignment> { + const { root, runId, task, project, snapshot } = context; + assertEligible(context, input.controls); + if (!Array.isArray(input.commandIds) || input.commandIds.some((id) => typeof id !== "string" || !Object.hasOwn(project.commands, id))) throw new Error("task commandIds must name configured project commands"); + if (new Set(input.commandIds).size !== input.commandIds.length) throw new Error("duplicate task command ID"); + if (requiresCollectorEvidence(task) && input.commandIds.length === 0) throw new Error("implementation preparation requires at least one configured verification command"); + const factsSource = await readSource(root, `.sdlc/runs/${runId}/facts.yaml`); + const facts = parseStrictYamlDocument(factsSource) as { revision: number; run_id: string; producer: string }; + assertValid(validateDocument("facts", facts), "task facts"); + if (facts.run_id !== runId || facts.producer !== "pm") throw new Error("facts identity does not match the selected run"); + const requiredInputs = await resolveRequiredInputs(root, runId, task); + if (requiredInputs.some((entry) => !entry.exists)) throw new Error("task preparation requires all required inputs to exist"); + const application = project.applications[task.target!]; + if (application === undefined) throw new Error(`task target ${task.target} is not configured`); + if (application.lifecycle === "planned") throw new Error("task preparation requires a scaffolded application; scaffold it and update project lifecycle first"); + const repository = repositoryForTask(project, task); + const archived = await readArchivedAssignment(root, runId, task.id, snapshot.manifest); + if (previous !== undefined && archived !== undefined && previous.revision <= archived.revision) throw new Error("current assignment is from an archived repair cycle"); + const assignment = { + schema_version: 1, kind: "delivery_assignment", assignment_id: previous?.assignment_id ?? archived?.assignment_id ?? `ASN-${BigInt(`0x${sha256(`${runId}:${task.id}`).slice(0, 16)}`)}`, + run_id: runId, task_id: task.id, producer: "pm", revision: previous?.revision ?? (archived?.revision ?? 0) + 1, + role: task.role, target: task.target, stage: task.stage, scenario: task.stage === "api_contract" ? "contract_ready" : "full_task", + ...(project.workspace?.mode === "multi-repository" ? { repository } : {}), + facts_revision: facts.revision, facts_sha256: sha256(factsSource), task_status: "running", scaffold_status: "scaffolded", + dependencies: task.dependencies.map((id) => ({ task_id: id, status: snapshot.manifest.tasks.find((entry) => entry.id === id)!.status })), + required_inputs: requiredInputs.map((entry) => ({ ...entry, status: "available" })), required_outputs: workflowOutputs(runId, task), + allowed_write_roots: [application.root, `.sdlc/runs/${runId}/artifacts/${task.target}`, ...(task.stage === "api_contract" ? [project.resources?.api_contracts?.root ?? "packages/api-contracts/openapi.yaml", ...(project.resources?.api_contracts ? [] : ["packages/api-contracts/generated"])] : [])], + available_evidence: (await collectorEvidence(context, input.commandIds)).map((entry) => entry.authority), + evidence_requirements: input.commandIds.map((command_key) => ({ command_key, required: true, owner: "runtime_collector" })), + transition_policy: { current_status: "running", allowed_request: "awaiting_review", unmet_disposition: "refuse" }, controls: structuredClone(input.controls), + } as DeliveryAssignment; + const roots = await resolvePermissionRoots(root, runId, task, project, assignment); + if (!roots.includes(application.root) && task.stage !== "api_contract") throw new Error(`permissions policy does not authorize configured application root ${application.root}`); + if (roots.includes(".") && application.root !== ".") throw new Error("task preparation cannot widen write scope to the repository root"); + assignment.allowed_write_roots = roots; + assertReportScope(context, assignment); + if (previous !== undefined && !isDeepStrictEqual(previous, assignment)) assignment.revision = previous.revision + 1; + assertValid(validateDocument("deliveryAssignment", assignment), "prepared assignment"); + return assignment; +} + +function assertEligible(context: Context, controls: PrepareTaskInput["controls"]): void { + const { task, snapshot } = context; + assertCompactDeliveryControls(snapshot.manifest, controls); + if (task.dependencies.some((id) => snapshot.manifest.tasks.find((entry) => entry.id === id)?.status !== "completed")) throw new Error("task has incomplete dependencies"); + if (snapshot.manifest.blockers.some((entry) => entry.task_id === task.id && entry.status === "open")) throw new Error("task has an unresolved blocker"); + if (snapshot.manifest.decisions?.some((entry) => entry.affected_tasks.includes(task.id) && ["pending", "rejected", "deferred"].includes(entry.status))) throw new Error("task has an unresolved approval"); + if (task.role === "backend") { + if (!("mode" in controls)) throw new Error("backend task requires backend controls"); + if (controls.mode !== (task.stage === "api_contract" ? "api_contract" : "implementation")) throw new Error("backend controls mode does not match task stage"); + if (controls.migration.impact === "destructive") { + const migration = controls.migration; + const decision = snapshot.manifest.decisions?.find((entry) => entry.id === migration.decision_id); + if (migration.approval_status !== "approved" || decision?.status !== "approved" || decision.approved_by !== "product-owner" + || decision.action !== `transition:${task.id}:running` || !decision.affected_tasks.includes(task.id) + || !decision.decision || !decision.decided_at || !decision.consumed_at || decision.consumed_by_transition !== `${task.id}:awaiting_approval->running` + || !task.transitions.some((entry) => entry.from === "awaiting_approval" && entry.to === "running" && entry.at === decision.consumed_at)) { + throw new Error("task has an unresolved destructive migration approval; complete the bound runtime approval/resume flow first"); + } + } + } else { + if (!("api_contract_status" in controls)) throw new Error("frontend task requires frontend controls"); + if (controls.api_contract_status !== "approved" || controls.gaps.some((entry) => entry.state === "unresolved") || controls.questions.some((entry) => entry.status !== "resolved")) throw new Error("task has an unresolved API/design gap or question"); + } +} + +async function collectorEvidence(context: Context, commandIds: string[]): Promise<Array<{ authority: CollectorEvidence; record: EvidenceRecord }>> { + const latest = new Map<string, { authority: CollectorEvidence; record: EvidenceRecord }>(); + for (const reference of context.task.evidence) { + const { record, source } = await readEvidenceReference({ root: context.root, runId: context.runId, manifest: context.snapshot.manifest, reference, project: context.project, owner: context.task.role, expectedTask: context.task, expectedStage: context.task.stage }); + if (!commandIds.includes(record.command_id)) continue; + const previous = latest.get(record.command_id); + if (previous !== undefined && previous.record.completed_at > record.completed_at) continue; + latest.set(record.command_id, { record, authority: { evidence_id: record.id, command_key: record.command_id, owner: "runtime_collector", reference, document_sha256: sha256(source), status: record.result_status } }); + } + return commandIds.flatMap((id) => latest.has(id) ? [latest.get(id)!] : []); +} + +async function assertFreshPassedEvidence(context: Context, assignment: DeliveryAssignment, latestModified: number): Promise<void> { + const actual = await collectorEvidence(context, assignment.evidence_requirements.map((entry) => entry.command_key)); + for (const requirement of assignment.evidence_requirements.filter((entry) => entry.required)) { + const evidence = actual.find((entry) => entry.record.command_id === requirement.command_key); + if (evidence === undefined || evidence.record.result_status !== "passed") throw new Error(`missing passed collector evidence for ${requirement.command_key}`); + // A check can read the input near its start and finish after a later edit. + // Completion time therefore cannot establish that it verified these bytes. + if (Date.parse(evidence.record.started_at) < latestModified) throw new Error(`stale collector evidence for ${requirement.command_key}: product files changed after verification started`); + } +} + +async function buildAuthority(context: Context, assignment: DeliveryAssignment, document: ChangedFilesAuthorityDocument, changedSource: string): Promise<ResolvedDeliveryAuthority> { + const { root, runId, task, snapshot, project } = context; + const paths = taskPaths(context); + const changedFiles = { path: changedFilesRelative, sha256: sha256(changedSource), files: changedFilesForTask(document, task, project, assignment.required_outputs.map((entry) => entry.path)) }; + const authoritySnapshot: DeliveryAuthoritySnapshot = { + schema_version: 1, kind: "delivery_authority_snapshot", captured_at: snapshot.manifest.run.updated_at ?? snapshot.manifest.run.created_at ?? "1970-01-01T00:00:00.000Z", + assignment: { path: paths.assignment, assignment_id: assignment.assignment_id, revision: assignment.revision, sha256: sha256(assignmentSource(assignment)) }, + run: { path: `.sdlc/runs/${runId}/manifest.yaml`, run_id: runId, sha256: sha256(snapshot.manifestSource) }, + task: { task_id: task.id, role: task.role, target: task.target, stage: task.stage, status: assignment.task_status, dependencies: assignment.dependencies, ...(assignment.repository === undefined ? {} : { repository: assignment.repository }) } as DeliveryAuthoritySnapshot["task"], + facts: { path: `.sdlc/runs/${runId}/facts.yaml`, producer: "pm", revision: assignment.facts_revision, sha256: assignment.facts_sha256! }, + required_inputs: await resolveRequiredInputs(root, runId, task), workflow: { required_outputs: workflowOutputs(runId, task), permission_roots: await resolvePermissionRoots(root, runId, task, project, assignment) }, + evidence_documents: assignment.available_evidence, changed_files: changedFiles, approval_decisions: resolveApprovalDecisions(snapshot.manifest, task), + }; + return { assignment, authoritySnapshot, authorityIntegrity: { assignment_sha256: authoritySnapshot.assignment.sha256, run_sha256: authoritySnapshot.run.sha256, facts_sha256: authoritySnapshot.facts.sha256, required_inputs: authoritySnapshot.required_inputs, evidence_documents: authoritySnapshot.evidence_documents, changed_files: changedFiles, approval_decisions: authoritySnapshot.approval_decisions } }; +} + +async function inspectProductWrites(context: Context, assignment: DeliveryAssignment, files: HandoffTaskInput["changedFiles"]) { + if (!Array.isArray(files)) throw new Error("changedFiles must be an explicit task-owned file inventory"); + const workspace = await resolveWorkspace(context.root, context.project); + const repository = repositoryForTask(context.project, context.task); + const dirtyByRepository = new Map<string, Set<string>>(); + const fingerprints: ProductFingerprint[] = []; + const writes: DeliveryWrite[] = []; + let latestModified = 0; + for (const file of files) { + assertClosed(file, ["path", "type"], "changed file", ["repository"]); + if (!["source", "test", "generated"].includes(file.type)) throw new Error("product changed files require source, test, or generated type"); + if (!isPortableRepositoryPath(file.path) || file.path.startsWith(".sdlc/")) throw new Error(`invalid product changed-file path ${file.path}`); + const selectedRepository = file.repository ?? repository; + if (selectedRepository !== repository) throw new Error(`changed-file repository ${selectedRepository} is outside task repository ${repository}`); + const write: DeliveryWrite = { path: file.path, type: file.type, ...(assignment.repository === undefined ? {} : { repository: selectedRepository }) }; + assertWriteScope(context, assignment, write); + const absolute = await resolveWorkspacePath(workspace, selectedRepository, file.path); + const info = await lstat(absolute).catch((error: unknown) => { + if (error !== null && typeof error === "object" && "code" in error && error.code === "ENOENT") throw new Error(`deleted or missing product files are unsupported by changed-file authority: ${file.path}`); + throw error; + }); + if (!info.isFile() || info.isSymbolicLink()) throw new Error(`product changed file must be an existing regular file: ${file.path}`); + let dirty = dirtyByRepository.get(selectedRepository); + if (dirty === undefined) { + const cwd = await resolveWorkspacePath(workspace, selectedRepository, "."); + const [tracked, untracked] = await Promise.all([ + execFileAsync("git", ["diff", "HEAD", "--name-only", "-z", "--"], { cwd, maxBuffer: 8 * 1024 * 1024 }), + execFileAsync("git", ["ls-files", "--others", "--exclude-standard", "-z"], { cwd, maxBuffer: 8 * 1024 * 1024 }), + ]); + dirty = new Set((tracked.stdout + untracked.stdout).split("\0").filter(Boolean)); + dirtyByRepository.set(selectedRepository, dirty); + } + if (!dirty.has(file.path)) throw new Error(`product file is not an actual Git change: ${file.path}`); + const source = await readFile(absolute); + const afterRead = await lstat(absolute); + if (!afterRead.isFile() || info.dev !== afterRead.dev || info.ino !== afterRead.ino + || info.size !== afterRead.size || info.mtimeMs !== afterRead.mtimeMs || info.ctimeMs !== afterRead.ctimeMs) { + throw new Error(`product file changed during handoff inspection: ${file.path}`); + } + fingerprints.push({ path: file.path, repository: selectedRepository, type: file.type, sha256: sha256(source) }); + writes.push(write); + latestModified = Math.max(latestModified, info.mtimeMs); + } + const identities = fingerprints.map((entry) => repositoryPathIdentity(entry)); + if (new Set(identities).size !== identities.length) throw new Error("duplicate product changed-file path"); + fingerprints.sort((left, right) => repositoryPathIdentity(left).localeCompare(repositoryPathIdentity(right))); + writes.sort((left, right) => left.path.localeCompare(right.path)); + return { fingerprints, writes, latestModified }; +} + +function assertWriteScope(context: Context, assignment: DeliveryAssignment, write: DeliveryWrite): void { + if (!isPortableRepositoryPath(write.path) || !assignment.allowed_write_roots.some((root) => portablePathContains(root, write.path))) throw new Error(`write path is outside task allowed roots: ${write.path}`); + const artifactRoot = `.sdlc/runs/${context.runId}/artifacts/${context.task.target}`; + if (write.type === "artifact") { + if (!portablePathContains(artifactRoot, write.path) || !assignment.required_outputs.some((entry) => entry.path === write.path)) throw new Error(`artifact path is outside task output inventory: ${write.path}`); + } else { + const applicationRoot = context.project.applications[context.task.target!]?.root; + const contractRoot = context.project.resources?.api_contracts?.root; + const roots = [applicationRoot, ...(context.task.stage === "api_contract" ? [contractRoot ?? "packages/api-contracts/openapi.yaml", ...(contractRoot ? [] : ["packages/api-contracts/generated"])] : [])].filter((entry): entry is string => entry !== undefined); + if (!roots.some((root) => portablePathContains(root, write.path))) throw new Error(`product path is outside selected application/contract scope: ${write.path}`); + } +} + +function assertReportScope(context: Context, assignment: DeliveryAssignment): void { + const relative = `artifacts/${context.task.target}/${context.task.id}-delivery-report.yaml`; + const canonical = `.sdlc/runs/${context.runId}/${relative}`; + if (!context.task.required_outputs.includes(relative) || !assignment.allowed_write_roots.some((root) => portablePathContains(root, canonical))) throw new Error("canonical delivery report is outside task output/write authority"); +} + +function mergeChangedFiles(context: Context, assignment: DeliveryAssignment, prior: ChangedFilesAuthorityDocument, writes: DeliveryWrite[]): ChangedFilesAuthorityDocument { + const paths = writes.map((write): PortableDeliveryPath => write.repository === undefined ? write.path : { repository: write.repository, path: write.path }); + const identities = new Set(paths.map(repositoryPathIdentity)); + const priorTaskFiles = changedFilesForTask(prior, context.task, context.project, assignment.required_outputs.map((entry) => entry.path)); + if (priorTaskFiles.some((entry) => !identities.has(repositoryPathIdentity(entry)))) throw new Error("handoff omits a previously recorded task-owned changed file"); + for (const owner of prior.ownership) { + if (owner.task_id !== context.task.id && identities.has(repositoryPathIdentity(owner.repository === undefined ? owner.path : { repository: owner.repository, path: owner.path }))) throw new Error(`changed file is already owned by another task: ${owner.path}`); + } + return { + files: [...prior.files.filter((entry) => !identities.has(repositoryPathIdentity(entry))), ...paths], + ownership: [...prior.ownership.filter((entry) => !identities.has(repositoryPathIdentity(entry.repository === undefined ? entry.path : { repository: entry.repository, path: entry.path }))), ...writes.map((write) => ({ path: write.path, ...(write.repository === undefined ? {} : { repository: write.repository }), task_id: context.task.id }))], + }; +} + +function deriveOutcomes(assignment: DeliveryAssignment, outcomes: HandoffTaskInput["requirementOutcomes"]) { + if (!Array.isArray(outcomes)) throw new Error("requirementOutcomes must be an explicit requirement outcome inventory"); + for (const outcome of outcomes) assertClosed(outcome, ["requirement_id", "capability", "status"], "requirement outcome"); + const requirements = assignment.role === "backend" ? assignment.controls.api_requirements : assignment.controls.requirements; + const keys = outcomes.map((entry) => `${entry.requirement_id}\0${entry.capability}`); + if (new Set(keys).size !== keys.length || keys.length !== requirements.length) throw new Error("requirement outcomes must exactly match assignment requirement/capability pairs"); + return requirements.map((requirement) => { + const outcome = outcomes.find((entry) => entry.requirement_id === requirement.requirement_id && entry.capability === requirement.capability); + if (outcome === undefined) throw new Error(`missing outcome for ${requirement.requirement_id}/${requirement.capability}`); + return { requirement_id: requirement.requirement_id, capability: requirement.capability, required: requirement.required, status: outcome.status }; + }); +} + +function assertOutcomes(assignment: DeliveryAssignment, outcomes: HandoffTaskInput["requirementOutcomes"], report: DeliveryReport): void { + if (!isDeepStrictEqual(deriveOutcomes(assignment, outcomes), report.observations.requirement_results)) throw new Error("handoff outcomes changed since publication"); +} + +async function readAssignment(context: Context): Promise<DeliveryAssignment | undefined> { + const source = await optionalSource(context.root, taskPaths(context).assignment); + if (source === undefined) return undefined; + const value = parseStrictYamlDocument(source) as DeliveryAssignment; + assertValid(validateDocument("deliveryAssignment", value), "existing assignment"); + if (value.run_id !== context.runId || value.task_id !== context.task.id || value.role !== context.task.role || value.target !== context.task.target || value.stage !== context.task.stage) throw new Error("existing assignment has conflicting task identity"); + return value; +} + +async function readChangedFiles(context: Context): Promise<{ document: ChangedFilesAuthorityDocument; source: string }> { + const source = await optionalSource(context.root, taskPaths(context).changedFiles); + if (source === undefined) return { document: { files: [], ownership: [] }, source: JSON.stringify({ files: [] }) }; + const document = await validateChangedFilesAuthorityDocument(context.root, JSON.parse(source), context.snapshot.manifest, context.project); + return { document, source }; +} + +function receiptFor(context: Context, assignment: DeliveryAssignment, reportSource: string, files: ProductFingerprint[]): HandoffReceipt { + return { schema_version: 1, run_id: context.runId, task_id: context.task.id, assignment_revision: assignment.revision, assignment_sha256: sha256(assignmentSource(assignment)), report_sha256: sha256(reportSource), files }; +} + +async function assertFingerprintsUnchanged(context: Context, assignment: DeliveryAssignment, files: ProductFingerprint[]): Promise<void> { + const current = await inspectProductWrites(context, assignment, files.map(({ path, repository, type }) => ({ path, repository, type }))); + if (!isDeepStrictEqual(current.fingerprints, files)) throw new Error("product files changed during handoff; retry verification and handoff"); +} + +async function requestReview(context: Context, receipt: HandoffReceipt, options: TaskOperationOptions): Promise<void> { + const { root, runId, task } = context; + const transaction = await mutateRunManifest(root, runId, async (manifest) => { + if (await readAuthorityVersion(root, runId) !== context.version) throw new Error("stale authority version before handoff transition"); + const current = manifest.tasks.find((entry) => entry.id === task.id)!; + if (current.status !== "running") throw new Error("task state changed before handoff transition"); + const assignmentSourceActual = await readSource(root, taskPaths(context).assignment); + const assignment = parseStrictYamlDocument(assignmentSourceActual) as DeliveryAssignment; + if (sha256(assignmentSourceActual) !== receipt.assignment_sha256 || sha256(await readSource(root, taskPaths(context).report)) !== receipt.report_sha256) throw new Error("handoff authority changed before transition"); + await assertFingerprintsUnchanged(context, assignment, receipt.files); + const request = { taskId: task.id, to: "awaiting_review" as const, actor: task.role, reason: "File-backed delivery handoff prepared for independent review", at: options.now ?? new Date().toISOString() }; + Object.assign(manifest, transitionTask(manifest, request, await prepareTransitionContext(root, runId, manifest, request))); + }); + context.version = transaction.authorityVersion; + context.task = transaction.manifest.tasks.find((entry) => entry.id === task.id)!; + context.snapshot = { manifest: transaction.manifest, manifestSource: stringify(transaction.manifest, { aliasDuplicateObjects: false }) }; +} + +function packet(context: Context, resolved: ResolvedDeliveryAuthority, dryRun: boolean): TaskPacket { + return { ...resolved, runId: context.runId, taskId: context.task.id, authorityVersion: context.version, dryRun, taskStatus: context.task.status, paths: taskPaths(context), executionAuthorized: !dryRun && context.task.status === "running" }; +} +function taskPaths(context: Context): TaskPacket["paths"] { + const base = `.sdlc/runs/${context.runId}`; + return { assignment: `${base}/tasks/${context.task.id}.assignment.yaml`, report: `${base}/artifacts/${context.task.target}/${context.task.id}-delivery-report.yaml`, receipt: `${base}/tasks/${context.task.id}.handoff.json`, changedFiles: `${base}/${changedFilesRelative}` }; +} +function assertReportPackage(value: ResolvedDeliveryAuthority & { report: DeliveryReport; artifactIntegrity: DeliveryArtifactIntegrity[] }): void { + assertValid(reconcileDeliveryReportPackage({ schema_version: 1, kind: "structured_delivery_evaluation", scenario_id: `task-${value.assignment.task_id}`, assignment: value.assignment, authority_snapshot: value.authoritySnapshot, authority_integrity: value.authorityIntegrity, artifact_integrity: value.artifactIntegrity, report: value.report }, { approvalLifecycle: "repository_runtime" }), "handoff report"); +} +function assertValid(value: { valid: boolean; diagnostics: unknown[] }, label: string): void { if (!value.valid) throw new Error(`${label} is invalid: ${value.diagnostics.join("; ")}`); } +function assertClosed(value: unknown, keys: string[], label: string, optional: string[] = []): void { + if (typeof value !== "object" || value === null || Array.isArray(value) || keys.some((key) => !Object.hasOwn(value, key)) || Object.keys(value).some((key) => ![...keys, ...optional].includes(key))) throw new Error(`${label} has missing or unsupported fields`); +} +function assignmentSource(assignment: DeliveryAssignment): string { return stringify(assignment, { aliasDuplicateObjects: false }); } +function sha256(source: string | Buffer): string { return createHash("sha256").update(source).digest("hex"); } +async function readSource(root: string, path: string): Promise<string> { return readFile(await resolvePathInsideRoot(root, path, { mustExist: true }), "utf8"); } +async function optionalSource(root: string, path: string): Promise<string | undefined> { + try { return await readFile(await resolvePathInsideRoot(root, path), "utf8"); } + catch (error) { if (error !== null && typeof error === "object" && "code" in error && error.code === "ENOENT") return undefined; throw error; } +} diff --git a/src/transitions.ts b/src/transitions.ts index ad2574b..b73a44b 100644 --- a/src/transitions.ts +++ b/src/transitions.ts @@ -5,7 +5,9 @@ import { loadProject } from "./config.js"; import { assertEvidenceReference } from "./evidence-validation.js"; import { resolvePathInsideRoot, SdlcPathError } from "./paths.js"; import { assertAcyclic, findReadyTaskIds } from "./graph.js"; -import { qualityGateForTask, requiresCollectorEvidence } from "./quality-gates.js"; +import { qualityGatesForTask, requiresCollectorEvidence } from "./quality-gates.js"; +import { assertCompactReview, assertCompactVerification } from "./compact-artifacts.js"; +import { isCompactRun } from "./workflow-profile.js"; import type { QualityGate, RunManifest, SdlcDecision, Task, TaskStatus } from "./types.js"; const allowedTransitions: Readonly<Record<TaskStatus, readonly TaskStatus[]>> = { @@ -75,6 +77,10 @@ export async function prepareTransitionContext( ): Promise<TransitionContext> { const task = manifest.tasks.find((candidate) => candidate.id === request.taskId); if (task === undefined) throw new SdlcTransitionError(`Task ${request.taskId} does not exist`); + if (isCompactRun(manifest)) { + if (!["PM-001", "BA-001"].includes(task.id) && ["running", "awaiting_review", "completed"].includes(request.to)) await assertCompactReview(root, runId, manifest); + if (task.id === "QC-001" && ["awaiting_review", "completed"].includes(request.to)) await assertCompactVerification(root, runId, manifest); + } assertPreparationOptions(task.status, request.to, options); if (task.role === "po" && ["awaiting_review", "completed"].includes(request.to)) await assertProductOwnerAdvisory(root, runId, manifest); @@ -272,6 +278,7 @@ function assertTransitionConditions( manifest: RunManifest, context: TransitionContext, ): void { + if (isCompactRun(manifest) && request.to === "completed" && request.actor !== "pm") throw new SdlcTransitionError("Compact task completion requires review by pm"); if (task.status === "ready" && request.to === "running") { const missing = task.required_inputs.filter((path) => !context.existingInputPaths.includes(path)); if (missing.length > 0) throw new SdlcTransitionError(`Task ${task.id} is missing required inputs: ${missing.join(", ")}`); @@ -299,7 +306,7 @@ function assertTransitionConditions( } } - if ((request.to === "awaiting_review" || request.to === "completed") && requiresCollectorEvidence(task)) { + if ((request.to === "awaiting_review" || request.to === "completed") && requiresCollectorEvidence(task, manifest)) { if (context.passedEvidencePaths.length === 0) { throw new SdlcTransitionError(`Task ${task.id} requires collector command evidence before ${request.to}`); } @@ -313,8 +320,7 @@ function assertTransitionConditions( } if (request.to === "completed") { - const qualityGate = qualityGateForTask(task); - if (qualityGate !== undefined && context.qualityGateStatuses[qualityGate] !== "passed") { + for (const qualityGate of qualityGatesForTask(task, manifest)) if (context.qualityGateStatuses[qualityGate] !== "passed") { throw new SdlcTransitionError(`Task ${task.id} requires passed ${qualityGate} quality gate`); } } diff --git a/src/types.ts b/src/types.ts index 0ce30e5..cbfc849 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ import type { AgentPolicy, AgentDispatch } from "./agents.js"; +import type { WorkflowProfile } from "./workflow-profile.js"; export type ApplicationLifecycle = "planned" | "scaffolded" | "active"; @@ -176,6 +177,8 @@ export interface TaskTransition { } export interface Task { + /** Ready activations retained in archived repair cycles; prevents stale dispatch reuse. */ + activation_offset?: number; agent_dispatches?: AgentDispatch[]; id: string; title: string; @@ -280,7 +283,31 @@ export interface SdlcBlocker { status: "open" | "resolved"; } +export interface RepairRecord { + id: string; + task_id: string; + defect_id: string; + reason: string; + actor: "pm"; + at: string; + affected_task_ids: string[]; + tasks: Task[]; + quality_gates: Record<string, QualityGate>; + archives: Array<{ path: string; archive_path: string; sha256: string }>; +} + +export interface CompactReview { + specification_revision: number; + specification_sha256: string; + facts_sha256: string; + semantic_claims_sha256: string; + acceptance_criteria_sha256: string; +} + export interface RunManifest { + workflow_profile?: WorkflowProfile; + compact_review?: CompactReview; + repair_history?: RepairRecord[]; agent_policy?: AgentPolicy; schema_version: 1; run: { diff --git a/src/workflow-profile.ts b/src/workflow-profile.ts new file mode 100644 index 0000000..7a0339d --- /dev/null +++ b/src/workflow-profile.ts @@ -0,0 +1,70 @@ +import { isDeepStrictEqual } from "node:util"; + +import type { RunManifest, TaskStage } from "./types.js"; +import type { BackendControls, FrontendControls } from "./semantic-contracts.js"; + +export interface CompactAssessment { + bounded_scope: true; + existing_patterns: true; + migrations: false; + breaking_api: false; + authorization_changes: false; + sensitive_data_exposure: false; + cross_system_uncertainty: false; + rationale: string; +} + +export interface WorkflowProfile { + name: "compact"; + revision: 1; + assessment: CompactAssessment; +} + +/** Revision 1 is a frozen contract, independent of installed Full workflow edits. */ +export const compactOutputs: Readonly<Partial<Record<TaskStage, readonly string[]>>> = Object.freeze({ + intake: Object.freeze(["request.md", "manifest.yaml", "facts.yaml"]), + requirements: Object.freeze(["artifacts/ba/specification.yaml", "artifacts/ba/semantic-claims.yaml", "artifacts/ba/acceptance-criteria.md"]), + backend_implementation: Object.freeze(["artifacts/backend/implementation-summary.md", "artifacts/backend/{task_id}-delivery-report.yaml"]), + web_implementation: Object.freeze(["artifacts/web/implementation-summary.md", "artifacts/web/{task_id}-delivery-report.yaml"]), + mobile_implementation: Object.freeze(["artifacts/mobile/implementation-summary.md", "artifacts/mobile/{task_id}-delivery-report.yaml"]), + qc: Object.freeze(["artifacts/qc/verification.yaml", "artifacts/qc/summary.md"]), + product_owner_advisory: Object.freeze(["artifacts/po/advisory-review.yaml"]), + product_owner_review: Object.freeze(["final-report.md"]), +}); + +export function resolveWorkflowProfile(profile?: "compact" | "full", assessment?: CompactAssessment): WorkflowProfile | undefined { + if (profile === undefined || profile === "full") { + if (assessment !== undefined) throw new Error("an assessment is valid only with the Compact workflow profile"); + return undefined; + } + if (profile !== "compact") throw new Error(`unknown workflow profile: ${String(profile)}`); + const result = { name: "compact", revision: 1, assessment }; + assertWorkflowProfile(result); + return structuredClone(result); +} + +export function assertWorkflowProfile(value: unknown): asserts value is WorkflowProfile { + if (!isRecord(value) || Object.keys(value).sort().join(",") !== "assessment,name,revision" || value.name !== "compact" || value.revision !== 1) throw new Error("unsupported or invalid frozen workflow profile"); + const assessment = value.assessment; + const required = { bounded_scope: true, existing_patterns: true, migrations: false, breaking_api: false, authorization_changes: false, sensitive_data_exposure: false, cross_system_uncertainty: false }; + if (!isRecord(assessment) || Object.keys(assessment).sort().join(",") !== [...Object.keys(required), "rationale"].sort().join(",") + || Object.entries(required).some(([key, expected]) => assessment[key] !== expected) + || typeof assessment.rationale !== "string" || !assessment.rationale.trim()) { + throw new Error("Compact requires a complete low-risk assessment with bounded scope and existing patterns; uncertain or higher-risk work requires a new Full run"); + } +} + +export function isCompactRun(manifest: Pick<RunManifest, "workflow_profile">): boolean { return manifest.workflow_profile?.name === "compact"; } + +export function assertCompactDeliveryControls(manifest: RunManifest, controls: BackendControls | FrontendControls<string>): void { + if (isCompactRun(manifest) && "migration" in controls && controls.migration.impact !== "none") { + throw new Error("Compact excludes migrations; start a new Full run before changing migration scope"); + } +} + +/** Generic manifest writes must not turn a saved run into a different workflow. */ +export function assertFrozenWorkflowProfile(previous: RunManifest, next: RunManifest): void { + if (!isDeepStrictEqual(previous.workflow_profile, next.workflow_profile)) throw new Error("workflow profile is immutable; escalate by starting a new Full run"); + if (previous.compact_review !== undefined && !isDeepStrictEqual(previous.compact_review, next.compact_review)) throw new Error("reviewed Compact specification bindings are immutable; start a new run for changed requirements"); +} +function isRecord(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === "object" && !Array.isArray(value); } diff --git a/tests/compact-artifacts.test.ts b/tests/compact-artifacts.test.ts new file mode 100644 index 0000000..0e0a7a2 --- /dev/null +++ b/tests/compact-artifacts.test.ts @@ -0,0 +1,257 @@ +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, resolve } from "node:path"; +import { stringify, parse } from "yaml"; +import { afterEach, describe, expect, test } from "vitest"; + +import { assertCompactReview, assertCompactVerification, compactPaths, publishCompactSpecification, readCompactSpecification, readCompactVerification, reviewCompactSpecification, type CompactObservation, type CompactQcVerification, type CompactSpecificationInput } from "../src/compact-artifacts.js"; +import { canonicalizeCommandDeclaration } from "../src/command-provenance.js"; +import { loadProject } from "../src/config.js"; +import { initializeProject } from "../src/install.js"; +import { readAuthorityVersion } from "../src/manifest-transaction.js"; +import { startRun, loadRun } from "../src/runs.js"; +import { validateDocument } from "../src/schemas.js"; +import type { RunManifest, Task } from "../src/types.js"; + +const roots: string[] = []; +const runId = "COMPACT-001"; +const at = "2026-09-25T01:00:00.000Z"; +const hash = (source: string) => createHash("sha256").update(source).digest("hex"); +const assessment = { bounded_scope: true as const, existing_patterns: true as const, migrations: false as const, breaking_api: false as const, authorization_changes: false as const, sensitive_data_exposure: false as const, cross_system_uncertainty: false as const, rationale: "An existing bounded portal behavior with unchanged service contract." }; +afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); +async function put(root: string, path: string, content: string) { await mkdir(dirname(resolve(root, path)), { recursive: true }); await writeFile(resolve(root, path), content); } +function input(): CompactSpecificationInput { + return { scope: { summary: "Show the selected status", in_scope: ["Portal status"], out_of_scope: ["New services"] }, requirements: [{ id: "REQ-001", description: "Show the current status", fact_ids: ["FACT-001", "FACT-002"] }], acceptance_criteria: [{ id: "AC-001", requirement_id: "REQ-001", given: "an existing record", when: "the user opens the portal", then: "the approved status and count appear", evidence_types: ["command", "ui"] }], api_change: { kind: "unchanged", description: "Use the existing status response." } }; +} +function setState(task: Task, status: "running" | "completed") { + task.status = status; task.started_at = at; task.completed_at = status === "completed" ? at : null; + task.transitions = [{ from: "pending", to: "ready", actor: "pm", reason: "Fixture dependencies", at }, { from: "ready", to: "running", actor: task.role, reason: "Fixture activation", at }]; + if (status === "completed") task.transitions.push({ from: "running", to: "awaiting_review", actor: task.role, reason: "Fixture handoff", at }, { from: "awaiting_review", to: "completed", actor: "pm", reason: "Fixture review", at }); +} +async function fixture() { + const root = await mkdtemp(resolve(tmpdir(), "sdlc-compact-artifacts-")); roots.push(root); + await mkdir(resolve(root, "apps/platform"), { recursive: true }); + await initializeProject({ root, projectName: "Compact artifacts", applications: ["web"], webRoot: "apps/platform", dryRun: false }); + await put(root, ".sdlc/requests/compact.md", "Show status and count using the existing behavior.\n"); + await startRun(root, { id: runId, title: "Status", requestFile: ".sdlc/requests/compact.md", affectedApplications: { backend: false, web: true, mobile: false, database: false, sharedPackages: false }, profile: "compact", assessment, now: at }); + const manifest = await loadRun(root, runId); + setState(manifest.tasks.find((task) => task.id === "PM-001")!, "completed"); + setState(manifest.tasks.find((task) => task.id === "BA-001")!, "running"); + const facts = { schema_version: 1, run_id: runId, producer: "pm", revision: 1, facts: [{ id: "FACT-001", subject: "portal.status", relation: "equals", value: { status: "ready", visible: true }, status: "approved" }, { id: "FACT-002", subject: "portal.count", relation: "range", value: { min: 0, max: 10, unit: "records", inclusive_min: true, inclusive_max: true }, status: "approved" }] }; + const prefix = `.sdlc/runs/${runId}`; + await put(root, `${prefix}/manifest.yaml`, stringify(manifest)); + await put(root, `${prefix}/facts.yaml`, stringify(facts)); + return { root, manifest, facts, prefix }; +} + +describe("compact specification", () => { + test("publishes one authored specification and exact typed compatibility views without passing a gate", async () => { + const f = await fixture(); + const before = await readFile(resolve(f.root, `${f.prefix}/manifest.yaml`), "utf8"); + const result = await publishCompactSpecification(f.root, runId, input()); + expect(result.paths).toEqual([compactPaths.specification, compactPaths.semanticClaims, compactPaths.acceptanceCriteria]); + expect(result.authorityVersion).toBe(1); + const view = parse(await readFile(resolve(f.root, `${f.prefix}/${compactPaths.semanticClaims}`), "utf8")); + expect(validateDocument("semanticClaims", view)).toEqual({ valid: true, diagnostics: [] }); + expect(view.claims.map((claim: Record<string, unknown>) => ({ id: claim.source_fact_id, subject: claim.subject, relation: claim.relation, value: claim.value, status: claim.status }))).toEqual(f.facts.facts); + expect(view.claims.every((claim: { requirement_ids: string[] }) => claim.requirement_ids.join() === "REQ-001")).toBe(true); + expect(await readFile(resolve(f.root, `${f.prefix}/manifest.yaml`), "utf8")).toBe(before); + expect((await readCompactSpecification(f.root, runId, f.manifest)).document).toEqual(result.document); + }); + + test("dry-run writes nothing and stale authority tokens reject before replacement", async () => { + const f = await fixture(); + const preview = await publishCompactSpecification(f.root, runId, input(), { dryRun: true }); + expect(preview.dryRun).toBe(true); + expect(await readAuthorityVersion(f.root, runId)).toBe(0); + await expect(readFile(resolve(f.root, `${f.prefix}/${compactPaths.specification}`))).rejects.toThrow(); + await publishCompactSpecification(f.root, runId, input()); + await expect(publishCompactSpecification(f.root, runId, input(), { expectedVersion: 0 })).rejects.toThrow("stale authority version"); + }); + + test.each([ + ["fabricated fact", (value: CompactSpecificationInput) => { value.requirements[0]!.fact_ids = ["FACT-999"]; }], + ["missing approved fact", (value: CompactSpecificationInput) => { value.requirements[0]!.fact_ids = ["FACT-001"]; }], + ["duplicate requirement", (value: CompactSpecificationInput) => { value.requirements.push(structuredClone(value.requirements[0]!)); }], + ["duplicate AC", (value: CompactSpecificationInput) => { value.acceptance_criteria.push(structuredClone(value.acceptance_criteria[0]!)); }], + ["unknown REQ", (value: CompactSpecificationInput) => { value.acceptance_criteria[0]!.requirement_id = "REQ-999"; }], + ["missing evidence declaration", (value: CompactSpecificationInput) => { value.acceptance_criteria[0]!.evidence_types = []; }], + ["breaking API", (value: CompactSpecificationInput) => { value.api_change.kind = "breaking" as "additive"; }], + ["unaffected backend additive API", (value: CompactSpecificationInput) => { value.api_change.kind = "additive"; }], + ] as const)("rejects %s without publishing", async (_name, change) => { + const f = await fixture(); const value = input(); change(value); + await expect(publishCompactSpecification(f.root, runId, value)).rejects.toThrow(); + expect(await readAuthorityVersion(f.root, runId)).toBe(0); + }); + + test("does not promote unresolved facts or duplicate typed facts", async () => { + const f = await fixture(); + f.facts.facts[0]!.status = "unresolved"; + await put(f.root, `${f.prefix}/facts.yaml`, stringify(f.facts)); + await expect(publishCompactSpecification(f.root, runId, input())).rejects.toThrow("unresolved"); + f.facts.facts[0]!.status = "approved"; + f.facts.facts.push(structuredClone(f.facts.facts[0]!)); + await put(f.root, `${f.prefix}/facts.yaml`, stringify(f.facts)); + await expect(publishCompactSpecification(f.root, runId, input())).rejects.toThrow("duplicate"); + }); + + test.each([compactPaths.specification, compactPaths.semanticClaims, compactPaths.acceptanceCriteria, "facts.yaml"])("review binding detects later edits to %s", async (path) => { + const f = await fixture(); await publishCompactSpecification(f.root, runId, input()); + f.manifest.compact_review = await reviewCompactSpecification(f.root, runId, f.manifest); + await assertCompactReview(f.root, runId, f.manifest); + await put(f.root, `${f.prefix}/${path}`, (await readFile(resolve(f.root, `${f.prefix}/${path}`), "utf8")) + "\n# changed after review\n"); + await expect(assertCompactReview(f.root, runId, f.manifest)).rejects.toThrow(); + }); + + test("rejects symlink escape for a generated specification view", async () => { + const f = await fixture(); await publishCompactSpecification(f.root, runId, input()); + const outside = await mkdtemp(resolve(tmpdir(), "sdlc-compact-outside-")); roots.push(outside); + const path = `${f.prefix}/${compactPaths.semanticClaims}`; + await put(outside, "claims.yaml", await readFile(resolve(f.root, path), "utf8")); + await rm(resolve(f.root, path)); await symlink(resolve(outside, "claims.yaml"), resolve(f.root, path)); + await expect(reviewCompactSpecification(f.root, runId, f.manifest)).rejects.toThrow("escapes repository root"); + }); +}); + +// These are explicit validator fixtures, not claims that an application test was executed. +async function verificationFixture() { + const f = await fixture(); await publishCompactSpecification(f.root, runId, input()); + f.manifest.compact_review = await reviewCompactSpecification(f.root, runId, f.manifest); + const qc = f.manifest.tasks.find((task) => task.id === "QC-001")!; + setState(qc, "running"); + const reference = "evidence/commands/EVD-0000000000000000/evidence.json"; + const project = await loadProject(f.root); + const command = await canonicalizeCommandDeclaration(f.root, project.commands.sdlc_test, [], project); + const collector = { schema_version: 1, id: "EVD-0000000000000000", run_id: runId, task_id: "QC-001", command_id: "sdlc_test", ...command.provenance, started_at: at, completed_at: at, exit_code: 0, result_status: "passed", evidence_path: reference, stdout_path: reference.replace("evidence.json", "stdout.txt"), stderr_path: reference.replace("evidence.json", "stderr.txt") }; + const collectorSource = JSON.stringify(collector); + qc.evidence = [reference]; + await put(f.root, `${f.prefix}/${reference}`, collectorSource); + await put(f.root, `${f.prefix}/${collector.stdout_path}`, "Fixture collector output.\n"); + await put(f.root, `${f.prefix}/${collector.stderr_path}`, ""); + const observation: CompactObservation = { schema_version: 1, run_id: runId, task_id: "QC-001", producer: "qc", revision: 1, evidence_type: "ui", observed_at: at, environment: "Isolated fixture browser", procedure: "Open existing status screen", actual_result: "Ready status and count displayed", status: "passed" }; + const observationReference = "artifacts/qc/evidence/status.yaml"; + await put(f.root, `${f.prefix}/${observationReference}`, stringify(observation)); + const document: CompactQcVerification = { schema_version: 1, run_id: runId, task_id: "QC-001", producer: "qc", revision: 1, reviewed_specification: f.manifest.compact_review, collector_evidence: [{ reference, sha256: hash(collectorSource), status: "passed" }], results: [{ ac_id: "AC-001", status: "passed", evidence: [{ type: "command", reference, sha256: hash(collectorSource) }, { type: "ui", reference: observationReference, sha256: hash(stringify(observation)) }], notes: "Direct checks matched the expected status." }], defects: [], recommendation: "ready" }; + return { ...f, document, reference, collector, observation, observationReference }; +} +async function writeVerification(f: Awaited<ReturnType<typeof verificationFixture>>) { + await put(f.root, `${f.prefix}/${compactPaths.verification}`, stringify(f.document)); + await put(f.root, `${f.prefix}/${compactPaths.summary}`, `---\nrun_id: ${runId}\ntask_id: QC-001\nproducer: qc\nrevision: ${f.document.revision}\n---\n\n# Independent verification\n\nRecommendation: ${f.document.recommendation}\n\n${f.document.results.map((result) => `- ${result.ac_id}: ${result.status}. ${result.notes}`).join("\n")}\n\nOpen defects: ${f.document.defects.filter((defect) => defect.status === "open").map((defect) => defect.id).join(", ") || "none"}\n`); +} + +describe("compact independent verification", () => { + test("accepts exact AC coverage backed by independent typed direct evidence", async () => { + const f = await verificationFixture(); await writeVerification(f); + await expect(assertCompactVerification(f.root, runId, f.manifest)).resolves.toBeUndefined(); + }); + test.each(["failed", "blocked", "not_tested"] as const)("preserves honest %s coverage without readiness", async (status) => { + const f = await verificationFixture(); f.document.results[0]!.status = status; f.document.recommendation = "changes_requested"; + await writeVerification(f); + expect((await readCompactVerification(f.root, runId, f.manifest)).results[0]!.status).toBe(status); + await expect(assertCompactVerification(f.root, runId, f.manifest)).rejects.toThrow("not ready"); + }); + test.each([ + ["missing AC", (f: Awaited<ReturnType<typeof verificationFixture>>) => { f.document.results = []; }], + ["duplicate AC", (f: Awaited<ReturnType<typeof verificationFixture>>) => { f.document.results.push(structuredClone(f.document.results[0]!)); }], + ["wrong AC", (f: Awaited<ReturnType<typeof verificationFixture>>) => { f.document.results[0]!.ac_id = "AC-999"; }], + ["missing evidence type", (f: Awaited<ReturnType<typeof verificationFixture>>) => { f.document.results[0]!.evidence.pop(); }], + ["self citation", (f: Awaited<ReturnType<typeof verificationFixture>>) => { f.document.results[0]!.evidence[1]!.reference = compactPaths.verification; }], + ["request citation", (f: Awaited<ReturnType<typeof verificationFixture>>) => { f.document.results[0]!.evidence[1]!.reference = "request.md"; }], + ["invented hash", (f: Awaited<ReturnType<typeof verificationFixture>>) => { f.document.results[0]!.evidence[1]!.sha256 = "0".repeat(64); }], + ["unknown defect AC", (f: Awaited<ReturnType<typeof verificationFixture>>) => { f.document.defects.push({ id: "DEF-001", ac_ids: ["AC-999"], status: "resolved", severity: "low", summary: "Fixture" }); }], + ] as const)("rejects %s", async (_name, change) => { + const f = await verificationFixture(); change(f); await writeVerification(f); + await expect(assertCompactVerification(f.root, runId, f.manifest)).rejects.toThrow(); + }); + test("open defects keep recommendation honest even when every AC passed", async () => { + const f = await verificationFixture(); + f.document.defects = [{ id: "DEF-001", ac_ids: ["AC-001"], status: "open", severity: "low", summary: "Additional observed issue" }]; + f.document.recommendation = "changes_requested"; await writeVerification(f); + expect((await readCompactVerification(f.root, runId, f.manifest)).recommendation).toBe("changes_requested"); + await expect(assertCompactVerification(f.root, runId, f.manifest)).rejects.toThrow("not ready"); + }); + test("implementation-owned collectors and observations cannot stand in for independent QC", async () => { + const f = await verificationFixture(); + f.collector.task_id = "WEB-001"; + await put(f.root, `${f.prefix}/${f.reference}`, JSON.stringify(f.collector)); + await writeVerification(f); + await expect(assertCompactVerification(f.root, runId, f.manifest)).rejects.toThrow("task ID does not match task"); + f.collector.task_id = "QC-001"; + await put(f.root, `${f.prefix}/${f.reference}`, JSON.stringify(f.collector)); + f.observation.producer = "frontend" as "qc"; + await put(f.root, `${f.prefix}/${f.observationReference}`, stringify(f.observation)); + await expect(assertCompactVerification(f.root, runId, f.manifest)).rejects.toThrow("direct QC observation"); + }); + test("requires independent collector even if all declared AC evidence is direct observation", async () => { + const f = await verificationFixture(); + const revisedInput = input(); revisedInput.acceptance_criteria[0]!.evidence_types = ["ui"]; + await publishCompactSpecification(f.root, runId, revisedInput); + f.manifest.compact_review = await reviewCompactSpecification(f.root, runId, f.manifest); + f.document.reviewed_specification = f.manifest.compact_review; + f.manifest.tasks.find((task) => task.id === "QC-001")!.evidence = []; + f.document.collector_evidence = []; + f.document.results[0]!.evidence.shift(); f.document.recommendation = "changes_requested"; + await writeVerification(f); + expect((await readCompactVerification(f.root, runId, f.manifest)).recommendation).toBe("changes_requested"); + await expect(assertCompactVerification(f.root, runId, f.manifest)).rejects.toThrow("not ready"); + }); + test("evidence edits after QC publication invalidate the verification", async () => { + const f = await verificationFixture(); await writeVerification(f); + await put(f.root, `${f.prefix}/${f.observationReference}`, stringify({ ...f.observation, actual_result: "Different result" })); + await expect(assertCompactVerification(f.root, runId, f.manifest)).rejects.toThrow("evidence changed"); + }); +}); + +test("Compact cannot silently omit an unresolved feature fact", async () => { + const f = await fixture(); + f.facts.facts[1]!.status = "unresolved"; + await put(f.root, `${f.prefix}/facts.yaml`, stringify(f.facts)); + const value = input(); value.requirements[0]!.fact_ids = ["FACT-001"]; + await expect(publishCompactSpecification(f.root, runId, value)).rejects.toThrow("cannot omit unresolved"); + f.facts.facts[1]!.status = "out_of_scope"; + await put(f.root, `${f.prefix}/facts.yaml`, stringify(f.facts)); + const result = await publishCompactSpecification(f.root, runId, value); + expect(result.document.requirements[0]!.fact_ids).toEqual(["FACT-001"]); + const claims = parse(await readFile(resolve(f.root, `${f.prefix}/${compactPaths.semanticClaims}`), "utf8")); + expect(claims.claims).toHaveLength(1); +}); + +async function appendCollector(f: Awaited<ReturnType<typeof verificationFixture>>, suffix: string, commandId: "sdlc_test" | "sdlc_typecheck", status: "passed" | "failed") { + const id = `EVD-${suffix.padStart(16, "0")}`; + const reference = `evidence/commands/${id}/evidence.json`; + const project = await loadProject(f.root); + const command = await canonicalizeCommandDeclaration(f.root, project.commands[commandId], [], project); + const record = { ...f.collector, ...command.provenance, id, command_id: commandId, exit_code: status === "passed" ? 0 : 1, result_status: status, evidence_path: reference, stdout_path: reference.replace("evidence.json", "stdout.txt"), stderr_path: reference.replace("evidence.json", "stderr.txt") }; + const content = JSON.stringify(record); + await put(f.root, `${f.prefix}/${reference}`, content); + await put(f.root, `${f.prefix}/${record.stdout_path}`, `Fixture ${status}.\n`); + await put(f.root, `${f.prefix}/${record.stderr_path}`, ""); + f.manifest.tasks.find((task) => task.id === "QC-001")!.evidence.push(reference); + return { reference, sha256: hash(content), status }; +} + +test("latest failed QC check blocks readiness despite another passed check; passing rerun recovers", async () => { + const f = await verificationFixture(); + const failedA = await appendCollector(f, "2", "sdlc_test", "failed"); + const passedB = await appendCollector(f, "3", "sdlc_typecheck", "passed"); + f.document.collector_evidence = [failedA, passedB]; + f.document.recommendation = "changes_requested"; + await writeVerification(f); + await expect(readCompactVerification(f.root, runId, f.manifest)).rejects.toThrow("superseded"); + + // A different passing check cannot hide the latest failure, even if all cases assert passed. + f.document.results[0]!.evidence[0] = { type: "command", reference: passedB.reference, sha256: passedB.sha256 }; + await writeVerification(f); + expect((await readCompactVerification(f.root, runId, f.manifest)).recommendation).toBe("changes_requested"); + await expect(assertCompactVerification(f.root, runId, f.manifest)).rejects.toThrow("not ready"); + f.document.recommendation = "ready"; + await writeVerification(f); + await expect(assertCompactVerification(f.root, runId, f.manifest)).rejects.toThrow("recommendation"); + + const rerunA = await appendCollector(f, "4", "sdlc_test", "passed"); + f.document.collector_evidence = [rerunA, passedB]; + f.document.results[0]!.evidence[0] = { type: "command", reference: rerunA.reference, sha256: rerunA.sha256 }; + await writeVerification(f); + await expect(assertCompactVerification(f.root, runId, f.manifest)).resolves.toBeUndefined(); +}); diff --git a/tests/compact-cli.test.ts b/tests/compact-cli.test.ts new file mode 100644 index 0000000..859a9d0 --- /dev/null +++ b/tests/compact-cli.test.ts @@ -0,0 +1,63 @@ +import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { afterEach, expect, test, vi } from "vitest"; +import { main } from "../src/cli.js"; +import { initializeProject } from "../src/install.js"; +import { loadRun } from "../src/runs.js"; + +const roots: string[] = []; +const assessment = { + bounded_scope: true, existing_patterns: true, migrations: false, breaking_api: false, + authorization_changes: false, sensitive_data_exposure: false, cross_system_uncertainty: false, + rationale: "A bounded display update using the existing data and authorization patterns.", +}; +afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); + +async function fixture() { + const root = await mkdtemp(resolve(tmpdir(), "sdlc-compact-cli-")); roots.push(root); + await initializeProject({ root, projectName: "Compact CLI fixture", applications: ["web"], webRoot: ".", webPreset: "nextjs", dryRun: false }); + await writeFile(resolve(root, ".sdlc/requests/change.md"), "Update the existing display using its established permissions.\n"); + await writeFile(resolve(root, ".sdlc/requests/assessment.json"), JSON.stringify(assessment)); + return root; +} + +async function invoke(root: string, args: string[]) { + const chunks: string[] = []; + const exit = process.exitCode; + const cwd = vi.spyOn(process, "cwd").mockReturnValue(root); + const stdout = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { chunks.push(String(chunk)); return true; }); + try { const code = await main([...args, "--json"]); return { code, body: JSON.parse(chunks.join("")) }; } + finally { cwd.mockRestore(); stdout.mockRestore(); process.exitCode = exit; } +} +function start(id: string, extra: string[] = []) { return ["start", "--id", id, "--title", "Display change", "--request", ".sdlc/requests/change.md", "--applications", "web", ...extra]; } + +test("Full stays default; Compact is explicit per run and leaves project settings and old runs unchanged", async () => { + const root = await fixture(); + const projectBefore = await readFile(resolve(root, ".sdlc/project.yaml"), "utf8"); + expect((await invoke(root, start("FULL-001"))).code).toBe(0); + const fullBefore = await readFile(resolve(root, ".sdlc/runs/FULL-001/manifest.yaml"), "utf8"); + const result = await invoke(root, start("SMALL-001", ["--profile", "compact", "--assessment", ".sdlc/requests/assessment.json"])); + expect(result.code).toBe(0); + expect(result.body.result.profile).toBe("compact"); + expect((await loadRun(root, "SMALL-001")).workflow_profile?.assessment).toEqual(assessment); + expect((await loadRun(root, "FULL-001")).workflow_profile).toBeUndefined(); + expect(await readFile(resolve(root, ".sdlc/runs/FULL-001/manifest.yaml"), "utf8")).toBe(fullBefore); + expect(await readFile(resolve(root, ".sdlc/project.yaml"), "utf8")).toBe(projectBefore); +}); + +test.each([ + ["missing assessment", ["--profile", "compact"], undefined], + ["unknown profile", ["--profile", "quick"], undefined], + ["assessment on Full", ["--profile", "full", "--assessment", ".sdlc/requests/assessment.json"], undefined], + ["unsafe scope", ["--profile", "compact", "--assessment", ".sdlc/requests/assessment.json"], JSON.stringify({ ...assessment, migrations: true })], + ["ambiguous JSON", ["--profile", "compact", "--assessment", ".sdlc/requests/assessment.json"], '{"bounded_scope":true,"bounded_scope":false}'], + ["unknown assessment field", ["--profile", "compact", "--assessment", ".sdlc/requests/assessment.json"], JSON.stringify({ ...assessment, skip_qc: true })], +] as const)("invalid Compact selection (%s) creates no run", async (_name, args, source) => { + const root = await fixture(); + if (source !== undefined) await writeFile(resolve(root, ".sdlc/requests/assessment.json"), source); + const result = await invoke(root, start("INVALID-001", [...args])); + expect(result.code).not.toBe(0); + expect(result.body.ok).toBe(false); + expect(await readdir(resolve(root, ".sdlc/runs"))).toEqual([]); +}); diff --git a/tests/compact-review.test.ts b/tests/compact-review.test.ts new file mode 100644 index 0000000..11bc7da --- /dev/null +++ b/tests/compact-review.test.ts @@ -0,0 +1,77 @@ +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, expect, test } from "vitest"; +import { parse, stringify } from "yaml"; +import { publishCompactQc, publishCompactSpecification, type CompactQcInput } from "../src/compact-artifacts.js"; +import { executeConfiguredCommand } from "../src/evidence.js"; +import { initializeProject } from "../src/install.js"; +import { recordQualityGate } from "../src/lifecycle.js"; +import { mutateRunManifest } from "../src/manifest-transaction.js"; +import { loadRun, startRun } from "../src/runs.js"; +import { prepareTask, handoffTask } from "../src/task-operations.js"; +import { prepareTransitionContext, transitionTask } from "../src/transitions.js"; +import type { TaskStatus } from "../src/types.js"; + +// These integration regressions traverse a full lifecycle; allow for parallel CI filesystem load. +const exec=promisify(execFile), roots:string[]=[]; +const runId="REVIEW-001"; +const assessment={bounded_scope:true as const,existing_patterns:true as const,migrations:false as const,breaking_api:false as const,authorization_changes:false as const,sensitive_data_exposure:false as const,cross_system_uncertainty:false as const,rationale:"Existing bounded display correction"}; +afterEach(async()=>{await Promise.all(roots.splice(0).map(root=>rm(root,{recursive:true,force:true})));}); +async function transition(root:string,taskId:string,to:TaskStatus,actor="pm") { + await mutateRunManifest(root,runId,async manifest=>{const request={taskId,to,actor,reason:"Independent regression fixture",at:new Date().toISOString()};Object.assign(manifest,transitionTask(manifest,request,await prepareTransitionContext(root,runId,manifest,request)));}); +} +async function collect(root:string,taskId:string) { + const previous=process.env.CODEX_SDLC_NETWORK_POLICY;process.env.CODEX_SDLC_NETWORK_POLICY="disabled"; + try{return await executeConfiguredCommand(root,runId,taskId,"sdlc_test");} + finally{if(previous===undefined)delete process.env.CODEX_SDLC_NETWORK_POLICY;else process.env.CODEX_SDLC_NETWORK_POLICY=previous;} +} +async function fixture() { + const root=await mkdtemp(resolve(tmpdir(),"compact-independent-review-"));roots.push(root); + await mkdir(resolve(root,"web"));await writeFile(resolve(root,"web/feature.cjs"),"module.exports='before';\n"); + await exec("git",["init","-b","main"],{cwd:root});await exec("git",["add","."],{cwd:root});await exec("git",["-c","user.name=Fixture","-c","user.email=fixture@example.invalid","commit","-m","Fixture baseline"],{cwd:root}); + await initializeProject({root,projectName:"Independent Compact regression",applications:["web"],webRoot:"web",dryRun:false}); + const projectPath=resolve(root,".sdlc/project.yaml"),project=parse(await readFile(projectPath,"utf8"));project.applications.web.lifecycle="scaffolded"; + project.commands.sdlc_test={executable:process.execPath,args:["-e","require('node:fs').appendFileSync('executed.log','check\\n');if(require('./feature.cjs')!=='after')process.exit(1)"],cwd:"web",network:"disabled",mutates:true};await writeFile(projectPath,stringify(project)); + await writeFile(resolve(root,".sdlc/requests/change.md"),"Correct the existing display."); + await startRun(root,{id:runId,title:"Correction",requestFile:".sdlc/requests/change.md",profile:"compact",assessment,affectedApplications:{web:true,backend:false,mobile:false,database:false,sharedPackages:false},now:new Date().toISOString()}); + const base=resolve(root,`.sdlc/runs/${runId}`); + await transition(root,"PM-001","running"); + await writeFile(resolve(base,"facts.yaml"),stringify({schema_version:1,run_id:runId,producer:"pm",revision:1,facts:[{id:"FACT-001",subject:"feature.status",relation:"equals",value:"after",status:"approved"}]})); + await transition(root,"PM-001","awaiting_review");await transition(root,"PM-001","completed");await transition(root,"BA-001","running","ba"); + await publishCompactSpecification(root,runId,{scope:{summary:"Existing display correction",in_scope:["Existing display"],out_of_scope:["New permissions"]},requirements:[{id:"REQ-001",description:"Correct existing status",fact_ids:["FACT-001"]}],acceptance_criteria:[{id:"AC-001",requirement_id:"REQ-001",given:"Existing feature",when:"Display opens",then:"Correct status is visible",evidence_types:["command","ui"]}],api_change:{kind:"unchanged",description:"Existing response contract"}}); + await transition(root,"BA-001","awaiting_review","ba");await recordQualityGate(root,runId,"requirements","passed",["artifacts/ba/specification.yaml"],"pm","Independent fixture specification review",new Date().toISOString());await transition(root,"BA-001","completed"); + await prepareTask(root,runId,"WEB-001",{controls:{api_contract_status:"approved",gaps:[],questions:[],requirements:[{requirement_id:"REQ-001",capability:"feature_status",required:true,parameters:{},source_references:[{kind:"fact",reference:"FACT-001" as any}]}]},commandIds:["sdlc_test"]}); + await transition(root,"WEB-001","running","frontend");await writeFile(resolve(root,"web/feature.cjs"),"module.exports='after';\n"); + await mkdir(resolve(base,"artifacts/web"),{recursive:true});await writeFile(resolve(base,"artifacts/web/implementation-summary.md"),"---\nrevision: 1\n---\nREQ-001: Corrected the status.\n"); + const implementation=await collect(root,"WEB-001"); + await handoffTask(root,runId,"WEB-001",{requirementOutcomes:[{requirement_id:"REQ-001",capability:"feature_status",status:"implemented"}],changedFiles:[{path:"web/feature.cjs",type:"source"}]}); + await recordQualityGate(root,runId,"web","passed",[implementation.evidence_path],"pm","Fixture implementation review",new Date().toISOString());await transition(root,"WEB-001","completed");await transition(root,"QC-001","running","qc"); + const evidence=await collect(root,"QC-001"); + const reference="artifacts/qc/evidence/display.yaml", observation={schema_version:1,run_id:runId,task_id:"QC-001",producer:"qc",revision:1,evidence_type:"ui",observed_at:new Date().toISOString(),environment:"Isolated fixture",procedure:"Read status view",actual_result:"Expected status appeared",status:"passed"}; + await mkdir(resolve(base,"artifacts/qc/evidence"),{recursive:true});await writeFile(resolve(base,reference),stringify(observation)); + const input:CompactQcInput={results:[{ac_id:"AC-001",status:"passed",evidence:[{type:"command",reference:evidence.evidence_path},{type:"ui",reference}],notes:"Independent fixed fixture result"}],defects:[]}; + return {root,base,input,evidence,reference,observation}; +} + +test("future-dated direct UI observations cannot support current acceptance",async()=>{ + const f=await fixture();await writeFile(resolve(f.base,f.reference),stringify({...f.observation,observed_at:"2099-01-01T00:00:00.000Z"})); + await expect(publishCompactQc(f.root,runId,f.input)).rejects.toThrow(/future|time|observation/); +}, 30_000); +test("future-dated collector metadata cannot support current acceptance",async()=>{ + const f=await fixture();await writeFile(resolve(f.base,f.evidence.evidence_path),JSON.stringify({...f.evidence,started_at:"2099-01-01T00:00:00.000Z",completed_at:"2099-01-01T00:00:01.000Z"})); + await expect(publishCompactQc(f.root,runId,f.input)).rejects.toThrow(/future|time|collector/); +}, 30_000); +test("QC reruns after gate review cannot execute and silently discard a failure",async()=>{ + const f=await fixture();await publishCompactQc(f.root,runId,f.input); + for(const gate of ["integration","qc"])await recordQualityGate(f.root,runId,gate,"passed",[f.evidence.evidence_path],"pm","Independent fixture gate review",new Date().toISOString()); + const before=await readFile(resolve(f.root,"web/executed.log"),"utf8"); + await writeFile(resolve(f.root,"web/feature.cjs"),"module.exports='broken';\n"); + let result, failure;try{result=await collect(f.root,"QC-001");}catch(error){failure=error;} + const executed=(await readFile(resolve(f.root,"web/executed.log"),"utf8"))!==before; + const manifest=await loadRun(f.root,runId); + if(executed){expect(failure).toBeUndefined();expect(result?.result_status).toBe("failed");expect(manifest.quality_gates.qc?.status).not.toBe("passed");} + else expect(failure).toBeDefined(); +}, 30_000); diff --git a/tests/compact-workflow.test.ts b/tests/compact-workflow.test.ts new file mode 100644 index 0000000..ea34841 --- /dev/null +++ b/tests/compact-workflow.test.ts @@ -0,0 +1,223 @@ +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, resolve } from "node:path"; +import { promisify } from "node:util"; + +import { afterEach, describe, expect, test } from "vitest"; +import { parse, stringify } from "yaml"; + +import { planAgent } from "../src/agents.js"; +import { publishCompactSpecification, publishCompactQc } from "../src/compact-artifacts.js"; +import { executeConfiguredCommand } from "../src/evidence.js"; +import { finalizeRun } from "../src/finalize.js"; +import { initializeProject } from "../src/install.js"; +import { recordQualityGate } from "../src/lifecycle.js"; +import { mutateRunManifest, publishRunAuthority, readAuthorityVersion } from "../src/manifest-transaction.js"; +import { repairTask } from "../src/repairs.js"; +import { loadRun, startRun, validateRun } from "../src/runs.js"; +import { activateTask } from "../src/task-activation.js"; +import { handoffTask, prepareTask } from "../src/task-operations.js"; +import { prepareTransitionContext, transitionTask } from "../src/transitions.js"; +import type { ProjectConfig, TaskStatus } from "../src/types.js"; +import { resolveWorkflowProfile, type CompactAssessment } from "../src/workflow-profile.js"; + +const exec = promisify(execFile); +const roots: string[] = []; +const assessment: CompactAssessment = { bounded_scope: true, existing_patterns: true, migrations: false, breaking_api: false, authorization_changes: false, sensitive_data_exposure: false, cross_system_uncertainty: false, rationale: "One status correction using an existing implementation and test pattern." }; +const capabilities = { source: "fixture host", model_selection: true, reasoning_selection: true, models: [] }; +const runId = "COMPACT-001"; +afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); + +async function fixture(options: { applications?: Array<"backend" | "web" | "mobile">; advisory?: boolean; profile?: "full" | "compact" } = {}) { + const root = await mkdtemp(resolve(tmpdir(), "sdlc-compact-")); + roots.push(root); + const applications = options.applications ?? ["web"]; + for (const app of applications) await mkdir(resolve(root, `apps/${app}`), { recursive: true }); + await writeFile(resolve(root, "feature.cjs"), "module.exports = 'before';\n"); + await exec("git", ["init", "-b", "main"], { cwd: root }); + await exec("git", ["add", "."], { cwd: root }); + await exec("git", ["-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "Baseline"], { cwd: root }); + await initializeProject({ root, projectName: "Compact", applications, backendRoot: "apps/backend", webRoot: "apps/web", mobileRoot: "apps/mobile", agents: options.advisory ? { roles: {}, product_owner_review: "advisory" } : undefined, dryRun: false }); + const projectPath = resolve(root, ".sdlc/project.yaml"); + const project = parse(await readFile(projectPath, "utf8")) as ProjectConfig; + for (const app of applications) project.applications[app]!.lifecycle = "scaffolded"; + project.commands.sdlc_test = { executable: process.execPath, args: ["-e", "if(require('./feature.cjs') !== 'after')process.exit(1)"], cwd: ".", network: "disabled", mutates: false }; + await writeFile(projectPath, stringify(project)); + await writeFile(resolve(root, ".sdlc/requests/change.md"), "REQ-001: Correct the existing status display.\n"); + await startRun(root, { id: runId, title: "Status correction", requestFile: ".sdlc/requests/change.md", profile: options.profile ?? "compact", ...(options.profile === "full" ? {} : { assessment }), affectedApplications: { backend: applications.includes("backend"), web: applications.includes("web"), mobile: applications.includes("mobile"), database: false, sharedPackages: false }, now: new Date().toISOString() }); + return root; +} +async function transition(root: string, taskId: string, to: TaskStatus, actor = "pm") { + return mutateRunManifest(root, runId, async (manifest) => { + const request = { taskId, to, actor, reason: "Fixture lifecycle verification", at: new Date().toISOString() }; + Object.assign(manifest, transitionTask(manifest, request, await prepareTransitionContext(root, runId, manifest, request))); + }); +} +async function activate(root: string, taskId: string) { + const plan = await planAgent(root, runId, taskId, capabilities); + await activateTask(root, runId, taskId, { plan, agent_id: `fixture-${taskId}-${plan.activation}`, actual_model: null, actual_reasoning_effort: null, observation_source: null }, "Fixture host dispatched role"); +} +async function put(root: string, path: string, source: string) { const destination = resolve(root, `.sdlc/runs/${runId}/${path}`); await mkdir(dirname(destination), { recursive: true }); await writeFile(destination, source); } +async function collect(root: string, taskId: string) { + const project = parse(await readFile(resolve(root, ".sdlc/project.yaml"), "utf8")) as ProjectConfig; + const key = project.security.network_policy_attestation_environment; + const previous = process.env[key]; process.env[key] = "disabled"; + try { return await executeConfiguredCommand(root, runId, taskId, "sdlc_test"); } + finally { if (previous === undefined) delete process.env[key]; else process.env[key] = previous; } +} +async function reviewedSpecification(root: string) { + await activate(root, "PM-001"); + await publishRunAuthority(root, runId, [{ path: "facts.yaml", source: stringify({ schema_version: 1, run_id: runId, producer: "pm", revision: 1, facts: [{ id: "FACT-001", subject: "feature.status", relation: "equals", value: "after", status: "approved" }] }) }], { expectedVersion: await readAuthorityVersion(root, runId) }); + await transition(root, "PM-001", "awaiting_review"); await transition(root, "PM-001", "completed"); + await activate(root, "BA-001"); + await publishCompactSpecification(root, runId, { + scope: { summary: "Correct the existing status", in_scope: ["Existing status output"], out_of_scope: ["New authorization or API behavior"] }, + requirements: [{ id: "REQ-001", description: "Display the existing approved status", fact_ids: ["FACT-001"] }], + acceptance_criteria: [{ id: "AC-001", requirement_id: "REQ-001", given: "The existing feature", when: "The status is read", then: "The status is after", evidence_types: ["command"] }], + api_change: { kind: "unchanged", description: "Uses the existing status response" }, + }); + await transition(root, "BA-001", "awaiting_review", "ba"); + await expect(recordQualityGate(root, runId, "requirements", "passed", ["artifacts/ba/specification.yaml"], "ba", "Self review is not accepted", new Date().toISOString())).rejects.toThrow("reviewed by pm"); + await recordQualityGate(root, runId, "requirements", "passed", ["artifacts/ba/specification.yaml"], "pm", "Specification and derived views reviewed", new Date().toISOString()); + await expect(transition(root, "BA-001", "completed", "ba")).rejects.toThrow("review by pm"); + await transition(root, "BA-001", "completed"); +} +async function implementTarget(root: string, target: "backend" | "web" | "mobile" = "web") { + const taskId = target === "backend" ? "BE-002" : target === "web" ? "WEB-001" : "MOB-001"; + const apiCapabilities = ["request", "response", "error", "authentication", "authorization"]; + const controls = target === "backend" + ? { mode: "implementation", api_requirements: apiCapabilities.map((capability) => ({ requirement_id: "REQ-001", capability, required: capability === "response", source_references: [{ kind: "fact", reference: "FACT-001" }] })), storage: { postgresql_role: "durable_truth", redis_roles: [], redis_authoritative: false }, migration: { impact: "none", approval_status: "not_required", decision_id: null } } + : { api_contract_status: "approved", gaps: [], questions: [], requirements: [{ requirement_id: "REQ-001", capability: "feature_status", required: true, parameters: {}, source_references: [{ kind: "fact", reference: "FACT-001" }] }] }; + await prepareTask(root, runId, taskId, { controls: controls as any, commandIds: ["sdlc_test"] }); + await activate(root, taskId); + await writeFile(resolve(root, "feature.cjs"), "module.exports = 'after';\n"); + await writeFile(resolve(root, `apps/${target}/status.cjs`), "module.exports = 'after';\n"); + await put(root, `artifacts/${target}/implementation-summary.md`, "---\nrevision: 1\n---\nREQ-001: Updated the status implementation.\n"); + const evidence = await collect(root, taskId); + const outcomes = target === "backend" ? apiCapabilities.map((capability) => ({ requirement_id: "REQ-001", capability, status: capability === "response" ? "implemented" as const : "not_applicable" as const })) : [{ requirement_id: "REQ-001", capability: "feature_status", status: "implemented" as const }]; + await handoffTask(root, runId, taskId, { requirementOutcomes: outcomes, changedFiles: [{ path: `apps/${target}/status.cjs`, type: "source" }] }); + await recordQualityGate(root, runId, target, "passed", [evidence.evidence_path], "pm", "Implementation verified", new Date().toISOString()); + await transition(root, taskId, "completed"); +} +async function verifyQc(root: string) { + await activate(root, "QC-001"); + const evidence = await collect(root, "QC-001"); + await publishCompactQc(root, runId, { results: [{ ac_id: "AC-001", status: "passed", evidence: [{ type: "command", reference: evidence.evidence_path }], notes: "Independent command verified the current status" }], defects: [] }); + await transition(root, "QC-001", "awaiting_review", "qc"); + await recordQualityGate(root, runId, "qc", "passed", [evidence.evidence_path], "pm", "Independent acceptance coverage verified", new Date().toISOString()); + await expect(transition(root, "QC-001", "completed")).rejects.toThrow("integration"); + await recordQualityGate(root, runId, "integration", "passed", [evidence.evidence_path], "pm", "Combined integration verification reviewed", new Date().toISOString()); + await transition(root, "QC-001", "completed"); +} + +describe("frozen Compact workflow profile", { timeout: 30_000 }, () => { + test("requires an explicit complete low-risk assessment and leaves Full the default", async () => { + expect(resolveWorkflowProfile()).toBeUndefined(); + expect(resolveWorkflowProfile("full")).toBeUndefined(); + expect(() => resolveWorkflowProfile("compact")).toThrow("complete low-risk"); + for (const field of ["migrations", "breaking_api", "authorization_changes", "sensitive_data_exposure", "cross_system_uncertainty"]) expect(() => resolveWorkflowProfile("compact", { ...assessment, [field]: true } as any)).toThrow("Full run"); + expect(() => resolveWorkflowProfile("compact", { ...assessment, bounded_scope: false } as any)).toThrow(); + expect(() => resolveWorkflowProfile("compact", { ...assessment, extra: false } as any)).toThrow(); + expect(() => resolveWorkflowProfile("unknown" as any, assessment)).toThrow("unknown"); + const full = await fixture({ profile: "full" }); + expect((await loadRun(full, runId)).workflow_profile).toBeUndefined(); + expect((await loadRun(full, runId)).tasks.map((task) => task.id)).toEqual(["PM-001", "BA-001", "PM-002", "WEB-001", "INT-001", "QC-001", "PM-004"]); + }); + + test("uses the revision1 graph across affected applications and preserves independent required gates", async () => { + const root = await fixture({ applications: ["backend", "web", "mobile"], advisory: true }); + const manifest = await loadRun(root, runId); + expect(manifest.workflow_profile).toEqual({ name: "compact", revision: 1, assessment }); + expect(manifest.tasks.map((task) => task.id)).toEqual(["PM-001", "BA-001", "BE-002", "WEB-001", "MOB-001", "QC-001", "PO-001", "PM-004"]); + expect(manifest.tasks.find((task) => task.id === "QC-001")?.dependencies).toEqual(["BE-002", "WEB-001", "MOB-001"]); + expect(manifest.quality_gates.api_contract?.status).toBe("not_applicable"); + expect(manifest.quality_gates.integration?.status).toBe("pending"); + const workflowPath = resolve(root, ".sdlc/workflows/feature-development.yaml"); + const workflow = parse(await readFile(workflowPath, "utf8")); + workflow.stages.find((stage: any) => stage.id === "qc").required_outputs.push("artifacts/qc/future-full-output.md"); + await writeFile(workflowPath, stringify(workflow)); + expect(await validateRun(root, runId)).toEqual({ valid: true, diagnostics: [] }); + await expect(mutateRunManifest(root, runId, (current) => { delete current.workflow_profile; })).rejects.toThrow("immutable"); + await expect(mutateRunManifest(root, runId, (current) => { current.workflow_profile!.assessment.rationale = "Changed assessment"; })).rejects.toThrow("immutable"); + await expect(mutateRunManifest(root, runId, (current) => { current.quality_gates.integration!.status = "not_applicable"; })).rejects.toThrow("integration gate is required"); + }); + + test.each([false, true])("completes actual Compact delivery with advisory=%s while human acceptance stays pending", async (advisory) => { + const root = await fixture({ advisory }); + await reviewedSpecification(root); + const binding = (await loadRun(root, runId)).compact_review; + expect(binding?.specification_sha256).toMatch(/^[a-f0-9]{64}$/); + await implementTarget(root); + await verifyQc(root); + if (advisory) { + await activate(root, "PO-001"); + await publishRunAuthority(root, runId, [{ path: "artifacts/po/advisory-review.yaml", source: stringify({ schema_version: 1, run_id: runId, task_id: "PO-001", producer: "po", advisory_only: true, recommendation: "ready_for_human_review", summary: "Verified acceptance evidence is ready for human review", acceptance_coverage: [{ acceptance_criteria_id: "AC-001", requirement_id: "REQ-001", assessment: "supported", sources: ["artifacts/ba/acceptance-criteria.md", "artifacts/qc/summary.md"] }], findings: [] }) }], { expectedVersion: await readAuthorityVersion(root, runId) }); + await transition(root, "PO-001", "awaiting_review", "po"); await transition(root, "PO-001", "completed"); + } + await activate(root, "PM-004"); + await put(root, "final-report.md", "---\nrevision: 1\n---\nDelivered the reviewed status correction; awaiting human acceptance.\n"); + await transition(root, "PM-004", "awaiting_review"); await transition(root, "PM-004", "completed"); + const finalized = await finalizeRun(root, runId, "pm", new Date().toISOString()); + expect(finalized.final_result?.status).toBe("ready"); + expect(finalized.product_owner_review?.decision).toBeNull(); + expect(finalized.compact_review).toEqual(binding); + expect(await validateRun(root, runId)).toEqual({ valid: true, diagnostics: [] }); + }); + + test("reviewed facts/spec cannot drift and repairs reset both integration and QC", async () => { + const root = await fixture(); + await reviewedSpecification(root); await implementTarget(root); await verifyQc(root); + const before = await loadRun(root, runId); + await expect(mutateRunManifest(root, runId, (current) => { current.quality_gates.integration!.evidence = ["request.md"]; })).rejects.toThrow("current independent QC collector evidence"); + const result = await repairTask(root, runId, "WEB-001", { actor: "pm", defectId: "DEF-COMPACT-001", reason: "Independent follow-up found an implementation defect" }); + expect(result.repair.affected_task_ids).toEqual(["WEB-001", "QC-001", "PM-004"]); + const repaired = await loadRun(root, runId); + expect(repaired.quality_gates.integration?.status).toBe("pending"); + expect(repaired.quality_gates.qc?.status).toBe("pending"); + expect(repaired.workflow_profile).toEqual(before.workflow_profile); + expect(repaired.compact_review).toEqual(before.compact_review); + expect(repaired.tasks.find((task) => task.id === "QC-001")?.evidence).toEqual([]); + await expect(mutateRunManifest(root, runId, (current) => { delete current.compact_review; })).rejects.toThrow("immutable"); + const factsPath = resolve(root, `.sdlc/runs/${runId}/facts.yaml`); + const facts = parse(await readFile(factsPath, "utf8")); facts.revision += 1; await writeFile(factsPath, stringify(facts)); + expect((await validateRun(root, runId)).valid).toBe(false); + }); + + test("later migration controls require escalation instead of silently widening Compact eligibility", async () => { + const root = await fixture({ applications: ["backend"] }); + await reviewedSpecification(root); + await expect(prepareTask(root, runId, "BE-002", { + controls: { mode: "implementation", api_requirements: ["request", "response", "error", "authentication", "authorization"].map((capability) => ({ requirement_id: "REQ-001", capability, required: true, source_references: [{ kind: "fact", reference: "FACT-001" }] })) as any, + storage: { postgresql_role: "durable_truth", redis_roles: [], redis_authoritative: false }, migration: { impact: "additive", approval_status: "not_required", decision_id: null } }, commandIds: ["sdlc_test"], + })).rejects.toThrow("Compact excludes migrations"); + expect((await loadRun(root, runId)).tasks.find((task) => task.id === "BE-002")?.status).toBe("ready"); + await expect(readFile(resolve(root, `.sdlc/runs/${runId}/tasks/BE-002.assignment.yaml`))).rejects.toThrow(); + }); + + test("rejects edited profile revisions and removed canonical QC tasks", async () => { + const root = await fixture(); + const path = resolve(root, `.sdlc/runs/${runId}/manifest.yaml`); + const original = await readFile(path, "utf8"); + const changed = parse(original); + changed.workflow_profile.revision = 2; + await writeFile(path, stringify(changed)); + expect((await validateRun(root, runId)).valid).toBe(false); + const missing = parse(original); + missing.tasks = missing.tasks.filter((task: any) => task.id !== "QC-001"); + await writeFile(path, stringify(missing)); + expect((await validateRun(root, runId)).valid).toBe(false); + await writeFile(path, original); + expect((await validateRun(root, runId)).valid).toBe(true); + }); + + test.each(["backend", "mobile"] as const)("runs Compact %s implementation through independent integration and QC", async (target) => { + const root = await fixture({ applications: [target] }); + await reviewedSpecification(root); await implementTarget(root, target); await verifyQc(root); + const manifest = await loadRun(root, runId); + expect(manifest.tasks.find((task) => task.id === "QC-001")?.status).toBe("completed"); + expect(manifest.quality_gates.integration?.status).toBe("passed"); + expect(manifest.quality_gates.api_contract?.status).toBe("not_applicable"); + expect(await validateRun(root, runId)).toEqual({ valid: true, diagnostics: [] }); + }); +}); diff --git a/tests/compatibility-v1.test.ts b/tests/compatibility-v1.test.ts new file mode 100644 index 0000000..9be273d --- /dev/null +++ b/tests/compatibility-v1.test.ts @@ -0,0 +1,170 @@ +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, resolve } from "node:path"; +import { stringify } from "yaml"; +import { afterEach, describe, expect, test } from "vitest"; + +import { resolveRepositoryDeliveryAuthority, resolveRepositoryDeliveryReportPackage } from "../src/delivery-authority-resolver.js"; +import { reconcileDeliveryReportPackage, type DeliveryReportPackage } from "../src/delivery-report-reconciliation.js"; +import { initializeProject } from "../src/install.js"; +import { isPortableRepositoryPath } from "../src/paths.js"; +import { loadRun, startRun } from "../src/runs.js"; +import { validateDocument } from "../src/schemas.js"; +import type { BackendRequirementInventory, DeliveryAssignment, DeliveryReport, WebDeliveryAssignment } from "../src/semantic-contracts.js"; + +const roots: string[] = []; +const runId = "COMPAT-001"; +const runRoot = `.sdlc/runs/${runId}`; +const at = "2026-09-25T00:00:00.000Z"; +const hash = (source: string) => createHash("sha256").update(source).digest("hex"); +afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); + +async function put(root: string, path: string, source: string) { + await mkdir(dirname(resolve(root, path)), { recursive: true }); + await writeFile(resolve(root, path), source); +} + +/** Exercise real configured roots, permission policy, run bytes, and on-disk report artifacts. */ +async function fixture(target: "web" | "backend" = "web") { + const root = await mkdtemp(resolve(tmpdir(), "sdlc-compatibility-")); + roots.push(root); + await mkdir(resolve(root, "apps/api"), { recursive: true }); + await mkdir(resolve(root, "apps/platform"), { recursive: true }); + await initializeProject({ root, projectName: "Compatibility", applications: ["backend", "web"], backendRoot: "apps/api", webRoot: "apps/platform", dryRun: false }); + await put(root, ".sdlc/requests/change.md", "Expose the business feature through the API and portal.\n"); + await startRun(root, { id: runId, title: "Compatibility", requestFile: ".sdlc/requests/change.md", affectedApplications: { backend: true, web: true, mobile: false, database: false, sharedPackages: false }, now: at }); + const manifest = await loadRun(root, runId); + const taskId = target === "web" ? "WEB-001" : "BE-002"; + const task = manifest.tasks.find((entry) => entry.id === taskId)!; + task.dependencies = []; + task.required_inputs = []; + task.required_outputs = [`artifacts/${target}/implementation-summary.md`]; + task.status = "running"; + task.started_at = at; + task.transitions = [ + { from: "pending", to: "ready", actor: "pm", reason: "Fixture activation", at }, + { from: "ready", to: "running", actor: "pm", reason: "Fixture implementation", at }, + ]; + await put(root, `${runRoot}/manifest.yaml`, stringify(manifest)); + const facts = "schema_version: 1\nrun_id: COMPAT-001\nproducer: pm\nrevision: 1\nfacts:\n - id: FACT-001\n subject: feature.delivery\n relation: equals\n value: true\n status: approved\n"; + await put(root, `${runRoot}/facts.yaml`, facts); + const artifactPath = `${runRoot}/artifacts/${target}/implementation-summary.md`; + const artifactSource = "---\nrevision: 1\n---\nImplemented the feature.\n"; + const appRoot = target === "web" ? "apps/platform" : "apps/api"; + const sourcePath = target === "web" ? `${appRoot}/app/(portal)/items/[id]/page.tsx` : `${appRoot}/src/items.ts`; + const common = { + schema_version: 1 as const, kind: "delivery_assignment" as const, assignment_id: "ASN-001", run_id: runId, task_id: taskId, + producer: "pm" as const, revision: 1, facts_revision: 1, facts_sha256: hash(facts), task_status: "running" as const, + scaffold_status: "scaffolded" as const, dependencies: [], required_inputs: [], + required_outputs: [{ path: artifactPath, artifact_kind: "implementation_summary" as const, producer: target === "web" ? "frontend" as const : "backend" as const, required: true }], + allowed_write_roots: [appRoot, `${runRoot}/artifacts/${target}`], available_evidence: [], evidence_requirements: [], + transition_policy: { current_status: "running" as const, allowed_request: "awaiting_review" as const, unmet_disposition: "refuse" as const }, + }; + const assignment: DeliveryAssignment = target === "web" ? { + ...common, role: "frontend", target: "web", stage: "web_implementation", scenario: "full_task", + controls: { api_contract_status: "approved", gaps: [], questions: [], requirements: ["semantic_status", "output_escaping"].map((capability) => ({ requirement_id: "REQ-001", capability, required: true, parameters: {}, source_references: [{ kind: "fact", reference: "FACT-001" }] })) }, + } : { + ...common, role: "backend", target: "backend", stage: "backend_implementation", scenario: "full_task", + controls: { mode: "implementation", storage: { postgresql_role: "durable_truth", redis_roles: [], redis_authoritative: false }, migration: { impact: "none", approval_status: "not_required", decision_id: null }, api_requirements: ["request", "response", "error", "authentication", "authorization"].map((capability) => ({ requirement_id: "REQ-001", capability, required: true, source_references: [{ kind: "fact", reference: "FACT-001" }] })) as BackendRequirementInventory }, + }; + const requirements = assignment.role === "backend" ? assignment.controls.api_requirements : assignment.controls.requirements; + const observations = assignment.role === "backend" ? { mode: assignment.controls.mode, storage: assignment.controls.storage, migration: assignment.controls.migration, requirement_results: requirements.map(({ requirement_id, capability, required }) => ({ requirement_id, capability, required, status: "implemented" })) } : { api_contract_status: "approved", gaps: [], questions: [], requirement_results: requirements.map(({ requirement_id, capability, required }) => ({ requirement_id, capability, required, status: "implemented" })) }; + const report = { + schema_version: 1, kind: "delivery_report", assignment_id: assignment.assignment_id, assignment_revision: 1, run_id: runId, task_id: taskId, + producer: assignment.role, role: assignment.role, target: assignment.target, stage: assignment.stage, scenario: assignment.scenario, + revision: 1, disposition: "proceed", transition_request: { from: "running", to: "awaiting_review" }, + artifacts: [{ path: artifactPath, artifact_kind: "implementation_summary", producer: assignment.role, status: "produced", revision: 1, sha256: hash(artifactSource) }], + writes: [{ path: sourcePath, type: "source" }, { path: artifactPath, type: "artifact" }], evidence: [], observations, + } as DeliveryReport; + const assignmentPath = `${runRoot}/tasks/${taskId}.assignment.yaml`; + await put(root, assignmentPath, stringify(assignment)); + await put(root, artifactPath, artifactSource); + await put(root, sourcePath, "export const status = 'ready';\n"); + await put(root, `${runRoot}/artifacts/${target}/${taskId}-delivery-report.yaml`, stringify(report)); + await put(root, `${runRoot}/evidence/diffs/changed-files.json`, JSON.stringify({ files: [sourcePath, artifactPath], ownership: [{ path: sourcePath, task_id: taskId }] })); + const resolved = await resolveRepositoryDeliveryReportPackage(root, assignmentPath); + const pkg: DeliveryReportPackage = { schema_version: 1, kind: "structured_delivery_evaluation", scenario_id: "real-layout", assignment: resolved.assignment, authority_snapshot: resolved.authoritySnapshot, authority_integrity: resolved.authorityIntegrity, artifact_integrity: resolved.artifactIntegrity, report: resolved.report }; + return { root, assignmentPath, pkg, sourcePath }; +} + +describe("v1 document compatibility", () => { + test.each(["web", "backend"] as const)("reconciles complete %s reports in configured apps/platform and apps/api roots", async (target) => { + const { pkg } = await fixture(target); + expect(reconcileDeliveryReportPackage(pkg)).toEqual({ valid: true, diagnostics: [] }); + expect(new Set(pkg.report.observations.requirement_results.map((result) => result.requirement_id))).toEqual(new Set(["REQ-001"])); + }); + + test("keeps existing unique requirement IDs valid and checks each capability independently", async () => { + const { pkg } = await fixture(); + const assignment = pkg.assignment as WebDeliveryAssignment; + assignment.controls.requirements.forEach((requirement, index) => { requirement.requirement_id = `REQ-00${index + 1}`; }); + pkg.report.observations.requirement_results.forEach((result, index) => { result.requirement_id = `REQ-00${index + 1}`; }); + expect(reconcileDeliveryReportPackage(pkg)).toEqual({ valid: true, diagnostics: [] }); + pkg.report.observations.requirement_results[1]!.requirement_id = "REQ-001"; + expect(reconcileDeliveryReportPackage(pkg).diagnostics.join("\n")).toContain("output_escaping.requirement_id"); + }); + + test("does not let a completed capability satisfy another capability under the same REQ", async () => { + const { pkg } = await fixture(); + pkg.report.observations.requirement_results[1]!.status = "planned"; + expect(reconcileDeliveryReportPackage(pkg).diagnostics.join("\n")).toContain("transition_request.to expected null"); + pkg.report.transition_request.to = null; + pkg.assignment.transition_policy.allowed_request = null; + expect(reconcileDeliveryReportPackage(pkg)).toEqual({ valid: true, diagnostics: [] }); + pkg.report.observations.requirement_results.pop(); + expect(reconcileDeliveryReportPackage(pkg).diagnostics.join("\n")).toContain("missing capability output_escaping"); + }); + + test("rejects duplicate capabilities in assignments and reports", async () => { + const { pkg } = await fixture(); + const assignment = pkg.assignment as WebDeliveryAssignment; + assignment.controls.requirements[1]!.capability = assignment.controls.requirements[0]!.capability; + expect(validateDocument("deliveryAssignment", assignment).diagnostics.join("\n")).toContain("duplicates semantic key capability"); + pkg.report.observations.requirement_results[1]!.capability = pkg.report.observations.requirement_results[0]!.capability; + expect(validateDocument("deliveryReport", pkg.report).diagnostics.join("\n")).toContain("duplicates semantic key capability"); + }); + + test("cannot enlarge project-backed authority by supplying another target's roots", async () => { + const { root, assignmentPath, pkg } = await fixture(); + pkg.assignment.allowed_write_roots.push("apps/api"); + await put(root, assignmentPath, stringify(pkg.assignment)); + await expect(resolveRepositoryDeliveryAuthority(root, assignmentPath)).rejects.toThrow("workflow.permission_roots"); + pkg.report.writes[0]!.path = "apps/api/src/stolen.ts"; + const validation = reconcileDeliveryReportPackage(pkg); + expect(validation.valid).toBe(false); + expect(validation.diagnostics.join("\n")).toContain("not owned by target web"); + }); + + test("rejects writes to another repository even when the path matches", async () => { + const { pkg } = await fixture(); + pkg.assignment.repository = "platform"; + pkg.report.repository = "platform"; + pkg.authority_snapshot.task.repository = "platform"; + pkg.report.writes[0]!.repository = "api"; + expect(reconcileDeliveryReportPackage(pkg).diagnostics.join("\n")).toContain("outside assignment repository platform"); + }); + + test("keeps filesystem symlink escape protection for valid route-group paths", async () => { + const { root, assignmentPath, sourcePath } = await fixture(); + const outside = await mkdtemp(resolve(tmpdir(), "sdlc-outside-")); + roots.push(outside); + await put(outside, "page.tsx", "outside\n"); + await rm(resolve(root, sourcePath)); + await symlink(resolve(outside, "page.tsx"), resolve(root, sourcePath)); + await expect(resolveRepositoryDeliveryAuthority(root, assignmentPath)).rejects.toThrow("escapes repository root"); + }); + + test("schema paths use the complete runtime portable grammar", async () => { + const { pkg } = await fixture(); + const paths = ["apps/platform/app/(portal)/[id]/page.tsx", "apps/platform/app/[[...slug]]/page.tsx", "apps/platform/nhãn có dấu.ts", ".", "../escape", "apps/../api", "apps//page.tsx", "C:/escape", "apps\\page.tsx", "apps/CON.txt", "apps/LPT¹.log", "apps/trailing.", "apps/trailing ", "apps/nul", "apps/a?b", "apps/e\u0301.ts", "apps/\u0000.ts"]; + for (const path of paths) { + const report = structuredClone(pkg.report); + report.writes[0]!.path = path; + expect(validateDocument("deliveryReport", report).valid, path).toBe(isPortableRepositoryPath(path)); + const assignment = structuredClone(pkg.assignment); + assignment.allowed_write_roots = [path]; + expect(validateDocument("deliveryAssignment", assignment).valid, path).toBe(isPortableRepositoryPath(path)); + } + }); +}); diff --git a/tests/helpers/delivery-fixture.ts b/tests/helpers/delivery-fixture.ts new file mode 100644 index 0000000..46a68eb --- /dev/null +++ b/tests/helpers/delivery-fixture.ts @@ -0,0 +1,121 @@ +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, resolve } from "node:path"; +import { promisify } from "node:util"; + +import { parse, stringify } from "yaml"; + +import { planAgent, recordAgentDispatch, type AgentCapabilities } from "../../src/agents.js"; +import { executeConfiguredCommand } from "../../src/evidence.js"; +import { initializeProject } from "../../src/install.js"; +import { mutateRunManifest } from "../../src/manifest-transaction.js"; +import { loadRun, startRun } from "../../src/runs.js"; +import { handoffTask, prepareTask, type HandoffTaskInput, type PrepareTaskInput } from "../../src/task-operations.js"; +import { prepareTransitionContext, synchronizeRunState, transitionTask } from "../../src/transitions.js"; +import type { ProjectConfig } from "../../src/types.js"; + +const exec = promisify(execFile); +export interface DeliveryFixture { + root: string; + runId: string; + taskId: string; + appRoot: string; + input: PrepareTaskInput; + handoffInput: HandoffTaskInput; +} + +/** Fixture starts after a reviewed BA/PM package; only delivery activation/handoff is under test. */ +export async function createDeliveryFixture(options: { appRoot?: string; target?: "web" | "mobile" | "backend" } = {}): Promise<DeliveryFixture> { + const root = await mkdtemp(resolve(tmpdir(), "codex-sdlc-task-operations-")); + const target = options.target ?? "web"; + const appRoot = options.appRoot ?? (target === "backend" ? "apps/api" : "apps/platform"); + const runId = "DELIVERY-001"; + const taskId = target === "web" ? "WEB-001" : target === "mobile" ? "MOB-001" : "BE-001"; + await mkdir(resolve(root, appRoot), { recursive: true }); + await writeFile(resolve(root, appRoot, "package.json"), JSON.stringify({ name: "fixture", private: true })); + await writeFile(resolve(root, appRoot, "feature.cjs"), "module.exports = 'before';\n"); + await exec("git", ["init", "-b", "main"], { cwd: root }); + await exec("git", ["add", "."], { cwd: root }); + await exec("git", ["-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "Fixture baseline"], { cwd: root }); + await initializeProject({ root, projectName: "Task fixture", applications: [target], ...(target === "web" ? { webRoot: appRoot, webPreset: "nextjs" as const } : target === "mobile" ? { mobileRoot: appRoot } : { backendRoot: appRoot }), agents: { roles: { [target === "backend" ? "backend" : "frontend"]: { model: "gpt-6-luna", reasoning_effort: "xhigh" } }, product_owner_review: "disabled" }, dryRun: false }); + const projectPath = resolve(root, ".sdlc/project.yaml"); + const project = parse(await readFile(projectPath, "utf8")) as ProjectConfig; + project.applications[target]!.lifecycle = "scaffolded"; + project.commands.sdlc_test = { executable: process.execPath, args: ["-e", "if(require('./feature.cjs') !== 'after') process.exit(1)"], cwd: appRoot, network: "disabled", mutates: false }; + await writeFile(projectPath, stringify(project)); + await writeFile(resolve(root, ".sdlc/requests/feature.md"), "REQ-001: Show the updated feature.\n"); + await startRun(root, { id: runId, title: "Update feature", requestFile: ".sdlc/requests/feature.md", affectedApplications: { backend: target === "backend", web: target === "web", mobile: target === "mobile", database: false, sharedPackages: false }, now: new Date().toISOString() }); + const manifest = await loadRun(root, runId); + for (const task of manifest.tasks.filter((entry) => ["PM-001", "BA-001", "PM-002"].includes(entry.id))) { + for (const path of task.required_outputs) { + if (["manifest.yaml", "request.md"].includes(path)) continue; + const destination = resolve(root, `.sdlc/runs/${runId}`, path); + await mkdir(dirname(destination), { recursive: true }); + const content = path === "facts.yaml" ? stringify({ schema_version: 1, run_id: runId, producer: "pm", revision: 1, facts: [{ id: "FACT-001", subject: "feature.status", relation: "equals", value: "after", status: "approved" }] }) + : path.endsWith("semantic-claims.yaml") ? stringify({ schema_version: 1, run_id: runId, task_id: "BA-001", producer: "ba", revision: 1, claims: [{ id: "CLAIM-001", fact_ids: ["FACT-001"], subject: "feature.status", relation: "equals", value: "after", status: "approved" }] }) + : `---\nrevision: 1\n---\n# Reviewed fixture requirement\n\n## REQ-001\nShow the updated feature.\n\n## AC-001\nrequirement_id: REQ-001\nExpected status is after.\n`; + await writeFile(destination, content); + } + } + await mutateRunManifest(root, runId, (current) => { + const at = new Date().toISOString(); + for (const task of current.tasks.filter((entry) => ["PM-001", "BA-001", "PM-002"].includes(entry.id))) { + task.status = "completed"; + task.started_at = at; + task.completed_at = at; + task.outputs = [...task.required_outputs]; + task.transitions = [{ from: "pending", to: "ready", actor: "system", reason: "Reviewed fixture prerequisites", at }, { from: "ready", to: "running", actor: task.role, reason: "Fixture prerequisite execution", at }, { from: "running", to: "awaiting_review", actor: task.role, reason: "Fixture prerequisite handoff", at }, { from: "awaiting_review", to: "completed", actor: "pm", reason: "Fixture prerequisite review", at }]; + } + const selected = current.tasks.find((entry) => entry.id === taskId)!; + selected.status = "ready"; + selected.transitions.push({ from: "pending", to: "ready", actor: "system", reason: "Fixture prerequisites reviewed", at }); + current.quality_gates.requirements!.status = "passed"; + synchronizeRunState(current); + }); + const input: PrepareTaskInput = target === "backend" ? { + controls: { mode: "api_contract", api_requirements: ["request", "response", "error", "authentication", "authorization"].map((capability, index) => ({ requirement_id: `REQ-00${index + 1}`, capability, required: true, source_references: [{ kind: "fact", reference: "FACT-001" }] })) as any, storage: { postgresql_role: "durable_truth", redis_roles: [], redis_authoritative: false }, migration: { impact: "none", approval_status: "not_required", decision_id: null } }, commandIds: [], + } : { + controls: { api_contract_status: "approved", gaps: [], questions: [], requirements: [{ requirement_id: "REQ-001", capability: "feature_status", required: true, parameters: {}, source_references: [{ kind: "fact", reference: "FACT-001" as any }] }] }, commandIds: ["sdlc_test"], + }; + return { root, runId, taskId, appRoot, input, handoffInput: { requirementOutcomes: (target === "backend" ? ["request", "response", "error", "authentication", "authorization"] : ["feature_status"]).map((capability, index) => ({ requirement_id: target === "backend" ? `REQ-00${index + 1}` : "REQ-001", capability, status: target === "backend" ? "documented" : "implemented" })), changedFiles: [{ path: `${appRoot}/feature.cjs`, type: "source" }] } }; +} + +export async function activateFixture(fixture: DeliveryFixture): Promise<void> { + const { root, runId, taskId } = fixture; + const capabilities: AgentCapabilities = { source: "test host adapter fixture", model_selection: true, reasoning_selection: true, models: [{ id: "gpt-6-luna", reasoning_efforts: ["xhigh"] }] }; + const plan = await planAgent(root, runId, taskId, capabilities); + await recordAgentDispatch(root, runId, taskId, { plan, agent_id: "fixture-delivery-agent", actual_model: "gpt-6-luna", actual_reasoning_effort: "xhigh", observation_source: "test host response fixture" }, new Date().toISOString()); + await mutateRunManifest(root, runId, async (manifest) => { + const request = { taskId, to: "running" as const, actor: plan.role, reason: "Fixture host dispatched the delivery role", at: new Date().toISOString() }; + Object.assign(manifest, transitionTask(manifest, request, await prepareTransitionContext(root, runId, manifest, request))); + }); +} + +export async function writeFixtureOutputs(fixture: DeliveryFixture): Promise<void> { + const { root, runId, taskId, appRoot } = fixture; + await writeFile(resolve(root, appRoot, "feature.cjs"), "module.exports = 'after';\n"); + const manifest = await loadRun(root, runId); + for (const path of manifest.tasks.find((entry) => entry.id === taskId)!.required_outputs.filter((path) => !path.endsWith("delivery-report.yaml"))) { + const destination = resolve(root, `.sdlc/runs/${runId}`, path); + await mkdir(dirname(destination), { recursive: true }); + await writeFile(destination, path.endsWith("openapi.yaml") ? stringify({ openapi: "3.1.0", info: { title: "Fixture API", version: "1.0.0" }, paths: {}, "x-sdlc-metadata": { schema_version: 1, run_id: runId, task_id: taskId, producer: "backend", revision: 1 } }) : "---\nrevision: 1\n---\n# Implementation evidence\n\nREQ-001: Updated the feature status and verified the result.\n"); + } +} + +export async function collectFixtureEvidence(fixture: DeliveryFixture): Promise<void> { + const project = parse(await readFile(resolve(fixture.root, ".sdlc/project.yaml"), "utf8")) as ProjectConfig; + const variable = project.security.network_policy_attestation_environment; + const previous = process.env[variable]; + process.env[variable] = "disabled"; + try { await executeConfiguredCommand(fixture.root, fixture.runId, fixture.taskId, "sdlc_test"); } + finally { if (previous === undefined) delete process.env[variable]; else process.env[variable] = previous; } +} + +export async function activateAndHandoffFixture(fixture: DeliveryFixture) { + await prepareTask(fixture.root, fixture.runId, fixture.taskId, fixture.input); + await activateFixture(fixture); + await writeFixtureOutputs(fixture); + await collectFixtureEvidence(fixture); + return handoffTask(fixture.root, fixture.runId, fixture.taskId, fixture.handoffInput); +} diff --git a/tests/package-content.test.ts b/tests/package-content.test.ts index 96a17eb..a31d202 100644 --- a/tests/package-content.test.ts +++ b/tests/package-content.test.ts @@ -31,7 +31,7 @@ describe("distribution content", () => { const manifest = JSON.parse(await readFile(resolve(root, "plugin.json"), "utf8")); expect(manifest.$schema).toBe("https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"); expect(manifest.name).toBe("codex-sdlc"); - expect(manifest.version).toBe("0.6.0"); + expect(manifest.version).toBe("1.0.0"); expect(manifest.license).toBe("Apache-2.0"); expect(manifest.extensions["com.openai"].interface.capabilities).toEqual(["Read", "Write"]); expect(manifest.extensions["com.openai"].interface.logo).toBe("./assets/brand/codex-sdlc.svg"); @@ -40,7 +40,7 @@ describe("distribution content", () => { expect(manifest.extensions["com.openai"].interface.defaultPrompt.every((prompt: string) => prompt.length <= 128)).toBe(true); expect(manifest.extensions["com.openai"].interface.websiteURL).toBe("https://github.com/prime-vimihi/codex-sdlc/blob/main/docs/getting-started.md"); const codexManifest = JSON.parse(await readFile(resolve(root, ".codex-plugin/plugin.json"), "utf8")); - expect(codexManifest.version).toBe("0.6.0"); + expect(codexManifest.version).toBe("1.0.0"); expect(codexManifest.interface.defaultPrompt.length).toBeLessThanOrEqual(3); expect(codexManifest.interface.defaultPrompt.every((prompt: string) => prompt.length <= 128)).toBe(true); const packageDocument = JSON.parse(await readFile(resolve(root, "package.json"), "utf8")); diff --git a/tests/preflight.test.ts b/tests/preflight.test.ts new file mode 100644 index 0000000..233ea41 --- /dev/null +++ b/tests/preflight.test.ts @@ -0,0 +1,87 @@ +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { parse, stringify } from "yaml"; +import { initializeProject } from "../src/install.js"; +import { preflightProject } from "../src/preflight.js"; +import type { ProjectConfig } from "../src/types.js"; + +const roots: string[] = []; +afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); +async function fixture(configured = true) { + const root = await mkdtemp(join(tmpdir(), "sdlc-preflight-")); roots.push(root); + await mkdir(join(root, "customer")); + await initializeProject({ root, projectName: "Readiness", applications: ["web"], webRoot: "customer", dryRun: false }); + if (configured) await change(root, (project) => { + for (const id of ["sdlc_test", "sdlc_typecheck"]) project.commands[id] = { executable: process.execPath, args: ["-e", "require('node:fs').writeFileSync('must-not-exist', 'executed')"], cwd: "customer", network: "disabled", mutates: true }; + }); + return root; +} +async function change(root: string, edit: (project: ProjectConfig) => void) { + const path = join(root, ".sdlc/project.yaml"); + const project = parse(await readFile(path, "utf8")) as ProjectConfig; edit(project); await writeFile(path, stringify(project)); +} + +describe("targeted readiness preflight", () => { + test("inspects local requirements without running even mutating commands or claiming live readiness", async () => { + const root = await fixture(); + await writeFile(join(root, "fixture.json"), '{"secret":"not-read-or-emitted"}'); + const before = await readFile(join(root, ".sdlc/project.yaml"), "utf8"); + const result = await preflightProject(root, { applications: ["web"], expectedRoots: { web: "customer" }, requiredFiles: ["fixture.json"] }); + expect(result.ready).toBe(true); + expect(result.checks.find((check) => check.id === "services")?.status).toBe("not_checked"); + expect(JSON.stringify(result)).not.toContain("not-read-or-emitted"); + expect(await readFile(join(root, ".sdlc/project.yaml"), "utf8")).toBe(before); + await expect(readFile(join(root, "customer/must-not-exist"))).rejects.toThrow(); + }); + test("blocks admin/customer mismatch and undeclared affected applications", async () => { + const root = await fixture(); await mkdir(join(root, "admin")); + const result = await preflightProject(root, { applications: ["web", "mobile"], expectedRoots: { web: "admin" } }); + expect(result.ready).toBe(false); + expect(result.diagnostics.join(" ")).toContain("root mismatch"); + expect(result.diagnostics.join(" ")).toContain("mobile is not declared"); + }); + test("blocks missing, directory, escaped, or outside-symlink fixture paths", async () => { + const root = await fixture(); + const outside = await mkdtemp(join(tmpdir(), "sdlc-outside-")); roots.push(outside); + await writeFile(join(outside, "fixture"), "private"); await symlink(join(outside, "fixture"), join(root, "linked")); + for (const file of ["missing.json", "customer", "../fixture", "linked"]) { + expect((await preflightProject(root, { requiredFiles: [file] })).ready).toBe(false); + } + }); + test("blocks generic unconfigured commands and explicitly missing declarations", async () => { + const root = await fixture(false); + expect((await preflightProject(root)).diagnostics.join(" ")).toContain("unconfigured"); + expect((await preflightProject(root, { commands: ["acceptance_test"] })).diagnostics.join(" ")).toContain("not declared"); + }); + test("blocks missing executable, root, and npm script without invoking npm", async () => { + const root = await fixture(); + await change(root, (project) => { project.commands.sdlc_test.executable = "sdlc-impossible-executable-for-test"; }); + expect((await preflightProject(root)).diagnostics.join(" ")).toContain("executable is unavailable"); + await change(root, (project) => { project.commands.sdlc_test.executable = "npm"; project.commands.sdlc_test.args = ["run", "acceptance"]; }); + await writeFile(join(root, "customer/package.json"), '{"scripts":{"test":"echo hi"}}'); + expect((await preflightProject(root)).diagnostics.join(" ")).toContain("npm script acceptance is missing"); + await rm(join(root, "customer"), { recursive: true }); + expect((await preflightProject(root)).ready).toBe(false); + }); + test("rejects missing/mismatched framework configuration and unknown expected-root selections", async () => { + const root = await fixture(); + expect((await preflightProject(root, { expectedRoots: { mobile: "mobile" } })).ready).toBe(false); + const frameworkPath = join(root, ".sdlc/framework.yaml"); + const framework = parse(await readFile(frameworkPath, "utf8")); framework.framework.version = "0.0.0"; + await writeFile(frameworkPath, stringify(framework)); + expect((await preflightProject(root)).diagnostics.join(" ")).toContain("framework.version"); + }); + test("target selection skips unrelated aggregate command steps", async () => { + const root = await fixture(); + await change(root, (project) => { + project.commands.sdlc_test.steps = [ + { repository: "coordinator", executable: process.execPath, args: ["-e", "process.exit(0)"], cwd: "customer" }, + { repository: "coordinator", executable: "nonexistent-tool", args: [], cwd: "unrelated" }, + ]; + }); + expect((await preflightProject(root, { applications: ["web"] })).ready).toBe(true); + expect((await preflightProject(root, { applications: ["web"], commands: ["sdlc_test"] })).ready).toBe(false); + }); +}); diff --git a/tests/qc-integration-review.test.ts b/tests/qc-integration-review.test.ts new file mode 100644 index 0000000..78baf42 --- /dev/null +++ b/tests/qc-integration-review.test.ts @@ -0,0 +1,124 @@ +import { mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, resolve } from 'node:path'; +import { createRequire } from 'node:module'; +import { parse, stringify } from 'yaml'; +import { afterAll, beforeAll, expect, test } from 'vitest'; +import { createDeliveryFixture, activateFixture, writeFixtureOutputs, collectFixtureEvidence } from './helpers/delivery-fixture.js'; +import { prepareTask, handoffTask } from '../src/task-operations.js'; + +// Complete CLI and repair cycles need time for child processes and CI filesystem load. +test('independent repro: changed-during-test source must not count as verified', async () => { + const fixture = await createDeliveryFixture(); + try { + const path = resolve(fixture.root,'.sdlc/project.yaml'); + const project = parse(await readFile(path,'utf8')); + project.commands.sdlc_test.args=['-e',"if(require('./feature.cjs') !== 'after') process.exit(1);require('node:fs').writeFileSync('test-read.done','yes');setTimeout(()=>process.exit(0),600)"]; + await writeFile(path,stringify(project)); + await prepareTask(fixture.root,fixture.runId,fixture.taskId,fixture.input); + await activateFixture(fixture); await writeFixtureOutputs(fixture); + const command=collectFixtureEvidence(fixture); + for(let i=0;i<100;i++) { + try { await readFile(resolve(fixture.root,fixture.appRoot,'test-read.done'));break; } catch { await new Promise(r=>setTimeout(r,10)); } + } + await writeFile(resolve(fixture.root,fixture.appRoot,'feature.cjs'),"module.exports = 'broken after test read';\n"); + await command; + await expect(handoffTask(fixture.root,fixture.runId,fixture.taskId,fixture.handoffInput)).rejects.toThrow(/stale|changed|verification/); + } finally { await rm(fixture.root,{recursive:true,force:true}); } +}, 30_000); + +import { activateAndHandoffFixture } from './helpers/delivery-fixture.js'; +import { recordQualityGate } from '../src/lifecycle.js'; +import { mutateRunManifest, readAuthorityVersion } from '../src/manifest-transaction.js'; +import { loadRun } from '../src/runs.js'; +import { prepareTransitionContext, transitionTask } from '../src/transitions.js'; +import { repairTask, recoverRepair } from '../src/repairs.js'; + +test('independent repro: committed repair recovery must advance authority version if interrupted before version update', async () => { + const fixture = await createDeliveryFixture(); + try { + await activateAndHandoffFixture(fixture); + let manifest=await loadRun(fixture.root,fixture.runId); + const task=manifest.tasks.find(t=>t.id===fixture.taskId)!; + await recordQualityGate(fixture.root,fixture.runId,'web','passed',task.evidence,'pm','Reviewed independent fixture',new Date().toISOString()); + await mutateRunManifest(fixture.root,fixture.runId,async current=>{ + const request={taskId:fixture.taskId,to:'completed' as const,actor:'pm',reason:'Fixture review',at:new Date().toISOString()}; + Object.assign(current,transitionTask(current,request,await prepareTransitionContext(fixture.root,fixture.runId,current,request))); + }); + const version=await readAuthorityVersion(fixture.root,fixture.runId); + const journalPath=resolve(fixture.root,`.sdlc/runs/${fixture.runId}/.repair-transaction.json`); + let journal=''; + await repairTask(fixture.root,fixture.runId,fixture.taskId,{actor:'pm',defectId:'DEF-001',reason:'Fixture repair'}, {beforeRelease:async()=>{journal=await readFile(journalPath,'utf8');}}); + // Reconstruct the exact crash window after manifest rename but before version update. + await writeFile(resolve(fixture.root,`.sdlc/runs/${fixture.runId}/.sdlc-authority-version.json`),JSON.stringify({schema_version:1,version})); + await writeFile(journalPath,journal); + expect(await recoverRepair(fixture.root,fixture.runId,'pm')).toMatchObject({recovered:true,outcome:'committed'}); + expect(await readAuthorityVersion(fixture.root,fixture.runId)).toBeGreaterThan(version); + } finally { await rm(fixture.root,{recursive:true,force:true}); } +}, 30_000); + +import { spawnSync } from 'node:child_process'; +let cliFixtureRoot: string; +beforeAll(async () => { + const repositoryRoot = resolve(import.meta.dirname, '..'); + cliFixtureRoot = await mkdtemp(resolve(tmpdir(), 'sdlc-independent-cli-build-')); + await Promise.all([ + symlink(resolve(repositoryRoot, 'node_modules'), resolve(cliFixtureRoot, 'node_modules'), 'junction'), + symlink(resolve(repositoryRoot, 'assets'), resolve(cliFixtureRoot, 'assets'), 'junction'), + writeFile(resolve(cliFixtureRoot, 'package.json'), JSON.stringify({ type: 'module' })), + ]); + const compilerPackage = createRequire(import.meta.url).resolve('typescript/package.json'); + const compilerMetadata = JSON.parse(await readFile(compilerPackage, 'utf8')); + const compiler = resolve(dirname(compilerPackage), compilerMetadata.bin.tsc ?? compilerMetadata.bin.tsc6); + const built = spawnSync(process.execPath, [compiler, '-p', resolve(repositoryRoot, 'tsconfig.json'), '--outDir', resolve(cliFixtureRoot, 'dist')], { encoding: 'utf8' }); + expect(built.status, built.stdout + built.stderr).toBe(0); +}, 30000); +afterAll(async () => { if (cliFixtureRoot) await rm(cliFixtureRoot, { recursive: true, force: true }); }); +test('independent built CLI: prepare, plan, activate, failed/passed checks, and handoff', async () => { + const fixture=await createDeliveryFixture(); + const cli=resolve(cliFixtureRoot, 'dist/bin.js'); + const call=(args:string[],input?:unknown)=>{ + const result=spawnSync(process.execPath,[cli,...args,'--json'],{cwd:fixture.root,input:input===undefined?undefined:JSON.stringify(input),encoding:'utf8',env:{...process.env,CODEX_SDLC_NETWORK_POLICY:'disabled'}}); + return {code:result.status,body:JSON.parse(result.stdout)}; + }; + try { + const prepared=call(['prepare-task',fixture.runId,fixture.taskId],fixture.input); + expect(prepared.code).toBe(0); expect(prepared.body.result.executionAuthorized).toBe(false); + const planned=call(['agent-plan',fixture.runId,fixture.taskId],{source:'independent CLI fixture',model_selection:true,reasoning_selection:true,models:[{id:'gpt-6-luna',reasoning_efforts:['xhigh']}]}); + expect(planned.code).toBe(0); + const activated=call(['activate-task',fixture.runId,fixture.taskId,'--reason','Actual CLI fixture dispatch'],{plan:planned.body.result,agent_id:'fixture-cli-agent',actual_model:'gpt-6-luna',actual_reasoning_effort:'xhigh',observation_source:'test host response'}); + expect(activated.code).toBe(0); expect(activated.body.result.status).toBe('running'); + const failed=call(['check-task',fixture.runId,fixture.taskId]); + expect(failed.code).not.toBe(0); expect(failed.body.ok).toBe(false); expect(failed.body.result.passed).toBe(false); + await writeFixtureOutputs(fixture); + const passed=call(['check-task',fixture.runId,fixture.taskId]); + expect(passed.code).toBe(0); expect(passed.body.result.passed).toBe(true); + const handed=call(['handoff-task',fixture.runId,fixture.taskId],fixture.handoffInput); + expect(handed.code).toBe(0); expect(handed.body.result.taskStatus).toBe('awaiting_review'); + expect((await loadRun(fixture.root,fixture.runId)).quality_gates.web?.status).toBe('pending'); + } finally {await rm(fixture.root,{recursive:true,force:true});} +}, 30_000); + +test('independent repro: unresolved repair journal blocks ordinary mutation, publication, and command execution', async () => { + const fixture=await createDeliveryFixture(); + try { + await activateAndHandoffFixture(fixture); + const task=(await loadRun(fixture.root,fixture.runId)).tasks.find(t=>t.id===fixture.taskId)!; + await recordQualityGate(fixture.root,fixture.runId,'web','passed',task.evidence,'pm','Fixture review',new Date().toISOString()); + await mutateRunManifest(fixture.root,fixture.runId,async current=>{ + const request={taskId:fixture.taskId,to:'completed' as const,actor:'pm',reason:'Fixture review',at:new Date().toISOString()}; + Object.assign(current,transitionTask(current,request,await prepareTransitionContext(fixture.root,fixture.runId,current,request))); + }); + const journalPath=resolve(fixture.root,`.sdlc/runs/${fixture.runId}/.repair-transaction.json`); + let journal=''; + await repairTask(fixture.root,fixture.runId,fixture.taskId,{actor:'pm',defectId:'DEF-001',reason:'Fixture repair'}, {beforeRelease:async()=>{journal=await readFile(journalPath,'utf8');}}); + await writeFile(journalPath,journal); + await expect(mutateRunManifest(fixture.root,fixture.runId,()=>undefined)).rejects.toThrow(/repair|interrupted/); + await expect(prepareTask(fixture.root,fixture.runId,fixture.taskId,fixture.input)).rejects.toThrow(/repair|interrupted/); + const projectPath=resolve(fixture.root,'.sdlc/project.yaml'), project=parse(await readFile(projectPath,'utf8')); + project.commands.sdlc_test.args=['-e',"require('node:fs').writeFileSync('must-not-run-during-recovery','ran')"]; + await writeFile(projectPath,stringify(project)); + await expect(collectFixtureEvidence(fixture)).rejects.toThrow(/repair|interrupted/); + await expect(readFile(resolve(fixture.root,fixture.appRoot,'must-not-run-during-recovery'))).rejects.toThrow(); + } finally {await rm(fixture.root,{recursive:true,force:true});} +}, 30_000); diff --git a/tests/repairs.test.ts b/tests/repairs.test.ts new file mode 100644 index 0000000..1d6df3b --- /dev/null +++ b/tests/repairs.test.ts @@ -0,0 +1,454 @@ +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, resolve } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { stringify } from "yaml"; + +import { assertTaskAgentDispatch, resolveAgentPlan, type AgentCapabilities } from "../src/agents.js"; +import { resolveRequiredInputs, workflowOutputs } from "../src/delivery-authority-resolver.js"; +import { readEvidenceReference } from "../src/evidence-validation.js"; +import { canonicalizeCommandDeclaration } from "../src/command-provenance.js"; +import { loadProject } from "../src/config.js"; +import { synchronizeRunState } from "../src/transitions.js"; +import type { WebDeliveryAssignment, WebDeliveryReport } from "../src/semantic-contracts.js"; +import { initializeProject } from "../src/install.js"; +import { readAuthorityVersion } from "../src/manifest-transaction.js"; +import { planRepair, recoverRepair, repairTask } from "../src/repairs.js"; +import { repairHistoryDiagnostics } from "../src/repair-validation.js"; +import { loadRun, startRun, validateRun } from "../src/runs.js"; +import type { RunManifest, Task } from "../src/types.js"; + +const roots: string[] = []; +const runId = "REPAIR-001"; +const at = "2026-09-25T00:00:00.000Z"; +const repairAt = "2026-09-25T01:00:00.000Z"; +const options = { actor: "pm", defectId: "DEF-001", reason: "Correct the portal response", now: repairAt }; +const hash = (source: string) => createHash("sha256").update(source).digest("hex"); +const encoded = (source: string) => Buffer.from(source).toString("base64"); +afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); +async function put(root: string, path: string, source: string) { + await mkdir(dirname(resolve(root, path)), { recursive: true }); + await writeFile(resolve(root, path), source); +} +function finish(task: Task) { + task.status = "completed"; + task.started_at = at; + task.completed_at = at; + task.transitions = [ + { from: "pending", to: "ready", actor: "pm", reason: "Fixture activation", at }, + { from: "ready", to: "running", actor: "pm", reason: "Fixture work", at }, + { from: "running", to: "awaiting_review", actor: "pm", reason: "Fixture handoff", at }, + { from: "awaiting_review", to: "completed", actor: "pm", reason: "Fixture review", at }, + ]; + task.outputs = [...task.required_outputs]; +} +async function base(webOnly = false) { + const root = await mkdtemp(resolve(tmpdir(), "sdlc-repair-test-")); + roots.push(root); + await mkdir(resolve(root, "apps/api"), { recursive: true }); + await mkdir(resolve(root, "apps/platform"), { recursive: true }); + await initializeProject({ root, projectName: "Repairs", applications: webOnly ? ["web"] : ["backend", "web"], backendRoot: "apps/api", webRoot: "apps/platform", dryRun: false }); + await put(root, ".sdlc/requests/repair.md", "Implement the API and portal.\n"); + await startRun(root, { id: runId, title: "Repair", requestFile: ".sdlc/requests/repair.md", affectedApplications: { backend: !webOnly, web: true, mobile: false, database: false, sharedPackages: false }, now: at }); + return { root, manifest: await loadRun(root, runId) }; +} +async function pureFixture() { + const { root, manifest } = await base(); + for (const task of manifest.tasks.filter((task) => task.id !== "PM-004")) finish(task); + manifest.run.status = "running"; + for (const gate of Object.values(manifest.quality_gates)) { + if (gate.status !== "not_applicable") gate.status = "passed"; + } + return { root, manifest }; +} + +describe("repair planning and lineage", () => { + test("resets only the selected implementation and transitive dependents while archiving history", async () => { + const { manifest } = await pureFixture(); + const before = structuredClone(manifest); + const result = planRepair(manifest, "WEB-001", options, repairAt); + expect(manifest).toEqual(before); + expect(result.record.affected_task_ids).toEqual(["WEB-001", "INT-001", "QC-001", "PM-004"]); + for (const task of result.manifest.tasks) { + const original = before.tasks.find((entry) => entry.id === task.id)!; + if (!result.record.affected_task_ids.includes(task.id)) expect(task).toEqual(original); + else { + expect(task.status).toBe(task.id === "WEB-001" ? "ready" : "pending"); + expect(task.outputs).toEqual([]); + expect(task.evidence).toEqual([]); + expect(task.agent_dispatches).toEqual([]); + expect(task.activation_offset).toBe(original.transitions.filter((entry) => entry.to === "ready").length); + } + } + expect(result.record.tasks).toEqual(before.tasks.filter((task) => result.record.affected_task_ids.includes(task.id))); + expect(result.manifest.quality_gates.web?.status).toBe("pending"); + expect(result.manifest.quality_gates.backend?.status).toBe(before.quality_gates.backend?.status); + expect(result.manifest.repair_history).toEqual([result.record]); + }); + + test.each([ + ["terminal run", (manifest: RunManifest) => { manifest.run.status = "completed"; }, /terminal/], + ["human decision", (manifest: RunManifest) => { manifest.product_owner_review = { status: "completed", decision: "accepted", comments: "Accepted", decided_at: at }; }, /human-reviewed/], + ["pending approval", (manifest: RunManifest) => { manifest.tasks.find((task) => task.id === "QC-001")!.status = "awaiting_approval"; }, /pending approvals/], + ["external blocker", (manifest: RunManifest) => { manifest.blockers.push({ id: "BLK-001", task_id: "QC-001", status: "open", description: "External review unavailable" }); }, /external blockers/], + ["unconsumed approval", (manifest: RunManifest) => { manifest.decisions = [{ id: "DEC-001", topic: "Resume", status: "approved", decision: "Approved", requested_by: "pm", approved_by: "product-owner", affected_tasks: ["WEB-001"], action: "transition:WEB-001:running", consumed_at: null, consumed_by_transition: null }]; }, /unconsumed affected approval/], + ] as const)("rejects %s", async (_name, change, pattern) => { + const { manifest } = await pureFixture(); + change(manifest); + expect(() => planRepair(manifest, "WEB-001", options, repairAt)).toThrow(pattern); + }); + + test("requires an existing completed implementation and valid actor, reason, defect, and time", async () => { + const { manifest } = await pureFixture(); + for (const taskId of ["MISSING-001", "BA-001", "PM-004"]) expect(() => planRepair(manifest, taskId, options, repairAt)).toThrow("completed implementation"); + expect(() => planRepair(manifest, "WEB-001", { ...options, actor: "frontend" }, repairAt)).toThrow(); + expect(() => planRepair(manifest, "WEB-001", { ...options, reason: " " }, repairAt)).toThrow(); + expect(() => planRepair(manifest, "WEB-001", { ...options, defectId: "invalid" }, repairAt)).toThrow(); + expect(() => planRepair(manifest, "WEB-001", options, "invalid")).toThrow(); + expect(() => planRepair(manifest, "WEB-001", options, "2020-01-01T00:00:00Z")).toThrow("predates"); + }); + + test("keeps repeated repair activations monotonic and rejects an archived dispatch", async () => { + const { root, manifest } = await pureFixture(); + const capabilities: AgentCapabilities = { source: "test", model_selection: true, reasoning_selection: true, omitted_reasoning_effort: "model-default", models: [{ id: "gpt-6-luna", reasoning_efforts: ["xhigh"] }] }; + manifest.agent_policy = { roles: { frontend: { model: "gpt-6-luna", reasoning_effort: "xhigh" } }, product_owner_review: "disabled" }; + const originalPlan = resolveAgentPlan(manifest, "WEB-001", capabilities); + const first = planRepair(manifest, "WEB-001", options, repairAt).manifest; + const task = first.tasks.find((entry) => entry.id === "WEB-001")!; + task.agent_dispatches = [{ plan: originalPlan, agent_id: "retired-agent", actual_model: "gpt-6-luna", actual_reasoning_effort: "xhigh", observation_source: "test", recorded_at: at }]; + expect(() => assertTaskAgentDispatch(first, task)).toThrow("requires a recorded agent dispatch"); + task.agent_dispatches = []; + expect(resolveAgentPlan(first, task.id, capabilities).activation).toBe(originalPlan.activation + 1); + finish(task); + const second = planRepair(first, task.id, { ...options, defectId: "DEF-002" }, "2026-09-25T02:00:00Z").manifest; + expect(second.repair_history?.map((entry) => entry.id)).toEqual(["RPR-001", "RPR-002"]); + expect(resolveAgentPlan(second, task.id, capabilities).activation).toBe(originalPlan.activation + 2); + expect(await repairHistoryDiagnostics(root, runId, second)).toEqual([]); + second.tasks.find((entry) => entry.id === task.id)!.activation_offset = 0; + expect((await repairHistoryDiagnostics(root, runId, second)).join("\n")).toContain("activation offset"); + }); +}); + +async function recoveryFixture() { + const { root } = await base(); + const manifestPath = `.sdlc/runs/${runId}/manifest.yaml`; + const manifestSource = await readFile(resolve(root, manifestPath), "utf8"); + const artifact = "artifacts/web/implementation-summary.md"; + const archive = `repairs/RPR-001/${artifact}`; + const original = "---\nrevision: 1\n---\nPrior implementation\n"; + await put(root, `.sdlc/runs/${runId}/${archive}`, original); + const beforeVersion = await readAuthorityVersion(root, runId); + const journal = { schema_version: 1, run_id: runId, repair_id: "RPR-001", before_version: beforeVersion, after_version: beforeVersion + 1, before_manifest: hash(manifestSource), after_manifest: hash("another manifest"), changes: [{ path: archive, before: null, after: encoded(original) }, { path: artifact, before: encoded(original), after: null }] }; + const journalPath = `.sdlc/runs/${runId}/.repair-transaction.json`; + await put(root, journalPath, JSON.stringify(journal)); + return { root, artifact, archive, original, journal, journalPath, manifestPath, manifestSource }; +} + +describe("interrupted repair recovery", () => { + test("safely rolls back partial file moves and is idempotent", async () => { + const fixture = await recoveryFixture(); + expect(await recoverRepair(fixture.root, runId, "pm")).toEqual({ recovered: true, repair_id: "RPR-001", outcome: "rolled_back" }); + expect(await readFile(resolve(fixture.root, `.sdlc/runs/${runId}/${fixture.artifact}`), "utf8")).toBe(fixture.original); + await expect(readFile(resolve(fixture.root, `.sdlc/runs/${runId}/${fixture.archive}`))).rejects.toThrow(); + expect(await readFile(resolve(fixture.root, fixture.manifestPath), "utf8")).toBe(fixture.manifestSource); + expect(await recoverRepair(fixture.root, runId, "pm")).toEqual({ recovered: false }); + }); + + test("refuses unauthorized actors, altered run bytes, changed files, and traversal journals", async () => { + const fixture = await recoveryFixture(); + await expect(recoverRepair(fixture.root, runId, "frontend")).rejects.toThrow("only pm"); + await put(fixture.root, fixture.manifestPath, fixture.manifestSource + "\n# independent edit\n"); + await expect(recoverRepair(fixture.root, runId, "pm")).rejects.toThrow("run changed"); + await put(fixture.root, fixture.manifestPath, fixture.manifestSource); + await put(fixture.root, `.sdlc/runs/${runId}/${fixture.artifact}`, "independent edit\n"); + await expect(recoverRepair(fixture.root, runId, "pm")).rejects.toThrow("file changed outside repair"); + fixture.journal.changes[0]!.path = "../../outside"; + await put(fixture.root, fixture.journalPath, JSON.stringify(fixture.journal)); + await expect(recoverRepair(fixture.root, runId, "pm")).rejects.toThrow("unsafe file change"); + expect(await readFile(resolve(fixture.root, `.sdlc/runs/${runId}/${fixture.artifact}`), "utf8")).toBe("independent edit\n"); + }); + + test("rejects a symlink escape even when the journal path is lexically valid", async () => { + const fixture = await recoveryFixture(); + const outside = await mkdtemp(resolve(tmpdir(), "sdlc-repair-outside-")); + roots.push(outside); + await put(outside, "original.md", fixture.original); + await mkdir(dirname(resolve(fixture.root, `.sdlc/runs/${runId}/${fixture.artifact}`)), { recursive: true }); + await symlink(resolve(outside, "original.md"), resolve(fixture.root, `.sdlc/runs/${runId}/${fixture.artifact}`)); + await expect(recoverRepair(fixture.root, runId, "pm")).rejects.toThrow("escapes repository root"); + expect(await readFile(resolve(outside, "original.md"), "utf8")).toBe(fixture.original); + }); +}); + +async function completedWebFixture() { + const { root, manifest } = await base(true); + const prefix = `.sdlc/runs/${runId}`; + const web = manifest.tasks.find((task) => task.id === "WEB-001")!; + for (const task of manifest.tasks.filter((entry) => ["PM-001", "BA-001", "PM-002", "WEB-001"].includes(entry.id))) finish(task); + synchronizeRunState(manifest); + const summary = "---\nrevision: 1\n---\nVerified implementation.\n"; + await put(root, `${prefix}/artifacts/pm/requirements-review.md`, "---\nrevision: 1\n---\nRequirements approved.\n"); + const facts = "schema_version: 1\nrun_id: REPAIR-001\nproducer: pm\nrevision: 1\nfacts:\n - id: FACT-001\n subject: feature.portal\n relation: equals\n value: true\n status: approved\n"; + await put(root, `${prefix}/facts.yaml`, facts); + const assignment: WebDeliveryAssignment = { + schema_version: 1, kind: "delivery_assignment", assignment_id: "ASN-001", run_id: runId, task_id: web.id, producer: "pm", + role: "frontend", target: "web", stage: "web_implementation", scenario: "full_task", revision: 1, facts_revision: 1, facts_sha256: hash(facts), task_status: "running", scaffold_status: "scaffolded", + dependencies: [{ task_id: "PM-002", status: "completed" }], + required_inputs: (await resolveRequiredInputs(root, runId, web)).map((input) => ({ ...input, status: input.exists ? "available" : "missing" })), + required_outputs: workflowOutputs(runId, web), allowed_write_roots: ["apps/platform", `${prefix}/artifacts/web`], available_evidence: [], evidence_requirements: [], + transition_policy: { current_status: "running", allowed_request: "awaiting_review", unmet_disposition: "refuse" }, + controls: { api_contract_status: "approved", gaps: [], questions: [], requirements: [{ requirement_id: "REQ-001", capability: "semantic_status", required: true, parameters: {}, source_references: [{ kind: "fact", reference: "FACT-001" }] }] }, + }; + const productPath = "apps/platform/app/(portal)/page.tsx"; + const summaryPath = `${prefix}/artifacts/web/implementation-summary.md`; + const report: WebDeliveryReport = { + schema_version: 1, kind: "delivery_report", assignment_id: assignment.assignment_id, assignment_revision: 1, run_id: runId, task_id: web.id, + producer: "frontend", role: "frontend", target: "web", stage: "web_implementation", scenario: "full_task", revision: 1, disposition: "proceed", + transition_request: { from: "running", to: "awaiting_review" }, + artifacts: [{ path: summaryPath, artifact_kind: "implementation_summary", producer: "frontend", status: "produced", revision: 1, sha256: hash(summary) }], + writes: [{ path: productPath, type: "source" }, { path: summaryPath, type: "artifact" }], evidence: [], + observations: { api_contract_status: "approved", gaps: [], questions: [], requirement_results: [{ requirement_id: "REQ-001", capability: "semantic_status", required: true, status: "implemented" }] }, + }; + await put(root, `${prefix}/tasks/WEB-001.assignment.yaml`, stringify(assignment)); + await put(root, `${prefix}/tasks/WEB-001.handoff.json`, JSON.stringify({ assignment_id: "ASN-001", revision: 1 })); + await put(root, `${prefix}/artifacts/web/WEB-001-delivery-report.yaml`, stringify(report)); + await put(root, summaryPath, summary); + await put(root, productPath, "export const status = 'ready';\n"); + await put(root, `${prefix}/evidence/diffs/changed-files.json`, JSON.stringify({ files: [productPath, summaryPath], ownership: [{ path: productPath, task_id: "WEB-001" }] })); + await put(root, `${prefix}/manifest.yaml`, stringify(manifest)); + expect(await validateRun(root, runId)).toEqual({ valid: true, diagnostics: [] }); + return { root, manifest, prefix, productPath, summaryPath }; +} + +describe("repository repair transactions", () => { + test("archives retired authority, clears changed-file ownership, validates, and reuses an open repair", async () => { + const fixture = await completedWebFixture(); + const oldManifest = await readFile(resolve(fixture.root, `${fixture.prefix}/manifest.yaml`), "utf8"); + const preview = await repairTask(fixture.root, runId, "WEB-001", { ...options, dryRun: true }); + expect(preview.dry_run).toBe(true); + expect(await readFile(resolve(fixture.root, `${fixture.prefix}/manifest.yaml`), "utf8")).toBe(oldManifest); + const result = await repairTask(fixture.root, runId, "WEB-001", options); + expect(result.repair.id).toBe("RPR-001"); + const repaired = await loadRun(fixture.root, runId); + expect(repaired.tasks.find((task) => task.id === "WEB-001")!.status).toBe("ready"); + expect(await validateRun(fixture.root, runId)).toEqual({ valid: true, diagnostics: [] }); + expect(result.repair.archives.map((entry) => entry.path)).toEqual(expect.arrayContaining(["artifacts/web/implementation-summary.md", "artifacts/web/WEB-001-delivery-report.yaml", "tasks/WEB-001.assignment.yaml", "tasks/WEB-001.handoff.json", "evidence/diffs/changed-files.json"])); + for (const archive of result.repair.archives) { + expect(hash(await readFile(resolve(fixture.root, `${fixture.prefix}/${archive.archive_path}`), "utf8"))).toBe(archive.sha256); + if (archive.path !== "evidence/diffs/changed-files.json") await expect(readFile(resolve(fixture.root, `${fixture.prefix}/${archive.path}`))).rejects.toThrow(); + } + expect(JSON.parse(await readFile(resolve(fixture.root, `${fixture.prefix}/evidence/diffs/changed-files.json`), "utf8"))).toEqual({ files: [], ownership: [] }); + expect(await readFile(resolve(fixture.root, fixture.productPath), "utf8")).toContain("ready"); + expect((await repairTask(fixture.root, runId, "WEB-001", options)).already_open).toBe(true); + expect((await loadRun(fixture.root, runId)).repair_history).toHaveLength(1); + }); + + test.each(["beforeReplace", "beforeRelease"] as const)("rolls back every moved artifact and run bytes when %s fails", async (hook) => { + const fixture = await completedWebFixture(); + const paths = ["manifest.yaml", "artifacts/web/implementation-summary.md", "artifacts/web/WEB-001-delivery-report.yaml", "tasks/WEB-001.assignment.yaml", "tasks/WEB-001.handoff.json", "evidence/diffs/changed-files.json"]; + const before = await Promise.all(paths.map((path) => readFile(resolve(fixture.root, `${fixture.prefix}/${path}`), "utf8"))); + await expect(repairTask(fixture.root, runId, "WEB-001", options, { [hook]: () => { throw new Error("injected transaction failure"); } })).rejects.toThrow("injected transaction failure"); + expect(await Promise.all(paths.map((path) => readFile(resolve(fixture.root, `${fixture.prefix}/${path}`), "utf8")))).toEqual(before); + await expect(readFile(resolve(fixture.root, `${fixture.prefix}/.repair-transaction.json`))).rejects.toThrow(); + await expect(readFile(resolve(fixture.root, `${fixture.prefix}/repairs/RPR-001/artifacts/web/implementation-summary.md`))).rejects.toThrow(); + expect(await validateRun(fixture.root, runId)).toEqual({ valid: true, diagnostics: [] }); + }); + + test("rejects unsafe run IDs and missing runs without creating a repair journal", async () => { + const { root } = await base(); + await expect(repairTask(root, "../escape", "WEB-001", options)).rejects.toThrow("invalid repair path"); + await expect(repairTask(root, "MISSING-001", "WEB-001", options)).rejects.toThrow(); + await expect(readFile(resolve(root, `.sdlc/runs/${runId}/.repair-transaction.json`))).rejects.toThrow(); + }); + + test("old command evidence cannot be reused after a repair", async () => { + const { root, manifest } = await pureFixture(); + const repaired = planRepair(manifest, "WEB-001", options, repairAt).manifest; + const reference = "evidence/commands/EVD-0000000000000000/evidence.json"; + const evidence = { schema_version: 1, id: "EVD-0000000000000000", run_id: runId, task_id: "WEB-001", command_id: "sdlc_test", executable: "node", args: [], cwd: "apps/platform", started_at: at, completed_at: at, exit_code: 0, result_status: "passed", evidence_path: reference, stdout_path: reference.replace("evidence.json", "stdout.txt"), stderr_path: reference.replace("evidence.json", "stderr.txt") }; + await put(root, `.sdlc/runs/${runId}/${reference}`, JSON.stringify(evidence)); + await expect(readEvidenceReference({ root, runId, manifest: repaired, reference, project: await loadProject(root), owner: "frontend" })).rejects.toThrow("predates the current repair cycle"); + }); +}); + +describe("repair authority boundaries", () => { + test("concurrent repair requests cannot create overlapping repair cycles", async () => { + const fixture = await completedWebFixture(); + const attempts = await Promise.allSettled([ + repairTask(fixture.root, runId, "WEB-001", options), + repairTask(fixture.root, runId, "WEB-001", options), + ]); + expect(attempts.some((attempt) => attempt.status === "fulfilled")).toBe(true); + const created = attempts.filter((attempt) => attempt.status === "fulfilled" && !attempt.value.already_open); + expect(created).toHaveLength(1); + expect((await loadRun(fixture.root, runId)).repair_history).toHaveLength(1); + expect(await validateRun(fixture.root, runId)).toEqual({ valid: true, diagnostics: [] }); + await expect(readFile(resolve(fixture.root, `${fixture.prefix}/.repair-transaction.json`))).rejects.toThrow(); + }); + + test("legacy changed-file documents without ownership remain repairable", async () => { + const fixture = await completedWebFixture(); + const reportPath = `${fixture.prefix}/artifacts/web/WEB-001-delivery-report.yaml`; + const { parse } = await import("yaml"); + const report = parse(await readFile(resolve(fixture.root, reportPath), "utf8")) as WebDeliveryReport; + report.writes = report.writes.filter((write) => write.type === "artifact"); + await put(fixture.root, reportPath, stringify(report)); + await put(fixture.root, `${fixture.prefix}/evidence/diffs/changed-files.json`, JSON.stringify({ files: [fixture.summaryPath] })); + expect(await validateRun(fixture.root, runId)).toEqual({ valid: true, diagnostics: [] }); + await repairTask(fixture.root, runId, "WEB-001", options); + expect(JSON.parse(await readFile(resolve(fixture.root, `${fixture.prefix}/evidence/diffs/changed-files.json`), "utf8"))).toEqual({ files: [], ownership: [] }); + expect(await validateRun(fixture.root, runId)).toEqual({ valid: true, diagnostics: [] }); + }); + + test("archive tampering invalidates the current run without erasing its history", async () => { + const fixture = await completedWebFixture(); + const result = await repairTask(fixture.root, runId, "WEB-001", options); + const archive = result.repair.archives.find((entry) => entry.path === "artifacts/web/implementation-summary.md")!; + await put(fixture.root, `${fixture.prefix}/${archive.archive_path}`, "tampered history\n"); + const validation = await validateRun(fixture.root, runId); + expect(validation.valid).toBe(false); + expect(validation.diagnostics.join("\n")).toContain("archive integrity changed"); + }); + + test("an archived assignment cannot be restored as current execution authority", async () => { + const fixture = await completedWebFixture(); + const result = await repairTask(fixture.root, runId, "WEB-001", options); + const archive = result.repair.archives.find((entry) => entry.path === "tasks/WEB-001.assignment.yaml")!; + await put(fixture.root, `${fixture.prefix}/${archive.path}`, await readFile(resolve(fixture.root, `${fixture.prefix}/${archive.archive_path}`), "utf8")); + const validation = await validateRun(fixture.root, runId); + expect(validation.valid).toBe(false); + expect(validation.diagnostics.join("\n")).toMatch(/repair|retired|revision|activation/); + }); +}); + +describe("repair crash boundaries", () => { + test("finishes journal cleanup after the repaired manifest was already committed", async () => { + const fixture = await completedWebFixture(); + const journalPath = `${fixture.prefix}/.repair-transaction.json`; + let journal = ""; + await repairTask(fixture.root, runId, "WEB-001", options, { beforeReplace: async () => { journal = await readFile(resolve(fixture.root, journalPath), "utf8"); } }); + expect(journal).not.toBe(""); + // Simulate interruption after successful manifest replacement but before journal removal. + await put(fixture.root, journalPath, journal); + expect(await recoverRepair(fixture.root, runId, "pm")).toEqual({ recovered: true, repair_id: "RPR-001", outcome: "committed" }); + expect(await validateRun(fixture.root, runId)).toEqual({ valid: true, diagnostics: [] }); + expect((await loadRun(fixture.root, runId)).repair_history).toHaveLength(1); + }); + + test("missing required current output is rejected before a repair moves files", async () => { + const fixture = await completedWebFixture(); + const before = await readFile(resolve(fixture.root, `${fixture.prefix}/manifest.yaml`), "utf8"); + await rm(resolve(fixture.root, fixture.summaryPath)); + await expect(repairTask(fixture.root, runId, "WEB-001", options)).rejects.toThrow(); + expect(await readFile(resolve(fixture.root, `${fixture.prefix}/manifest.yaml`), "utf8")).toBe(before); + await expect(readFile(resolve(fixture.root, `${fixture.prefix}/.repair-transaction.json`))).rejects.toThrow(); + expect(await readFile(resolve(fixture.root, `${fixture.prefix}/tasks/WEB-001.assignment.yaml`), "utf8")).toContain("ASN-001"); + }); +}); + + +test("known retired evidence is rejected even when the repair shares its timestamp", async () => { + const { root, manifest } = await pureFixture(); + const reference = "evidence/commands/EVD-0000000000000000/evidence.json"; + manifest.tasks.find((task) => task.id === "WEB-001")!.evidence = [reference]; + const repaired = planRepair(manifest, "WEB-001", options, at).manifest; + const project = await loadProject(root); + const command = await canonicalizeCommandDeclaration(root, project.commands.sdlc_test, [], project); + const evidence = { + schema_version: 1, id: "EVD-0000000000000000", run_id: runId, task_id: "WEB-001", command_id: "sdlc_test", ...command.provenance, + started_at: at, completed_at: at, exit_code: 0, result_status: "passed", evidence_path: reference, + stdout_path: reference.replace("evidence.json", "stdout.txt"), stderr_path: reference.replace("evidence.json", "stderr.txt"), + }; + await put(root, `.sdlc/runs/${runId}/${reference}`, JSON.stringify(evidence)); + await put(root, `.sdlc/runs/${runId}/${evidence.stdout_path}`, ""); + await put(root, `.sdlc/runs/${runId}/${evidence.stderr_path}`, ""); + // The document is valid command evidence for the original cycle. + expect((await readEvidenceReference({ root, runId, manifest, reference, project, owner: "frontend" })).record.id).toBe(evidence.id); + await expect(readEvidenceReference({ root, runId, manifest: repaired, reference, project, owner: "frontend" })).rejects.toThrow(/repair|retired/); +}); + + +describe("review rework repair", () => { + test("archives an awaiting-review implementation and starts a fresh validated execution cycle", async () => { + const fixture = await completedWebFixture(); + const web = fixture.manifest.tasks.find((task) => task.id === "WEB-001")!; + web.status = "awaiting_review"; + web.completed_at = null; + web.transitions.pop(); + const integration = fixture.manifest.tasks.find((task) => task.id === "INT-001")!; + integration.status = "pending"; + integration.transitions = []; + synchronizeRunState(fixture.manifest); + await put(fixture.root, `${fixture.prefix}/manifest.yaml`, stringify(fixture.manifest)); + expect(await validateRun(fixture.root, runId)).toEqual({ valid: true, diagnostics: [] }); + const result = await repairTask(fixture.root, runId, web.id, options); + expect(result.repair.tasks.find((task) => task.id === web.id)!.status).toBe("awaiting_review"); + const repaired = await loadRun(fixture.root, runId); + expect(repaired.tasks.find((task) => task.id === web.id)!.status).toBe("ready"); + expect(repaired.tasks.find((task) => task.id === web.id)!.activation_offset).toBe(1); + expect(await validateRun(fixture.root, runId)).toEqual({ valid: true, diagnostics: [] }); + await expect(readFile(resolve(fixture.root, `${fixture.prefix}/tasks/WEB-001.assignment.yaml`))).rejects.toThrow(); + expect(result.repair.archives.some((entry) => entry.path === "artifacts/web/WEB-001-delivery-report.yaml")).toBe(true); + }); + + test("does not turn running implementation into a repair cycle", async () => { + const { manifest } = await pureFixture(); + manifest.tasks.find((task) => task.id === "WEB-001")!.status = "running"; + expect(() => planRepair(manifest, "WEB-001", options, repairAt)).toThrow("completed implementation task or an implementation awaiting review"); + }); +}); + +test("a second review rejection of the same defect opens a new repair after re-handoff", async () => { + const fixture = await completedWebFixture(); + const { parse } = await import("yaml"); + const firstWeb = fixture.manifest.tasks.find((task) => task.id === "WEB-001")!; + firstWeb.status = "awaiting_review"; + firstWeb.completed_at = null; + firstWeb.transitions.pop(); + const integration = fixture.manifest.tasks.find((task) => task.id === "INT-001")!; + integration.status = "pending"; + integration.transitions = []; + synchronizeRunState(fixture.manifest); + await put(fixture.root, `${fixture.prefix}/manifest.yaml`, stringify(fixture.manifest)); + const first = await repairTask(fixture.root, runId, "WEB-001", options); + + // Recreate a fully validated subsequent handoff, with fresh assignment/report revisions. + for (const archive of first.repair.archives) { + let source = await readFile(resolve(fixture.root, `${fixture.prefix}/${archive.archive_path}`), "utf8"); + if (archive.path === "tasks/WEB-001.assignment.yaml") { + const assignment = parse(source) as WebDeliveryAssignment; + assignment.revision += 1; + source = stringify(assignment); + } else if (archive.path === "artifacts/web/WEB-001-delivery-report.yaml") { + const report = parse(source) as WebDeliveryReport; + report.revision += 1; + report.assignment_revision += 1; + source = stringify(report); + } + await put(fixture.root, `${fixture.prefix}/${archive.path}`, source); + } + const handedOff = await loadRun(fixture.root, runId); + const web = handedOff.tasks.find((task) => task.id === "WEB-001")!; + web.status = "awaiting_review"; + web.started_at = "2026-09-25T01:01:00.000Z"; + web.outputs = [...web.required_outputs]; + web.transitions.push( + { from: "ready", to: "running", actor: "pm", reason: "Execute first repair", at: web.started_at }, + { from: "running", to: "awaiting_review", actor: "frontend", reason: "Review corrected behavior", at: "2026-09-25T01:02:00.000Z" }, + ); + handedOff.run.updated_at = "2026-09-25T01:02:00.000Z"; + synchronizeRunState(handedOff); + await put(fixture.root, `${fixture.prefix}/manifest.yaml`, stringify(handedOff)); + expect(await validateRun(fixture.root, runId)).toEqual({ valid: true, diagnostics: [] }); + + const second = await repairTask(fixture.root, runId, "WEB-001", { ...options, now: "2026-09-25T01:03:00.000Z" }); + expect(second.already_open).toBe(false); + expect(second.repair.id).toBe("RPR-002"); + expect(second.repair.defect_id).toBe(first.repair.defect_id); + expect(second.repair.tasks.find((task) => task.id === "WEB-001")!.status).toBe("awaiting_review"); + const repaired = await loadRun(fixture.root, runId); + expect(repaired.repair_history).toHaveLength(2); + expect(repaired.tasks.find((task) => task.id === "WEB-001")!.activation_offset).toBe(2); + expect(await validateRun(fixture.root, runId)).toEqual({ valid: true, diagnostics: [] }); +}); diff --git a/tests/run-timing.test.ts b/tests/run-timing.test.ts new file mode 100644 index 0000000..ff0f5ca --- /dev/null +++ b/tests/run-timing.test.ts @@ -0,0 +1,131 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { parse, stringify } from "yaml"; +import { initializeProject } from "../src/install.js"; +import { startRun } from "../src/runs.js"; +import { getRunTiming, summarizeRunTiming } from "../src/run-timing.js"; +import type { EvidenceRecord, RunManifest, Task, TaskStatus } from "../src/types.js"; +const epoch = Date.parse("2026-09-25T00:00:00.000Z"); +const at = (seconds: number) => new Date(epoch + seconds * 1000).toISOString(); +const roots: string[] = []; +afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "sdlc-timing-")); roots.push(root); + await mkdir(join(root, "web")); + await initializeProject({ root, projectName: "Timing", applications: ["web"], webRoot: "web", dryRun: false }); + await writeFile(join(root, ".sdlc/requests/request.md"), "Timing fixture"); + await startRun(root, { id: "TIME-001", title: "Timing", requestFile: ".sdlc/requests/request.md", affectedApplications: { backend: false, web: true, mobile: false, database: false, sharedPackages: false }, now: at(0) }); + const manifest = parse(await readFile(join(root, ".sdlc/runs/TIME-001/manifest.yaml"), "utf8")) as RunManifest; + return { root, manifest }; +} +function timeline(base: Task, states: Array<[TaskStatus, number]>): Task { + return { ...base, status: states.at(-1)?.[0] ?? "pending", transitions: states.map(([to, seconds], index) => ({ from: states[index - 1]?.[0] ?? null, to, at: at(seconds), actor: "test", reason: "fixture" })) }; +} +function evidence(): EvidenceRecord { + return { schema_version: 1, id: "EVD-0000000000000000", run_id: "TIME-001", task_id: "PM-001", command_id: "sdlc_test", executable: "node", args: [], cwd: ".", started_at: at(2), completed_at: at(5), exit_code: 0, result_status: "passed", evidence_path: "evidence/commands/EVD-0000000000000000/evidence.json", stdout_path: "evidence/commands/EVD-0000000000000000/stdout.txt", stderr_path: "evidence/commands/EVD-0000000000000000/stderr.txt" }; +} +describe("durable run timing", () => { + test("new run has zero elapsed time and no inferred command or LLM duration", async () => { + const { manifest } = await fixture(); + const report = summarizeRunTiming(manifest, { now: at(0) }); + expect(report.wall_ms).toBe(0); expect(report.collector_command_ms).toBe(0); + expect(report.tasks.flatMap((task) => task.intervals).every((interval) => interval.duration_ms === 0)).toBe(true); + }); + test("separates ready, running, review, approval, blocked, and resumed running intervals", async () => { + const { manifest } = await fixture(); + manifest.tasks = [timeline(manifest.tasks[0]!, [["ready", 0], ["running", 2], ["awaiting_review", 5], ["awaiting_approval", 9], ["blocked", 14], ["running", 20]])]; + const report = summarizeRunTiming(manifest, { now: at(25) }); + expect(report.task_status_ms).toEqual({ ready: 2000, running: 8000, awaiting_review: 4000, awaiting_approval: 5000, blocked: 6000 }); + expect(report.tasks[0]!.intervals.at(-1)?.ongoing).toBe(true); + expect(report.wall_ms).toBe(25000); + }); + test("counts historical failed waiting time before retry and resumed execution", async () => { + const { manifest } = await fixture(); + manifest.tasks = [timeline(manifest.tasks[0]!, [["ready", 0], ["running", 2], ["failed", 5], ["ready", 11], ["running", 13]])]; + const report = summarizeRunTiming(manifest, { now: at(20) }); + expect(report.task_status_ms).toEqual({ ready: 4000, running: 10000, failed: 6000 }); + expect(report.tasks[0]!.intervals.find((interval) => interval.status === "failed")?.ongoing).toBe(false); + expect(report.wall_ms).toBe(20000); + }); + test("recoverable failed run and task continue accumulating time while final result is pending", async () => { + const { manifest } = await fixture(); + manifest.tasks = [timeline(manifest.tasks[0]!, [["running", 0], ["failed", 5]])]; + manifest.run.status = "failed"; manifest.run.updated_at = at(5); + manifest.final_result = { status: "pending", completed_at: null, report: "final-report.md" }; + const report = summarizeRunTiming(manifest, { now: at(20) }); + expect(report.wall_ms).toBe(20000); + expect(report.task_status_ms).toEqual({ running: 5000, failed: 15000 }); + expect(report.tasks[0]!.intervals.at(-1)?.ongoing).toBe(true); + }); + test.each(["final-result", "human-decision"])("terminal rejected delivery freezes at recorded %s time", async (authority) => { + const { manifest } = await fixture(); + manifest.tasks = [timeline(manifest.tasks[0]!, [["running", 0], ["failed", 5]])]; + manifest.run.status = "failed"; manifest.run.updated_at = at(18); + manifest.final_result = { status: authority === "final-result" ? "failed" : "pending", completed_at: authority === "final-result" ? at(15) : null, report: "final-report.md" }; + if (authority === "human-decision") manifest.product_owner_review = { status: "completed", decision: "rejected", comments: "Rejected delivery", decided_at: at(15) }; + const report = summarizeRunTiming(manifest, { now: at(100) }); + expect(report.wall_ms).toBe(15000); + expect(report.task_status_ms).toEqual({ running: 5000, failed: 10000 }); + expect(report.tasks[0]!.intervals.at(-1)?.ongoing).toBe(false); + }); + test("deferred human review keeps the delivery clock open", async () => { + const { manifest } = await fixture(); + manifest.run.status = "product_owner_review"; manifest.run.updated_at = at(5); + manifest.final_result = { status: "ready", completed_at: null, report: "final-report.md" }; + manifest.product_owner_review = { status: "ready", decision: "deferred", comments: "Review later", decided_at: at(5) }; + expect(summarizeRunTiming(manifest, { now: at(20) }).wall_ms).toBe(20000); + }); + test("overlapping tasks and repeated command references do not inflate wall time or collector totals", async () => { + const { manifest } = await fixture(); + manifest.tasks = manifest.tasks.slice(0, 2).map((task) => timeline(task, [["running", 0], ["completed", 10]])); + manifest.run.status = "completed"; manifest.run.updated_at = at(10); + const record = evidence(); + const report = summarizeRunTiming(manifest, { now: at(100), evidence: [record, record] }); + expect(report.wall_ms).toBe(10000); expect(report.task_status_ms.running).toBe(20000); expect(report.collector_command_ms).toBe(3000); + }); + test("archived repair cycles end at repair time while current blocked cycle continues", async () => { + const { manifest } = await fixture(); + const archived = timeline(manifest.tasks[0]!, [["running", 0], ["awaiting_review", 4]]); + manifest.tasks = [timeline(manifest.tasks[0]!, [["ready", 10], ["running", 12], ["blocked", 15]])]; + const report = summarizeRunTiming(manifest, { now: at(20), archivedCycles: [{ id: "REPAIR-001", tasks: [archived], ended_at: at(10) }] }); + expect(report.wall_ms).toBe(20000); expect(report.task_status_ms).toEqual({ running: 7000, awaiting_review: 6000, ready: 2000, blocked: 5000 }); + expect(report.tasks[0]!.intervals.at(-1)?.ongoing).toBe(false); + }); + test("invalid/missing timestamps are explicit and never yield negative or NaN durations", async () => { + const { manifest } = await fixture(); + manifest.run.created_at = null; + manifest.tasks = [timeline(manifest.tasks[0]!, [["running", 10], ["blocked", 5]])]; + const record = evidence(); record.completed_at = "invalid"; + const report = summarizeRunTiming(manifest, { now: at(20), evidence: [record] }); + expect(report.wall_ms).toBeNull(); expect(report.task_status_ms).toEqual({}); expect(report.collector_command_ms).toBe(0); expect(report.diagnostics).toHaveLength(3); + expect(() => summarizeRunTiming(manifest, { now: "invalid" })).toThrow("as-of"); + }); + test("reader counts only referenced valid collector metadata and conceals malformed content", async () => { + const { root, manifest } = await fixture(); + const record = evidence(); + const path = join(root, ".sdlc/runs/TIME-001", record.evidence_path); + await mkdir(join(path, ".."), { recursive: true }); + await writeFile(path, JSON.stringify(record)); + manifest.tasks[0]!.evidence = [record.evidence_path, record.stdout_path]; + await writeFile(join(root, ".sdlc/runs/TIME-001/manifest.yaml"), stringify(manifest)); + const valid = await getRunTiming(root, "TIME-001", { now: at(10) }); + expect(valid.collector_command_ms).toBe(3000); + await writeFile(path, "private-secret-broken-json"); + const invalid = await getRunTiming(root, "TIME-001", { now: at(10) }); + expect(invalid.collector_command_ms).toBe(0); + expect(invalid.diagnostics).toHaveLength(1); + expect(JSON.stringify(invalid)).not.toContain("private-secret"); + }); + test("reader handles legacy manifests and ignores raw output logs and unreferenced metadata", async () => { + const { root } = await fixture(); + const path = join(root, ".sdlc/runs/TIME-001/evidence/commands/EVD-0000000000000000"); await mkdir(path, { recursive: true }); + await writeFile(join(path, "evidence.json"), JSON.stringify(evidence())); + await writeFile(join(path, "stdout.txt"), "private output never read"); + const report = await getRunTiming(root, "TIME-001", { now: at(10) }); + expect(report.collector_command_ms).toBe(0); + expect(JSON.stringify(report)).not.toContain("private output"); + expect(report.wall_ms).toBe(10000); + }); +}); diff --git a/tests/task-execution.test.ts b/tests/task-execution.test.ts new file mode 100644 index 0000000..f58dcc7 --- /dev/null +++ b/tests/task-execution.test.ts @@ -0,0 +1,129 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { parse, stringify } from "yaml"; +import { planAgent, type AgentCapabilities, type AgentDispatchInput } from "../src/agents.js"; +import { validateCliArguments } from "../src/cli-grammar.js"; +import { initializeProject } from "../src/install.js"; +import { loadRun, startRun } from "../src/runs.js"; +import { activateTask } from "../src/task-activation.js"; +import { collectTaskChecks } from "../src/task-checks.js"; +import { prepareTask } from "../src/task-operations.js"; +import type { ProjectConfig } from "../src/types.js"; +import { createDeliveryFixture } from "./helpers/delivery-fixture.js"; + +const roots: string[] = []; +const capabilities: AgentCapabilities = { source: "independent fixture host response", model_selection: true, reasoning_selection: true, models: [{ id: "gpt-6-luna", reasoning_efforts: ["xhigh"] }] }; +afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); +async function dispatch(root: string, runId: string, taskId: string): Promise<AgentDispatchInput> { + const plan = await planAgent(root, runId, taskId, capabilities); + return { plan, agent_id: "execution-fixture-agent", actual_model: plan.selected?.model ?? null, actual_reasoning_effort: plan.selected?.reasoning_effort ?? null, observation_source: "independent fixture host response" }; +} +async function intake() { + const root = await mkdtemp(resolve(tmpdir(), "sdlc-execution-")); roots.push(root); + await mkdir(resolve(root, "web")); + await initializeProject({ root, projectName: "Execution", applications: ["web"], webRoot: "web", agents: { roles: { pm: { model: "gpt-6-luna", reasoning_effort: "xhigh" } }, product_owner_review: "disabled" }, dryRun: false }); + await writeFile(resolve(root, ".sdlc/requests/request.md"), "Verify execution helpers"); + const runId = "EXECUTION-001", taskId = "PM-001"; + await startRun(root, { id: runId, title: "Execution helpers", requestFile: ".sdlc/requests/request.md", affectedApplications: { backend: false, web: true, mobile: false, database: false, sharedPackages: false }, now: new Date().toISOString() }); + return { root, runId, taskId }; +} +async function delivery() { const result = await createDeliveryFixture(); roots.push(result.root); return result; } +async function configureChecks(root: string, cwd: string, failed?: string) { + const path = resolve(root, ".sdlc/project.yaml"); + const project = parse(await readFile(path, "utf8")) as ProjectConfig; + for (const id of ["sdlc_test", "sdlc_typecheck", "web_build", "web_test"]) project.commands[id] = { executable: process.execPath, args: ["-e", `require('node:fs').appendFileSync('order.txt','${id}\\n');process.exit(${id === failed ? 3 : 0})`], cwd, network: "disabled", mutates: true }; + await writeFile(path, stringify(project)); + return project.security.network_policy_attestation_environment; +} +async function checks(root: string, runId: string, taskId: string, commands?: string[]) { + const variable = (parse(await readFile(resolve(root, ".sdlc/project.yaml"), "utf8")) as ProjectConfig).security.network_policy_attestation_environment; + const previous = process.env[variable]; process.env[variable] = "disabled"; + try { return await collectTaskChecks(root, runId, taskId, commands); } + finally { if (previous === undefined) delete process.env[variable]; else process.env[variable] = previous; } +} +async function runningDelivery(failed?: string) { + const value = await delivery(); await configureChecks(value.root, value.appRoot, failed); + await prepareTask(value.root, value.runId, value.taskId, { ...value.input, commandIds: ["sdlc_test", "sdlc_typecheck", "web_build"] }); + await activateTask(value.root, value.runId, value.taskId, await dispatch(value.root, value.runId, value.taskId), "Start declared delivery checks"); + return value; +} +async function manifestBytes(value: {root:string;runId:string}) { return readFile(resolve(value.root, `.sdlc/runs/${value.runId}/manifest.yaml`), "utf8"); } + +describe("audited activation", () => { + test("records and starts once, then retry leaves dispatch and transitions unchanged", async () => { + const value = await intake(), input = await dispatch(value.root, value.runId, value.taskId); + expect(await activateTask(value.root, value.runId, value.taskId, input, "Start requested intake")).toMatchObject({ status: "running", already_active: false }); + const before = await manifestBytes(value); + expect(await activateTask(value.root, value.runId, value.taskId, input, "Retry interrupted client")).toMatchObject({ status: "running", already_active: true }); + expect(await manifestBytes(value)).toBe(before); + const task = (await loadRun(value.root, value.runId)).tasks.find((task) => task.id === value.taskId)!; + expect(task.agent_dispatches).toHaveLength(1); + expect(task.transitions.filter((transition) => transition.to === "running")).toHaveLength(1); + await expect(activateTask(value.root, value.runId, value.taskId, { ...input, agent_id: "different-agent" }, "Conflicting launch")).rejects.toThrow("ready task"); + }); + test("rejects wrong task/run, stale plans, empty host IDs, and blank reasons before mutation", async () => { + const value = await intake(), input = await dispatch(value.root, value.runId, value.taskId), before = await manifestBytes(value); + for (const changed of [ + { ...input, agent_id: "" }, + { ...input, plan: { ...input.plan, task_id: "BA-001" } }, + { ...input, plan: { ...input.plan, run_id: "OTHER-001" } }, + { ...input, plan: { ...input.plan, task_revision: input.plan.task_revision + 1 } }, + { ...input, actual_model: "gpt-6-astra" }, + ]) { + await expect(activateTask(value.root, value.runId, value.taskId, changed, "Invalid activation")).rejects.toThrow(); + expect(await manifestBytes(value)).toBe(before); + } + await expect(activateTask(value.root, value.runId, value.taskId, input, " ")).rejects.toThrow("reason"); + await expect(activateTask(value.root, value.runId, "UNKNOWN-001", input, "Invalid task")).rejects.toThrow("unknown task"); + }); + test("failed start cannot leave task running and retry works after prerequisites are prepared", async () => { + const value = await delivery(), input = await dispatch(value.root, value.runId, value.taskId); + await expect(activateTask(value.root, value.runId, value.taskId, input, "Attempt unprepared task")).rejects.toThrow(); + expect((await loadRun(value.root, value.runId)).tasks.find((task) => task.id === value.taskId)?.status).toBe("ready"); + await prepareTask(value.root, value.runId, value.taskId, value.input); + const fresh = await dispatch(value.root, value.runId, value.taskId); + expect(await activateTask(value.root, value.runId, value.taskId, fresh, "Prepared task may start")).toMatchObject({ status: "running", already_active: false }); + }); +}); + +describe("declared check collection", () => { + test("runs required delivery checks in assignment order without completing or approving the task", async () => { + const value = await runningDelivery(); + const before = await loadRun(value.root, value.runId); + const result = await checks(value.root, value.runId, value.taskId); + expect(result.passed).toBe(true); expect(result.evidence.map((record) => record.command_id)).toEqual(["sdlc_test", "sdlc_typecheck", "web_build"]); + expect(await readFile(resolve(value.root, value.appRoot, "order.txt"), "utf8")).toBe("sdlc_test\nsdlc_typecheck\nweb_build\n"); + const after = await loadRun(value.root, value.runId); + expect(after.tasks.find((task) => task.id === value.taskId)?.status).toBe("running"); + expect(after.quality_gates).toEqual(before.quality_gates); + }); + test("stops immediately on failure and reports unexecuted remaining commands", async () => { + const value = await runningDelivery("sdlc_typecheck"); + const result = await checks(value.root, value.runId, value.taskId); + expect(result.passed).toBe(false); expect(result.remaining_commands).toEqual(["web_build"]); + expect(result.evidence.map((record) => record.result_status)).toEqual(["passed", "failed"]); + expect(await readFile(resolve(value.root, value.appRoot, "order.txt"), "utf8")).toBe("sdlc_test\nsdlc_typecheck\n"); + }); + test("rejects an available but undeclared override and duplicate command IDs before execution", async () => { + const value = await runningDelivery(), before = await manifestBytes(value); + for (const commandIds of [["web_test"], ["sdlc_test", "sdlc_test"]]) await expect(checks(value.root, value.runId, value.taskId, commandIds)).rejects.toThrow(); + expect(await manifestBytes(value)).toBe(before); + await expect(readFile(resolve(value.root, value.appRoot, "order.txt"))).rejects.toThrow(); + }); + test("non-delivery tasks require explicit configured commands and a running state", async () => { + const value = await intake(); await configureChecks(value.root, "web"); + await expect(checks(value.root, value.runId, value.taskId, ["sdlc_test"])).rejects.toThrow("running"); + await activateTask(value.root, value.runId, value.taskId, await dispatch(value.root, value.runId, value.taskId), "Start intake checks"); + await expect(checks(value.root, value.runId, value.taskId)).rejects.toThrow("at least one"); + await expect(checks(value.root, value.runId, value.taskId, ["undeclared"])).rejects.toThrow("unknown configured command"); + expect((await checks(value.root, value.runId, value.taskId, ["web_build", "sdlc_test"])).evidence.map((record) => record.command_id)).toEqual(["web_build", "sdlc_test"]); + }); + test("CLI grammar accepts execution commands and rejects invented automatic approval flags", () => { + expect(validateCliArguments(["activate-task", "EXECUTION-001", "PM-001", "--reason", "Start intake", "--json"])).toEqual([]); + expect(validateCliArguments(["check-task", "EXECUTION-001", "PM-001", "--command", "sdlc_test", "--command", "sdlc_typecheck", "--json"])).toEqual([]); + expect(validateCliArguments(["activate-task", "EXECUTION-001", "PM-001"])).not.toEqual([]); + expect(validateCliArguments(["check-task", "EXECUTION-001", "PM-001", "--approve"])).not.toEqual([]); + }); +}); diff --git a/tests/task-operations.test.ts b/tests/task-operations.test.ts new file mode 100644 index 0000000..43d675d --- /dev/null +++ b/tests/task-operations.test.ts @@ -0,0 +1,213 @@ +import { readFile, rm, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { parse, stringify } from "yaml"; + +import { mutateRunManifest, readAuthorityVersion } from "../src/manifest-transaction.js"; +import { recordQualityGate } from "../src/lifecycle.js"; +import { repairTask } from "../src/repairs.js"; +import { loadRun } from "../src/runs.js"; +import { handoffTask, prepareTask } from "../src/task-operations.js"; +import * as transitions from "../src/transitions.js"; +import { activateAndHandoffFixture, activateFixture, collectFixtureEvidence, createDeliveryFixture, writeFixtureOutputs, type DeliveryFixture } from "./helpers/delivery-fixture.js"; + +const roots: string[] = []; +afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); +async function fixture(options: Parameters<typeof createDeliveryFixture>[0] = {}) { const result = await createDeliveryFixture(options); roots.push(result.root); return result; } +async function runningFixture() { const value = await fixture(); await prepareTask(value.root, value.runId, value.taskId, value.input); await activateFixture(value); await writeFixtureOutputs(value); return value; } +async function runBytes(value: DeliveryFixture) { return readFile(resolve(value.root, `.sdlc/runs/${value.runId}/manifest.yaml`), "utf8"); } + +// These integration cases create Git repositories and execute delivery cycles under CI filesystem load. +describe("automatic task preparation and handoff", { timeout: 30_000 }, () => { + test("prepares a compact authoritative packet idempotently without activating or changing dry-run files", async () => { + const value = await fixture(); + const before = await runBytes(value); + const version = await readAuthorityVersion(value.root, value.runId); + const preview = await prepareTask(value.root, value.runId, value.taskId, value.input, { dryRun: true }); + expect(preview.taskStatus).toBe("ready"); + expect(preview.executionAuthorized).toBe(false); + expect(preview.assignment.allowed_write_roots).toEqual(expect.arrayContaining([value.appRoot, `.sdlc/runs/${value.runId}/artifacts/web`])); + expect(preview.assignment.allowed_write_roots).not.toContain("."); + expect(preview.assignment.facts_sha256).toMatch(/^[a-f0-9]{64}$/); + expect(preview.assignment.required_inputs.every((entry) => entry.exists && entry.sha256)).toBe(true); + await expect(readFile(resolve(value.root, preview.paths.assignment))).rejects.toThrow(); + expect(await runBytes(value)).toBe(before); + expect(await readAuthorityVersion(value.root, value.runId)).toBe(version); + const first = await prepareTask(value.root, value.runId, value.taskId, value.input); + expect(first.assignment.revision).toBe(1); + const repeated = await prepareTask(value.root, value.runId, value.taskId, value.input); + expect(repeated.authorityVersion).toBe(first.authorityVersion); + expect(repeated.assignment).toEqual(first.assignment); + await expect(prepareTask(value.root, value.runId, value.taskId, value.input, { expectedVersion: version })).rejects.toThrow("stale authority version"); + }); + + test("actual recorded activation, collector evidence, and generated report end at awaiting_review with stable retries", async () => { + const value = await fixture(); + await writeFile(resolve(value.root, value.appRoot, "unrelated.txt"), "unrelated user work"); + const result = await activateAndHandoffFixture(value); + expect(result.taskStatus).toBe("awaiting_review"); + expect(result.report.disposition).toBe("proceed"); + expect(result.report.evidence).toEqual([expect.objectContaining({ command_key: "sdlc_test", status: "passed", owner: "runtime_collector" })]); + expect(result.report.writes.map((entry) => entry.path)).not.toContain(`${value.appRoot}/unrelated.txt`); + expect(result.report.observations.requirement_results).toEqual([{ requirement_id: "REQ-001", capability: "feature_status", required: true, status: "implemented" }]); + const before = await runBytes(value); + const repeated = await handoffTask(value.root, value.runId, value.taskId, value.handoffInput); + expect(repeated.alreadyHandedOff).toBe(true); + expect(repeated.authorityVersion).toBe(result.authorityVersion); + expect(await runBytes(value)).toBe(before); + await writeFile(resolve(value.root, value.appRoot, "feature.cjs"), "module.exports = 'changed after handoff';\n"); + await expect(handoffTask(value.root, value.runId, value.taskId, value.handoffInput)).rejects.toThrow("changed since publication"); + }); + + test("handoff dry-run validates a real package and leaves authority/report state unchanged", async () => { + const value = await runningFixture(); + await collectFixtureEvidence(value); + const before = await runBytes(value); + const version = await readAuthorityVersion(value.root, value.runId); + const result = await handoffTask(value.root, value.runId, value.taskId, value.handoffInput, { dryRun: true }); + expect(result.taskStatus).toBe("running"); + expect(result.report.transition_request.to).toBe("awaiting_review"); + expect(await runBytes(value)).toBe(before); + expect(await readAuthorityVersion(value.root, value.runId)).toBe(version); + await expect(readFile(resolve(value.root, result.paths.report))).rejects.toThrow(); + }); + + test("missing or stale collector evidence and changed command provenance cannot produce a handoff", async () => { + const value = await runningFixture(); + await expect(handoffTask(value.root, value.runId, value.taskId, value.handoffInput)).rejects.toThrow("missing passed collector evidence"); + await collectFixtureEvidence(value); + await writeFile(resolve(value.root, value.appRoot, "feature.cjs"), "module.exports = 'after'; // revised\n"); + await expect(handoffTask(value.root, value.runId, value.taskId, value.handoffInput)).rejects.toThrow("stale collector evidence"); + const projectPath = resolve(value.root, ".sdlc/project.yaml"); + const project = parse(await readFile(projectPath, "utf8")); + project.commands.sdlc_test.args = ["-e", "process.exit(0)"]; + await writeFile(projectPath, stringify(project)); + await expect(handoffTask(value.root, value.runId, value.taskId, value.handoffInput)).rejects.toThrow("provenance"); + expect((await loadRun(value.root, value.runId)).tasks.find((entry) => entry.id === value.taskId)?.status).toBe("running"); + }); + + test("wrong-scope files, unchanged files, and fabricated mechanical metadata are rejected without publication", async () => { + const value = await runningFixture(); + await collectFixtureEvidence(value); + const before = await runBytes(value); + for (const changedFiles of [[{ path: "README.md", type: "source" as const }], [{ path: `${value.appRoot}/package.json`, type: "source" as const }], [{ path: `${value.appRoot}/feature.cjs`, type: "source" as const, repository: "another" }], [{ path: `.sdlc/runs/${value.runId}/facts.yaml`, type: "source" as const }]]) { + await expect(handoffTask(value.root, value.runId, value.taskId, { ...value.handoffInput, changedFiles })).rejects.toThrow(); + } + await expect(handoffTask(value.root, value.runId, value.taskId, { ...value.handoffInput, evidence: ["invented"] } as any)).rejects.toThrow("unsupported fields"); + await expect(handoffTask(value.root, value.runId, value.taskId, { ...value.handoffInput, requirementOutcomes: [] })).rejects.toThrow("exactly match"); + expect(await runBytes(value)).toBe(before); + }); + + test("backend contract and mobile tasks derive the correct target inventory and semantic observations", async () => { + for (const target of ["backend", "mobile"] as const) { + const value = await fixture({ target }); + const result = await activateAndHandoffFixture(value); + expect(result.assignment.role).toBe(target === "backend" ? "backend" : "frontend"); + expect(result.assignment.target).toBe(target); + expect(result.assignment.allowed_write_roots).toContain(value.appRoot); + expect(result.report.artifacts.every((artifact) => artifact.path.includes(`/artifacts/${target}/`))).toBe(true); + expect(result.taskStatus).toBe("awaiting_review"); + } + }); + + test("Next.js route-group application roots remain literal and scoped", async () => { + const value = await fixture({ appRoot: "apps/platform/(portal)" }); + const result = await activateAndHandoffFixture(value); + expect(result.assignment.allowed_write_roots).toContain("apps/platform/(portal)"); + expect(result.report.writes).toContainEqual({ path: "apps/platform/(portal)/feature.cjs", type: "source" }); + expect(result.assignment.allowed_write_roots).not.toContain("."); + }); + + test("publication followed by an interrupted transition can resume without changing its report or receipt", async () => { + const value = await runningFixture(); + await collectFixtureEvidence(value); + const failOnce = vi.spyOn(transitions, "prepareTransitionContext").mockRejectedValueOnce(new Error("simulated interruption before transition")); + try { await expect(handoffTask(value.root, value.runId, value.taskId, value.handoffInput)).rejects.toThrow("simulated interruption"); } + finally { failOnce.mockRestore(); } + const reportPath = resolve(value.root, `.sdlc/runs/${value.runId}/artifacts/web/${value.taskId}-delivery-report.yaml`); + const before = await readFile(reportPath, "utf8"); + expect((await loadRun(value.root, value.runId)).tasks.find((entry) => entry.id === value.taskId)?.status).toBe("running"); + const result = await handoffTask(value.root, value.runId, value.taskId, value.handoffInput); + expect(result.alreadyHandedOff).toBe(true); + expect(result.taskStatus).toBe("awaiting_review"); + expect(await readFile(reportPath, "utf8")).toBe(before); + await collectFixtureEvidence(value); + await expect(handoffTask(value.root, value.runId, value.taskId, value.handoffInput)).rejects.toThrow("collector evidence changed since publication"); + }); + + test("unresolved frontend decisions, missing inputs, and stale fact bindings block automatic work", async () => { + const value = await fixture(); + const inputPath = resolve(value.root, `.sdlc/runs/${value.runId}/artifacts/pm/requirements-review.md`); + const inputSource = await readFile(inputPath, "utf8"); + await rm(inputPath); + await expect(prepareTask(value.root, value.runId, value.taskId, value.input)).rejects.toThrow("required inputs"); + await writeFile(inputPath, inputSource); + const blockedInput = structuredClone(value.input); + if (!("api_contract_status" in blockedInput.controls)) throw new Error("expected frontend fixture"); + blockedInput.controls.api_contract_status = "missing"; + await expect(prepareTask(value.root, value.runId, value.taskId, blockedInput)).rejects.toThrow("unresolved"); + await prepareTask(value.root, value.runId, value.taskId, value.input); + await activateFixture(value); + await writeFixtureOutputs(value); + await collectFixtureEvidence(value); + const factsPath = resolve(value.root, `.sdlc/runs/${value.runId}/facts.yaml`); + const facts = parse(await readFile(factsPath, "utf8")); + facts.revision += 1; + await writeFile(factsPath, stringify(facts)); + await expect(handoffTask(value.root, value.runId, value.taskId, value.handoffInput)).rejects.toThrow("authority mismatch"); + }); + + test("a repaired task advances assignment revision, collects fresh evidence, and keeps archived work intact", async () => { + const value = await fixture(); + const original = await activateAndHandoffFixture(value); + await recordQualityGate(value.root, value.runId, "web", "passed", original.report.evidence.map((entry) => entry.reference!), "pm", "Fixture review passed", new Date().toISOString()); + await mutateRunManifest(value.root, value.runId, async (manifest) => { + const request = { taskId: value.taskId, to: "completed" as const, actor: "pm", reason: "Fixture independent review accepted", at: new Date().toISOString() }; + Object.assign(manifest, transitions.transitionTask(manifest, request, await transitions.prepareTransitionContext(value.root, value.runId, manifest, request))); + }); + const repair = await repairTask(value.root, value.runId, value.taskId, { actor: "pm", defectId: "DEF-001", reason: "Correct the reviewed feature" }); + const prepared = await prepareTask(value.root, value.runId, value.taskId, value.input); + expect(prepared.assignment.assignment_id).toBe(original.assignment.assignment_id); + expect(prepared.assignment.revision).toBeGreaterThan(original.assignment.revision); + expect(prepared.assignment.available_evidence).toEqual([]); + await activateFixture(value); + await writeFixtureOutputs(value); + await expect(handoffTask(value.root, value.runId, value.taskId, value.handoffInput)).rejects.toThrow("missing passed collector evidence"); + await collectFixtureEvidence(value); + const fixed = await handoffTask(value.root, value.runId, value.taskId, value.handoffInput); + expect(fixed.taskStatus).toBe("awaiting_review"); + expect(fixed.report.evidence[0]!.evidence_id).not.toBe(original.report.evidence[0]!.evidence_id); + const archivedReport = repair.repair.archives.find((entry) => entry.path.endsWith("delivery-report.yaml"))!; + expect(parse(await readFile(resolve(value.root, `.sdlc/runs/${value.runId}/${archivedReport.archive_path}`), "utf8"))).toEqual(original.report); + }); + + test("a rejected review starts a fresh repair cycle without approving the rejected handoff", async () => { + const value = await fixture(); + const rejected = await activateAndHandoffFixture(value); + const repair = await repairTask(value.root, value.runId, value.taskId, { actor: "pm", defectId: "DEF-REVIEW-001", reason: "Independent review found incorrect status rendering" }); + expect(repair.repair.tasks.find((entry) => entry.id === value.taskId)?.status).toBe("awaiting_review"); + const prepared = await prepareTask(value.root, value.runId, value.taskId, value.input); + expect(prepared.taskStatus).toBe("ready"); + expect(prepared.assignment.revision).toBeGreaterThan(rejected.assignment.revision); + expect(prepared.assignment.available_evidence).toEqual([]); + await activateFixture(value); + await writeFixtureOutputs(value); + await collectFixtureEvidence(value); + const fixed = await handoffTask(value.root, value.runId, value.taskId, value.handoffInput); + expect(fixed.taskStatus).toBe("awaiting_review"); + const current = (await loadRun(value.root, value.runId)).tasks.find((entry) => entry.id === value.taskId)!; + expect(current.transitions.some((entry) => entry.to === "completed")).toBe(false); + expect(fixed.report.evidence[0]!.evidence_id).not.toBe(rejected.report.evidence[0]!.evidence_id); + }); + + test("a closed rejected gap remains rejected throughout preparation and handoff", async () => { + const value = await fixture(); + if (!("gaps" in value.input.controls)) throw new Error("expected frontend fixture"); + const gap = { gap_id: "GAP-001", kind: "design" as const, state: "rejected" as const, question_ids: [] as [], resolution_reference: "FACT-001" }; + value.input.controls.gaps = [gap]; + const result = await activateAndHandoffFixture(value); + expect(result.taskStatus).toBe("awaiting_review"); + expect(result.assignment.controls).toMatchObject({ gaps: [gap] }); + expect(result.report.observations).toMatchObject({ gaps: [gap] }); + }); +});