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 4e9f9cc..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: ready_to_plan
-last_updated: "2026-06-11T19:11:00.000Z"
+status: ready_to_discuss
+last_updated: "2026-06-12T17:45:00.000Z"
progress:
total_phases: 7
- completed_phases: 2
- total_plans: 6
- completed_plans: 6
- percent: 29
+ completed_phases: 3
+ total_plans: 9
+ 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
+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
-- 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: Not started
## Next Action
-Run `$gsd-discuss-phase 3` before planning Transactional Plan and Apply Engine.
+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
new file mode 100644
index 0000000..c4744f1
--- /dev/null
+++ b/.planning/phases/03-transactional-plan-and-apply-engine/03-01-PLAN.md
@@ -0,0 +1,29 @@
+---
+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.
+
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..595b511
--- /dev/null
+++ b/.planning/phases/03-transactional-plan-and-apply-engine/03-01-SUMMARY.md
@@ -0,0 +1,17 @@
+---
+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/.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..3f6929f
--- /dev/null
+++ b/.planning/phases/03-transactional-plan-and-apply-engine/03-02-PLAN.md
@@ -0,0 +1,30 @@
+---
+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.
+
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..dcae520
--- /dev/null
+++ b/.planning/phases/03-transactional-plan-and-apply-engine/03-02-SUMMARY.md
@@ -0,0 +1,17 @@
+---
+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/.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..52c224f
--- /dev/null
+++ b/.planning/phases/03-transactional-plan-and-apply-engine/03-03-PLAN.md
@@ -0,0 +1,40 @@
+---
+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.
+
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..a977bb4
--- /dev/null
+++ b/.planning/phases/03-transactional-plan-and-apply-engine/03-03-SUMMARY.md
@@ -0,0 +1,18 @@
+---
+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/.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..5c45e0b
--- /dev/null
+++ b/.planning/phases/03-transactional-plan-and-apply-engine/03-CONTEXT.md
@@ -0,0 +1,29 @@
+# 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..b8fc879
--- /dev/null
+++ b/.planning/phases/03-transactional-plan-and-apply-engine/03-RESEARCH.md
@@ -0,0 +1,22 @@
+# 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.
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.
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/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..37dcbd9 100644
--- a/scripts/Cas.Workstation.psm1
+++ b/scripts/Cas.Workstation.psm1
@@ -986,6 +986,459 @@ 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) {
+ (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() }
+}
+
+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", "behind", "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."
+ }
+ $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."
+ }
+ 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 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
+ plan = $Plan
+ 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 {
+ $null = Get-CasRepositorySafetyStatus -Path $Operation.target -ExpectedOrigin $Operation.source -ExpectedBranch $Operation.defaultBranch
+ & git -C $Operation.target fetch origin
+ 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
+ }
+ 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 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
@@ -1052,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
new file mode 100644
index 0000000..7a0194f
--- /dev/null
+++ b/tests/Apply.Tests.ps1
@@ -0,0 +1,72 @@
+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 = $null
+ correlationId = $null
+ 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" }
+ )
+ }
+ $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" {
+ 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
+ }
+
+ 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
new file mode 100644
index 0000000..f66d132
--- /dev/null
+++ b/tests/Plan.Tests.ps1
@@ -0,0 +1,44 @@
+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
+ }
+
+ 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/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"}]}
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