From 57fdc7731cc467aee52f3aa8fc4027c5837ee199 Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Wed, 2 Sep 2026 00:59:16 +0200 Subject: [PATCH] feat: compile accepted plans into bounded slices --- TODO.md | 5 + project/TICKETS.md | 2 + project/ticket-096/README.md | 83 +++ project/ticket-096/ai-codex-logs.txt | 7 + project/ticket-096/ai-codex.md | 48 ++ project/ticket-096/changelog.md | 13 + project/ticket-096/intent.json | 93 ++++ project/ticket-096/preprompt.md | 8 + src/operations/delivery-plan-slice-types.ts | 132 +++++ .../delivery-plan-slices.schema.json | 340 ++++++++++++ src/operations/delivery-plan-slices.ts | 487 ++++++++++++++++++ test/operation-delivery-plan-slices.test.ts | 338 ++++++++++++ 12 files changed, 1556 insertions(+) create mode 100644 project/ticket-096/README.md create mode 100644 project/ticket-096/ai-codex-logs.txt create mode 100644 project/ticket-096/ai-codex.md create mode 100644 project/ticket-096/changelog.md create mode 100644 project/ticket-096/intent.json create mode 100644 project/ticket-096/preprompt.md create mode 100644 src/operations/delivery-plan-slice-types.ts create mode 100644 src/operations/delivery-plan-slices.schema.json create mode 100644 src/operations/delivery-plan-slices.ts create mode 100644 test/operation-delivery-plan-slices.test.ts diff --git a/TODO.md b/TODO.md index beffc273..4dc7e8d2 100644 --- a/TODO.md +++ b/TODO.md @@ -2,6 +2,11 @@ ## Active tickets +- [ ] [`ticket-096`](project/ticket-096/README.md) — compile accepted + Wellmanifest delivery plans and explicit oversized-task refinements into + bounded, disjoint, dependency-aware inert ticket candidates. Current state: + `IN_PROGRESS / PUBLICATION`; workstream: `runtime`. + - [ ] [`ticket-089`](project/ticket-089/README.md) — route semantic requests through public SubLLM with direct Z.AI GLM 5.3 by default. Current state: `IN_PROGRESS / PUBLICATION`; workstream: `llm`. diff --git a/project/TICKETS.md b/project/TICKETS.md index a9237ba0..fb6046a3 100644 --- a/project/TICKETS.md +++ b/project/TICKETS.md @@ -71,5 +71,7 @@ of `project/README.md`, which remains a generated technical-analysis artifact. | **ticket-089** | [`README.md`](./ticket-089/README.md) | [`preprompt.md`](./ticket-089/preprompt.md) | - | [`ai-codex.md`](./ticket-089/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-089/ai-codex-logs.txt) | [`changelog.md`](./ticket-089/changelog.md) | | **ticket-090** | [`README.md`](./ticket-090/README.md) | [`preprompt.md`](./ticket-090/preprompt.md) | - | [`ai-codex.md`](./ticket-090/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-090/ai-codex-logs.txt) | [`changelog.md`](./ticket-090/changelog.md) | | **ticket-091** | [`README.md`](./ticket-091/README.md) | [`preprompt.md`](./ticket-091/preprompt.md) | - | [`ai-codex.md`](./ticket-091/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-091/ai-codex-logs.txt) | [`changelog.md`](./ticket-091/changelog.md) | +| **ticket-093** | [`README.md`](./ticket-093/README.md) | [`preprompt.md`](./ticket-093/preprompt.md) | - | [`ai-cursor.md`](./ticket-093/ai-cursor.md) | [`ai-cursor-logs.txt`](./ticket-093/ai-cursor-logs.txt) | [`changelog.md`](./ticket-093/changelog.md) | | **ticket-094** | [`README.md`](./ticket-094/README.md) | [`preprompt.md`](./ticket-094/preprompt.md) | - | [`ai-codex.md`](./ticket-094/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-094/ai-codex-logs.txt) | [`changelog.md`](./ticket-094/changelog.md) | +| **ticket-096** | [`README.md`](./ticket-096/README.md) | [`preprompt.md`](./ticket-096/preprompt.md) | - | [`ai-codex.md`](./ticket-096/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-096/ai-codex-logs.txt) | [`changelog.md`](./ticket-096/changelog.md) | diff --git a/project/ticket-096/README.md b/project/ticket-096/README.md new file mode 100644 index 00000000..fd837ba0 --- /dev/null +++ b/project/ticket-096/README.md @@ -0,0 +1,83 @@ +# Ticket 096: Compile accepted delivery plans into bounded implementation slices + +- **ID**: ticket-096 +- **Owner**: agent:codex under SESSION_EXECUTION_AUTHORIZATION +- **Status**: IN_PROGRESS +- **Workflow state**: PUBLICATION +- **Created**: 2026-09-01 + +## Goal and scope + +Materially implement GitHub issue #115. The runtime consumes an accepted, +closed `wellmanifest.delivery-plan/v1` value plus source-NL and accepted-DSL +hashes, then deterministically compiles it into inert, dependency-aware +`t2c.implementation-slice-candidate/v1` proposals. + +Already bounded source slices pass through unchanged. An oversized source +slice is compiled only from an explicit advisory decomposition whose child +paths are an exact, disjoint partition of the parent paths, whose acceptance +and test bindings cover the parent contract, and whose children independently +fit the Wellmanifest XS/S delivery budget. Missing or invalid decomposition +fails closed; the compiler never invents work, widens paths, creates an issue, +or dispatches a tool. + +The compiler validates the Strategy DSL boundary: closed fields, accepted +status, plan/source hash bindings, acyclic dependencies, exact workstream path +ownership, integration-owned paths, HOME/SHAPE/ADOPT placement and globally +disjoint candidate paths. It emits stable candidate digests and dedupe keys. + +## Acceptance criteria + +- [x] AC-01: The originating user request and GitHub issue #115 authorize this + exact bounded runtime implementation before source edits. +- [x] AC-02: The request preserves and verifies source-NL and normalized + accepted-DSL SHA-256 hashes without carrying raw NL into candidates. +- [x] AC-03: Closed Strategy/Wellmanifest delivery-plan validation rejects + unknown fields, stale hashes, invalid HOME/SHAPE/ADOPT, ambiguous path + ownership, integration-owner violations, cycles and path overlap. +- [x] AC-04: Oversized source slices require an explicit advisory + decomposition into bounded children with an exact disjoint path + partition and complete acceptance/test coverage. +- [x] AC-05: Candidate dependencies remain acyclic after replacement of a + source slice by child roots/leaves, and output ordering, digests and + dedupe keys are deterministic. +- [x] AC-06: Output is inert and carries no issue mutation, tool execution, + shell, grant, credential or LLM authority surface. +- [x] AC-07: Focused tests, full host verification, governance, Docker and + diff validation pass before protected publication. + +## Delivery boundary + +- Workstream: `runtime`. +- Accepted base: `main@f51f20ab1669c98fadd470c54fdf86513fb48b18`. +- Complexity: `S`; four implementation files, two components, no dependency. +- Material paths: `src/operations/delivery-plan-slice-types.ts`, + `src/operations/delivery-plan-slices.ts`, + `src/operations/delivery-plan-slices.schema.json`, and + `test/operation-delivery-plan-slices.test.ts`. +- No CLI/MCP/A2A/SDK/package export, GitHub mutation, LLM call, raw NL + persistence, repository scan, branch/worktree allocation, or tool dispatch. + +## Validation evidence + +- Focused compiler suite: 11 passed, 0 failed. +- Full host verification: 445 tests, 444 passed, 1 explicit missing-JDK skip, + 0 failed; type, module, environment, workflow, schema and structured-response + gates passed. +- `project/governance-check.sh`: `GOV-PASS`, 0 errors and 0 warnings. +- Lizard across the material TypeScript/test paths: 0 threshold warnings + at CCN 15, length 100 and five parameters. +- JSON Schema 2020-12 metaschema validation and `git diff --check`: pass. +- Required Docker smoke: pass, including production image build and health + validation. +- The additional `make e2e-core` experiment passed its complete 445-test + image suite (438 pass, 7 toolchain skips), then reproduced an unrelated + `gold-v2` documentation recall failure already present on pristine + `origin/main@f51f20a` (0 false positives, two missing documentation records). + The exact baseline comparison proves this ticket did not introduce it. + +## Participants + +- Human participant: authorization is captured by the originating request and + GitHub issue #115; no user-* file was created. +- Agent participant: [ai-codex.md](ai-codex.md) diff --git a/project/ticket-096/ai-codex-logs.txt b/project/ticket-096/ai-codex-logs.txt new file mode 100644 index 00000000..60fb14b5 --- /dev/null +++ b/project/ticket-096/ai-codex-logs.txt @@ -0,0 +1,7 @@ +2026-09-01T23:00:00Z SCOPE GitHub issue #115 and the originating user instruction authorize the exact four-file runtime slice. +2026-09-01T23:00:01Z EDIT acceptedBase=f51f20ab1669c98fadd470c54fdf86513fb48b18 workstream=runtime implementationFiles=4 dependencies=0 +2026-09-01T23:30:00Z FOCUSED node-tests=11-pass,0-fail schema-draft2020=pass typescript=pass +2026-09-01T23:31:00Z HOST npm-verify=445-total,444-pass,1-jdk-skip,0-fail governance=0-errors,0-warnings lizard=0-threshold-warnings diff-check=pass +2026-09-01T23:32:00Z DOCKER smoke=pass image-suite=438-pass,7-toolchain-skip,0-fail optional-gold-v2=pre-existing-two-documentation-fn +2026-09-01T23:33:00Z BASELINE origin-main=f51f20ab1669c98fadd470c54fdf86513fb48b18 optional-gold-v2=same-two-documentation-fn no-ticket-regression=true +2026-09-01T23:34:00Z PUBLICATION material=types,compiler,json-schema,tests authority=none execution=inert diff --git a/project/ticket-096/ai-codex.md b/project/ticket-096/ai-codex.md new file mode 100644 index 00000000..1cf771ae --- /dev/null +++ b/project/ticket-096/ai-codex.md @@ -0,0 +1,48 @@ +--- +participant-id: agent:codex +participant: codex +role: agent +ticket: ticket-096 +--- +# Participant: codex (AI agent) + +## Understanding + +The accepted delivery-plan DSL is authority-free planning evidence. todo2code +must preserve its provenance, validate its closed ownership/dependency/budget +contract, and turn it into inert candidates. A semantic split cannot be safely +invented from a list of paths, so an oversized source slice requires explicit +advisory child proposals; deterministic code proves confinement and coverage. + +## Execution plan + +1. Define the internal closed TypeScript and JSON Schema contracts. +2. Validate and normalize accepted Strategy/Wellmanifest delivery plans, + ownership, placement, delivery budgets, acceptance and test bindings. +3. Validate explicit oversized-slice refinements as exact disjoint partitions + and rewrite source dependencies through child roots and leaves. +4. Emit only bounded inert candidates with stable digests and dedupe keys. +5. Cover hashing, determinism, integration ownership, cycles, overlap, + oversized decomposition and authority-field attacks. +6. Run focused, full host, governance, Docker and protected publication gates. + +## Actual changes + +- Added closed TypeScript DTOs and a JSON Schema projection for accepted + delivery plans, advisory decompositions, inert candidates and candidate sets. +- Added deterministic normalization, accepted-plan hashing, strict ownership, + integration, placement, dependency and delivery-budget validation. +- Added fail-closed oversized-source decomposition with exact disjoint path, + component, interface, dependency, acceptance and test partition checks. +- Added dependency rewriting through split roots/leaves plus stable candidate, + dedupe and compilation hashes. +- Added 11 focused cases covering valid compilation, order invariance, + provenance, required splits, widening, cycles, overlap, integration + ownership, closed fields and the no-authority boundary. +- Completed host, governance, complexity, schema and required Docker checks. + +## Blockers + +- None for publication. The optional core E2E benchmark has a pre-existing + `gold-v2` documentation-recall failure reproduced unchanged on pristine + `origin/main`; the required Docker smoke is green. diff --git a/project/ticket-096/changelog.md b/project/ticket-096/changelog.md new file mode 100644 index 00000000..d2786a22 --- /dev/null +++ b/project/ticket-096/changelog.md @@ -0,0 +1,13 @@ +# Ticket Changelog (ticket-096) + +## [0.1.0] - 2026-09-01 + +- Initial governance scaffold created. +- No human participant identity or content was generated. +- Bound GitHub issue #115 to a four-file runtime slice. +- Recorded the accepted Strategy/Wellmanifest compiler and safety contract. +- Added deterministic accepted-plan and advisory-decomposition validation. +- Added bounded inert candidate compilation with rewritten DAG dependencies, + provenance hashes, digests and dedupe keys. +- Added a closed JSON Schema and 11 focused regression/safety cases. +- Passed required host, governance, complexity and Docker smoke validation. diff --git a/project/ticket-096/intent.json b/project/ticket-096/intent.json new file mode 100644 index 00000000..54621db2 --- /dev/null +++ b/project/ticket-096/intent.json @@ -0,0 +1,93 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-096", + "summary": "Compile accepted delivery plans into bounded implementation slices", + "workstream": "runtime", + "classification": { + "kind": "FEATURE", + "priority": "P1", + "origin": "requested" + }, + "allowedPaths": [ + "src/operations/delivery-plan-slice-types.ts", + "src/operations/delivery-plan-slices.ts", + "src/operations/delivery-plan-slices.schema.json", + "test/operation-delivery-plan-slices.test.ts", + "project/ticket-096/**", + "TODO.md", + "project/TICKETS.md" + ], + "forbiddenPaths": ["project/ticket-*/user-*.md"], + "stacks": ["node", "typescript", "docker"], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "f51f20ab1669c98fadd470c54fdf86513fb48b18", + "targetBranch": "main", + "outcome": "Compile an accepted Wellmanifest delivery plan and explicit oversized-slice refinements into deterministic, bounded and inert ticket candidates", + "nonGoals": [ + "Create or update GitHub issues, branches, worktrees or pull requests", + "Execute tools, shell commands or generated candidates", + "Invoke an LLM or grant advisory output authority", + "Persist raw natural-language source", + "Publish a CLI, MCP, A2A or SDK surface" + ], + "complexity": "S", + "estimatedMinutes": 30, + "budgets": { + "maxImplementationFiles": 4, + "maxAffectedComponents": 2, + "maxPublicInterfaceChanges": 0, + "maxRuntimeDependencies": 0 + }, + "architecture": { + "status": "accepted", + "decision": "Keep accepted Strategy DSL validation and deterministic inert compilation in the runtime workstream; require explicit advisory refinements for semantic decomposition and validate them without assigning execution authority", + "components": [ + { + "name": "delivery-slice-contract", + "paths": [ + "src/operations/delivery-plan-slice-types.ts", + "src/operations/delivery-plan-slices.schema.json" + ] + }, + { + "name": "delivery-slice-compiler", + "paths": [ + "src/operations/delivery-plan-slices.ts", + "test/operation-delivery-plan-slices.test.ts" + ] + } + ], + "responsibilityChanges": false, + "interfaceChanges": [], + "dataChanges": [ + "Add internal t2c implementation-slice request and candidate DTOs; no persisted runtime state" + ], + "ui": {"impact": "none", "states": [], "evidence": []}, + "rollback": "Remove the four material files; all existing todo2code plan, operation and synthesis behavior remains unchanged" + }, + "runtimeDependencies": [], + "validation": [ + { + "criterion": "AC-02", + "commands": [ + "npm run build", + "node --test dist/test/operation-delivery-plan-slices.test.js" + ], + "evidence": "Focused deterministic contract, decomposition and safety cases" + }, + { + "criterion": "AC-07", + "commands": [ + "npm run verify", + "./project/governance-check.sh", + "make e2e-core", + "git diff --check" + ], + "evidence": "Full host, governed range, Docker and textual validation" + } + ] + } +} diff --git a/project/ticket-096/preprompt.md b/project/ticket-096/preprompt.md new file mode 100644 index 00000000..47ed250f --- /dev/null +++ b/project/ticket-096/preprompt.md @@ -0,0 +1,8 @@ +# Ticket preprompt + +- **Task ID**: ticket-096 +- **Task title**: Compile accepted delivery plans into bounded implementation slices +- **Created**: 2026-09-01T22:41:21Z + +Keep executable implementation outside this governance/evidence directory. +Read a human-owned user-*.md file only when one exists. diff --git a/src/operations/delivery-plan-slice-types.ts b/src/operations/delivery-plan-slice-types.ts new file mode 100644 index 00000000..04c974cc --- /dev/null +++ b/src/operations/delivery-plan-slice-types.ts @@ -0,0 +1,132 @@ +export type DeliveryHome = 'wellmanifest' | 'subactor' | 'semcod'; +export type DeliveryShape = 'domain_pack' | 'runtime_service' | 'both'; +export type DeliveryComplexity = 'XS' | 'S' | 'M' | 'L'; +export type DeliveryTestKind = 'docker' | 'governance' | 'node-test' | 'python-test'; + +export interface DeliveryPlacement { + home: DeliveryHome; + shape: DeliveryShape; + runtime_owner: DeliveryHome; + adopt: string[]; +} + +export interface DeliveryWorkstream { + id: string; + paths: string[]; +} + +export interface DeliveryOwnership { + workstreams: DeliveryWorkstream[]; + integration: { + workstream: string; + required_paths: string[]; + }; +} + +export interface DeliveryBudget { + complexity: DeliveryComplexity; + estimated_minutes: number; + max_implementation_files: number; + max_affected_components: number; + max_public_interface_changes: number; + max_runtime_dependencies: number; +} + +export interface DeliveryTestBinding { + id: string; + kind: DeliveryTestKind; + target: string; +} + +export interface DeliveryAcceptanceCriterion { + id: string; + statement: string; + test_ids: string[]; +} + +export interface DeliveryPlanSlice { + key: string; + title: string; + workstream: string; + depends_on: string[]; + paths: string[]; + components: string[]; + public_interfaces: string[]; + runtime_dependencies: string[]; + delivery: DeliveryBudget; + acceptance: DeliveryAcceptanceCriterion[]; + tests: DeliveryTestBinding[]; +} + +export interface AcceptedDeliveryPlan { + schema: 'wellmanifest.delivery-plan/v1'; + status: 'accepted'; + plan_id: string; + plan_ref: string; + repository: string; + accepted_base_sha: string; + target_branch: string; + placement: DeliveryPlacement; + ownership: DeliveryOwnership; + slices: DeliveryPlanSlice[]; +} + +export interface DeliverySliceDecomposition { + source_slice_key: string; + slices: DeliveryPlanSlice[]; +} + +export interface DeliverySliceCompilationRequest { + schema: 't2c.delivery-slice-request/v1'; + source_nl_hash: string; + accepted_dsl_hash: string; + plan: AcceptedDeliveryPlan; + decompositions: DeliverySliceDecomposition[]; +} + +export interface ImplementationSliceCandidate { + schema: 't2c.implementation-slice-candidate/v1'; + plan_id: string; + plan_ref: string; + plan_hash: string; + source_nl_hash: string; + accepted_dsl_hash: string; + source_slice_key: string; + candidate_key: string; + order: number; + title: string; + workstream: string; + depends_on: string[]; + allowed_paths: string[]; + components: string[]; + public_interfaces: string[]; + runtime_dependencies: string[]; + delivery: DeliveryBudget; + acceptance: DeliveryAcceptanceCriterion[]; + tests: DeliveryTestBinding[]; + proposal_origin: 'accepted_plan' | 'advisory_decomposition'; + execution: 'inert'; + authority: 'none'; + issue_mutation: 'forbidden'; + tool_dispatch: 'forbidden'; + candidate_digest: string; + dedupe_key: string; +} + +export interface ImplementationSliceSet { + schema: 't2c.implementation-slice-set/v1'; + status: 'ready'; + execution: 'inert'; + authority: 'none'; + source_nl_hash: string; + accepted_dsl_hash: string; + plan_id: string; + plan_ref: string; + plan_hash: string; + repository: string; + accepted_base_sha: string; + target_branch: string; + candidate_count: number; + candidates: ImplementationSliceCandidate[]; + compilation_hash: string; +} diff --git a/src/operations/delivery-plan-slices.schema.json b/src/operations/delivery-plan-slices.schema.json new file mode 100644 index 00000000..038aa172 --- /dev/null +++ b/src/operations/delivery-plan-slices.schema.json @@ -0,0 +1,340 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://todo2code.local/schemas/delivery-plan-slices.schema.json", + "title": "todo2code accepted delivery-plan slice compiler contracts", + "oneOf": [ + { "$ref": "#/$defs/request" }, + { "$ref": "#/$defs/result" } + ], + "$defs": { + "hash": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{1,63}$" + }, + "safePath": { + "type": "string", + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)*$" + }, + "stringSet": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "budget": { + "type": "object", + "additionalProperties": false, + "required": [ + "complexity", + "estimated_minutes", + "max_implementation_files", + "max_affected_components", + "max_public_interface_changes", + "max_runtime_dependencies" + ], + "properties": { + "complexity": { "enum": ["XS", "S", "M", "L"] }, + "estimated_minutes": { "type": "integer", "minimum": 1, "maximum": 240 }, + "max_implementation_files": { "type": "integer", "minimum": 1, "maximum": 30 }, + "max_affected_components": { "type": "integer", "minimum": 1, "maximum": 10 }, + "max_public_interface_changes": { "type": "integer", "minimum": 0, "maximum": 10 }, + "max_runtime_dependencies": { "type": "integer", "minimum": 0, "maximum": 10 } + } + }, + "testBinding": { + "type": "object", + "additionalProperties": false, + "required": ["id", "kind", "target"], + "properties": { + "id": { "$ref": "#/$defs/id" }, + "kind": { "enum": ["docker", "governance", "node-test", "python-test"] }, + "target": { "$ref": "#/$defs/safePath" } + } + }, + "acceptance": { + "type": "object", + "additionalProperties": false, + "required": ["id", "statement", "test_ids"], + "properties": { + "id": { "type": "string", "pattern": "^AC-[0-9]{2}$" }, + "statement": { "type": "string", "minLength": 1 }, + "test_ids": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "$ref": "#/$defs/id" } + } + } + }, + "sourceSlice": { + "type": "object", + "additionalProperties": false, + "required": [ + "key", + "title", + "workstream", + "depends_on", + "paths", + "components", + "public_interfaces", + "runtime_dependencies", + "delivery", + "acceptance", + "tests" + ], + "properties": { + "key": { "$ref": "#/$defs/id" }, + "title": { "type": "string", "minLength": 1 }, + "workstream": { "$ref": "#/$defs/id" }, + "depends_on": { + "type": "array", + "uniqueItems": true, + "items": { "$ref": "#/$defs/id" } + }, + "paths": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "$ref": "#/$defs/safePath" } + }, + "components": { + "allOf": [ + { "$ref": "#/$defs/stringSet" }, + { "minItems": 1 } + ] + }, + "public_interfaces": { "$ref": "#/$defs/stringSet" }, + "runtime_dependencies": { "$ref": "#/$defs/stringSet" }, + "delivery": { "$ref": "#/$defs/budget" }, + "acceptance": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/acceptance" } + }, + "tests": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/testBinding" } + } + } + }, + "ownership": { + "type": "object", + "additionalProperties": false, + "required": ["workstreams", "integration"], + "properties": { + "workstreams": { + "type": "array", + "minItems": 1, + "maxItems": 16, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "paths"], + "properties": { + "id": { "$ref": "#/$defs/id" }, + "paths": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + } + } + } + }, + "integration": { + "type": "object", + "additionalProperties": false, + "required": ["workstream", "required_paths"], + "properties": { + "workstream": { "$ref": "#/$defs/id" }, + "required_paths": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + } + } + } + } + }, + "placement": { + "type": "object", + "additionalProperties": false, + "required": ["home", "shape", "runtime_owner", "adopt"], + "properties": { + "home": { "enum": ["wellmanifest", "subactor", "semcod"] }, + "shape": { "enum": ["domain_pack", "runtime_service", "both"] }, + "runtime_owner": { "enum": ["wellmanifest", "subactor", "semcod"] }, + "adopt": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "pattern": "^wellmanifest/[a-z0-9][a-z0-9-]{1,79}$" } + } + } + }, + "plan": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "status", + "plan_id", + "plan_ref", + "repository", + "accepted_base_sha", + "target_branch", + "placement", + "ownership", + "slices" + ], + "properties": { + "schema": { "const": "wellmanifest.delivery-plan/v1" }, + "status": { "const": "accepted" }, + "plan_id": { "$ref": "#/$defs/id" }, + "plan_ref": { "type": "string", "pattern": "^(?:artifact|knowledge)://[^\\s?#]+$" }, + "repository": { "type": "string", "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" }, + "accepted_base_sha": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "target_branch": { "type": "string", "minLength": 1 }, + "placement": { "$ref": "#/$defs/placement" }, + "ownership": { "$ref": "#/$defs/ownership" }, + "slices": { + "type": "array", + "minItems": 1, + "maxItems": 12, + "items": { "$ref": "#/$defs/sourceSlice" } + } + } + }, + "decomposition": { + "type": "object", + "additionalProperties": false, + "required": ["source_slice_key", "slices"], + "properties": { + "source_slice_key": { "$ref": "#/$defs/id" }, + "slices": { + "type": "array", + "minItems": 2, + "items": { "$ref": "#/$defs/sourceSlice" } + } + } + }, + "request": { + "type": "object", + "additionalProperties": false, + "required": ["schema", "source_nl_hash", "accepted_dsl_hash", "plan", "decompositions"], + "properties": { + "schema": { "const": "t2c.delivery-slice-request/v1" }, + "source_nl_hash": { "$ref": "#/$defs/hash" }, + "accepted_dsl_hash": { "$ref": "#/$defs/hash" }, + "plan": { "$ref": "#/$defs/plan" }, + "decompositions": { + "type": "array", + "items": { "$ref": "#/$defs/decomposition" } + } + } + }, + "candidate": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "plan_id", + "plan_ref", + "plan_hash", + "source_nl_hash", + "accepted_dsl_hash", + "source_slice_key", + "candidate_key", + "order", + "title", + "workstream", + "depends_on", + "allowed_paths", + "components", + "public_interfaces", + "runtime_dependencies", + "delivery", + "acceptance", + "tests", + "proposal_origin", + "execution", + "authority", + "issue_mutation", + "tool_dispatch", + "candidate_digest", + "dedupe_key" + ], + "properties": { + "schema": { "const": "t2c.implementation-slice-candidate/v1" }, + "plan_id": { "$ref": "#/$defs/id" }, + "plan_ref": { "type": "string", "minLength": 1 }, + "plan_hash": { "$ref": "#/$defs/hash" }, + "source_nl_hash": { "$ref": "#/$defs/hash" }, + "accepted_dsl_hash": { "$ref": "#/$defs/hash" }, + "source_slice_key": { "$ref": "#/$defs/id" }, + "candidate_key": { "$ref": "#/$defs/id" }, + "order": { "type": "integer", "minimum": 0 }, + "title": { "type": "string", "minLength": 1 }, + "workstream": { "$ref": "#/$defs/id" }, + "depends_on": { "type": "array", "uniqueItems": true, "items": { "$ref": "#/$defs/id" } }, + "allowed_paths": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "$ref": "#/$defs/safePath" } }, + "components": { "$ref": "#/$defs/stringSet" }, + "public_interfaces": { "$ref": "#/$defs/stringSet" }, + "runtime_dependencies": { "$ref": "#/$defs/stringSet" }, + "delivery": { "$ref": "#/$defs/budget" }, + "acceptance": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/acceptance" } }, + "tests": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/testBinding" } }, + "proposal_origin": { "enum": ["accepted_plan", "advisory_decomposition"] }, + "execution": { "const": "inert" }, + "authority": { "const": "none" }, + "issue_mutation": { "const": "forbidden" }, + "tool_dispatch": { "const": "forbidden" }, + "candidate_digest": { "$ref": "#/$defs/hash" }, + "dedupe_key": { "$ref": "#/$defs/hash" } + } + }, + "result": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "status", + "execution", + "authority", + "source_nl_hash", + "accepted_dsl_hash", + "plan_id", + "plan_ref", + "plan_hash", + "repository", + "accepted_base_sha", + "target_branch", + "candidate_count", + "candidates", + "compilation_hash" + ], + "properties": { + "schema": { "const": "t2c.implementation-slice-set/v1" }, + "status": { "const": "ready" }, + "execution": { "const": "inert" }, + "authority": { "const": "none" }, + "source_nl_hash": { "$ref": "#/$defs/hash" }, + "accepted_dsl_hash": { "$ref": "#/$defs/hash" }, + "plan_id": { "$ref": "#/$defs/id" }, + "plan_ref": { "type": "string", "minLength": 1 }, + "plan_hash": { "$ref": "#/$defs/hash" }, + "repository": { "type": "string", "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" }, + "accepted_base_sha": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "target_branch": { "type": "string", "minLength": 1 }, + "candidate_count": { "type": "integer", "minimum": 1, "maximum": 64 }, + "candidates": { "type": "array", "minItems": 1, "maxItems": 64, "items": { "$ref": "#/$defs/candidate" } }, + "compilation_hash": { "$ref": "#/$defs/hash" } + } + } + } +} diff --git a/src/operations/delivery-plan-slices.ts b/src/operations/delivery-plan-slices.ts new file mode 100644 index 00000000..6007a7ad --- /dev/null +++ b/src/operations/delivery-plan-slices.ts @@ -0,0 +1,487 @@ +import { createHash } from 'node:crypto'; + +import type { + AcceptedDeliveryPlan, + DeliveryAcceptanceCriterion, + DeliveryBudget, + DeliveryOwnership, + DeliveryPlanSlice, + DeliveryPlacement, + DeliverySliceCompilationRequest, + DeliverySliceDecomposition, + DeliveryTestBinding, + ImplementationSliceCandidate, + ImplementationSliceSet, +} from './delivery-plan-slice-types.js'; + +const ID = /^[a-z][a-z0-9-]{1,63}$/u; +const REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u; +const GIT_SHA = /^[a-f0-9]{40}$/u; +const SHA256 = /^sha256:[a-f0-9]{64}$/u; +const REFERENCE = /^(?:artifact|knowledge):\/\/[^\s?#]+$/u; +const BRANCH = /^(?!\/)(?!.*(?:\.\.|\/\/|@\{|[~^:?*\[\\]))[A-Za-z0-9._/-]+$/u; +const SAFE_PATH = /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/u; +const SAFE_PATH_PATTERN = /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*(?:\/[*][*])?$/u; +const ADOPTION = /^wellmanifest\/[a-z0-9][a-z0-9-]{1,79}$/u; +const ACCEPTANCE_ID = /^AC-[0-9]{2}$/u; +const HOMES = new Set(['wellmanifest', 'subactor', 'semcod']); +const SHAPES = new Set(['domain_pack', 'runtime_service', 'both']); +const TEST_KINDS = new Set(['docker', 'governance', 'node-test', 'python-test']); +const MAX_SOURCE_SLICES = 12; +const MAX_OUTPUT_SLICES = 64; + +const PROFILES = { + XS: { minutes: 15, files: 2, components: 1, interfaces: 0, dependencies: 0 }, + S: { minutes: 30, files: 5, components: 2, interfaces: 1, dependencies: 1 }, + M: { minutes: 60, files: 9, components: 3, interfaces: 2, dependencies: 2 }, + L: { minutes: 120, files: 15, components: 5, interfaces: 3, dependencies: 3 }, +} as const; + +type RecordValue = Record; +type CandidateDraft = { + source: DeliveryPlanSlice; + slice: DeliveryPlanSlice; + dependsOn: string[]; + origin: ImplementationSliceCandidate['proposal_origin']; +}; + +export class DeliverySliceCompileError extends Error { + constructor(public readonly code: string, public readonly details: RecordValue = {}) { + super(code); + this.name = 'DeliverySliceCompileError'; + } +} + +function fail(code: string, details: RecordValue = {}): never { + throw new DeliverySliceCompileError(code, details); +} + +function record(value: unknown, keys: string[], code: string): RecordValue { + if (value === null || typeof value !== 'object' || Array.isArray(value)) fail(code); + const result = value as RecordValue; + const actual = Object.keys(result).sort(); + const expected = [...keys].sort(); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + fail(code, { missing: expected.filter((key) => !actual.includes(key)), unknown: actual.filter((key) => !expected.includes(key)) }); + } + return result; +} + +const text = (value: unknown, code: string): string => { + if (typeof value !== 'string' || !value.trim()) fail(code); + return value; +}; + +const matchingText = (value: unknown, code: string, pattern: RegExp): string => { + const result = text(value, code); + if (!pattern.test(result)) fail(code); + return result; +}; + +const strings = (value: unknown, code: string, options: { min?: number; pattern?: RegExp } = {}): string[] => { + if (!Array.isArray(value) || value.length < (options.min ?? 0)) fail(code); + const result = value.map((item) => ( + options.pattern ? matchingText(item, code, options.pattern) : text(item, code) + )); + if (new Set(result).size !== result.length) fail(code, { duplicate: true }); + return result.sort((left, right) => left.localeCompare(right)); +}; + +const integer = (value: unknown, min: number, max: number, code: string): number => { + if (!Number.isInteger(value) || (value as number) < min || (value as number) > max) fail(code); + return value as number; +}; + +const canonical = (value: unknown): unknown => { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return value; + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (Array.isArray(value)) return value.map(canonical); + if (typeof value !== 'object') fail('delivery_slice_non_json_value'); + return Object.fromEntries(Object.entries(value as RecordValue) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, canonical(item)])); +}; + +export function stableDeliverySliceStringify(value: unknown): string { + return JSON.stringify(canonical(value)); +} + +function digest(value: unknown): string { + return `sha256:${createHash('sha256').update(stableDeliverySliceStringify(value)).digest('hex')}`; +} + +function freeze(value: T): T { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + Object.freeze(value); + Object.values(value as Record).forEach(freeze); + } + return value; +} + +function covers(pattern: string, path: string): boolean { + if (!pattern.endsWith('/**')) return pattern === path; + const base = pattern.slice(0, -3); + return path === base || path.startsWith(`${base}/`); +} + +function coversPattern(owner: string, required: string): boolean { + if (!owner.endsWith('/**')) return owner === required; + const ownerBase = owner.slice(0, -3); + const requiredBase = required.endsWith('/**') ? required.slice(0, -3) : required; + return requiredBase === ownerBase || requiredBase.startsWith(`${ownerBase}/`); +} + +function placement(value: unknown): DeliveryPlacement { + const item = record(value, ['home', 'shape', 'runtime_owner', 'adopt'], 'delivery_slice_placement_shape'); + const home = text(item.home, 'delivery_slice_home') as DeliveryPlacement['home']; + const shape = text(item.shape, 'delivery_slice_shape') as DeliveryPlacement['shape']; + const runtimeOwner = text(item.runtime_owner, 'delivery_slice_runtime_owner') as DeliveryPlacement['runtime_owner']; + if (!HOMES.has(home) || !HOMES.has(runtimeOwner) || !SHAPES.has(shape)) fail('delivery_slice_placement_value'); + if (shape === 'runtime_service' && (home === 'wellmanifest' || runtimeOwner === 'wellmanifest')) { + fail('delivery_slice_runtime_home'); + } + return { home, shape, runtime_owner: runtimeOwner, adopt: strings(item.adopt, 'delivery_slice_adopt', { min: 1, pattern: ADOPTION }) }; +} + +function ownership(value: unknown): DeliveryOwnership { + const item = record(value, ['workstreams', 'integration'], 'delivery_slice_ownership_shape'); + if (!Array.isArray(item.workstreams) || item.workstreams.length === 0 || item.workstreams.length > 16) { + fail('delivery_slice_workstreams'); + } + const ids = new Set(); + const workstreams = item.workstreams.map((raw) => { + const workstream = record(raw, ['id', 'paths'], 'delivery_slice_workstream_shape'); + const id = matchingText(workstream.id, 'delivery_slice_workstream_id', ID); + if (ids.has(id)) fail('delivery_slice_workstream_id', { duplicate: true }); + ids.add(id); + return { id, paths: strings(workstream.paths, 'delivery_slice_workstream_paths', { min: 1, pattern: SAFE_PATH_PATTERN }) }; + }).sort((left, right) => left.id.localeCompare(right.id)); + const integration = record(item.integration, ['workstream', 'required_paths'], 'delivery_slice_integration_shape'); + const integrationWorkstream = matchingText(integration.workstream, 'delivery_slice_integration_workstream', ID); + if (!ids.has(integrationWorkstream)) fail('delivery_slice_integration_workstream'); + const requiredPaths = strings(integration.required_paths, 'delivery_slice_integration_paths', { min: 1, pattern: SAFE_PATH_PATTERN }); + const owner = workstreams.find(({ id }) => id === integrationWorkstream); + if (!owner || requiredPaths.some((required) => !owner.paths.some((path) => coversPattern(path, required)))) { + fail('delivery_slice_integration_paths_unowned'); + } + return { workstreams, integration: { workstream: integrationWorkstream, required_paths: requiredPaths } }; +} + +function budget(value: unknown): DeliveryBudget { + const item = record(value, [ + 'complexity', 'estimated_minutes', 'max_implementation_files', 'max_affected_components', + 'max_public_interface_changes', 'max_runtime_dependencies', + ], 'delivery_slice_budget_shape'); + const complexity = text(item.complexity, 'delivery_slice_complexity') as DeliveryBudget['complexity']; + if (!(complexity in PROFILES)) fail('delivery_slice_complexity'); + return { + complexity, + estimated_minutes: integer(item.estimated_minutes, 1, 240, 'delivery_slice_budget'), + max_implementation_files: integer(item.max_implementation_files, 1, 30, 'delivery_slice_budget'), + max_affected_components: integer(item.max_affected_components, 1, 10, 'delivery_slice_budget'), + max_public_interface_changes: integer(item.max_public_interface_changes, 0, 10, 'delivery_slice_budget'), + max_runtime_dependencies: integer(item.max_runtime_dependencies, 0, 10, 'delivery_slice_budget'), + }; +} + +function tests(value: unknown): DeliveryTestBinding[] { + if (!Array.isArray(value) || value.length === 0) fail('delivery_slice_tests'); + const ids = new Set(); + return value.map((raw) => { + const item = record(raw, ['id', 'kind', 'target'], 'delivery_slice_test_shape'); + const id = matchingText(item.id, 'delivery_slice_test_id', ID); + const kind = text(item.kind, 'delivery_slice_test_kind') as DeliveryTestBinding['kind']; + if (ids.has(id) || !TEST_KINDS.has(kind)) fail('delivery_slice_test_value'); + ids.add(id); + return { id, kind, target: matchingText(item.target, 'delivery_slice_test_target', SAFE_PATH) }; + }).sort((left, right) => left.id.localeCompare(right.id)); +} + +function acceptance(value: unknown, testIds: Set): DeliveryAcceptanceCriterion[] { + if (!Array.isArray(value) || value.length === 0) fail('delivery_slice_acceptance'); + const ids = new Set(); + return value.map((raw) => { + const item = record(raw, ['id', 'statement', 'test_ids'], 'delivery_slice_acceptance_shape'); + const id = matchingText(item.id, 'delivery_slice_acceptance_id', ACCEPTANCE_ID); + if (ids.has(id)) fail('delivery_slice_acceptance_id', { duplicate: true }); + ids.add(id); + const test_ids = strings(item.test_ids, 'delivery_slice_acceptance_tests', { min: 1, pattern: ID }); + if (test_ids.some((testId) => !testIds.has(testId))) fail('delivery_slice_acceptance_test_unknown', { criterion: id }); + return { id, statement: text(item.statement, 'delivery_slice_acceptance_statement'), test_ids }; + }).sort((left, right) => left.id.localeCompare(right.id)); +} + +function slice(value: unknown, planOwnership: DeliveryOwnership): DeliveryPlanSlice { + const item = record(value, [ + 'key', 'title', 'workstream', 'depends_on', 'paths', 'components', 'public_interfaces', + 'runtime_dependencies', 'delivery', 'acceptance', 'tests', + ], 'delivery_slice_source_shape'); + const workstream = matchingText(item.workstream, 'delivery_slice_workstream', ID); + const paths = strings(item.paths, 'delivery_slice_paths', { min: 1, pattern: SAFE_PATH }); + if (!planOwnership.workstreams.some(({ id }) => id === workstream)) fail('delivery_slice_workstream_unknown'); + for (const path of paths) { + const owners = planOwnership.workstreams.filter((candidate) => candidate.paths.some((pattern) => covers(pattern, path))); + if (owners.length !== 1) fail('delivery_slice_path_ownership', { path, owner_count: owners.length }); + if (owners[0]?.id !== workstream) fail('delivery_slice_path_owner_mismatch', { path }); + const integrationOwned = planOwnership.integration.required_paths.some((pattern) => covers(pattern, path)); + if (integrationOwned && workstream !== planOwnership.integration.workstream) fail('delivery_slice_integration_owner_required', { path }); + } + const testBindings = tests(item.tests); + return { + key: matchingText(item.key, 'delivery_slice_key', ID), + title: text(item.title, 'delivery_slice_title'), + workstream, + depends_on: strings(item.depends_on, 'delivery_slice_dependencies', { pattern: ID }), + paths, + components: strings(item.components, 'delivery_slice_components', { min: 1 }), + public_interfaces: strings(item.public_interfaces, 'delivery_slice_interfaces'), + runtime_dependencies: strings(item.runtime_dependencies, 'delivery_slice_runtime_dependencies'), + delivery: budget(item.delivery), + acceptance: acceptance(item.acceptance, new Set(testBindings.map(({ id }) => id))), + tests: testBindings, + }; +} + +function topological(values: T[], code: string): T[] { + const byKey = new Map(values.map((value) => [value.key, value])); + if (byKey.size !== values.length) fail(`${code}_duplicate`); + for (const value of values) { + if (value.depends_on.includes(value.key) || value.depends_on.some((dependency) => !byKey.has(dependency))) { + fail(`${code}_unknown`, { key: value.key }); + } + } + const pending = new Map(values.map((value) => [value.key, new Set(value.depends_on)])); + const result: T[] = []; + while (pending.size > 0) { + const ready = [...pending.entries()].filter(([, dependencies]) => dependencies.size === 0) + .map(([key]) => key).sort((left, right) => left.localeCompare(right)); + if (ready.length === 0) fail(`${code}_cycle`, { keys: [...pending.keys()].sort() }); + for (const key of ready) { + pending.delete(key); + const value = byKey.get(key); + if (value) result.push(value); + for (const dependencies of pending.values()) dependencies.delete(key); + } + } + return result; +} + +function plan(value: unknown): AcceptedDeliveryPlan { + const item = record(value, [ + 'schema', 'status', 'plan_id', 'plan_ref', 'repository', 'accepted_base_sha', 'target_branch', + 'placement', 'ownership', 'slices', + ], 'delivery_slice_plan_shape'); + if (item.schema !== 'wellmanifest.delivery-plan/v1' || item.status !== 'accepted') fail('delivery_slice_plan_identity'); + const planOwnership = ownership(item.ownership); + if (!Array.isArray(item.slices) || item.slices.length === 0 || item.slices.length > MAX_SOURCE_SLICES) { + fail('delivery_slice_plan_count'); + } + const slices = item.slices.map((raw) => slice(raw, planOwnership)); + topological(slices, 'delivery_slice_plan_dependencies'); + return { + schema: 'wellmanifest.delivery-plan/v1', + status: 'accepted', + plan_id: matchingText(item.plan_id, 'delivery_slice_plan_id', ID), + plan_ref: matchingText(item.plan_ref, 'delivery_slice_plan_ref', REFERENCE), + repository: matchingText(item.repository, 'delivery_slice_repository', REPOSITORY), + accepted_base_sha: matchingText(item.accepted_base_sha, 'delivery_slice_base', GIT_SHA), + target_branch: matchingText(item.target_branch, 'delivery_slice_branch', BRANCH), + placement: placement(item.placement), + ownership: planOwnership, + slices: [...slices].sort((left, right) => left.key.localeCompare(right.key)), + }; +} + +export function hashAcceptedDeliveryPlan(value: unknown): string { + return digest(plan(structuredClone(value))); +} + +function boundedReasons(value: DeliveryPlanSlice): string[] { + const profile = PROFILES[value.delivery.complexity]; + const reasons: string[] = []; + if (!['XS', 'S'].includes(value.delivery.complexity)) reasons.push('complexity_not_implementation_slice'); + const checks: Array<[number, number, string]> = [ + [value.delivery.estimated_minutes, profile.minutes, 'complexity_time_limit'], + [value.delivery.max_implementation_files, profile.files, 'complexity_file_limit'], + [value.delivery.max_affected_components, profile.components, 'complexity_component_limit'], + [value.delivery.max_public_interface_changes, profile.interfaces, 'complexity_interface_limit'], + [value.delivery.max_runtime_dependencies, profile.dependencies, 'complexity_dependency_limit'], + [value.paths.length, value.delivery.max_implementation_files, 'implementation_file_budget'], + [value.components.length, value.delivery.max_affected_components, 'affected_component_budget'], + [value.public_interfaces.length, value.delivery.max_public_interface_changes, 'public_interface_budget'], + [value.runtime_dependencies.length, value.delivery.max_runtime_dependencies, 'runtime_dependency_budget'], + ]; + for (const [actual, limit, reason] of checks) if (actual > limit) reasons.push(reason); + return [...new Set(reasons)].sort((left, right) => left.localeCompare(right)); +} + +function exactPartition(parent: T[], children: T[][], code: string, identity: (value: T) => string = String): void { + const expected = parent.map(identity).sort(); + const actual = children.flat().map(identity).sort(); + if (new Set(actual).size !== actual.length || JSON.stringify(expected) !== JSON.stringify(actual)) fail(code); +} + +function decomposition(value: unknown, source: DeliveryPlanSlice, planOwnership: DeliveryOwnership): DeliverySliceDecomposition { + const item = record(value, ['source_slice_key', 'slices'], 'delivery_slice_decomposition_shape'); + if (item.source_slice_key !== source.key || !Array.isArray(item.slices) || item.slices.length < 2) { + fail('delivery_slice_decomposition_identity', { source_slice_key: source.key }); + } + const children = item.slices.map((raw) => slice(raw, planOwnership)); + if (children.some((child) => child.workstream !== source.workstream)) fail('delivery_slice_decomposition_workstream'); + if (children.some((child) => boundedReasons(child).length > 0)) { + fail('delivery_slice_decomposition_child_oversized', { + source_slice_key: source.key, + children: children.filter((child) => boundedReasons(child).length > 0).map(({ key }) => key).sort(), + }); + } + topological(children, 'delivery_slice_decomposition_dependencies'); + exactPartition(source.paths, children.map(({ paths }) => paths), 'delivery_slice_decomposition_paths'); + exactPartition(source.components, children.map(({ components }) => components), 'delivery_slice_decomposition_components'); + exactPartition(source.public_interfaces, children.map(({ public_interfaces }) => public_interfaces), 'delivery_slice_decomposition_interfaces'); + exactPartition(source.runtime_dependencies, children.map(({ runtime_dependencies }) => runtime_dependencies), 'delivery_slice_decomposition_runtime_dependencies'); + exactPartition(source.tests, children.map(({ tests: childTests }) => childTests), 'delivery_slice_decomposition_tests', stableDeliverySliceStringify); + exactPartition(source.acceptance, children.map(({ acceptance: childAcceptance }) => childAcceptance), 'delivery_slice_decomposition_acceptance', stableDeliverySliceStringify); + return { source_slice_key: source.key, slices: children.sort((left, right) => left.key.localeCompare(right.key)) }; +} + +function request(value: unknown): DeliverySliceCompilationRequest { + const item = record(value, ['schema', 'source_nl_hash', 'accepted_dsl_hash', 'plan', 'decompositions'], 'delivery_slice_request_shape'); + if (item.schema !== 't2c.delivery-slice-request/v1') fail('delivery_slice_request_identity'); + const acceptedPlan = plan(item.plan); + const acceptedDslHash = matchingText(item.accepted_dsl_hash, 'delivery_slice_accepted_hash', SHA256); + if (acceptedDslHash !== digest(acceptedPlan)) fail('delivery_slice_accepted_hash_mismatch'); + if (!Array.isArray(item.decompositions)) fail('delivery_slice_decompositions'); + const sourceByKey = new Map(acceptedPlan.slices.map((source) => [source.key, source])); + const seen = new Set(); + const decompositions = item.decompositions.map((raw) => { + const rawItem = record(raw, ['source_slice_key', 'slices'], 'delivery_slice_decomposition_shape'); + const sourceKey = matchingText(rawItem.source_slice_key, 'delivery_slice_decomposition_source', ID); + const source = sourceByKey.get(sourceKey); + if (!source || seen.has(sourceKey)) fail('delivery_slice_decomposition_source', { source_slice_key: sourceKey }); + seen.add(sourceKey); + if (boundedReasons(source).length === 0) fail('delivery_slice_decomposition_not_required', { source_slice_key: sourceKey }); + return decomposition(raw, source, acceptedPlan.ownership); + }).sort((left, right) => left.source_slice_key.localeCompare(right.source_slice_key)); + const missing = acceptedPlan.slices.filter((source) => boundedReasons(source).length > 0 && !seen.has(source.key)); + if (missing.length > 0) { + fail('delivery_slice_decomposition_required', { + sources: missing.map((source) => ({ key: source.key, reasons: boundedReasons(source) })), + }); + } + const sourceKeys = new Set(acceptedPlan.slices.map(({ key }) => key)); + const childKeys = decompositions.flatMap(({ slices: children }) => children.map(({ key }) => key)); + if (new Set(childKeys).size !== childKeys.length || childKeys.some((key) => sourceKeys.has(key))) { + fail('delivery_slice_candidate_key_collision'); + } + return { + schema: 't2c.delivery-slice-request/v1', + source_nl_hash: matchingText(item.source_nl_hash, 'delivery_slice_source_hash', SHA256), + accepted_dsl_hash: acceptedDslHash, + plan: acceptedPlan, + decompositions, + }; +} + +function expand(accepted: DeliverySliceCompilationRequest): CandidateDraft[] { + const decompositions = new Map(accepted.decompositions.map((item) => [item.source_slice_key, item])); + const resolved = new Map(); + const drafts: CandidateDraft[] = []; + for (const source of topological(accepted.plan.slices, 'delivery_slice_plan_dependencies')) { + const inherited = source.depends_on.flatMap((dependency) => resolved.get(dependency)?.leaves ?? []); + const split = decompositions.get(source.key); + if (!split) { + drafts.push({ source, slice: source, dependsOn: [...new Set(inherited)].sort(), origin: 'accepted_plan' }); + resolved.set(source.key, { roots: [source.key], leaves: [source.key] }); + continue; + } + const dependedOn = new Set(split.slices.flatMap(({ depends_on }) => depends_on)); + const roots = split.slices.filter(({ depends_on }) => depends_on.length === 0).map(({ key }) => key).sort(); + const leaves = split.slices.filter(({ key }) => !dependedOn.has(key)).map(({ key }) => key).sort(); + for (const child of split.slices) { + drafts.push({ + source, + slice: child, + dependsOn: child.depends_on.length > 0 ? child.depends_on : [...new Set(inherited)].sort(), + origin: 'advisory_decomposition', + }); + } + resolved.set(source.key, { roots, leaves }); + } + if (drafts.length > MAX_OUTPUT_SLICES) fail('delivery_slice_candidate_count'); + return drafts; +} + +function assertDisjointPaths(drafts: CandidateDraft[]): void { + const owners = new Map(); + for (const draft of drafts) { + for (const path of draft.slice.paths) { + const previous = owners.get(path); + if (previous) fail('delivery_slice_candidate_path_overlap', { path, candidates: [previous, draft.slice.key].sort() }); + owners.set(path, draft.slice.key); + } + } +} + +function orderDrafts(drafts: CandidateDraft[]): CandidateDraft[] { + const sortable = drafts.map((draft) => ({ key: draft.slice.key, depends_on: draft.dependsOn, draft })); + return topological(sortable, 'delivery_slice_candidate_dependencies').map(({ draft }) => draft); +} + +export function compileDeliveryPlanSlices(value: unknown): ImplementationSliceSet { + const accepted = request(structuredClone(value)); + const planHash = digest(accepted.plan); + const drafts = expand(accepted); + assertDisjointPaths(drafts); + const ordered = orderDrafts(drafts); + const candidates: ImplementationSliceCandidate[] = ordered.map((draft, order) => { + const core = { + schema: 't2c.implementation-slice-candidate/v1' as const, + plan_id: accepted.plan.plan_id, + plan_ref: accepted.plan.plan_ref, + plan_hash: planHash, + source_nl_hash: accepted.source_nl_hash, + accepted_dsl_hash: accepted.accepted_dsl_hash, + source_slice_key: draft.source.key, + candidate_key: draft.slice.key, + order, + title: draft.slice.title, + workstream: draft.slice.workstream, + depends_on: draft.dependsOn, + allowed_paths: draft.slice.paths, + components: draft.slice.components, + public_interfaces: draft.slice.public_interfaces, + runtime_dependencies: draft.slice.runtime_dependencies, + delivery: draft.slice.delivery, + acceptance: draft.slice.acceptance, + tests: draft.slice.tests, + proposal_origin: draft.origin, + execution: 'inert' as const, + authority: 'none' as const, + issue_mutation: 'forbidden' as const, + tool_dispatch: 'forbidden' as const, + }; + const candidateDigest = digest(core); + return { + ...core, + candidate_digest: candidateDigest, + dedupe_key: digest({ accepted_dsl_hash: accepted.accepted_dsl_hash, candidate_key: draft.slice.key, candidate_digest: candidateDigest }), + }; + }); + const base = { + schema: 't2c.implementation-slice-set/v1' as const, + status: 'ready' as const, + execution: 'inert' as const, + authority: 'none' as const, + source_nl_hash: accepted.source_nl_hash, + accepted_dsl_hash: accepted.accepted_dsl_hash, + plan_id: accepted.plan.plan_id, + plan_ref: accepted.plan.plan_ref, + plan_hash: planHash, + repository: accepted.plan.repository, + accepted_base_sha: accepted.plan.accepted_base_sha, + target_branch: accepted.plan.target_branch, + candidate_count: candidates.length, + candidates, + }; + return freeze({ ...base, compilation_hash: digest(base) }); +} diff --git a/test/operation-delivery-plan-slices.test.ts b/test/operation-delivery-plan-slices.test.ts new file mode 100644 index 00000000..29e8c924 --- /dev/null +++ b/test/operation-delivery-plan-slices.test.ts @@ -0,0 +1,338 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +import { + DeliverySliceCompileError, + compileDeliveryPlanSlices, + hashAcceptedDeliveryPlan, +} from '../src/operations/delivery-plan-slices.js'; +import type { + AcceptedDeliveryPlan, + DeliveryPlanSlice, + DeliverySliceCompilationRequest, +} from '../src/operations/delivery-plan-slice-types.js'; + +const SOURCE_HASH = `sha256:${'1'.repeat(64)}`; +const BASE_SHA = 'a'.repeat(40); + +function boundedSlice(overrides: Partial = {}): DeliveryPlanSlice { + return { + key: 'runtime-contract', + title: 'Implement the bounded runtime contract', + workstream: 'runtime', + depends_on: [], + paths: ['src/operations/runtime-contract.ts'], + components: ['runtime-contract'], + public_interfaces: [], + runtime_dependencies: [], + delivery: { + complexity: 'XS', + estimated_minutes: 10, + max_implementation_files: 1, + max_affected_components: 1, + max_public_interface_changes: 0, + max_runtime_dependencies: 0, + }, + acceptance: [{ + id: 'AC-01', + statement: 'The bounded runtime contract is deterministic.', + test_ids: ['runtime-test'], + }], + tests: [{ id: 'runtime-test', kind: 'node-test', target: 'test/operation-runtime-contract.test.ts' }], + ...overrides, + }; +} + +function acceptedPlan(): AcceptedDeliveryPlan { + return { + schema: 'wellmanifest.delivery-plan/v1', + status: 'accepted', + plan_id: 'bounded-delivery', + plan_ref: 'artifact://subactor/strategy/delivery-plan-r1', + repository: 'autogrammar/todo2code', + accepted_base_sha: BASE_SHA, + target_branch: 'main', + placement: { + home: 'subactor', + shape: 'runtime_service', + runtime_owner: 'subactor', + adopt: ['wellmanifest/new-project', 'wellmanifest/dsl', 'wellmanifest/logs'], + }, + ownership: { + workstreams: [ + { id: 'integration', paths: ['package.json', 'docs/**'] }, + { id: 'runtime', paths: ['src/**', 'test/**'] }, + ], + integration: { workstream: 'integration', required_paths: ['package.json', 'docs/**'] }, + }, + slices: [ + boundedSlice(), + boundedSlice({ + key: 'large-feature', + title: 'Implement the feature currently too large for one ticket', + depends_on: ['runtime-contract'], + paths: ['src/services/feature-a.ts', 'src/services/feature-b.ts', 'src/services/feature-c.ts'], + components: ['feature-core', 'feature-validation'], + delivery: { + complexity: 'M', + estimated_minutes: 45, + max_implementation_files: 3, + max_affected_components: 2, + max_public_interface_changes: 0, + max_runtime_dependencies: 0, + }, + acceptance: [ + { id: 'AC-02', statement: 'Feature core works independently.', test_ids: ['feature-core-test'] }, + { id: 'AC-03', statement: 'Feature validation rejects unsafe input.', test_ids: ['feature-validation-test'] }, + ], + tests: [ + { id: 'feature-core-test', kind: 'node-test', target: 'test/operation-feature-core.test.ts' }, + { id: 'feature-validation-test', kind: 'node-test', target: 'test/operation-feature-validation.test.ts' }, + ], + }), + boundedSlice({ + key: 'package-contract', + title: 'Publish the integration-owned package contract', + workstream: 'integration', + depends_on: ['large-feature'], + paths: ['package.json'], + components: ['package-contract'], + acceptance: [{ id: 'AC-04', statement: 'Package contract exposes the accepted result.', test_ids: ['package-test'] }], + tests: [{ id: 'package-test', kind: 'governance', target: 'package.json' }], + }), + ], + }; +} + +function decomposition(): DeliverySliceCompilationRequest['decompositions'][number] { + return { + source_slice_key: 'large-feature', + slices: [ + boundedSlice({ + key: 'feature-core-slice', + title: 'Ship the independently working feature core', + paths: ['src/services/feature-a.ts', 'src/services/feature-b.ts'], + components: ['feature-core'], + delivery: { + complexity: 'XS', estimated_minutes: 15, max_implementation_files: 2, + max_affected_components: 1, max_public_interface_changes: 0, max_runtime_dependencies: 0, + }, + acceptance: [{ id: 'AC-02', statement: 'Feature core works independently.', test_ids: ['feature-core-test'] }], + tests: [{ id: 'feature-core-test', kind: 'node-test', target: 'test/operation-feature-core.test.ts' }], + }), + boundedSlice({ + key: 'feature-validation-slice', + title: 'Ship fail-closed feature validation', + depends_on: ['feature-core-slice'], + paths: ['src/services/feature-c.ts'], + components: ['feature-validation'], + acceptance: [{ id: 'AC-03', statement: 'Feature validation rejects unsafe input.', test_ids: ['feature-validation-test'] }], + tests: [{ id: 'feature-validation-test', kind: 'node-test', target: 'test/operation-feature-validation.test.ts' }], + }), + ], + }; +} + +function compilationRequest(plan = acceptedPlan()): DeliverySliceCompilationRequest { + return { + schema: 't2c.delivery-slice-request/v1', + source_nl_hash: SOURCE_HASH, + accepted_dsl_hash: hashAcceptedDeliveryPlan(plan), + plan, + decompositions: [decomposition()], + }; +} + +function expectCode(callback: () => unknown, code: string): DeliverySliceCompileError { + assert.throws(callback, (error) => error instanceof DeliverySliceCompileError && error.code === code); + try { + callback(); + } catch (error) { + return error as DeliverySliceCompileError; + } + throw new Error('Expected callback to throw'); +} + +test('compiles accepted and advisory source slices into one bounded inert dependency DAG', () => { + const result = compileDeliveryPlanSlices(compilationRequest()); + assert.equal(result.status, 'ready'); + assert.equal(result.execution, 'inert'); + assert.equal(result.authority, 'none'); + assert.equal(result.source_nl_hash, SOURCE_HASH); + assert.equal(result.accepted_dsl_hash, result.plan_hash); + assert.deepEqual(result.candidates.map(({ candidate_key }) => candidate_key), [ + 'runtime-contract', + 'feature-core-slice', + 'feature-validation-slice', + 'package-contract', + ]); + assert.deepEqual(result.candidates[1]?.depends_on, ['runtime-contract']); + assert.deepEqual(result.candidates[2]?.depends_on, ['feature-core-slice']); + assert.deepEqual(result.candidates[3]?.depends_on, ['feature-validation-slice']); + assert.equal(result.candidates[1]?.proposal_origin, 'advisory_decomposition'); + assert.equal(result.candidates[3]?.workstream, 'integration'); + assert.ok(result.candidates.every((candidate) => ( + candidate.delivery.complexity === 'XS' || candidate.delivery.complexity === 'S' + ))); + assert.ok(Object.isFrozen(result)); + assert.ok(Object.isFrozen(result.candidates[0]?.allowed_paths)); +}); + +test('canonicalizes set order into stable plan, candidate, dedupe and compilation hashes', () => { + const first = compileDeliveryPlanSlices(compilationRequest()); + const reorderedPlan = acceptedPlan(); + reorderedPlan.slices.reverse(); + reorderedPlan.ownership.workstreams.reverse(); + reorderedPlan.placement.adopt.reverse(); + const reordered = compilationRequest(reorderedPlan); + reordered.decompositions[0]?.slices.reverse(); + const second = compileDeliveryPlanSlices(reordered); + assert.equal(second.plan_hash, first.plan_hash); + assert.equal(second.compilation_hash, first.compilation_hash); + assert.deepEqual(second.candidates.map(({ candidate_digest }) => candidate_digest), first.candidates.map(({ candidate_digest }) => candidate_digest)); + assert.deepEqual(second.candidates.map(({ dedupe_key }) => dedupe_key), first.candidates.map(({ dedupe_key }) => dedupe_key)); +}); + +test('requires exact accepted DSL and source NL hash provenance', () => { + const stale = compilationRequest(); + stale.accepted_dsl_hash = `sha256:${'2'.repeat(64)}`; + expectCode(() => compileDeliveryPlanSlices(stale), 'delivery_slice_accepted_hash_mismatch'); + + const malformed = compilationRequest(); + malformed.source_nl_hash = 'raw natural language' as `sha256:${string}`; + expectCode(() => compileDeliveryPlanSlices(malformed), 'delivery_slice_source_hash'); +}); + +test('flags an oversized source with reasons until an explicit decomposition exists', () => { + const input = compilationRequest(); + input.decompositions = []; + const error = expectCode(() => compileDeliveryPlanSlices(input), 'delivery_slice_decomposition_required'); + assert.deepEqual(error.details.sources, [{ + key: 'large-feature', + reasons: ['complexity_not_implementation_slice'], + }]); +}); + +test('rejects widened, omitted, duplicated and still-oversized child slices', () => { + const widened = compilationRequest(); + const widenedChild = widened.decompositions[0]?.slices[0]; + assert.ok(widenedChild); + widenedChild.paths[1] = 'src/services/not-accepted.ts'; + expectCode(() => compileDeliveryPlanSlices(widened), 'delivery_slice_decomposition_paths'); + + const duplicated = compilationRequest(); + const duplicatedChild = duplicated.decompositions[0]?.slices[1]; + assert.ok(duplicatedChild); + duplicatedChild.paths[0] = 'src/services/feature-a.ts'; + expectCode(() => compileDeliveryPlanSlices(duplicated), 'delivery_slice_decomposition_paths'); + + const oversized = compilationRequest(); + const child = oversized.decompositions[0]?.slices[0]; + assert.ok(child); + child.delivery.estimated_minutes = 16; + expectCode(() => compileDeliveryPlanSlices(oversized), 'delivery_slice_decomposition_child_oversized'); +}); + +test('requires exact acceptance, test, component, interface and dependency coverage', () => { + const missingCriterion = compilationRequest(); + const child = missingCriterion.decompositions[0]?.slices[1]; + assert.ok(child); + child.acceptance = [{ id: 'AC-03', statement: 'Changed advisory meaning.', test_ids: ['feature-validation-test'] }]; + expectCode(() => compileDeliveryPlanSlices(missingCriterion), 'delivery_slice_decomposition_acceptance'); + + const duplicateComponent = compilationRequest(); + const duplicateComponentChild = duplicateComponent.decompositions[0]?.slices[1]; + assert.ok(duplicateComponentChild); + duplicateComponentChild.components[0] = 'feature-core'; + expectCode(() => compileDeliveryPlanSlices(duplicateComponent), 'delivery_slice_decomposition_components'); + + const unknownDependency = compilationRequest(); + const dependent = unknownDependency.decompositions[0]?.slices[1]; + assert.ok(dependent); + dependent.depends_on = ['runtime-contract']; + expectCode(() => compileDeliveryPlanSlices(unknownDependency), 'delivery_slice_decomposition_dependencies_unknown'); +}); + +test('flags source and advisory dependency cycles', () => { + const sourceCyclePlan = acceptedPlan(); + const first = sourceCyclePlan.slices.find(({ key }) => key === 'runtime-contract'); + assert.ok(first); + first.depends_on = ['package-contract']; + expectCode(() => hashAcceptedDeliveryPlan(sourceCyclePlan), 'delivery_slice_plan_dependencies_cycle'); + + const childCycle = compilationRequest(); + const firstChild = childCycle.decompositions[0]?.slices[0]; + assert.ok(firstChild); + firstChild.depends_on = ['feature-validation-slice']; + expectCode(() => compileDeliveryPlanSlices(childCycle), 'delivery_slice_decomposition_dependencies_cycle'); +}); + +test('separates integration ownership and rejects ambiguous or mismatched path owners', () => { + const wrongIntegrationOwner = acceptedPlan(); + const integration = wrongIntegrationOwner.slices.find(({ key }) => key === 'package-contract'); + assert.ok(integration); + integration.workstream = 'runtime'; + expectCode(() => hashAcceptedDeliveryPlan(wrongIntegrationOwner), 'delivery_slice_path_owner_mismatch'); + + const ambiguous = acceptedPlan(); + ambiguous.ownership.workstreams.find(({ id }) => id === 'runtime')?.paths.push('package.json'); + expectCode(() => hashAcceptedDeliveryPlan(ambiguous), 'delivery_slice_path_ownership'); +}); + +test('requires globally disjoint candidate paths even when source tasks are serialized', () => { + const input = compilationRequest(); + input.plan.slices.push(boundedSlice({ + key: 'later-overlap', + title: 'A later writer to the same accepted path', + depends_on: ['runtime-contract'], + })); + input.accepted_dsl_hash = hashAcceptedDeliveryPlan(input.plan); + expectCode(() => compileDeliveryPlanSlices(input), 'delivery_slice_candidate_path_overlap'); +}); + +test('keeps LLM advice authority-free and rejects command, grant and lifecycle injection', () => { + const command = compilationRequest() as DeliverySliceCompilationRequest & { execute: string }; + command.execute = 'create issues'; + expectCode(() => compileDeliveryPlanSlices(command), 'delivery_slice_request_shape'); + + const testCommand = compilationRequest(); + const binding = testCommand.decompositions[0]?.slices[0]?.tests[0] as DeliveryPlanSlice['tests'][number] & { command: string }; + binding.command = 'run arbitrary shell'; + expectCode(() => compileDeliveryPlanSlices(testCommand), 'delivery_slice_test_shape'); + + const grant = compilationRequest(); + const child = grant.decompositions[0]?.slices[0] as DeliveryPlanSlice & { grant: string }; + child.grant = 'llm:execute'; + expectCode(() => compileDeliveryPlanSlices(grant), 'delivery_slice_source_shape'); + + const output = compileDeliveryPlanSlices(compilationRequest()); + const serialized = JSON.stringify(output); + assert.equal(serialized.includes('command'), false); + assert.equal(serialized.includes('credential'), false); + assert.equal(serialized.includes('llm:execute'), false); + assert.ok(output.candidates.every((candidate) => ( + candidate.authority === 'none' + && candidate.execution === 'inert' + && candidate.issue_mutation === 'forbidden' + && candidate.tool_dispatch === 'forbidden' + ))); +}); + +test('ships a closed JSON Schema projection for request and candidate-set boundaries', async () => { + const raw = await readFile('src/operations/delivery-plan-slices.schema.json', 'utf8'); + const schema = JSON.parse(raw) as { + $id: string; + oneOf: Array<{ $ref: string }>; + $defs: Record }>; + }; + assert.equal(schema.$id, 'https://todo2code.local/schemas/delivery-plan-slices.schema.json'); + assert.deepEqual(schema.oneOf, [{ $ref: '#/$defs/request' }, { $ref: '#/$defs/result' }]); + for (const name of ['request', 'plan', 'sourceSlice', 'decomposition', 'candidate', 'result']) { + assert.equal(schema.$defs[name]?.additionalProperties, false); + } + assert.ok(schema.$defs.request?.required?.includes('source_nl_hash')); + assert.ok(schema.$defs.request?.required?.includes('accepted_dsl_hash')); + assert.ok(schema.$defs.candidate?.required?.includes('dedupe_key')); + assert.ok(schema.$defs.candidate?.required?.includes('issue_mutation')); +});