From fb16cc69cbb497ff343dfb14f4484c7ca2bb91dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20Harjam=C3=A4ki?= Date: Fri, 12 Jun 2026 20:30:41 +0300 Subject: [PATCH 1/5] docs(03): plan transactional plan and apply engine --- .planning/STATE.md | 12 +++--- .../03-01-PLAN.md | 30 ++++++++++++++ .../03-02-PLAN.md | 31 ++++++++++++++ .../03-03-PLAN.md | 41 +++++++++++++++++++ .../03-CONTEXT.md | 30 ++++++++++++++ .../03-RESEARCH.md | 23 +++++++++++ 6 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 .planning/phases/03-transactional-plan-and-apply-engine/03-01-PLAN.md create mode 100644 .planning/phases/03-transactional-plan-and-apply-engine/03-02-PLAN.md create mode 100644 .planning/phases/03-transactional-plan-and-apply-engine/03-03-PLAN.md create mode 100644 .planning/phases/03-transactional-plan-and-apply-engine/03-CONTEXT.md create mode 100644 .planning/phases/03-transactional-plan-and-apply-engine/03-RESEARCH.md diff --git a/.planning/STATE.md b/.planning/STATE.md index 4e9f9cc..f8acf25 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -2,12 +2,12 @@ gsd_state_version: 1.0 milestone: v1.0 milestone_name: milestone -status: ready_to_plan -last_updated: "2026-06-11T19:11:00.000Z" +status: executing +last_updated: "2026-06-12T00:00:00.000Z" progress: total_phases: 7 completed_phases: 2 - total_plans: 6 + total_plans: 9 completed_plans: 6 percent: 29 --- @@ -24,14 +24,14 @@ See: `.planning/PROJECT.md` (updated 2026-06-11) ## Current Position Phase: 3 -Plan: Not started +Plan: 03-01 - Project initialization: complete - Research: complete - Requirements: 35 v1 requirements, all mapped - Roadmap: 7 phases - Completed phases: Phase 1 and Phase 2 -- Active phase: Phase 3 - Transactional Plan and Apply Engine +- Active phase: Phase 3 - Transactional Plan and Apply Engine (executing) - Phase 2 plans: 3/3 complete - Implementation: Phase 2 verified @@ -47,7 +47,7 @@ Plan: Not started ## Next Action -Run `$gsd-discuss-phase 3` before planning Transactional Plan and Apply Engine. +Execute Phase 3 plans in dependency order and verify the phase goal. ## Decisions and Risks diff --git a/.planning/phases/03-transactional-plan-and-apply-engine/03-01-PLAN.md b/.planning/phases/03-transactional-plan-and-apply-engine/03-01-PLAN.md new file mode 100644 index 0000000..355dceb --- /dev/null +++ b/.planning/phases/03-transactional-plan-and-apply-engine/03-01-PLAN.md @@ -0,0 +1,30 @@ +--- +phase: 03-transactional-plan-and-apply-engine +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - scripts/Cas.Workstation.psm1 + - schemas/operation-plan.schema.json + - tests/Plan.Tests.ps1 +autonomous: true +requirements: [OPS-01, OPS-02, OPS-03] +--- + + +Build a deterministic, inspectable operation planner shared by all operation modes. + + + + + Implement deterministic planning contracts + scripts/Cas.Workstation.psm1, schemas/operation-plan.schema.json, tests/Plan.Tests.ps1 + Create stable operations from desired state and inventory, stable plan IDs, explicit changes/skips/commands/sources/risks, and equivalent interactive/non-interactive behavior. + Invoke-Pester tests/Plan.Tests.ps1 + + + +Equivalent inputs produce byte-equivalent canonical plans and satisfied state produces skips. +Create `03-01-SUMMARY.md` after execution. + diff --git a/.planning/phases/03-transactional-plan-and-apply-engine/03-02-PLAN.md b/.planning/phases/03-transactional-plan-and-apply-engine/03-02-PLAN.md new file mode 100644 index 0000000..cc9fe1e --- /dev/null +++ b/.planning/phases/03-transactional-plan-and-apply-engine/03-02-PLAN.md @@ -0,0 +1,31 @@ +--- +phase: 03-transactional-plan-and-apply-engine +plan: 02 +type: execute +wave: 2 +depends_on: ["03-01"] +files_modified: + - scripts/Cas.Workstation.psm1 + - schemas/managed-state.schema.json + - schemas/event.schema.json + - tests/Apply.Tests.ps1 +autonomous: true +requirements: [OPS-04, OPS-05] +--- + + +Build a durable correlated apply engine with bounded retry and recovery guidance. + + + + + Implement journaled apply and recovery + scripts/Cas.Workstation.psm1, schemas/managed-state.schema.json, schemas/event.schema.json, tests/Apply.Tests.ps1 + Persist journal state before and after operations, emit correlated JSONL events, stop safely on failure, and support bounded retry/resume guidance through an injected operation handler. + Invoke-Pester tests/Apply.Tests.ps1 + + + +Partial failure is durable, observable, bounded, and actionable. +Create `03-02-SUMMARY.md` after execution. + diff --git a/.planning/phases/03-transactional-plan-and-apply-engine/03-03-PLAN.md b/.planning/phases/03-transactional-plan-and-apply-engine/03-03-PLAN.md new file mode 100644 index 0000000..16869f6 --- /dev/null +++ b/.planning/phases/03-transactional-plan-and-apply-engine/03-03-PLAN.md @@ -0,0 +1,41 @@ +--- +phase: 03-transactional-plan-and-apply-engine +plan: 03 +type: execute +wave: 3 +depends_on: ["03-01", "03-02"] +files_modified: + - scripts/Cas.Workstation.psm1 + - setup.ps1 + - upgrade.ps1 + - repair.ps1 + - tests/RepositorySafety.Tests.ps1 + - tests/OperationWorkflow.Tests.ps1 + - README.md + - docs/traceability.json +autonomous: true +requirements: [OPS-01, OPS-06, OPS-07] +--- + + +Route setup, upgrade, and repair through the engine and fail closed on risky repository state. + + + + + Integrate workflows and repository safety + scripts/Cas.Workstation.psm1, setup.ps1, upgrade.ps1, repair.ps1, tests/RepositorySafety.Tests.ps1, tests/OperationWorkflow.Tests.ps1 + Add fail-closed Git synchronization checks and make all operational entry points preview-first wrappers over the shared planner/apply engine. + Invoke-Pester tests/RepositorySafety.Tests.ps1,tests/OperationWorkflow.Tests.ps1 + + + Complete Phase 3 evidence + README.md, docs/traceability.json + Document plan/apply/recovery usage and map OPS-01 through OPS-07 to executable evidence. + .\Invoke-Quality.ps1 + + + +All operational modes share one safe engine and risky repositories fail closed. +Create `03-03-SUMMARY.md` after execution. + diff --git a/.planning/phases/03-transactional-plan-and-apply-engine/03-CONTEXT.md b/.planning/phases/03-transactional-plan-and-apply-engine/03-CONTEXT.md new file mode 100644 index 0000000..a58e207 --- /dev/null +++ b/.planning/phases/03-transactional-plan-and-apply-engine/03-CONTEXT.md @@ -0,0 +1,30 @@ +# Phase 3 Context: Transactional Plan and Apply Engine + +## Scope + +Setup, upgrade, and repair must use one deterministic planner and one apply +engine. Preview is the default. Mutation requires explicit apply intent. + +## Decisions + +- Plans are derived from resolved desired state plus current inventory and use + stable operation IDs and ordering. +- Correlation IDs identify executions, while deterministic plan IDs identify + equivalent desired/current-state inputs. +- Every apply writes JSONL events and an atomic durable journal before and + after each operation. +- Resume and retry are bounded. Automatic rollback is not implied for external + package or Git operations; the journal must provide explicit guidance. +- Existing repositories that are dirty, detached, on an unexpected branch, or + diverged from the configured upstream fail closed. +- Setup, upgrade, and repair are modes over the same engine, not separate + mutation implementations. + +## Safety Boundaries + +- No external process runs during planning. +- Apply accepts only a plan generated by the engine and validates its digest. +- Satisfied resources become `skip` operations. +- Failed operations stop the run and leave later operations pending. +- Retry count is bounded and persisted. + diff --git a/.planning/phases/03-transactional-plan-and-apply-engine/03-RESEARCH.md b/.planning/phases/03-transactional-plan-and-apply-engine/03-RESEARCH.md new file mode 100644 index 0000000..4e76917 --- /dev/null +++ b/.planning/phases/03-transactional-plan-and-apply-engine/03-RESEARCH.md @@ -0,0 +1,23 @@ +# Phase 3 Research: Transactional Plan and Apply Engine + +## Recommended Design + +Use a PowerShell orchestration core with serializable plan, journal, and event +contracts. Keep external execution behind an injected operation handler so +Pester can prove behavior without installing packages or contacting networks. + +## Key Risks + +- Random identifiers or timestamps in plan identity break deterministic preview. +- Treating command presence as desired-state satisfaction breaks idempotency. +- Git pull against dirty or diverged repositories can destroy user work. +- Writing journal state only after an operation loses recovery evidence. +- Unbounded retry can repeat unsafe external side effects. + +## Verification Strategy + +- Compare canonical plan JSON from equivalent interactive/non-interactive calls. +- Apply a synthetic plan twice and assert the second plan contains skips only. +- Inject operation failure and prove journal/event correlation and resume scope. +- Exercise dirty, detached, unexpected-branch, and diverged Git status parsing. + From ec93bdf499efc260e4d093d9484ee44ed0419a1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20Harjam=C3=A4ki?= Date: Fri, 12 Jun 2026 20:32:39 +0300 Subject: [PATCH 2/5] feat(03-01): add deterministic operation planning --- .../03-01-SUMMARY.md | 18 +++ schemas/operation-plan.schema.json | 10 +- scripts/Cas.Workstation.psm1 | 119 ++++++++++++++++++ tests/Plan.Tests.ps1 | 37 ++++++ .../contracts/operation-plan.valid.json | 2 +- 5 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 .planning/phases/03-transactional-plan-and-apply-engine/03-01-SUMMARY.md create mode 100644 tests/Plan.Tests.ps1 diff --git a/.planning/phases/03-transactional-plan-and-apply-engine/03-01-SUMMARY.md b/.planning/phases/03-transactional-plan-and-apply-engine/03-01-SUMMARY.md new file mode 100644 index 0000000..3850e1f --- /dev/null +++ b/.planning/phases/03-transactional-plan-and-apply-engine/03-01-SUMMARY.md @@ -0,0 +1,18 @@ +--- +phase: 03-transactional-plan-and-apply-engine +plan: 01 +requirements-completed: [OPS-01, OPS-02, OPS-03] +completed: 2026-06-12 +--- + +# Phase 3 Plan 1 Summary + +Added a deterministic operation planner with stable plan identity, explicit +commands, sources, risks, reasons, and idempotent skip outcomes. + +## Verification + +- Plan Pester tests: 3/3 passed. +- Operation-plan schema fixtures: passed. +- `git diff --check`: passed. + diff --git a/schemas/operation-plan.schema.json b/schemas/operation-plan.schema.json index 64b55b5..77bbf63 100644 --- a/schemas/operation-plan.schema.json +++ b/schemas/operation-plan.schema.json @@ -4,14 +4,16 @@ "title": "CAS Workstation Operation Plan", "type": "object", "additionalProperties": false, - "required": ["schemaVersion", "planId", "correlationId", "profile", "desiredStateDigest", "operations"], + "required": ["schemaVersion", "planId", "correlationId", "mode", "profile", "rootPath", "configPath", "desiredStateDigest", "operations"], "properties": { "schemaVersion": { "const": "1.0.0" }, - "planId": { "type": "string", "minLength": 1 }, + "planId": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, "correlationId": { "type": "string", "minLength": 1 }, + "mode": { "enum": ["setup", "upgrade", "repair"] }, "profile": { "type": "string", "minLength": 1 }, + "rootPath": { "type": "string", "minLength": 1 }, + "configPath": { "type": "string", "minLength": 1 }, "desiredStateDigest": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, - "operations": { "type": "array", "items": { "type": "object", "additionalProperties": false, "required": ["id", "kind", "target", "risk", "action"], "properties": { "id": { "type": "string" }, "kind": { "type": "string" }, "target": { "type": "string" }, "risk": { "enum": ["low", "medium", "high"] }, "action": { "enum": ["create", "update", "remove", "skip"] } } } } + "operations": { "type": "array", "items": { "type": "object", "additionalProperties": false, "required": ["id", "kind", "target", "risk", "action", "command", "source", "reason"], "properties": { "id": { "type": "string" }, "kind": { "enum": ["tool", "repository", "directory", "file", "configuration"] }, "target": { "type": "string" }, "risk": { "enum": ["low", "medium", "high"] }, "action": { "enum": ["create", "update", "remove", "skip"] }, "command": { "type": "string" }, "source": { "type": "string" }, "reason": { "type": "string" }, "defaultBranch": { "type": "string" } } } } } } - diff --git a/scripts/Cas.Workstation.psm1 b/scripts/Cas.Workstation.psm1 index 69fdc67..94329be 100644 --- a/scripts/Cas.Workstation.psm1 +++ b/scripts/Cas.Workstation.psm1 @@ -986,6 +986,125 @@ function Write-CasDoctorReport { $Report } +function Get-CasOperationInventory { + param( + [string]$Profile = "full", + [string]$RootPath = (Get-CasDefaultRootPath), + [pscustomobject]$Manifest = (Get-CasManifest) + ) + + $resources = New-Object System.Collections.Generic.List[object] + foreach ($tool in Get-CasProfileToolDefinitions -Profile $Profile -Manifest $Manifest) { + $status = Get-CasToolStatus -Tool $tool + $null = $resources.Add([pscustomobject]@{ id = "tool:$($tool.id)"; status = $status.status; detail = $status.installedVersion }) + } + foreach ($repo in Get-CasProfileRepos -Profile $Profile -Manifest $Manifest) { + $path = Join-Path (Join-Path $RootPath $Manifest.paths.reposRoot) $repo.id + $status = if (Test-Path -LiteralPath $path -PathType Container) { "present" } else { "missing" } + $null = $resources.Add([pscustomobject]@{ id = "repo:$($repo.id)"; status = $status; detail = $path }) + } + [pscustomobject]@{ resources = $resources.ToArray() } +} + +function New-CasOperationPlan { + param( + [ValidateSet("setup", "upgrade", "repair")][string]$Mode = "setup", + [string]$Profile = "full", + [string]$RootPath = (Get-CasDefaultRootPath), + [string]$ConfigPath = (Get-CasDefaultConfigPath), + [pscustomobject]$Manifest = (Get-CasManifest), + [pscustomobject]$Inventory + ) + + if (-not $Inventory) { + $Inventory = [pscustomobject]@{ resources = @() } + } + $resolved = Resolve-CasDesiredState -Profile $Profile -Manifest $Manifest + $operations = New-Object System.Collections.Generic.List[object] + + foreach ($resource in @($resolved.desiredState.resources | Sort-Object category, id)) { + $inventoryId = "$($resource.category.TrimEnd('s')):$($resource.id)" + $actual = @($Inventory.resources | Where-Object id -eq $inventoryId | Select-Object -First 1) + switch ($resource.category) { + "tools" { + $installer = @($resource.definition.installers | Where-Object kind -ne "manual" | Select-Object -First 1) + $satisfied = $actual.Count -gt 0 -and $actual[0].status -eq "installed" + $command = if ($installer.Count -gt 0) { "$($installer[0].kind) install $($installer[0].id)" } else { "manual" } + $source = if ($installer.Count -gt 0) { "$($installer[0].kind):$($installer[0].id)" } else { "manual" } + $null = $operations.Add([ordered]@{ + id = "tool:$($resource.id)" + kind = "tool" + target = $resource.id + risk = if ($satisfied) { "low" } else { "medium" } + action = if ($satisfied) { "skip" } else { "update" } + command = $command + source = $source + reason = if ($satisfied) { "Desired tool state is satisfied." } else { "Tool is missing or below policy." } + }) + } + "repos" { + $target = Join-Path (Join-Path $RootPath $Manifest.paths.reposRoot) $resource.id + $satisfied = $actual.Count -gt 0 -and $actual[0].status -eq "synchronized" + $present = $actual.Count -gt 0 -and $actual[0].status -in @("present", "synchronized") + $null = $operations.Add([ordered]@{ + id = "repo:$($resource.id)" + kind = "repository" + target = $target + risk = if ($satisfied) { "low" } else { "medium" } + action = if ($satisfied) { "skip" } elseif ($present) { "update" } else { "create" } + command = if ($present) { "git fetch and fast-forward" } else { "git clone" } + source = $resource.definition.url + reason = if ($satisfied) { "Repository is synchronized." } elseif ($present) { "Repository requires safe synchronization." } else { "Repository is missing." } + defaultBranch = $resource.definition.defaultBranch + }) + } + } + } + + $sortedOperations = @($operations.ToArray() | Sort-Object { $_.id }) + $identity = [ordered]@{ + schemaVersion = "1.0.0" + mode = $Mode + profile = $Profile + rootPath = Resolve-CasCanonicalPath -Path $RootPath + configPath = Resolve-CasCanonicalPath -Path $ConfigPath + desiredStateDigest = $resolved.digest + operations = $sortedOperations + } + $planId = Get-CasSha256 -Value (ConvertTo-CasCanonicalJson -InputObject $identity) + [pscustomobject]@{ + schemaVersion = "1.0.0" + planId = $planId + correlationId = $planId + mode = $Mode + profile = $Profile + rootPath = $identity.rootPath + configPath = $identity.configPath + desiredStateDigest = $resolved.digest + operations = $sortedOperations + } +} + +function Assert-CasOperationPlan { + param([Parameter(Mandatory = $true)][pscustomobject]$Plan) + + if ($Plan.schemaVersion -ne "1.0.0" -or $Plan.planId -notmatch '^sha256:[a-f0-9]{64}$') { + throw "Operation plan has an invalid schema version or plan id." + } + if ($Plan.desiredStateDigest -notmatch '^sha256:[a-f0-9]{64}$') { + throw "Operation plan has an invalid desired-state digest." + } + if (@($Plan.operations | ForEach-Object id | Group-Object | Where-Object Count -gt 1).Count -gt 0) { + throw "Operation plan contains duplicate operation ids." + } + foreach ($operation in @($Plan.operations)) { + if ($operation.action -notin @("create", "update", "remove", "skip") -or $operation.risk -notin @("low", "medium", "high")) { + throw "Operation '$($operation.id)' has an invalid action or risk." + } + } + $Plan +} + function Install-CasTool { param( [pscustomobject]$Tool diff --git a/tests/Plan.Tests.ps1 b/tests/Plan.Tests.ps1 new file mode 100644 index 0000000..ce52de0 --- /dev/null +++ b/tests/Plan.Tests.ps1 @@ -0,0 +1,37 @@ +BeforeAll { + $script:repoRoot = Split-Path -Parent $PSScriptRoot + Import-Module (Join-Path $script:repoRoot "scripts\Cas.Workstation.psm1") -Force + $script:root = Join-Path $TestDrive "cas" + $script:config = Join-Path $TestDrive "config" +} + +Describe "CAS deterministic operation planning" { + It "produces canonical equivalent plans for equivalent invocation modes" { + $inventory = [pscustomobject]@{ resources = @() } + $first = New-CasOperationPlan -Mode setup -Profile core -RootPath $script:root -ConfigPath $script:config -Inventory $inventory + $second = New-CasOperationPlan -Mode setup -Profile core -RootPath $script:root -ConfigPath $script:config -Inventory $inventory + + (ConvertTo-CasCanonicalJson $first) | Should -BeExactly (ConvertTo-CasCanonicalJson $second) + $first.planId | Should -Match "^sha256:[a-f0-9]{64}$" + $first.operations.id | Should -Be ($first.operations.id | Sort-Object) + } + + It "shows commands sources risks and changes before apply" { + $plan = New-CasOperationPlan -Mode upgrade -Profile core -RootPath $script:root -ConfigPath $script:config -Inventory ([pscustomobject]@{ resources = @() }) + + @($plan.operations).Count | Should -BeGreaterThan 0 + @($plan.operations | Where-Object { -not $_.command -or -not $_.source -or -not $_.reason }).Count | Should -Be 0 + @($plan.operations | Where-Object action -ne "skip").Count | Should -BeGreaterThan 0 + { Assert-CasOperationPlan $plan } | Should -Not -Throw + } + + It "turns satisfied desired state into idempotent skips" { + $initial = New-CasOperationPlan -Mode repair -Profile core -RootPath $script:root -ConfigPath $script:config -Inventory ([pscustomobject]@{ resources = @() }) + $resources = @($initial.operations | ForEach-Object { + [pscustomobject]@{ id = $_.id; status = if ($_.kind -eq "tool") { "installed" } else { "synchronized" }; detail = $null } + }) + $repeat = New-CasOperationPlan -Mode repair -Profile core -RootPath $script:root -ConfigPath $script:config -Inventory ([pscustomobject]@{ resources = $resources }) + + @($repeat.operations | Where-Object action -ne "skip").Count | Should -Be 0 + } +} diff --git a/tests/fixtures/contracts/operation-plan.valid.json b/tests/fixtures/contracts/operation-plan.valid.json index 4e97e16..558ec86 100644 --- a/tests/fixtures/contracts/operation-plan.valid.json +++ b/tests/fixtures/contracts/operation-plan.valid.json @@ -1 +1 @@ -{"schemaVersion":"1.0.0","planId":"plan-1","correlationId":"run-1","profile":"core","desiredStateDigest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","operations":[{"id":"op-1","kind":"directory","target":"C:\\CAS","risk":"low","action":"create"}]} +{"schemaVersion":"1.0.0","planId":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","correlationId":"run-1","mode":"setup","profile":"core","rootPath":"C:\\CAS","configPath":"C:\\Users\\Example\\.cas","desiredStateDigest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","operations":[{"id":"op-1","kind":"directory","target":"C:\\CAS","risk":"low","action":"create","command":"New-Item","source":"manifest","reason":"Missing directory"}]} From af4c846b855753718111ec876e0f8e56f17e7f40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20Harjam=C3=A4ki?= Date: Fri, 12 Jun 2026 20:39:28 +0300 Subject: [PATCH 3/5] feat(03-02): add journaled resumable apply engine --- .../03-02-SUMMARY.md | 18 ++ scripts/Cas.Workstation.psm1 | 208 ++++++++++++++++++ tests/Apply.Tests.ps1 | 54 +++++ 3 files changed, 280 insertions(+) create mode 100644 .planning/phases/03-transactional-plan-and-apply-engine/03-02-SUMMARY.md create mode 100644 tests/Apply.Tests.ps1 diff --git a/.planning/phases/03-transactional-plan-and-apply-engine/03-02-SUMMARY.md b/.planning/phases/03-transactional-plan-and-apply-engine/03-02-SUMMARY.md new file mode 100644 index 0000000..47351a2 --- /dev/null +++ b/.planning/phases/03-transactional-plan-and-apply-engine/03-02-SUMMARY.md @@ -0,0 +1,18 @@ +--- +phase: 03-transactional-plan-and-apply-engine +plan: 02 +requirements-completed: [OPS-04, OPS-05] +completed: 2026-06-12 +--- + +# Phase 3 Plan 2 Summary + +Added atomic operation journals, correlated JSONL events, pre/post-operation +persistence, bounded retry, fail-stop behavior, and resumable execution. + +## Verification + +- Apply Pester tests: 3/3 passed. +- PSScriptAnalyzer: no findings. +- `git diff --check`: passed. + diff --git a/scripts/Cas.Workstation.psm1 b/scripts/Cas.Workstation.psm1 index 94329be..2617099 100644 --- a/scripts/Cas.Workstation.psm1 +++ b/scripts/Cas.Workstation.psm1 @@ -1105,6 +1105,214 @@ function Assert-CasOperationPlan { $Plan } +function Get-CasOperationFilePaths { + param( + [Parameter(Mandatory = $true)][pscustomobject]$Plan, + [Parameter(Mandatory = $true)][string]$ConfigPath, + [pscustomobject]$Manifest = (Get-CasManifest) + ) + + $safeId = $Plan.planId -replace '[^A-Za-z0-9._-]', '-' + [pscustomobject]@{ + journal = Join-Path (Join-Path $ConfigPath $Manifest.paths.state) "operation-$safeId.json" + events = Join-Path (Join-Path $ConfigPath $Manifest.paths.logs) "operation-$safeId.jsonl" + } +} + +function Write-CasOperationEvent { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$CorrelationId, + [Parameter(Mandatory = $true)][string]$EventType, + [Parameter(Mandatory = $true)][ValidateSet("started", "succeeded", "failed", "skipped")][string]$Outcome, + [Parameter(Mandatory = $true)][string]$Message, + [hashtable]$Metadata = @{} + ) + + $event = [ordered]@{ + schemaVersion = "1.0.0" + timestampUtc = [DateTime]::UtcNow.ToString("o") + correlationId = $CorrelationId + eventType = $EventType + outcome = $Outcome + message = $Message + metadata = $Metadata + } + Add-Content -LiteralPath $Path -Value (ConvertTo-CasCanonicalJson -InputObject $event) -Encoding UTF8 + [pscustomobject]$event +} + +function New-CasOperationJournal { + param( + [Parameter(Mandatory = $true)][pscustomobject]$Plan, + [Parameter(Mandatory = $true)][string]$CorrelationId, + [Parameter(Mandatory = $true)][int]$MaxRetries + ) + + [pscustomobject]@{ + schemaVersion = "1.0.0" + planId = $Plan.planId + correlationId = $CorrelationId + status = "pending" + maxRetries = $MaxRetries + startedAtUtc = [DateTime]::UtcNow.ToString("o") + completedAtUtc = $null + operations = @($Plan.operations | ForEach-Object { + [pscustomobject]@{ + id = $_.id + status = "pending" + attempts = 0 + lastError = $null + guidance = "Not started." + } + }) + } +} + +function Write-CasOperationJournal { + param( + [Parameter(Mandatory = $true)][pscustomobject]$Journal, + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string[]]$ApprovedRoots + ) + + $null = Write-CasAtomicJson -InputObject $Journal -Path $Path -ApprovedRoots $ApprovedRoots +} + +function Read-CasOperationJournal { + param([Parameter(Mandatory = $true)][string]$Path) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "Operation journal was not found: $Path" + } + try { + Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json + } + catch { + throw "Operation journal '$Path' is not valid JSON: $($_.Exception.Message)" + } +} + +function Invoke-CasPlannedOperation { + param([Parameter(Mandatory = $true)][pscustomobject]$Operation) + + if ($Operation.action -eq "skip") { + return + } + if ($Operation.kind -eq "tool") { + $parts = $Operation.source -split ':', 2 + switch ($parts[0]) { + "winget" { & winget install --exact --id $parts[1] --accept-package-agreements --accept-source-agreements } + "scoop" { & scoop install $parts[1] } + "npm" { & npm install -g $parts[1] } + default { throw "Tool operation '$($Operation.id)' has no executable allowlisted adapter." } + } + if ($LASTEXITCODE -ne 0) { throw "Tool operation '$($Operation.id)' failed with exit code $LASTEXITCODE." } + return + } + if ($Operation.kind -eq "repository") { + if ($Operation.action -eq "create") { + & git clone $Operation.source $Operation.target + } + else { + & git -C $Operation.target fetch origin + if ($LASTEXITCODE -eq 0) { & git -C $Operation.target merge --ff-only "origin/$($Operation.defaultBranch)" } + } + if ($LASTEXITCODE -ne 0) { throw "Repository operation '$($Operation.id)' failed with exit code $LASTEXITCODE." } + return + } + throw "No executor is registered for operation kind '$($Operation.kind)'." +} + +function Invoke-CasOperationPlan { + param( + [Parameter(Mandatory = $true)][pscustomobject]$Plan, + [Parameter(Mandatory = $true)][string]$ConfigPath, + [string[]]$ApprovedRoots, + [ValidateRange(0, 3)][int]$MaxRetries = 1, + [switch]$Resume, + [scriptblock]$OperationHandler = { param($operation) Invoke-CasPlannedOperation -Operation $operation }, + [pscustomobject]$Manifest = (Get-CasManifest) + ) + + $null = Assert-CasOperationPlan -Plan $Plan + if (-not $ApprovedRoots) { + $ApprovedRoots = @($Plan.rootPath, $ConfigPath) + } + $stateRoot = Join-Path $ConfigPath $Manifest.paths.state + $logRoot = Join-Path $ConfigPath $Manifest.paths.logs + foreach ($directory in @($ConfigPath, $stateRoot, $logRoot)) { + $null = Assert-CasSafePath -Path $directory -ApprovedRoots $ApprovedRoots -AllowBoundary + if (-not (Test-Path -LiteralPath $directory -PathType Container)) { + New-Item -ItemType Directory -Path $directory -Force | Out-Null + } + } + + $paths = Get-CasOperationFilePaths -Plan $Plan -ConfigPath $ConfigPath -Manifest $Manifest + if ($Resume) { + $journal = Read-CasOperationJournal -Path $paths.journal + if ($journal.planId -ne $Plan.planId) { + throw "Operation journal does not match the requested plan." + } + } + else { + $journal = New-CasOperationJournal -Plan $Plan -CorrelationId ([Guid]::NewGuid().ToString()) -MaxRetries $MaxRetries + } + + $journal.status = "running" + Write-CasOperationJournal -Journal $journal -Path $paths.journal -ApprovedRoots $ApprovedRoots + $null = Write-CasOperationEvent -Path $paths.events -CorrelationId $journal.correlationId -EventType "plan" -Outcome "started" -Message "Operation plan apply started." -Metadata @{ planId = $Plan.planId } + + foreach ($operation in @($Plan.operations)) { + $entry = $journal.operations | Where-Object id -eq $operation.id | Select-Object -First 1 + if ($entry.status -in @("succeeded", "skipped")) { + continue + } + if ($operation.action -eq "skip") { + $entry.status = "skipped" + $entry.guidance = "No action required." + Write-CasOperationJournal -Journal $journal -Path $paths.journal -ApprovedRoots $ApprovedRoots + $null = Write-CasOperationEvent -Path $paths.events -CorrelationId $journal.correlationId -EventType $operation.id -Outcome "skipped" -Message $operation.reason -Metadata @{ command = $operation.command; source = $operation.source } + continue + } + + $succeeded = $false + for ($attempt = 0; $attempt -le $MaxRetries -and -not $succeeded; $attempt++) { + $entry.attempts++ + $entry.status = "running" + $entry.guidance = "Operation is running." + Write-CasOperationJournal -Journal $journal -Path $paths.journal -ApprovedRoots $ApprovedRoots + $null = Write-CasOperationEvent -Path $paths.events -CorrelationId $journal.correlationId -EventType $operation.id -Outcome "started" -Message "Operation attempt $($entry.attempts) started." -Metadata @{ command = $operation.command; source = $operation.source } + try { + & $OperationHandler $operation + $entry.status = "succeeded" + $entry.lastError = $null + $entry.guidance = "No recovery action required." + $succeeded = $true + $null = Write-CasOperationEvent -Path $paths.events -CorrelationId $journal.correlationId -EventType $operation.id -Outcome "succeeded" -Message "Operation succeeded." -Metadata @{ attempts = $entry.attempts } + } + catch { + $entry.status = "failed" + $entry.lastError = $_.Exception.Message + $entry.guidance = "Inspect the correlated event log, correct the cause, then resume this plan. External operations are not automatically rolled back." + $null = Write-CasOperationEvent -Path $paths.events -CorrelationId $journal.correlationId -EventType $operation.id -Outcome "failed" -Message $_.Exception.Message -Metadata @{ attempts = $entry.attempts } + } + Write-CasOperationJournal -Journal $journal -Path $paths.journal -ApprovedRoots $ApprovedRoots + } + if (-not $succeeded) { + $journal.status = "failed" + Write-CasOperationJournal -Journal $journal -Path $paths.journal -ApprovedRoots $ApprovedRoots + return $journal + } + } + + $journal.status = "succeeded" + $journal.completedAtUtc = [DateTime]::UtcNow.ToString("o") + Write-CasOperationJournal -Journal $journal -Path $paths.journal -ApprovedRoots $ApprovedRoots + $null = Write-CasOperationEvent -Path $paths.events -CorrelationId $journal.correlationId -EventType "plan" -Outcome "succeeded" -Message "Operation plan apply completed." -Metadata @{ planId = $Plan.planId } + $journal +} + function Install-CasTool { param( [pscustomobject]$Tool diff --git a/tests/Apply.Tests.ps1 b/tests/Apply.Tests.ps1 new file mode 100644 index 0000000..44bd26c --- /dev/null +++ b/tests/Apply.Tests.ps1 @@ -0,0 +1,54 @@ +BeforeAll { + $script:repoRoot = Split-Path -Parent $PSScriptRoot + Import-Module (Join-Path $script:repoRoot "scripts\Cas.Workstation.psm1") -Force + $script:root = Join-Path $TestDrive "cas" + $script:config = Join-Path $script:root "config" + $script:plan = [pscustomobject]@{ + schemaVersion = "1.0.0" + planId = "sha256:$('a' * 64)" + correlationId = "sha256:$('a' * 64)" + mode = "repair" + profile = "core" + rootPath = $script:root + configPath = $script:config + desiredStateDigest = "sha256:$('b' * 64)" + operations = @( + [pscustomobject]@{ id = "repo:one"; kind = "repository"; target = (Join-Path $script:root one); risk = "medium"; action = "update"; command = "git fetch"; source = "https://example.invalid/one.git"; reason = "drift"; defaultBranch = "main" }, + [pscustomobject]@{ id = "tool:done"; kind = "tool"; target = "done"; risk = "low"; action = "skip"; command = "none"; source = "inventory"; reason = "satisfied" } + ) + } +} + +Describe "CAS journaled plan apply" { + It "persists correlated success and skip outcomes" { + $journal = Invoke-CasOperationPlan -Plan $script:plan -ConfigPath $script:config -MaxRetries 0 -OperationHandler { param($operation) } + $paths = Get-CasOperationFilePaths -Plan $script:plan -ConfigPath $script:config + $events = @(Get-Content $paths.events | ForEach-Object { $_ | ConvertFrom-Json }) + + $journal.status | Should -Be "succeeded" + $journal.operations.status | Should -Contain "succeeded" + $journal.operations.status | Should -Contain "skipped" + @($events.correlationId | Sort-Object -Unique).Count | Should -Be 1 + $events.outcome | Should -Contain "succeeded" + } + + It "stops after bounded failure and leaves actionable durable guidance" { + $journal = Invoke-CasOperationPlan -Plan $script:plan -ConfigPath (Join-Path $script:root failure) -MaxRetries 1 -OperationHandler { param($operation) throw "synthetic failure" } + + $journal.status | Should -Be "failed" + $journal.operations[0].attempts | Should -Be 2 + $journal.operations[0].guidance | Should -Match "resume" + $journal.operations[1].status | Should -Be "pending" + } + + It "resumes failed work without replaying completed work" { + $config = Join-Path $script:root resume + $script:attempts = 0 + $failed = Invoke-CasOperationPlan -Plan $script:plan -ConfigPath $config -MaxRetries 0 -OperationHandler { param($operation) $script:attempts++; throw "first failure" } + $resumed = Invoke-CasOperationPlan -Plan $script:plan -ConfigPath $config -MaxRetries 0 -Resume -OperationHandler { param($operation) $script:attempts++ } + + $failed.status | Should -Be "failed" + $resumed.status | Should -Be "succeeded" + $script:attempts | Should -Be 2 + } +} From 12d73d0c2593365ac1edcfd203676afd610d482f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20Harjam=C3=A4ki?= Date: Fri, 12 Jun 2026 20:43:31 +0300 Subject: [PATCH 4/5] feat(03-03): integrate safe operational workflows --- .../03-03-SUMMARY.md | 19 +++ README.md | 30 ++++ docs/traceability.json | 101 +++++++------ repair.ps1 | 18 +++ scripts/Cas.Workstation.psm1 | 143 +++++++++++++++++- setup.ps1 | 39 +---- stack.manifest.json | 22 ++- tests/Apply.Tests.ps1 | 22 ++- tests/OperationWorkflow.Tests.ps1 | 40 +++++ tests/Plan.Tests.ps1 | 7 + tests/RepositorySafety.Tests.ps1 | 19 +++ upgrade.ps1 | 22 +-- 12 files changed, 374 insertions(+), 108 deletions(-) create mode 100644 .planning/phases/03-transactional-plan-and-apply-engine/03-03-SUMMARY.md create mode 100644 repair.ps1 create mode 100644 tests/OperationWorkflow.Tests.ps1 create mode 100644 tests/RepositorySafety.Tests.ps1 diff --git a/.planning/phases/03-transactional-plan-and-apply-engine/03-03-SUMMARY.md b/.planning/phases/03-transactional-plan-and-apply-engine/03-03-SUMMARY.md new file mode 100644 index 0000000..6e5b6c3 --- /dev/null +++ b/.planning/phases/03-transactional-plan-and-apply-engine/03-03-SUMMARY.md @@ -0,0 +1,19 @@ +--- +phase: 03-transactional-plan-and-apply-engine +plan: 03 +requirements-completed: [OPS-01, OPS-06, OPS-07] +completed: 2026-06-12 +--- + +# Phase 3 Plan 3 Summary + +Routed setup, upgrade, and repair through one preview-first plan/apply engine, +added persisted CLI resume, enforced fail-closed repository synchronization, +and declared the four public CAS golden-path repositories in the full profile. + +## Verification + +- Phase 3 focused tests: 14/14 passed. +- Full quality gate: 46/46 tests passed with contracts, governance, and static analysis. +- `git diff --check`: passed. + diff --git a/README.md b/README.md index af60d28..c37a91f 100644 --- a/README.md +++ b/README.md @@ -91,3 +91,33 @@ managed-state ledger: Observed resources are preserved. Modified resources require a recorded backup. Created directories are removed only when empty; unexpected contents block removal instead of triggering recursive deletion. + +## Plan, Apply, And Recover + +Setup, upgrade, and repair are preview-first entry points over the same +deterministic operation engine. Preview shows stable operation IDs, changes, +skips, commands, sources, and risks without mutating the workstation: + +```powershell +.\setup.ps1 -Profile full +.\upgrade.ps1 -Profile full +.\repair.ps1 -Profile full +``` + +Mutation requires explicit intent. Every apply receives a correlation ID and +writes an atomic operation journal under `.cas\state` plus JSONL events under +`.cas\logs`: + +```powershell +.\setup.ps1 -Profile full -Apply +.\repair.ps1 -Profile full -Apply -Resume +``` + +Retries are bounded. A failed operation stops later work and records actionable +resume guidance. External operations are not automatically rolled back. +Repository updates fail closed when the checkout is dirty, detached, on an +unexpected branch, has local commits, uses an unexpected origin, or cannot +prove a fast-forward relationship. + +The `full` profile is the declarative golden path and includes `cas-platform`, +`cas-contracts`, `cas-evals`, and `cas-reference-product`. diff --git a/docs/traceability.json b/docs/traceability.json index 60e1451..8e4008a 100644 --- a/docs/traceability.json +++ b/docs/traceability.json @@ -1,4 +1,4 @@ -{ +{ "schemaVersion": "1.0.0", "requirements": [ { @@ -201,100 +201,101 @@ { "id": "OPS-01", "phase": 3, - "status": "pending", + "status": "verified", "adrs": { }, - "tests": { - - }, - "evidence": { - - } + "tests": [ + "tests/Plan.Tests.ps1", + "tests/OperationWorkflow.Tests.ps1" + ], + "evidence": [ + ".\\Invoke-Quality.ps1" + ] }, { "id": "OPS-02", "phase": 3, - "status": "pending", + "status": "verified", "adrs": { }, - "tests": { - - }, - "evidence": { - - } + "tests": [ + "tests/Plan.Tests.ps1" + ], + "evidence": [ + ".\\Invoke-Quality.ps1" + ] }, { "id": "OPS-03", "phase": 3, - "status": "pending", + "status": "verified", "adrs": { }, - "tests": { - - }, - "evidence": { - - } + "tests": [ + "tests/Plan.Tests.ps1" + ], + "evidence": [ + ".\\Invoke-Quality.ps1" + ] }, { "id": "OPS-04", "phase": 3, - "status": "pending", + "status": "verified", "adrs": { }, - "tests": { - - }, - "evidence": { - - } + "tests": [ + "tests/Apply.Tests.ps1" + ], + "evidence": [ + ".\\Invoke-Quality.ps1" + ] }, { "id": "OPS-05", "phase": 3, - "status": "pending", + "status": "verified", "adrs": { }, - "tests": { - - }, - "evidence": { - - } + "tests": [ + "tests/Apply.Tests.ps1" + ], + "evidence": [ + ".\\Invoke-Quality.ps1" + ] }, { "id": "OPS-06", "phase": 3, - "status": "pending", + "status": "verified", "adrs": { }, - "tests": { - - }, - "evidence": { - - } + "tests": [ + "tests/RepositorySafety.Tests.ps1" + ], + "evidence": [ + ".\\Invoke-Quality.ps1" + ] }, { "id": "OPS-07", "phase": 3, - "status": "pending", + "status": "verified", "adrs": { }, - "tests": { - - }, - "evidence": { - - } + "tests": [ + "tests/OperationWorkflow.Tests.ps1" + ], + "evidence": [ + ".\\Invoke-Quality.ps1" + ] }, { "id": "CFG-01", diff --git a/repair.ps1 b/repair.ps1 new file mode 100644 index 0000000..22ae5a2 --- /dev/null +++ b/repair.ps1 @@ -0,0 +1,18 @@ +[CmdletBinding()] +param( + [ValidateSet("core", "full")][string]$Profile = "full", + [switch]$NonInteractive, + [switch]$Apply, + [switch]$Resume, + [string]$RootPath, + [string]$ConfigPath +) + +$ErrorActionPreference = "Stop" +$env:USERPROFILE = "C:\Users\KimHarjamaki" +$env:HOME = "C:\Users\KimHarjamaki" +$env:AZURE_CONFIG_DIR = "C:\Users\KimHarjamaki\.azure" +Import-Module (Join-Path $PSScriptRoot "scripts\Cas.Workstation.psm1") -Force + +$result = Invoke-CasWorkstationOperation -Mode repair -Profile $Profile -RootPath $RootPath -ConfigPath $ConfigPath -Apply:$Apply -Resume:$Resume +$result | ConvertTo-Json -Depth 30 diff --git a/scripts/Cas.Workstation.psm1 b/scripts/Cas.Workstation.psm1 index 2617099..37dcbd9 100644 --- a/scripts/Cas.Workstation.psm1 +++ b/scripts/Cas.Workstation.psm1 @@ -1000,7 +1000,12 @@ function Get-CasOperationInventory { } foreach ($repo in Get-CasProfileRepos -Profile $Profile -Manifest $Manifest) { $path = Join-Path (Join-Path $RootPath $Manifest.paths.reposRoot) $repo.id - $status = if (Test-Path -LiteralPath $path -PathType Container) { "present" } else { "missing" } + $status = if (Test-Path -LiteralPath $path -PathType Container) { + (Get-CasRepositorySafetyStatus -Path $path -ExpectedOrigin $repo.url -ExpectedBranch $repo.defaultBranch).status + } + else { + "missing" + } $null = $resources.Add([pscustomobject]@{ id = "repo:$($repo.id)"; status = $status; detail = $path }) } [pscustomobject]@{ resources = $resources.ToArray() } @@ -1045,7 +1050,7 @@ function New-CasOperationPlan { "repos" { $target = Join-Path (Join-Path $RootPath $Manifest.paths.reposRoot) $resource.id $satisfied = $actual.Count -gt 0 -and $actual[0].status -eq "synchronized" - $present = $actual.Count -gt 0 -and $actual[0].status -in @("present", "synchronized") + $present = $actual.Count -gt 0 -and $actual[0].status -in @("present", "behind", "synchronized") $null = $operations.Add([ordered]@{ id = "repo:$($resource.id)" kind = "repository" @@ -1094,6 +1099,19 @@ function Assert-CasOperationPlan { if ($Plan.desiredStateDigest -notmatch '^sha256:[a-f0-9]{64}$') { throw "Operation plan has an invalid desired-state digest." } + $identity = [ordered]@{ + schemaVersion = $Plan.schemaVersion + mode = $Plan.mode + profile = $Plan.profile + rootPath = $Plan.rootPath + configPath = $Plan.configPath + desiredStateDigest = $Plan.desiredStateDigest + operations = @($Plan.operations) + } + $expectedPlanId = Get-CasSha256 -Value (ConvertTo-CasCanonicalJson -InputObject $identity) + if ($Plan.planId -ne $expectedPlanId) { + throw "Operation plan integrity validation failed." + } if (@($Plan.operations | ForEach-Object id | Group-Object | Where-Object Count -gt 1).Count -gt 0) { throw "Operation plan contains duplicate operation ids." } @@ -1157,6 +1175,7 @@ function New-CasOperationJournal { maxRetries = $MaxRetries startedAtUtc = [DateTime]::UtcNow.ToString("o") completedAtUtc = $null + plan = $Plan operations = @($Plan.operations | ForEach-Object { [pscustomobject]@{ id = $_.id @@ -1215,8 +1234,12 @@ function Invoke-CasPlannedOperation { & git clone $Operation.source $Operation.target } else { + $null = Get-CasRepositorySafetyStatus -Path $Operation.target -ExpectedOrigin $Operation.source -ExpectedBranch $Operation.defaultBranch & git -C $Operation.target fetch origin - if ($LASTEXITCODE -eq 0) { & git -C $Operation.target merge --ff-only "origin/$($Operation.defaultBranch)" } + if ($LASTEXITCODE -eq 0) { + $null = Get-CasRepositorySafetyStatus -Path $Operation.target -ExpectedOrigin $Operation.source -ExpectedBranch $Operation.defaultBranch + & git -C $Operation.target merge --ff-only "origin/$($Operation.defaultBranch)" + } } if ($LASTEXITCODE -ne 0) { throw "Repository operation '$($Operation.id)' failed with exit code $LASTEXITCODE." } return @@ -1313,6 +1336,109 @@ function Invoke-CasOperationPlan { $journal } +function ConvertFrom-CasGitRepositoryEvidence { + param( + [Parameter(Mandatory = $true)][string]$ExpectedOrigin, + [Parameter(Mandatory = $true)][string]$ExpectedBranch, + [string]$ActualOrigin, + [string]$ActualBranch, + [string]$PorcelainStatus, + [ValidateRange(0, [int]::MaxValue)][int]$Ahead = 0, + [ValidateRange(0, [int]::MaxValue)][int]$Behind = 0 + ) + + if ($ActualOrigin -ne $ExpectedOrigin) { + throw "Repository origin '$ActualOrigin' does not match expected origin '$ExpectedOrigin'." + } + if (-not $ActualBranch) { + throw "Repository is in detached HEAD state." + } + if ($ActualBranch -ne $ExpectedBranch) { + throw "Repository branch '$ActualBranch' does not match expected branch '$ExpectedBranch'." + } + if ($PorcelainStatus) { + throw "Repository has uncommitted changes and cannot be synchronized safely." + } + if ($Ahead -gt 0) { + throw "Repository has local commits or diverged history and cannot be reconciled automatically." + } + + [pscustomobject]@{ + status = if ($Behind -gt 0) { "behind" } else { "synchronized" } + ahead = $Ahead + behind = $Behind + branch = $ActualBranch + origin = $ActualOrigin + } +} + +function Get-CasRepositorySafetyStatus { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$ExpectedOrigin, + [Parameter(Mandatory = $true)][string]$ExpectedBranch + ) + + $git = Get-Command git -ErrorAction Stop + $actualOrigin = (& $git.Source -C $Path remote get-url origin 2>$null | Select-Object -First 1) + if ($LASTEXITCODE -ne 0) { throw "Repository at '$Path' does not have a readable origin." } + $actualBranch = (& $git.Source -C $Path symbolic-ref --quiet --short HEAD 2>$null | Select-Object -First 1) + $porcelain = [string]::Join([Environment]::NewLine, @(& $git.Source -C $Path status --porcelain 2>$null)) + $counts = [string]::Join(" ", @(& $git.Source -C $Path rev-list --left-right --count "HEAD...origin/$ExpectedBranch" 2>$null)).Trim() -split '\s+' + if ($LASTEXITCODE -ne 0 -or $counts.Count -ne 2) { + throw "Repository at '$Path' cannot prove its relationship to origin/$ExpectedBranch." + } + ConvertFrom-CasGitRepositoryEvidence -ExpectedOrigin $ExpectedOrigin -ExpectedBranch $ExpectedBranch -ActualOrigin $actualOrigin -ActualBranch $actualBranch -PorcelainStatus $porcelain -Ahead ([int]$counts[0]) -Behind ([int]$counts[1]) +} + +function Invoke-CasWorkstationOperation { + param( + [ValidateSet("setup", "upgrade", "repair")][string]$Mode, + [ValidateSet("core", "full")][string]$Profile = "full", + [string]$RootPath, + [string]$ConfigPath, + [switch]$Apply, + [switch]$Resume, + [pscustomobject]$Inventory, + [scriptblock]$OperationHandler, + [pscustomobject]$Manifest = (Get-CasManifest) + ) + + if (-not $RootPath) { $RootPath = Get-CasDefaultRootPath -Manifest $Manifest } + if (-not $ConfigPath) { $ConfigPath = Get-CasDefaultConfigPath -Manifest $Manifest } + if ($Resume -and -not $Apply) { + throw "Resume requires explicit apply intent." + } + if ($Resume) { + $stateRoot = Join-Path $ConfigPath $Manifest.paths.state + $failedJournal = @(Get-ChildItem -LiteralPath $stateRoot -Filter "operation-*.json" -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTimeUtc -Descending | ForEach-Object { + $candidate = Read-CasOperationJournal -Path $_.FullName + if ($candidate.status -eq "failed" -and $candidate.plan.mode -eq $Mode -and $candidate.plan.profile -eq $Profile) { + $candidate + } + } | Select-Object -First 1) + if ($failedJournal.Count -eq 0) { + throw "No failed $Mode operation journal was found for profile '$Profile'." + } + if ($OperationHandler) { + return Invoke-CasOperationPlan -Plan $failedJournal[0].plan -ConfigPath $ConfigPath -Resume -Manifest $Manifest -OperationHandler $OperationHandler + } + return Invoke-CasOperationPlan -Plan $failedJournal[0].plan -ConfigPath $ConfigPath -Resume -Manifest $Manifest + } + if (-not $Inventory) { + $Inventory = Get-CasOperationInventory -Profile $Profile -RootPath $RootPath -Manifest $Manifest + } + $plan = New-CasOperationPlan -Mode $Mode -Profile $Profile -RootPath $RootPath -ConfigPath $ConfigPath -Manifest $Manifest -Inventory $Inventory + if (-not $Apply) { + return $plan + } + + if ($OperationHandler) { + return Invoke-CasOperationPlan -Plan $plan -ConfigPath $ConfigPath -Manifest $Manifest -OperationHandler $OperationHandler + } + Invoke-CasOperationPlan -Plan $plan -ConfigPath $ConfigPath -Manifest $Manifest +} + function Install-CasTool { param( [pscustomobject]$Tool @@ -1379,10 +1505,17 @@ function Sync-CasRepo { return } + $null = Get-CasRepositorySafetyStatus -Path $repoPath -ExpectedOrigin $Repo.url -ExpectedBranch $Repo.defaultBranch Write-Host "[update] $($Repo.id)" & $git.Source -C $repoPath fetch origin - & $git.Source -C $repoPath checkout $Repo.defaultBranch - & $git.Source -C $repoPath pull --ff-only origin $Repo.defaultBranch + if ($LASTEXITCODE -ne 0) { + throw "Fetch failed for repository '$($Repo.id)'." + } + $null = Get-CasRepositorySafetyStatus -Path $repoPath -ExpectedOrigin $Repo.url -ExpectedBranch $Repo.defaultBranch + & $git.Source -C $repoPath merge --ff-only "origin/$($Repo.defaultBranch)" + if ($LASTEXITCODE -ne 0) { + throw "Fast-forward failed for repository '$($Repo.id)'." + } } function New-CasClientConfigs { diff --git a/setup.ps1 b/setup.ps1 index 12e43e8..ced3842 100644 --- a/setup.ps1 +++ b/setup.ps1 @@ -1,8 +1,9 @@ [CmdletBinding()] param( - [ValidateSet("core", "full")] - [string]$Profile = "full", + [ValidateSet("core", "full")][string]$Profile = "full", [switch]$NonInteractive, + [switch]$Apply, + [switch]$Resume, [string]$RootPath, [string]$ConfigPath ) @@ -11,37 +12,7 @@ $ErrorActionPreference = "Stop" $env:USERPROFILE = "C:\Users\KimHarjamaki" $env:HOME = "C:\Users\KimHarjamaki" $env:AZURE_CONFIG_DIR = "C:\Users\KimHarjamaki\.azure" - Import-Module (Join-Path $PSScriptRoot "scripts\Cas.Workstation.psm1") -Force -$manifest = Get-CasManifest -if (-not $RootPath) { $RootPath = Get-CasDefaultRootPath -Manifest $manifest } -if (-not $ConfigPath) { $ConfigPath = Get-CasDefaultConfigPath -Manifest $manifest } - -Write-Host "CAS Workstation setup" -Write-Host "Profile: $Profile" -Write-Host "RootPath: $RootPath" -Write-Host "ConfigPath: $ConfigPath" - -New-CasDirectoryLayout -RootPath $RootPath -ConfigPath $ConfigPath -Manifest $manifest - -foreach ($tool in Get-CasProfileToolDefinitions -Profile $Profile -Manifest $manifest) { - Install-CasTool -Tool $tool -} - -foreach ($repo in Get-CasProfileRepos -Profile $Profile -Manifest $manifest) { - Sync-CasRepo -Repo $repo -RootPath $RootPath -Manifest $manifest -} - -New-CasClientConfigs -ConfigPath $ConfigPath -RootPath $RootPath -Manifest $manifest - -$doctorPath = Join-Path $ConfigPath "config\doctor.post-setup.json" -$report = Get-CasDoctorReport -Profile $Profile -RootPath $RootPath -ConfigPath $ConfigPath -Manifest $manifest -Write-CasDoctorReport -Report $report -JsonPath $doctorPath | Out-Null - -if ($report.overallStatus -eq "ready") { - Write-Host "CAS Workstation setup completed and the workstation is ready." -} -else { - Write-Warning "Setup completed with follow-up actions. Run .\doctor.ps1 for details." -} +$result = Invoke-CasWorkstationOperation -Mode setup -Profile $Profile -RootPath $RootPath -ConfigPath $ConfigPath -Apply:$Apply -Resume:$Resume +$result | ConvertTo-Json -Depth 30 diff --git a/stack.manifest.json b/stack.manifest.json index ed64d71..ca4c32f 100644 --- a/stack.manifest.json +++ b/stack.manifest.json @@ -26,7 +26,7 @@ "full": { "description": "Full workstation with org automation components.", "tools": { "required": ["git", "gh", "node", "npm", "python", "uv", "dotnet", "docker", "az", "wsl", "codex", "claude", "gemini"], "optional": [] }, - "repos": { "required": ["Promptimprover", "autogen", "gsd-orchestrator", "autopilot-core", "ci-autopilot"], "optional": [] }, + "repos": { "required": ["Promptimprover", "autogen", "gsd-orchestrator", "autopilot-core", "ci-autopilot", "cas-platform", "cas-contracts", "cas-evals", "cas-reference-product"], "optional": [] }, "services": { "required": ["docker-daemon", "gh-auth"], "optional": [] }, "clients": { "required": ["codex", "claude", "gemini"], "optional": [] }, "skills": { "required": ["prompt-refiner"], "optional": [] }, @@ -299,6 +299,26 @@ "id": "ci-autopilot", "url": "https://github.com/Coding-Autopilot-System/ci-autopilot.git", "defaultBranch": "main" + }, + { + "id": "cas-platform", + "url": "https://github.com/Coding-Autopilot-System/cas-platform.git", + "defaultBranch": "main" + }, + { + "id": "cas-contracts", + "url": "https://github.com/Coding-Autopilot-System/cas-contracts.git", + "defaultBranch": "main" + }, + { + "id": "cas-evals", + "url": "https://github.com/Coding-Autopilot-System/cas-evals.git", + "defaultBranch": "main" + }, + { + "id": "cas-reference-product", + "url": "https://github.com/Coding-Autopilot-System/cas-reference-product.git", + "defaultBranch": "main" } ], "services": [ diff --git a/tests/Apply.Tests.ps1 b/tests/Apply.Tests.ps1 index 44bd26c..7a0194f 100644 --- a/tests/Apply.Tests.ps1 +++ b/tests/Apply.Tests.ps1 @@ -5,8 +5,8 @@ BeforeAll { $script:config = Join-Path $script:root "config" $script:plan = [pscustomobject]@{ schemaVersion = "1.0.0" - planId = "sha256:$('a' * 64)" - correlationId = "sha256:$('a' * 64)" + planId = $null + correlationId = $null mode = "repair" profile = "core" rootPath = $script:root @@ -17,6 +17,17 @@ BeforeAll { [pscustomobject]@{ id = "tool:done"; kind = "tool"; target = "done"; risk = "low"; action = "skip"; command = "none"; source = "inventory"; reason = "satisfied" } ) } + $identity = [ordered]@{ + schemaVersion = $script:plan.schemaVersion + mode = $script:plan.mode + profile = $script:plan.profile + rootPath = $script:plan.rootPath + configPath = $script:plan.configPath + desiredStateDigest = $script:plan.desiredStateDigest + operations = $script:plan.operations + } + $script:plan.planId = Get-CasSha256 -Value (ConvertTo-CasCanonicalJson -InputObject $identity) + $script:plan.correlationId = $script:plan.planId } Describe "CAS journaled plan apply" { @@ -51,4 +62,11 @@ Describe "CAS journaled plan apply" { $resumed.status | Should -Be "succeeded" $script:attempts | Should -Be 2 } + + It "rejects a plan changed after integrity identity was assigned" { + $tampered = $script:plan | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $tampered.operations[0].source = "https://example.invalid/tampered.git" + + { Invoke-CasOperationPlan -Plan $tampered -ConfigPath (Join-Path $script:root tampered) -OperationHandler { param($operation) } } | Should -Throw "*integrity*" + } } diff --git a/tests/OperationWorkflow.Tests.ps1 b/tests/OperationWorkflow.Tests.ps1 new file mode 100644 index 0000000..d861447 --- /dev/null +++ b/tests/OperationWorkflow.Tests.ps1 @@ -0,0 +1,40 @@ +BeforeAll { + $script:repoRoot = Split-Path -Parent $PSScriptRoot + Import-Module (Join-Path $script:repoRoot "scripts\Cas.Workstation.psm1") -Force + $script:manifest = Get-CasManifest + $script:root = Join-Path $TestDrive cas + $script:config = Join-Path $script:root config + $script:inventory = [pscustomobject]@{ resources = @() } +} + +Describe "CAS shared operational workflow" { + It "uses the same deterministic planner for setup upgrade and repair" { + foreach ($mode in @("setup", "upgrade", "repair")) { + $plan = Invoke-CasWorkstationOperation -Mode $mode -Profile core -RootPath $script:root -ConfigPath $script:config -Inventory $script:inventory -Manifest $script:manifest + $plan.mode | Should -Be $mode + $plan.operations.Count | Should -BeGreaterThan 0 + } + } + + It "keeps preview free of filesystem mutations" { + $null = Invoke-CasWorkstationOperation -Mode setup -Profile core -RootPath $script:root -ConfigPath $script:config -Inventory $script:inventory -Manifest $script:manifest + Test-Path $script:root | Should -BeFalse + } + + It "declares the public golden-path repositories in the full profile" { + $resolved = Resolve-CasDesiredState -Profile full -Manifest $script:manifest + $ids = @($resolved.desiredState.resources | Where-Object category -eq repos | ForEach-Object id) + foreach ($id in @("cas-platform", "cas-contracts", "cas-evals", "cas-reference-product")) { + $ids | Should -Contain $id + } + } + + It "resumes the latest persisted failed plan through the shared workflow" { + $config = Join-Path $script:root recovery + $failed = Invoke-CasWorkstationOperation -Mode repair -Profile core -RootPath $script:root -ConfigPath $config -Inventory $script:inventory -Manifest $script:manifest -Apply -OperationHandler { param($operation) throw "synthetic" } + $resumed = Invoke-CasWorkstationOperation -Mode repair -Profile core -RootPath $script:root -ConfigPath $config -Manifest $script:manifest -Apply -Resume -OperationHandler { param($operation) } + + $failed.status | Should -Be "failed" + $resumed.status | Should -Be "succeeded" + } +} diff --git a/tests/Plan.Tests.ps1 b/tests/Plan.Tests.ps1 index ce52de0..f66d132 100644 --- a/tests/Plan.Tests.ps1 +++ b/tests/Plan.Tests.ps1 @@ -34,4 +34,11 @@ Describe "CAS deterministic operation planning" { @($repeat.operations | Where-Object action -ne "skip").Count | Should -Be 0 } + + It "plans a fast-forward update for a clean behind repository" { + $inventory = [pscustomobject]@{ resources = @([pscustomobject]@{ id = "repo:autogen"; status = "behind"; detail = $null }) } + $plan = New-CasOperationPlan -Mode upgrade -Profile core -RootPath $script:root -ConfigPath $script:config -Inventory $inventory + + ($plan.operations | Where-Object id -eq "repo:autogen").action | Should -Be "update" + } } diff --git a/tests/RepositorySafety.Tests.ps1 b/tests/RepositorySafety.Tests.ps1 new file mode 100644 index 0000000..aec1b43 --- /dev/null +++ b/tests/RepositorySafety.Tests.ps1 @@ -0,0 +1,19 @@ +BeforeAll { + $script:repoRoot = Split-Path -Parent $PSScriptRoot + Import-Module (Join-Path $script:repoRoot "scripts\Cas.Workstation.psm1") -Force + $script:origin = "https://github.com/Coding-Autopilot-System/example.git" +} + +Describe "CAS repository synchronization safety" { + It "allows only clean expected branches without local commits" { + $status = ConvertFrom-CasGitRepositoryEvidence -ExpectedOrigin $script:origin -ActualOrigin $script:origin -ExpectedBranch main -ActualBranch main -Behind 2 + $status.status | Should -Be "behind" + } + + It "fails closed for dirty detached unexpected and diverged repositories" { + { ConvertFrom-CasGitRepositoryEvidence -ExpectedOrigin $script:origin -ActualOrigin $script:origin -ExpectedBranch main -ActualBranch main -PorcelainStatus " M file" } | Should -Throw "*uncommitted*" + { ConvertFrom-CasGitRepositoryEvidence -ExpectedOrigin $script:origin -ActualOrigin $script:origin -ExpectedBranch main -ActualBranch $null } | Should -Throw "*detached*" + { ConvertFrom-CasGitRepositoryEvidence -ExpectedOrigin $script:origin -ActualOrigin $script:origin -ExpectedBranch main -ActualBranch feature } | Should -Throw "*expected branch*" + { ConvertFrom-CasGitRepositoryEvidence -ExpectedOrigin $script:origin -ActualOrigin $script:origin -ExpectedBranch main -ActualBranch main -Ahead 1 -Behind 1 } | Should -Throw "*diverged*" + } +} diff --git a/upgrade.ps1 b/upgrade.ps1 index 2fd7288..724d243 100644 --- a/upgrade.ps1 +++ b/upgrade.ps1 @@ -1,7 +1,9 @@ [CmdletBinding()] param( - [ValidateSet("core", "full")] - [string]$Profile = "full", + [ValidateSet("core", "full")][string]$Profile = "full", + [switch]$NonInteractive, + [switch]$Apply, + [switch]$Resume, [string]$RootPath, [string]$ConfigPath ) @@ -10,19 +12,7 @@ $ErrorActionPreference = "Stop" $env:USERPROFILE = "C:\Users\KimHarjamaki" $env:HOME = "C:\Users\KimHarjamaki" $env:AZURE_CONFIG_DIR = "C:\Users\KimHarjamaki\.azure" - Import-Module (Join-Path $PSScriptRoot "scripts\Cas.Workstation.psm1") -Force -$manifest = Get-CasManifest -if (-not $RootPath) { $RootPath = Get-CasDefaultRootPath -Manifest $manifest } -if (-not $ConfigPath) { $ConfigPath = Get-CasDefaultConfigPath -Manifest $manifest } - -New-CasDirectoryLayout -RootPath $RootPath -ConfigPath $ConfigPath -Manifest $manifest - -foreach ($repo in Get-CasProfileRepos -Profile $Profile -Manifest $manifest) { - Sync-CasRepo -Repo $repo -RootPath $RootPath -Manifest $manifest -} - -New-CasClientConfigs -ConfigPath $ConfigPath -RootPath $RootPath -Manifest $manifest - -Write-Host "CAS Workstation upgrade completed." +$result = Invoke-CasWorkstationOperation -Mode upgrade -Profile $Profile -RootPath $RootPath -ConfigPath $ConfigPath -Apply:$Apply -Resume:$Resume +$result | ConvertTo-Json -Depth 30 From cb538bd7f3f0b974da1a21f8ed0578bc010f411b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20Harjam=C3=A4ki?= Date: Fri, 12 Jun 2026 20:44:43 +0300 Subject: [PATCH 5/5] docs(phase-3): complete phase execution --- .planning/PROJECT.md | 6 ++-- .planning/REQUIREMENTS.md | 28 ++++++++-------- .planning/ROADMAP.md | 2 +- .planning/STATE.md | 26 +++++++-------- .../03-01-PLAN.md | 1 - .../03-01-SUMMARY.md | 1 - .../03-02-PLAN.md | 1 - .../03-02-SUMMARY.md | 1 - .../03-03-PLAN.md | 1 - .../03-03-SUMMARY.md | 1 - .../03-CONTEXT.md | 1 - .../03-RESEARCH.md | 1 - .../03-VERIFICATION.md | 32 +++++++++++++++++++ 13 files changed, 63 insertions(+), 39 deletions(-) create mode 100644 .planning/phases/03-transactional-plan-and-apply-engine/03-VERIFICATION.md diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md index 08c45a8..22897f5 100644 --- a/.planning/PROJECT.md +++ b/.planning/PROJECT.md @@ -17,11 +17,11 @@ An AI developer can run one safe, repeatable workflow and receive a complete, wo - ✓ Doctor can emit human-readable and JSON readiness output — existing seed - ✓ The seed can discover tools, repositories, and basic service health — existing seed - ✓ Governance, schemas, Pester, static analysis, Windows CI, ADRs, and requirement traceability — validated in Phase 1 +- ✓ Manifest, inventory, ownership, path safety, and ledger-only uninstall — validated in Phase 2 +- ✓ Deterministic plan/apply, durable recovery, repair, and repository fail-closed behavior — validated in Phase 3 ### Active -- [ ] Make manifest parsing, allowlisting, path handling, and destructive operations fail closed. -- [ ] Make setup and upgrade idempotent, observable, transactional, and recoverable after partial failure. - [ ] Generate and merge profile-specific AI client, MCP, skill, workspace, and service configuration without overwriting unrelated user state. - [ ] Provide actionable diagnostics, structured logs, state inventory, recovery, and redacted support bundles. - [ ] Publish signed, reproducible releases with provenance and clean-machine end-to-end verification. @@ -82,4 +82,4 @@ This document evolves at phase transitions and milestone boundaries. 4. Update context with evidence, users, feedback, and operational metrics. --- -*Last updated: 2026-06-11 after Phase 1 completion* +*Last updated: 2026-06-12 after Phase 3 completion* diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 8264f97..fa7d842 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -30,13 +30,13 @@ ### Setup, Upgrade, and Recovery -- [ ] **OPS-01**: User can run one documented interactive or non-interactive setup command with equivalent outcomes. -- [ ] **OPS-02**: Setup and upgrade first produce a deterministic operation plan showing changes, skips, commands, sources, and risks. -- [ ] **OPS-03**: Re-running setup or upgrade on satisfied desired state performs no unintended mutations. -- [ ] **OPS-04**: Every external process and network-affecting operation emits observable, correlated, auditable events. -- [ ] **OPS-05**: Partial failure leaves a durable journal and actionable resume, retry, or rollback guidance. -- [ ] **OPS-06**: Repository synchronization detects dirty/diverged state and refuses destructive reconciliation by default. -- [ ] **OPS-07**: User can run a repair command that safely reconciles detected drift through the same plan/apply engine. +- [x] **OPS-01**: User can run one documented interactive or non-interactive setup command with equivalent outcomes. +- [x] **OPS-02**: Setup and upgrade first produce a deterministic operation plan showing changes, skips, commands, sources, and risks. +- [x] **OPS-03**: Re-running setup or upgrade on satisfied desired state performs no unintended mutations. +- [x] **OPS-04**: Every external process and network-affecting operation emits observable, correlated, auditable events. +- [x] **OPS-05**: Partial failure leaves a durable journal and actionable resume, retry, or rollback guidance. +- [x] **OPS-06**: Repository synchronization detects dirty/diverged state and refuses destructive reconciliation by default. +- [x] **OPS-07**: User can run a repair command that safely reconciles detected drift through the same plan/apply engine. ### Client and Workspace Integration @@ -102,13 +102,13 @@ | SAFE-03 | Phase 2 | Complete | | SAFE-04 | Phase 2 | Complete | | SAFE-05 | Phase 2 | Complete | -| OPS-01 | Phase 3 | Pending | -| OPS-02 | Phase 3 | Pending | -| OPS-03 | Phase 3 | Pending | -| OPS-04 | Phase 3 | Pending | -| OPS-05 | Phase 3 | Pending | -| OPS-06 | Phase 3 | Pending | -| OPS-07 | Phase 3 | Pending | +| OPS-01 | Phase 3 | Complete | +| OPS-02 | Phase 3 | Complete | +| OPS-03 | Phase 3 | Complete | +| OPS-04 | Phase 3 | Complete | +| OPS-05 | Phase 3 | Complete | +| OPS-06 | Phase 3 | Complete | +| OPS-07 | Phase 3 | Complete | | CFG-01 | Phase 4 | Pending | | CFG-02 | Phase 4 | Pending | | CFG-03 | Phase 4 | Pending | diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index c1a0580..2c2cf6c 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -42,7 +42,7 @@ CAS Workstation v1 progresses from a functional seed to a trustworthy desired-st 3. Canonical path and ownership policies reject forbidden, escaping, junction, and unrelated targets. 4. Uninstall preview and apply can affect only ledger-owned resources, with backup and atomic-write contracts verified. -### Phase 3: Transactional Plan and Apply Engine +### Phase 3: Transactional Plan and Apply Engine (Complete: 2026-06-12) **Goal:** Setup, upgrade, and repair use one observable, idempotent, recoverable plan/apply engine. diff --git a/.planning/STATE.md b/.planning/STATE.md index f8acf25..15f3d20 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -2,14 +2,14 @@ gsd_state_version: 1.0 milestone: v1.0 milestone_name: milestone -status: executing -last_updated: "2026-06-12T00:00:00.000Z" +status: ready_to_discuss +last_updated: "2026-06-12T17:45:00.000Z" progress: total_phases: 7 - completed_phases: 2 + completed_phases: 3 total_plans: 9 - completed_plans: 6 - percent: 29 + completed_plans: 9 + percent: 43 --- # Project State @@ -19,21 +19,21 @@ progress: See: `.planning/PROJECT.md` (updated 2026-06-11) **Core value:** An AI developer can run one safe, repeatable workflow and receive a complete, working workstation without manually discovering or reconciling prerequisites. -**Current focus:** Phase 3 - transactional plan and apply engine +**Current focus:** Phase 4 - client, skills, and workspace profiles ## Current Position -Phase: 3 -Plan: 03-01 +Phase: 4 +Plan: Not started - Project initialization: complete - Research: complete - Requirements: 35 v1 requirements, all mapped - Roadmap: 7 phases -- Completed phases: Phase 1 and Phase 2 -- Active phase: Phase 3 - Transactional Plan and Apply Engine (executing) -- Phase 2 plans: 3/3 complete -- Implementation: Phase 2 verified +- Completed phases: Phase 1, Phase 2, and Phase 3 +- Active phase: Phase 4 - Client, Skills, and Workspace Profiles +- Phase 3 plans: 3/3 complete +- Implementation: Phase 3 verified ## Workflow @@ -47,7 +47,7 @@ Plan: 03-01 ## Next Action -Execute Phase 3 plans in dependency order and verify the phase goal. +Run `$gsd-discuss-phase 4` before planning client, skills, and workspace profiles. ## Decisions and Risks diff --git a/.planning/phases/03-transactional-plan-and-apply-engine/03-01-PLAN.md b/.planning/phases/03-transactional-plan-and-apply-engine/03-01-PLAN.md index 355dceb..c4744f1 100644 --- a/.planning/phases/03-transactional-plan-and-apply-engine/03-01-PLAN.md +++ b/.planning/phases/03-transactional-plan-and-apply-engine/03-01-PLAN.md @@ -27,4 +27,3 @@ Build a deterministic, inspectable operation planner shared by all operation mod Equivalent inputs produce byte-equivalent canonical plans and satisfied state produces skips. Create `03-01-SUMMARY.md` after execution. - diff --git a/.planning/phases/03-transactional-plan-and-apply-engine/03-01-SUMMARY.md b/.planning/phases/03-transactional-plan-and-apply-engine/03-01-SUMMARY.md index 3850e1f..595b511 100644 --- a/.planning/phases/03-transactional-plan-and-apply-engine/03-01-SUMMARY.md +++ b/.planning/phases/03-transactional-plan-and-apply-engine/03-01-SUMMARY.md @@ -15,4 +15,3 @@ commands, sources, risks, reasons, and idempotent skip outcomes. - Plan Pester tests: 3/3 passed. - Operation-plan schema fixtures: passed. - `git diff --check`: passed. - diff --git a/.planning/phases/03-transactional-plan-and-apply-engine/03-02-PLAN.md b/.planning/phases/03-transactional-plan-and-apply-engine/03-02-PLAN.md index cc9fe1e..3f6929f 100644 --- a/.planning/phases/03-transactional-plan-and-apply-engine/03-02-PLAN.md +++ b/.planning/phases/03-transactional-plan-and-apply-engine/03-02-PLAN.md @@ -28,4 +28,3 @@ Build a durable correlated apply engine with bounded retry and recovery guidance Partial failure is durable, observable, bounded, and actionable. Create `03-02-SUMMARY.md` after execution. - diff --git a/.planning/phases/03-transactional-plan-and-apply-engine/03-02-SUMMARY.md b/.planning/phases/03-transactional-plan-and-apply-engine/03-02-SUMMARY.md index 47351a2..dcae520 100644 --- a/.planning/phases/03-transactional-plan-and-apply-engine/03-02-SUMMARY.md +++ b/.planning/phases/03-transactional-plan-and-apply-engine/03-02-SUMMARY.md @@ -15,4 +15,3 @@ persistence, bounded retry, fail-stop behavior, and resumable execution. - Apply Pester tests: 3/3 passed. - PSScriptAnalyzer: no findings. - `git diff --check`: passed. - diff --git a/.planning/phases/03-transactional-plan-and-apply-engine/03-03-PLAN.md b/.planning/phases/03-transactional-plan-and-apply-engine/03-03-PLAN.md index 16869f6..52c224f 100644 --- a/.planning/phases/03-transactional-plan-and-apply-engine/03-03-PLAN.md +++ b/.planning/phases/03-transactional-plan-and-apply-engine/03-03-PLAN.md @@ -38,4 +38,3 @@ Route setup, upgrade, and repair through the engine and fail closed on risky rep All operational modes share one safe engine and risky repositories fail closed. Create `03-03-SUMMARY.md` after execution. - diff --git a/.planning/phases/03-transactional-plan-and-apply-engine/03-03-SUMMARY.md b/.planning/phases/03-transactional-plan-and-apply-engine/03-03-SUMMARY.md index 6e5b6c3..a977bb4 100644 --- a/.planning/phases/03-transactional-plan-and-apply-engine/03-03-SUMMARY.md +++ b/.planning/phases/03-transactional-plan-and-apply-engine/03-03-SUMMARY.md @@ -16,4 +16,3 @@ and declared the four public CAS golden-path repositories in the full profile. - Phase 3 focused tests: 14/14 passed. - Full quality gate: 46/46 tests passed with contracts, governance, and static analysis. - `git diff --check`: passed. - diff --git a/.planning/phases/03-transactional-plan-and-apply-engine/03-CONTEXT.md b/.planning/phases/03-transactional-plan-and-apply-engine/03-CONTEXT.md index a58e207..5c45e0b 100644 --- a/.planning/phases/03-transactional-plan-and-apply-engine/03-CONTEXT.md +++ b/.planning/phases/03-transactional-plan-and-apply-engine/03-CONTEXT.md @@ -27,4 +27,3 @@ engine. Preview is the default. Mutation requires explicit apply intent. - Satisfied resources become `skip` operations. - Failed operations stop the run and leave later operations pending. - Retry count is bounded and persisted. - diff --git a/.planning/phases/03-transactional-plan-and-apply-engine/03-RESEARCH.md b/.planning/phases/03-transactional-plan-and-apply-engine/03-RESEARCH.md index 4e76917..b8fc879 100644 --- a/.planning/phases/03-transactional-plan-and-apply-engine/03-RESEARCH.md +++ b/.planning/phases/03-transactional-plan-and-apply-engine/03-RESEARCH.md @@ -20,4 +20,3 @@ Pester can prove behavior without installing packages or contacting networks. - Apply a synthetic plan twice and assert the second plan contains skips only. - Inject operation failure and prove journal/event correlation and resume scope. - Exercise dirty, detached, unexpected-branch, and diverged Git status parsing. - diff --git a/.planning/phases/03-transactional-plan-and-apply-engine/03-VERIFICATION.md b/.planning/phases/03-transactional-plan-and-apply-engine/03-VERIFICATION.md new file mode 100644 index 0000000..c04c89d --- /dev/null +++ b/.planning/phases/03-transactional-plan-and-apply-engine/03-VERIFICATION.md @@ -0,0 +1,32 @@ +--- +status: passed +phase: 03-transactional-plan-and-apply-engine +verified: 2026-06-12 +score: 7/7 +--- + +# Phase 3 Verification + +Phase 3 achieved its goal: setup, upgrade, and repair now use one observable, +idempotent, recoverable plan/apply engine. + +## Requirement Evidence + +| Requirement | Evidence | Result | +|-------------|----------|--------| +| OPS-01 | `tests/Plan.Tests.ps1`, `tests/OperationWorkflow.Tests.ps1` | Passed | +| OPS-02 | `tests/Plan.Tests.ps1` | Passed | +| OPS-03 | `tests/Plan.Tests.ps1` | Passed | +| OPS-04 | `tests/Apply.Tests.ps1` | Passed | +| OPS-05 | `tests/Apply.Tests.ps1`, `tests/OperationWorkflow.Tests.ps1` | Passed | +| OPS-06 | `tests/RepositorySafety.Tests.ps1` | Passed | +| OPS-07 | `tests/OperationWorkflow.Tests.ps1` | Passed | + +## Quality Evidence + +- Full quality gate: passed. +- Pester: 46/46 passed. +- PSScriptAnalyzer: passed. +- Contract fixtures: passed. +- Governance validation: 35 requirements mapped, 21 verified. +- `git diff --check`: passed after planning document normalization.