From a1592c984bc274aed1df69a30b464b25389ffa03 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Wed, 25 Mar 2026 00:30:24 -0500 Subject: [PATCH] =?UTF-8?q?feat(gsd):=20single-writer=20engine=20v3=20?= =?UTF-8?q?=E2=80=94=20state=20machine=20guards,=20actor=20identity,=20rev?= =?UTF-8?q?ersibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three work streams bundled into one phase to close the behavioral control gaps identified in the v2 handler audit: Stream 1 — State machine guards on all 8 tool handlers: - Entity existence checks before mutations (milestone, slice, task) - Valid status transition enforcement (can't double-complete, can't re-plan closed work, can't complete inside a closed parent) - depends_on validation for plan-milestone (deps must exist + be complete) - blockerTaskId verification in replan-slice (must exist + be complete) - Deep task check in complete-milestone (all tasks, not just slice status) Stream 2 — Actor identity + persistent audit log: - WorkflowEvent extended with actor_name, trigger_reason, session_id - Engine-generated UUID session_id stable per process lifetime - All 8 handlers accept optional actorName/triggerReason and pass through - workflow-logger now flushes to .gsd/audit-log.jsonl (survives context resets) - New setLogBasePath() and readAuditLog() API Stream 3 — Reversibility + unit ownership: - New gsd_task_reopen handler (reset task to pending with full guards) - New gsd_slice_reopen handler (reset slice + all tasks with transaction) - Opt-in unit ownership via .gsd/unit-claims.json (claim/release/check) - Ownership enforced in complete-task and complete-slice when claims exist - insertReplanHistory converted to upsert via schema v11 unique index Bug fixes (pre-existing): - renderPlanContent checkbox: checked "done" but tasks are "complete" - renderRoadmapContent: same "done" vs "complete" mismatch - renderPlanContent format: **T01:** title didn't match parsePlan regex - Tests updated to seed DB entities and match projection output format --- .../single-writer-engine-v3-control-plane.md | 396 +++++++++++++ src/resources/extensions/gsd/gsd-db.ts | 13 +- .../gsd/tests/complete-slice.test.ts | 452 +++++++------- .../gsd/tests/complete-task.test.ts | 553 ++++++++++-------- .../gsd/tools/complete-milestone.ts | 27 + .../extensions/gsd/tools/complete-slice.ts | 32 + .../extensions/gsd/tools/complete-task.ts | 38 ++ .../extensions/gsd/tools/plan-milestone.ts | 26 + .../extensions/gsd/tools/plan-slice.ts | 18 + .../extensions/gsd/tools/plan-task.ts | 16 +- .../extensions/gsd/tools/reassess-roadmap.ts | 21 +- .../extensions/gsd/tools/reopen-slice.ts | 113 ++++ .../extensions/gsd/tools/reopen-task.ts | 115 ++++ .../extensions/gsd/tools/replan-slice.ts | 20 +- src/resources/extensions/gsd/types.ts | 8 + .../extensions/gsd/unit-ownership.ts | 104 ++++ .../extensions/gsd/workflow-events.ts | 27 +- .../extensions/gsd/workflow-logger.ts | 52 +- .../extensions/gsd/workflow-projections.ts | 6 +- 19 files changed, 1573 insertions(+), 464 deletions(-) create mode 100644 .plans/single-writer-engine-v3-control-plane.md create mode 100644 src/resources/extensions/gsd/tools/reopen-slice.ts create mode 100644 src/resources/extensions/gsd/tools/reopen-task.ts create mode 100644 src/resources/extensions/gsd/unit-ownership.ts diff --git a/.plans/single-writer-engine-v3-control-plane.md b/.plans/single-writer-engine-v3-control-plane.md new file mode 100644 index 000000000..ad294ef55 --- /dev/null +++ b/.plans/single-writer-engine-v3-control-plane.md @@ -0,0 +1,396 @@ +# Single-Writer Engine v3: Agent Control Plane +# Plan: State machine guards + actor causation + reversibility +# Created: 2026-03-25 + +--- + +## Background + +v2 gave the engine **write discipline** — agents can't corrupt STATE.md directly, +every mutation goes through the DB, event log is append-only. + +What v2 did NOT give us: **behavioral control**. Agents can still: +- Complete a task twice (silent overwrite) +- Complete a slice with open tasks (if they bypass the slice status check) +- Complete a milestone in any status +- Re-plan already-completed slices/tasks +- Call any tool on any unit regardless of ownership +- Leave no trace of *who* did what or *why* + +This plan bundles three work streams that close those gaps together, since they +share infrastructure (WorkflowEvent schema, DB query surface, handler preconditions). + +--- + +## Work Streams + +### Stream 1 — State Machine Guards (P0) +Add precondition checks to all 8 tool handlers so invalid transitions return an +error instead of silently succeeding. + +### Stream 2 — Actor Identity + Persistent Audit Log (P1) +Extend `WorkflowEvent` with `actor_name` and `trigger_reason`. Flush the +in-process `workflow-logger` buffer to a persistent `.gsd/audit-log.jsonl` +after every tool invocation, so "who did what and why" is durable. + +### Stream 3 — Reversibility + Unit Ownership (P2) +Add `gsd_task_reopen` and `gsd_slice_reopen` tools. Add a unit-ownership +validation layer so an agent can only complete/reopen units it explicitly claimed. + +--- + +## Detailed Task Breakdown + +--- + +### Stream 1: State Machine Guards + +#### S1-T1: Add `getTask`, `getSlice`, `getMilestone` existence helpers to `gsd-db.ts` + +**Files:** `src/resources/extensions/gsd/gsd-db.ts` + +These are read-only DB helpers to confirm an entity exists and return its current +`status` field before any mutation. Each returns `null` if not found. + +```ts +getTask(taskId: string, sliceId: string): { status: string } | null +getSlice(sliceId: string, milestoneId: string): { status: string } | null +getMilestoneById(milestoneId: string): { status: string } | null +``` + +Note: `getSlice` may already exist — check before adding a duplicate. The audit +report references it in `complete-slice.ts` line 207 but only to list tasks. +Need a version that returns the slice row itself. + +--- + +#### S1-T2: Guard `complete-task.ts` — enforce valid transitions + +**File:** `src/resources/extensions/gsd/tools/complete-task.ts` + +Preconditions to add (before the transaction block): +1. `getMilestoneById(milestoneId)` → must exist, must NOT be `"complete"` or `"done"` +2. `getSlice(sliceId, milestoneId)` → must exist, must be `"pending"` or `"in_progress"` +3. `getTask(taskId, sliceId)` → if exists, status must be `"pending"` (not already `"complete"`) + +On failure: return `{ error: "" }` — do NOT throw. + +--- + +#### S1-T3: Guard `complete-slice.ts` — enforce valid transitions + +**File:** `src/resources/extensions/gsd/tools/complete-slice.ts` + +Preconditions to add: +1. `getSlice(sliceId, milestoneId)` → must exist, status must be `"pending"` or `"in_progress"` (not already `"complete"`) +2. `getMilestoneById(milestoneId)` → must exist, must NOT be `"complete"` +3. All tasks in slice must be `"complete"` (already enforced — keep it, add explicit slice-status check before this) + +--- + +#### S1-T4: Guard `complete-milestone.ts` — enforce valid transitions + +**File:** `src/resources/extensions/gsd/tools/complete-milestone.ts` + +Preconditions to add: +1. `getMilestoneById(milestoneId)` → must exist, status must be `"active"` (not already `"complete"`) +2. Keep existing all-slices-complete check +3. Add deep check: all tasks across all slices must also be `"complete"` (not just slice status) + +--- + +#### S1-T5: Guard `plan-task.ts` — block re-planning completed tasks + +**File:** `src/resources/extensions/gsd/tools/plan-task.ts` + +Preconditions to add: +1. `getSlice(sliceId, milestoneId)` → must exist, status must NOT be `"complete"` (already blocks planning on a closed slice) +2. If task exists (`getTask`), status must be `"pending"` — block re-planning a `"complete"` task + +--- + +#### S1-T6: Guard `plan-slice.ts` — block re-planning completed slices + +**File:** `src/resources/extensions/gsd/tools/plan-slice.ts` + +Preconditions to add: +1. `getSlice(sliceId, milestoneId)` → if exists, status must NOT be `"complete"` +2. `getMilestoneById(milestoneId)` → must exist, status must NOT be `"complete"` + +--- + +#### S1-T7: Guard `plan-milestone.ts` — block re-planning completed milestones + +**File:** `src/resources/extensions/gsd/tools/plan-milestone.ts` + +Preconditions to add: +1. If milestone exists (`getMilestoneById`), status must NOT be `"complete"` +2. Validate `depends_on` array: each referenced milestoneId must exist and be `"complete"` before this milestone can be planned + +--- + +#### S1-T8: Guard `reassess-roadmap.ts` — verify completedSliceId is actually complete + +**File:** `src/resources/extensions/gsd/tools/reassess-roadmap.ts` + +Gap: `completedSliceId` is accepted without confirming it is actually `"complete"` status. +Also: no check that milestone is still `"active"` (could reassess after milestone is done). + +Preconditions to add: +1. `getSlice(completedSliceId, milestoneId)` → status must be `"complete"` +2. `getMilestoneById(milestoneId)` → status must be `"active"` + +--- + +#### S1-T9: Guard `replan-slice.ts` — verify blockerTaskId exists and is complete + +**File:** `src/resources/extensions/gsd/tools/replan-slice.ts` + +Gaps: +- `blockerTaskId` is accepted without verifying it exists or is `"complete"` +- No check that slice is still `"in_progress"` (could replan after slice is complete) + +Preconditions to add: +1. `getSlice(sliceId, milestoneId)` → status must be `"in_progress"` or `"pending"`, NOT `"complete"` +2. `getTask(blockerTaskId, sliceId)` → must exist, status must be `"complete"` + +--- + +### Stream 2: Actor Identity + Persistent Audit Log + +#### S2-T1: Extend `WorkflowEvent` with actor identity and causation fields + +**File:** `src/resources/extensions/gsd/workflow-events.ts` + +Extend the `WorkflowEvent` interface: +```ts +export interface WorkflowEvent { + cmd: string; + params: Record; + ts: string; + hash: string; + actor: "agent" | "system"; + actor_name?: string; // ADD: e.g. "executor-agent-01", "gsd-orchestrator" + trigger_reason?: string; // ADD: e.g. "plan-phase complete", "user invoked gsd_complete_task" + session_id?: string; // ADD: process.env.GSD_SESSION_ID if set +} +``` + +Update `appendEvent` to accept and persist these new optional fields. +Hash computation must remain stable (still hashes only `cmd + params`, not the new fields) +so fork detection isn't broken. + +--- + +#### S2-T2: Update all 8 tool handlers to pass actor identity to `appendEvent` + +**Files:** All 8 handlers in `src/resources/extensions/gsd/tools/` + +Each handler receives its inputs. Add a convention where params can include: +- `actor_name` (optional string) — caller passes their agent identity +- `trigger_reason` (optional string) — caller passes why this action was triggered + +If not provided, default to `actor_name: "agent"`, `trigger_reason: undefined`. + +Handlers pass these through to `appendEvent`. + +The tool schemas (in the MCP tool definitions) should expose `actor_name` and +`trigger_reason` as optional string params so agents can self-identify. + +--- + +#### S2-T3: Persist `workflow-logger` to `.gsd/audit-log.jsonl` + +**File:** `src/resources/extensions/gsd/workflow-logger.ts` + +Current behavior: `_buffer` is in-process memory, drained per-unit and dropped. +This means errors/warnings disappear across context resets. + +Change: After `_push()` writes to the in-process buffer, also append the entry +to `.gsd/audit-log.jsonl` (using `appendFileSync`). This requires the basePath +to be available — either pass it as a module-level setter (`setLogBasePath(path)`) +called at engine init, or accept it as a param on `logWarning`/`logError`. + +The audit log format should match `LogEntry` serialized as JSON + newline, +consistent with `event-log.jsonl`. + +--- + +#### S2-T4: Add `readAuditLog` helper to `workflow-logger.ts` + +**File:** `src/resources/extensions/gsd/workflow-logger.ts` + +Expose a read function so the auto-loop and diagnostics can surface persistent +audit entries without replaying the event log: + +```ts +export function readAuditLog(basePath: string): LogEntry[] +``` + +--- + +### Stream 3: Reversibility + Unit Ownership + +#### S3-T1: Add `updateTaskStatus` and `updateSliceStatus` DB helpers + +**File:** `src/resources/extensions/gsd/gsd-db.ts` + +If they don't already exist (check first): +```ts +updateTaskStatus(taskId: string, sliceId: string, status: string): void +updateSliceStatus(sliceId: string, milestoneId: string, status: string): void +``` + +These are the write primitives needed by reopen tools. + +--- + +#### S3-T2: Implement `gsd_task_reopen` tool handler + +**New file:** `src/resources/extensions/gsd/tools/reopen-task.ts` + +Logic: +1. Validate `taskId`, `sliceId`, `milestoneId` are non-empty strings +2. `getTask(taskId, sliceId)` → must exist, status must be `"complete"` (can't reopen what isn't closed) +3. `getSlice(sliceId, milestoneId)` → must exist, status must NOT be `"complete"` (can't reopen a task inside a closed slice — too late) +4. `getMilestoneById(milestoneId)` → must exist, status must NOT be `"complete"` +5. In a transaction: `updateTaskStatus(taskId, sliceId, "pending")` +6. Append event: `cmd: "reopen_task"`, include `actor_name`, `trigger_reason` +7. Invalidate state cache + render projections + +--- + +#### S3-T3: Implement `gsd_slice_reopen` tool handler + +**New file:** `src/resources/extensions/gsd/tools/reopen-slice.ts` + +Logic: +1. Validate `sliceId`, `milestoneId` +2. `getSlice(sliceId, milestoneId)` → must exist, status must be `"complete"` +3. `getMilestoneById(milestoneId)` → must NOT be `"complete"` +4. In a transaction: `updateSliceStatus(sliceId, milestoneId, "in_progress")` + set all tasks back to `"pending"` +5. Append event: `cmd: "reopen_slice"` +6. Invalidate state cache + render projections + +--- + +#### S3-T4: Add unit ownership claim/check mechanism + +**New file:** `src/resources/extensions/gsd/unit-ownership.ts` + +Lightweight JSON file at `.gsd/unit-claims.json` mapping unit IDs to agent names: +```json +{ + "M01/S01/T01": { "agent": "executor-01", "claimed_at": "2026-03-25T..." }, + "M01/S01": { "agent": "executor-01", "claimed_at": "2026-03-25T..." } +} +``` + +Functions: +```ts +claimUnit(basePath, unitKey, agentName): void // atomic write +releaseUnit(basePath, unitKey): void +getOwner(basePath, unitKey): string | null +``` + +`unitKey` format: `"//"` for tasks, `"/"` for slices. + +--- + +#### S3-T5: Wire ownership check into `complete-task` and `complete-slice` + +**Files:** `complete-task.ts`, `complete-slice.ts` + +If `actor_name` is provided AND `.gsd/unit-claims.json` exists AND the unit is claimed: +- Verify `actor_name` matches the registered owner +- If mismatch: return `{ error: "Unit is owned by , not " }` +- If no claim file / unit is unclaimed: allow the operation (opt-in ownership) + +Ownership is enforced only when claims are present, keeping the feature opt-in. + +--- + +## Files Changed Summary + +| File | Change Type | +|------|-------------| +| `gsd-db.ts` | Add `getTask`, `getMilestoneById` existence helpers; add `updateTaskStatus`, `updateSliceStatus` | +| `workflow-events.ts` | Extend `WorkflowEvent` with `actor_name`, `trigger_reason`, `session_id` | +| `workflow-logger.ts` | Add persistent flush to `.gsd/audit-log.jsonl`; add `setLogBasePath`; add `readAuditLog` | +| `tools/complete-task.ts` | State machine guards + ownership check + actor passthrough | +| `tools/complete-slice.ts` | State machine guards + ownership check + actor passthrough | +| `tools/complete-milestone.ts` | State machine guards + deep task check | +| `tools/plan-task.ts` | Block re-planning complete tasks | +| `tools/plan-slice.ts` | Block re-planning complete slices | +| `tools/plan-milestone.ts` | Block re-planning complete milestones + depends_on validation | +| `tools/reassess-roadmap.ts` | Verify completedSliceId status + milestone status check | +| `tools/replan-slice.ts` | Verify blockerTaskId exists + slice status check | +| `tools/reopen-task.ts` | NEW — gsd_task_reopen handler | +| `tools/reopen-slice.ts` | NEW — gsd_slice_reopen handler | +| `unit-ownership.ts` | NEW — claim/release/check ownership | + +--- + +## Execution Order (Dependencies) + +``` +S1-T1 (DB helpers) + └── S1-T2 (complete-task guards) + └── S1-T3 (complete-slice guards) + └── S1-T4 (complete-milestone guards) + └── S1-T5 (plan-task guards) + └── S1-T6 (plan-slice guards) + └── S1-T7 (plan-milestone guards) + └── S1-T8 (reassess-roadmap guards) + └── S1-T9 (replan-slice guards) + └── S3-T1 (updateTask/SliceStatus helpers) ── S3-T2, S3-T3 + +S2-T1 (WorkflowEvent schema) + └── S2-T2 (handler actor passthrough) + +S2-T3 (audit-log flush) + └── S2-T4 (readAuditLog) + +S3-T4 (unit-ownership.ts) + └── S3-T5 (wire into complete-task/slice) +``` + +Parallelizable: +- All of Stream 1 (S1-T2 through S1-T9) can run in parallel once S1-T1 is done +- Stream 2 and Stream 3 are fully independent of Stream 1 + +--- + +## What Success Looks Like + +After this phase: + +1. **Double-complete** → returns `{ error: "Task T01 is already complete" }` instead of silently overwriting +2. **Complete slice with open tasks** → still blocked (was already caught), plus slice status guard added +3. **Re-plan closed work** → returns `{ error: "Cannot re-plan: slice S01 is already complete" }` +4. **Wrong agent completes task** → returns `{ error: "Unit M01/S01/T01 is owned by executor-01, not executor-02" }` +5. **Post-mortem** → `.gsd/audit-log.jsonl` has full trace with actor_name + trigger_reason across context resets +6. **Oops recovery** → `gsd_task_reopen` / `gsd_slice_reopen` without manual SQL surgery +7. **depends_on enforcement** → cannot plan M02 if M01 is not yet complete + +--- + +## Decisions + +1. **Ownership: opt-in** — enforced only when `.gsd/unit-claims.json` exists. Zero breaking change for existing workflows; teams adopt incrementally. + +2. **Slice reopen: reset all tasks to `"pending"`** — simpler invariant. If you're reopening a slice, you're re-doing the work. Partial resets create ambiguous state. + +3. **`trigger_reason`: caller-provided** — agents know *why* they acted; the engine can only know *what* was called. Default to `undefined` if not passed. + +4. **Session ID: engine-generated** — UUID generated once at engine startup, stored in module state in `workflow-events.ts`. No reliance on agents setting env vars correctly. + +5. **Idempotency: fix in this phase** — convert `insertAssessment` and `insertReplanHistory` to upserts (keyed on `milestoneId+sliceId` and `milestoneId+sliceId+ts` respectively). Accumulating duplicate records on retry is a bug, not a feature. + +### Additional task from decision 5: +#### S1-T10: Convert `insertAssessment` and `insertReplanHistory` to upserts + +**File:** `src/resources/extensions/gsd/gsd-db.ts` + +- `insertAssessment`: upsert keyed on `(milestone_id, completed_slice_id)` — one assessment per completed slice per milestone +- `insertReplanHistory`: upsert keyed on `(milestone_id, slice_id, blocker_task_id)` — one replan record per blocker per slice diff --git a/src/resources/extensions/gsd/gsd-db.ts b/src/resources/extensions/gsd/gsd-db.ts index d581c855c..2c777e0f0 100644 --- a/src/resources/extensions/gsd/gsd-db.ts +++ b/src/resources/extensions/gsd/gsd-db.ts @@ -149,7 +149,7 @@ function openRawDb(path: string): unknown { return new Database(path); } -const SCHEMA_VERSION = 10; +const SCHEMA_VERSION = 11; function initSchema(db: DbAdapter, fileBacked: boolean): void { if (fileBacked) db.exec("PRAGMA journal_mode=WAL"); @@ -623,6 +623,13 @@ function migrateSchema(db: DbAdapter): void { if (currentVersion < 11) { ensureColumn(db, "tasks", "full_plan_md", `ALTER TABLE tasks ADD COLUMN full_plan_md TEXT NOT NULL DEFAULT ''`); + // Add unique constraint to replan_history for idempotency: + // one replan record per blocker task per slice per milestone. + db.exec(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_replan_history_unique + ON replan_history(milestone_id, slice_id, task_id) + WHERE slice_id IS NOT NULL AND task_id IS NOT NULL + `); db.prepare("INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)").run({ ":version": 11, @@ -1606,8 +1613,10 @@ export function insertReplanHistory(entry: { replacementArtifactPath?: string | null; }): void { if (!currentDb) throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open"); + // INSERT OR REPLACE: idempotent on (milestone_id, slice_id, task_id) via schema v11 unique index. + // Retrying the same replan silently updates summary instead of accumulating duplicate rows. currentDb.prepare( - `INSERT INTO replan_history (milestone_id, slice_id, task_id, summary, previous_artifact_path, replacement_artifact_path, created_at) + `INSERT OR REPLACE INTO replan_history (milestone_id, slice_id, task_id, summary, previous_artifact_path, replacement_artifact_path, created_at) VALUES (:milestone_id, :slice_id, :task_id, :summary, :previous_artifact_path, :replacement_artifact_path, :created_at)`, ).run({ ":milestone_id": entry.milestoneId, diff --git a/src/resources/extensions/gsd/tests/complete-slice.test.ts b/src/resources/extensions/gsd/tests/complete-slice.test.ts index efacd80d8..44f78b4c3 100644 --- a/src/resources/extensions/gsd/tests/complete-slice.test.ts +++ b/src/resources/extensions/gsd/tests/complete-slice.test.ts @@ -1,5 +1,4 @@ -import { describe, test, afterEach } from "node:test"; -import assert from "node:assert/strict"; +import { createTestContext } from './test-helpers.ts'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; @@ -18,6 +17,8 @@ import { import { handleCompleteSlice } from '../tools/complete-slice.ts'; import type { CompleteSliceParams } from '../types.ts'; +const { assertEq, assertTrue, assertMatch, report } = createTestContext(); + // ═══════════════════════════════════════════════════════════════════════════ // Helpers // ═══════════════════════════════════════════════════════════════════════════ @@ -114,262 +115,297 @@ Run the test suite and verify all assertions pass. } // ═══════════════════════════════════════════════════════════════════════════ -// Tests +// complete-slice: Schema v6 migration // ═══════════════════════════════════════════════════════════════════════════ -describe("complete-slice: schema v6 migration", () => { - test("schema version and columns exist", () => { - const dbPath = tempDbPath(); - openDatabase(dbPath); +console.log('\n=== complete-slice: schema v6 migration ==='); +{ + const dbPath = tempDbPath(); + openDatabase(dbPath); - const adapter = _getAdapter()!; + const adapter = _getAdapter()!; - // Verify schema version is current (v10 after M001 planning migrations) - const versionRow = adapter.prepare('SELECT MAX(version) as v FROM schema_version').get(); - assert.strictEqual(versionRow?.['v'], 10, 'schema version should be 10'); + // Verify schema version is current (v10 after M001 planning migrations) + const versionRow = adapter.prepare('SELECT MAX(version) as v FROM schema_version').get(); + assertEq(versionRow?.['v'], 11, 'schema version should be 11'); - // Verify slices table has full_summary_md and full_uat_md columns - const cols = adapter.prepare("PRAGMA table_info(slices)").all(); - const colNames = cols.map(c => c['name'] as string); - assert.ok(colNames.includes('full_summary_md'), 'slices table should have full_summary_md column'); - assert.ok(colNames.includes('full_uat_md'), 'slices table should have full_uat_md column'); + // Verify slices table has full_summary_md and full_uat_md columns + const cols = adapter.prepare("PRAGMA table_info(slices)").all(); + const colNames = cols.map(c => c['name'] as string); + assertTrue(colNames.includes('full_summary_md'), 'slices table should have full_summary_md column'); + assertTrue(colNames.includes('full_uat_md'), 'slices table should have full_uat_md column'); - cleanup(dbPath); - }); -}); + cleanup(dbPath); +} -describe("complete-slice: getSlice/updateSliceStatus accessors", () => { - test("getSlice and updateSliceStatus work correctly", () => { - const dbPath = tempDbPath(); - openDatabase(dbPath); +// ═══════════════════════════════════════════════════════════════════════════ +// complete-slice: getSlice/updateSliceStatus accessors +// ═══════════════════════════════════════════════════════════════════════════ - // Insert milestone and slice - insertMilestone({ id: 'M001' }); - insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Test Slice', risk: 'high' }); +console.log('\n=== complete-slice: getSlice/updateSliceStatus accessors ==='); +{ + const dbPath = tempDbPath(); + openDatabase(dbPath); - // getSlice returns correct row - const slice = getSlice('M001', 'S01'); - assert.ok(slice !== null, 'getSlice should return non-null for existing slice'); - assert.strictEqual(slice!.id, 'S01', 'slice id'); - assert.strictEqual(slice!.milestone_id, 'M001', 'slice milestone_id'); - assert.strictEqual(slice!.title, 'Test Slice', 'slice title'); - assert.strictEqual(slice!.risk, 'high', 'slice risk'); - assert.strictEqual(slice!.status, 'pending', 'slice default status should be pending'); - assert.strictEqual(slice!.completed_at, null, 'slice completed_at should be null initially'); - assert.strictEqual(slice!.full_summary_md, '', 'slice full_summary_md should be empty initially'); - assert.strictEqual(slice!.full_uat_md, '', 'slice full_uat_md should be empty initially'); + // Insert milestone and slice + insertMilestone({ id: 'M001' }); + insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Test Slice', risk: 'high' }); - // getSlice returns null for non-existent - const noSlice = getSlice('M001', 'S99'); - assert.strictEqual(noSlice, null, 'non-existent slice should return null'); + // getSlice returns correct row + const slice = getSlice('M001', 'S01'); + assertTrue(slice !== null, 'getSlice should return non-null for existing slice'); + assertEq(slice!.id, 'S01', 'slice id'); + assertEq(slice!.milestone_id, 'M001', 'slice milestone_id'); + assertEq(slice!.title, 'Test Slice', 'slice title'); + assertEq(slice!.risk, 'high', 'slice risk'); + assertEq(slice!.status, 'pending', 'slice default status should be pending'); + assertEq(slice!.completed_at, null, 'slice completed_at should be null initially'); + assertEq(slice!.full_summary_md, '', 'slice full_summary_md should be empty initially'); + assertEq(slice!.full_uat_md, '', 'slice full_uat_md should be empty initially'); - // updateSliceStatus changes status and completed_at - const now = new Date().toISOString(); - updateSliceStatus('M001', 'S01', 'complete', now); - const updated = getSlice('M001', 'S01'); - assert.strictEqual(updated!.status, 'complete', 'slice status should be updated to complete'); - assert.strictEqual(updated!.completed_at, now, 'slice completed_at should be set'); + // getSlice returns null for non-existent + const noSlice = getSlice('M001', 'S99'); + assertEq(noSlice, null, 'non-existent slice should return null'); - cleanup(dbPath); - }); -}); + // updateSliceStatus changes status and completed_at + const now = new Date().toISOString(); + updateSliceStatus('M001', 'S01', 'complete', now); + const updated = getSlice('M001', 'S01'); + assertEq(updated!.status, 'complete', 'slice status should be updated to complete'); + assertEq(updated!.completed_at, now, 'slice completed_at should be set'); -describe("complete-slice: handler", () => { - test("happy path", async () => { - const dbPath = tempDbPath(); - openDatabase(dbPath); + cleanup(dbPath); +} - const { basePath, roadmapPath } = createTempProject(); +// ═══════════════════════════════════════════════════════════════════════════ +// complete-slice: Handler happy path +// ═══════════════════════════════════════════════════════════════════════════ - // Set up DB state: milestone, slice, 2 complete tasks - insertMilestone({ id: 'M001' }); - insertSlice({ id: 'S01', milestoneId: 'M001' }); - insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', status: 'complete', title: 'Task 1' }); - insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', status: 'complete', title: 'Task 2' }); +console.log('\n=== complete-slice: handler happy path ==='); +{ + const dbPath = tempDbPath(); + openDatabase(dbPath); - const params = makeValidSliceParams(); - const result = await handleCompleteSlice(params, basePath); + const { basePath, roadmapPath } = createTempProject(); - assert.ok(!('error' in result), 'handler should succeed without error'); - if (!('error' in result)) { - assert.strictEqual(result.sliceId, 'S01', 'result sliceId'); - assert.strictEqual(result.milestoneId, 'M001', 'result milestoneId'); - assert.ok(result.summaryPath.endsWith('S01-SUMMARY.md'), 'summaryPath should end with S01-SUMMARY.md'); - assert.ok(result.uatPath.endsWith('S01-UAT.md'), 'uatPath should end with S01-UAT.md'); + // Set up DB state: milestone, slices (S01 + S02), 2 complete tasks + insertMilestone({ id: 'M001' }); + insertSlice({ id: 'S01', milestoneId: 'M001' }); + insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Second Slice' }); + insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', status: 'complete', title: 'Task 1' }); + insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', status: 'complete', title: 'Task 2' }); - // (a) Verify SUMMARY.md exists on disk with correct YAML frontmatter - assert.ok(fs.existsSync(result.summaryPath), 'summary file should exist on disk'); - const summaryContent = fs.readFileSync(result.summaryPath, 'utf-8'); - assert.match(summaryContent, /^---\n/, 'summary should start with YAML frontmatter'); - assert.match(summaryContent, /id: S01/, 'summary should contain id: S01'); - assert.match(summaryContent, /parent: M001/, 'summary should contain parent: M001'); - assert.match(summaryContent, /milestone: M001/, 'summary should contain milestone: M001'); - assert.match(summaryContent, /blocker_discovered: false/, 'summary should contain blocker_discovered'); - assert.match(summaryContent, /verification_result: passed/, 'summary should contain verification_result'); - assert.match(summaryContent, /key_files:/, 'summary should contain key_files'); - assert.match(summaryContent, /patterns_established:/, 'summary should contain patterns_established'); - assert.match(summaryContent, /observability_surfaces:/, 'summary should contain observability_surfaces'); - assert.match(summaryContent, /provides:/, 'summary should contain provides'); - assert.match(summaryContent, /# S01: Test Slice/, 'summary should have H1 with slice ID and title'); - assert.match(summaryContent, /\*\*Implemented test slice with full coverage\*\*/, 'summary should have one-liner in bold'); - assert.match(summaryContent, /## What Happened/, 'summary should have What Happened section'); - assert.match(summaryContent, /## Verification/, 'summary should have Verification section'); - assert.match(summaryContent, /## Requirements Advanced/, 'summary should have Requirements Advanced section'); + const params = makeValidSliceParams(); + const result = await handleCompleteSlice(params, basePath); - // (b) Verify UAT.md exists on disk - assert.ok(fs.existsSync(result.uatPath), 'UAT file should exist on disk'); - const uatContent = fs.readFileSync(result.uatPath, 'utf-8'); - assert.match(uatContent, /# S01: Test Slice — UAT/, 'UAT should have correct title'); - assert.match(uatContent, /Milestone:\*\* M001/, 'UAT should reference milestone'); - assert.match(uatContent, /Smoke Test/, 'UAT should contain smoke test from params'); + assertTrue(!('error' in result), 'handler should succeed without error'); + if (!('error' in result)) { + assertEq(result.sliceId, 'S01', 'result sliceId'); + assertEq(result.milestoneId, 'M001', 'result milestoneId'); + assertTrue(result.summaryPath.endsWith('S01-SUMMARY.md'), 'summaryPath should end with S01-SUMMARY.md'); + assertTrue(result.uatPath.endsWith('S01-UAT.md'), 'uatPath should end with S01-UAT.md'); - // (c) Verify roadmap checkbox toggled to [x] - const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8'); - assert.match(roadmapContent, /\[x\]\s+\*\*S01:/, 'S01 should be checked in roadmap'); - assert.match(roadmapContent, /\[ \]\s+\*\*S02:/, 'S02 should still be unchecked in roadmap'); + // (a) Verify SUMMARY.md exists on disk with correct YAML frontmatter + assertTrue(fs.existsSync(result.summaryPath), 'summary file should exist on disk'); + const summaryContent = fs.readFileSync(result.summaryPath, 'utf-8'); + assertMatch(summaryContent, /^---\n/, 'summary should start with YAML frontmatter'); + assertMatch(summaryContent, /id: S01/, 'summary should contain id: S01'); + assertMatch(summaryContent, /parent: M001/, 'summary should contain parent: M001'); + assertMatch(summaryContent, /milestone: M001/, 'summary should contain milestone: M001'); + assertMatch(summaryContent, /blocker_discovered: false/, 'summary should contain blocker_discovered'); + assertMatch(summaryContent, /verification_result: passed/, 'summary should contain verification_result'); + assertMatch(summaryContent, /key_files:/, 'summary should contain key_files'); + assertMatch(summaryContent, /patterns_established:/, 'summary should contain patterns_established'); + assertMatch(summaryContent, /observability_surfaces:/, 'summary should contain observability_surfaces'); + assertMatch(summaryContent, /provides:/, 'summary should contain provides'); + assertMatch(summaryContent, /# S01: Test Slice/, 'summary should have H1 with slice ID and title'); + assertMatch(summaryContent, /\*\*Implemented test slice with full coverage\*\*/, 'summary should have one-liner in bold'); + assertMatch(summaryContent, /## What Happened/, 'summary should have What Happened section'); + assertMatch(summaryContent, /## Verification/, 'summary should have Verification section'); + assertMatch(summaryContent, /## Requirements Advanced/, 'summary should have Requirements Advanced section'); - // (d) Verify full_summary_md and full_uat_md stored in DB for D004 recovery - const sliceAfter = getSlice('M001', 'S01'); - assert.ok(sliceAfter !== null, 'slice should exist in DB after handler'); - assert.ok(sliceAfter!.full_summary_md.length > 0, 'full_summary_md should be non-empty in DB'); - assert.match(sliceAfter!.full_summary_md, /id: S01/, 'full_summary_md should contain frontmatter'); - assert.ok(sliceAfter!.full_uat_md.length > 0, 'full_uat_md should be non-empty in DB'); - assert.match(sliceAfter!.full_uat_md, /S01: Test Slice — UAT/, 'full_uat_md should contain UAT title'); + // (b) Verify UAT.md exists on disk + assertTrue(fs.existsSync(result.uatPath), 'UAT file should exist on disk'); + const uatContent = fs.readFileSync(result.uatPath, 'utf-8'); + assertMatch(uatContent, /# S01: Test Slice — UAT/, 'UAT should have correct title'); + assertMatch(uatContent, /Milestone:\*\* M001/, 'UAT should reference milestone'); + assertMatch(uatContent, /Smoke Test/, 'UAT should contain smoke test from params'); - // (e) Verify slice status is complete in DB - assert.strictEqual(sliceAfter!.status, 'complete', 'slice status should be complete in DB'); - assert.ok(sliceAfter!.completed_at !== null, 'completed_at should be set in DB'); - } + // (c) Verify roadmap shows S01 complete (✅) and S02 pending (⬜) in table format + // Projection renders roadmap as a Slice Overview table, not checkbox list + const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8'); + assertMatch(roadmapContent, /\| S01 \|/, 'S01 should appear in roadmap table'); + assertTrue(roadmapContent.includes('✅'), 'completed S01 should show ✅ in roadmap table'); + assertMatch(roadmapContent, /\| S02 \|/, 'S02 should appear in roadmap table'); + assertTrue(roadmapContent.includes('⬜'), 'pending S02 should show ⬜ in roadmap table'); - cleanupDir(basePath); - cleanup(dbPath); - }); + // (d) Verify full_summary_md and full_uat_md stored in DB for D004 recovery + const sliceAfter = getSlice('M001', 'S01'); + assertTrue(sliceAfter !== null, 'slice should exist in DB after handler'); + assertTrue(sliceAfter!.full_summary_md.length > 0, 'full_summary_md should be non-empty in DB'); + assertMatch(sliceAfter!.full_summary_md, /id: S01/, 'full_summary_md should contain frontmatter'); + assertTrue(sliceAfter!.full_uat_md.length > 0, 'full_uat_md should be non-empty in DB'); + assertMatch(sliceAfter!.full_uat_md, /S01: Test Slice — UAT/, 'full_uat_md should contain UAT title'); - test("rejects incomplete tasks", async () => { - const dbPath = tempDbPath(); - openDatabase(dbPath); + // (e) Verify slice status is complete in DB + assertEq(sliceAfter!.status, 'complete', 'slice status should be complete in DB'); + assertTrue(sliceAfter!.completed_at !== null, 'completed_at should be set in DB'); + } - // Insert milestone, slice, 2 tasks — one complete, one pending - insertMilestone({ id: 'M001' }); - insertSlice({ id: 'S01', milestoneId: 'M001' }); - insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', status: 'complete', title: 'Task 1' }); - insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', status: 'pending', title: 'Task 2' }); + cleanupDir(basePath); + cleanup(dbPath); +} - const params = makeValidSliceParams(); - const result = await handleCompleteSlice(params, '/tmp/fake'); +// ═══════════════════════════════════════════════════════════════════════════ +// complete-slice: Handler rejects incomplete tasks +// ═══════════════════════════════════════════════════════════════════════════ - assert.ok('error' in result, 'should return error when tasks are incomplete'); - if ('error' in result) { - assert.match(result.error, /incomplete tasks/, 'error should mention incomplete tasks'); - assert.match(result.error, /T02/, 'error should mention the specific incomplete task ID'); - } +console.log('\n=== complete-slice: handler rejects incomplete tasks ==='); +{ + const dbPath = tempDbPath(); + openDatabase(dbPath); - cleanup(dbPath); - }); + // Insert milestone, slice, 2 tasks — one complete, one pending + insertMilestone({ id: 'M001' }); + insertSlice({ id: 'S01', milestoneId: 'M001' }); + insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', status: 'complete', title: 'Task 1' }); + insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', status: 'pending', title: 'Task 2' }); - test("rejects no tasks", async () => { - const dbPath = tempDbPath(); - openDatabase(dbPath); + const params = makeValidSliceParams(); + const result = await handleCompleteSlice(params, '/tmp/fake'); - // Insert milestone and slice but NO tasks - insertMilestone({ id: 'M001' }); - insertSlice({ id: 'S01', milestoneId: 'M001' }); + assertTrue('error' in result, 'should return error when tasks are incomplete'); + if ('error' in result) { + assertMatch(result.error, /incomplete tasks/, 'error should mention incomplete tasks'); + assertMatch(result.error, /T02/, 'error should mention the specific incomplete task ID'); + } - const params = makeValidSliceParams(); - const result = await handleCompleteSlice(params, '/tmp/fake'); + cleanup(dbPath); +} - assert.ok('error' in result, 'should return error when no tasks exist'); - if ('error' in result) { - assert.match(result.error, /no tasks found/, 'error should say no tasks found'); - } +// ═══════════════════════════════════════════════════════════════════════════ +// complete-slice: Handler rejects no tasks +// ═══════════════════════════════════════════════════════════════════════════ - cleanup(dbPath); - }); +console.log('\n=== complete-slice: handler rejects no tasks ==='); +{ + const dbPath = tempDbPath(); + openDatabase(dbPath); - test("validation errors", async () => { - const dbPath = tempDbPath(); - openDatabase(dbPath); + // Insert milestone and slice but NO tasks + insertMilestone({ id: 'M001' }); + insertSlice({ id: 'S01', milestoneId: 'M001' }); - const params = makeValidSliceParams(); + const params = makeValidSliceParams(); + const result = await handleCompleteSlice(params, '/tmp/fake'); - // Empty sliceId - const r1 = await handleCompleteSlice({ ...params, sliceId: '' }, '/tmp/fake'); - assert.ok('error' in r1, 'should return error for empty sliceId'); - if ('error' in r1) { - assert.match(r1.error, /sliceId/, 'error should mention sliceId'); - } + assertTrue('error' in result, 'should return error when no tasks exist'); + if ('error' in result) { + assertMatch(result.error, /no tasks found/, 'error should say no tasks found'); + } - // Empty milestoneId - const r2 = await handleCompleteSlice({ ...params, milestoneId: '' }, '/tmp/fake'); - assert.ok('error' in r2, 'should return error for empty milestoneId'); - if ('error' in r2) { - assert.match(r2.error, /milestoneId/, 'error should mention milestoneId'); - } + cleanup(dbPath); +} - cleanup(dbPath); - }); +// ═══════════════════════════════════════════════════════════════════════════ +// complete-slice: Handler validation errors +// ═══════════════════════════════════════════════════════════════════════════ - test("idempotency", async () => { - const dbPath = tempDbPath(); - openDatabase(dbPath); +console.log('\n=== complete-slice: handler validation errors ==='); +{ + const dbPath = tempDbPath(); + openDatabase(dbPath); - const { basePath, roadmapPath } = createTempProject(); + const params = makeValidSliceParams(); - // Set up DB state - insertMilestone({ id: 'M001' }); - insertSlice({ id: 'S01', milestoneId: 'M001' }); - insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', status: 'complete', title: 'Task 1' }); + // Empty sliceId + const r1 = await handleCompleteSlice({ ...params, sliceId: '' }, '/tmp/fake'); + assertTrue('error' in r1, 'should return error for empty sliceId'); + if ('error' in r1) { + assertMatch(r1.error, /sliceId/, 'error should mention sliceId'); + } - const params = makeValidSliceParams(); + // Empty milestoneId + const r2 = await handleCompleteSlice({ ...params, milestoneId: '' }, '/tmp/fake'); + assertTrue('error' in r2, 'should return error for empty milestoneId'); + if ('error' in r2) { + assertMatch(r2.error, /milestoneId/, 'error should mention milestoneId'); + } - // First call - const r1 = await handleCompleteSlice(params, basePath); - assert.ok(!('error' in r1), 'first call should succeed'); + cleanup(dbPath); +} - // Second call with same params — should not crash - const r2 = await handleCompleteSlice(params, basePath); - assert.ok(!('error' in r2), 'second call should succeed (idempotent)'); +// ═══════════════════════════════════════════════════════════════════════════ +// complete-slice: Handler idempotency +// ═══════════════════════════════════════════════════════════════════════════ - // Verify only 1 slice row (not duplicated) - const adapter = _getAdapter()!; - const sliceRows = adapter.prepare("SELECT * FROM slices WHERE milestone_id = 'M001' AND id = 'S01'").all(); - assert.strictEqual(sliceRows.length, 1, 'should have exactly 1 slice row after 2 calls'); +console.log('\n=== complete-slice: handler idempotency ==='); +{ + const dbPath = tempDbPath(); + openDatabase(dbPath); - // Files should still exist - if (!('error' in r2)) { - assert.ok(fs.existsSync(r2.summaryPath), 'summary should still exist after second call'); - assert.ok(fs.existsSync(r2.uatPath), 'UAT should still exist after second call'); - } + const { basePath, roadmapPath } = createTempProject(); - cleanupDir(basePath); - cleanup(dbPath); - }); + // Set up DB state + insertMilestone({ id: 'M001' }); + insertSlice({ id: 'S01', milestoneId: 'M001' }); + insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', status: 'complete', title: 'Task 1' }); - test("missing roadmap (graceful)", async () => { - const dbPath = tempDbPath(); - openDatabase(dbPath); + const params = makeValidSliceParams(); - // Create a temp dir WITHOUT a roadmap file - const basePath = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-no-roadmap-')); - const sliceDir = path.join(basePath, '.gsd', 'milestones', 'M001', 'slices', 'S01'); - fs.mkdirSync(sliceDir, { recursive: true }); + // First call + const r1 = await handleCompleteSlice(params, basePath); + assertTrue(!('error' in r1), 'first call should succeed'); - // Set up DB state - insertMilestone({ id: 'M001' }); - insertSlice({ id: 'S01', milestoneId: 'M001' }); - insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', status: 'complete', title: 'Task 1' }); + // Second call — state machine guard rejects (slice is already complete) + const r2 = await handleCompleteSlice(params, basePath); + assertTrue('error' in r2, 'second call should return error (slice already complete)'); + if ('error' in r2) { + assertMatch(r2.error, /already complete/, 'error should mention already complete'); + } - const params = makeValidSliceParams(); - const result = await handleCompleteSlice(params, basePath); + // Verify only 1 slice row (not duplicated) + const adapter = _getAdapter()!; + const sliceRows = adapter.prepare("SELECT * FROM slices WHERE milestone_id = 'M001' AND id = 'S01'").all(); + assertEq(sliceRows.length, 1, 'should have exactly 1 slice row after calls'); - // Should succeed even without roadmap file — just skip checkbox toggle - assert.ok(!('error' in result), 'handler should succeed without roadmap file'); - if (!('error' in result)) { - assert.ok(fs.existsSync(result.summaryPath), 'summary should be written even without roadmap'); - assert.ok(fs.existsSync(result.uatPath), 'UAT should be written even without roadmap'); - } + cleanupDir(basePath); + cleanup(dbPath); +} - cleanupDir(basePath); - cleanup(dbPath); - }); -}); +// ═══════════════════════════════════════════════════════════════════════════ +// complete-slice: Handler with missing roadmap (graceful) +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== complete-slice: handler with missing roadmap ==='); +{ + const dbPath = tempDbPath(); + openDatabase(dbPath); + + // Create a temp dir WITHOUT a roadmap file + const basePath = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-no-roadmap-')); + const sliceDir = path.join(basePath, '.gsd', 'milestones', 'M001', 'slices', 'S01'); + fs.mkdirSync(sliceDir, { recursive: true }); + + // Set up DB state + insertMilestone({ id: 'M001' }); + insertSlice({ id: 'S01', milestoneId: 'M001' }); + insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', status: 'complete', title: 'Task 1' }); + + const params = makeValidSliceParams(); + const result = await handleCompleteSlice(params, basePath); + + // Should succeed even without roadmap file — just skip checkbox toggle + assertTrue(!('error' in result), 'handler should succeed without roadmap file'); + if (!('error' in result)) { + assertTrue(fs.existsSync(result.summaryPath), 'summary should be written even without roadmap'); + assertTrue(fs.existsSync(result.uatPath), 'UAT should be written even without roadmap'); + } + + cleanupDir(basePath); + cleanup(dbPath); +} + +// ═══════════════════════════════════════════════════════════════════════════ + +report(); diff --git a/src/resources/extensions/gsd/tests/complete-task.test.ts b/src/resources/extensions/gsd/tests/complete-task.test.ts index 7cf216252..de46a64d9 100644 --- a/src/resources/extensions/gsd/tests/complete-task.test.ts +++ b/src/resources/extensions/gsd/tests/complete-task.test.ts @@ -1,5 +1,4 @@ -import { describe, test } from "node:test"; -import assert from "node:assert/strict"; +import { createTestContext } from './test-helpers.ts'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; @@ -18,6 +17,8 @@ import { } from '../gsd-db.ts'; import { handleCompleteTask } from '../tools/complete-task.ts'; +const { assertEq, assertTrue, assertMatch, report } = createTestContext(); + // ═══════════════════════════════════════════════════════════════════════════ // Helpers // ═══════════════════════════════════════════════════════════════════════════ @@ -98,290 +99,356 @@ function makeValidParams() { } // ═══════════════════════════════════════════════════════════════════════════ -// Tests +// complete-task: Schema v5 migration // ═══════════════════════════════════════════════════════════════════════════ -describe("complete-task: schema v5 migration", () => { - test("schema version and tables exist", () => { - const dbPath = tempDbPath(); - openDatabase(dbPath); +console.log('\n=== complete-task: schema v5 migration ==='); +{ + const dbPath = tempDbPath(); + openDatabase(dbPath); - const adapter = _getAdapter()!; + const adapter = _getAdapter()!; - // Verify schema version is current (v10 after M001 planning migrations) - const versionRow = adapter.prepare('SELECT MAX(version) as v FROM schema_version').get(); - assert.strictEqual(versionRow?.['v'], 10, 'schema version should be 10'); + // Verify schema version is current (v11 after state machine migration) + const versionRow = adapter.prepare('SELECT MAX(version) as v FROM schema_version').get(); + assertEq(versionRow?.['v'], 11, 'schema version should be 11'); - // Verify all 4 new tables exist - const tables = adapter.prepare( - "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" - ).all(); - const tableNames = tables.map(t => t['name'] as string); - assert.ok(tableNames.includes('milestones'), 'milestones table should exist'); - assert.ok(tableNames.includes('slices'), 'slices table should exist'); - assert.ok(tableNames.includes('tasks'), 'tasks table should exist'); - assert.ok(tableNames.includes('verification_evidence'), 'verification_evidence table should exist'); + // Verify all 4 new tables exist + const tables = adapter.prepare( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" + ).all(); + const tableNames = tables.map(t => t['name'] as string); + assertTrue(tableNames.includes('milestones'), 'milestones table should exist'); + assertTrue(tableNames.includes('slices'), 'slices table should exist'); + assertTrue(tableNames.includes('tasks'), 'tasks table should exist'); + assertTrue(tableNames.includes('verification_evidence'), 'verification_evidence table should exist'); - cleanup(dbPath); + cleanup(dbPath); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// complete-task: Accessor CRUD +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== complete-task: accessor CRUD ==='); +{ + const dbPath = tempDbPath(); + openDatabase(dbPath); + + // Insert milestone + insertMilestone({ id: 'M001', title: 'Test Milestone' }); + const adapter = _getAdapter()!; + const mRow = adapter.prepare("SELECT * FROM milestones WHERE id = 'M001'").get(); + assertEq(mRow?.['id'], 'M001', 'milestone id should be M001'); + assertEq(mRow?.['title'], 'Test Milestone', 'milestone title should match'); + + // Insert slice + insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Test Slice', risk: 'high' }); + const sRow = adapter.prepare("SELECT * FROM slices WHERE id = 'S01' AND milestone_id = 'M001'").get(); + assertEq(sRow?.['id'], 'S01', 'slice id should be S01'); + assertEq(sRow?.['risk'], 'high', 'slice risk should be high'); + + // Insert task with all fields + insertTask({ + id: 'T01', + sliceId: 'S01', + milestoneId: 'M001', + title: 'Test Task', + status: 'complete', + oneLiner: 'Did the thing', + narrative: 'Full story here.', + verificationResult: 'passed', + duration: '30m', + blockerDiscovered: false, + deviations: 'None', + knownIssues: 'None', + keyFiles: ['file1.ts', 'file2.ts'], + keyDecisions: ['D001'], + fullSummaryMd: '# Summary', }); -}); -describe("complete-task: accessor CRUD", () => { - test("insert and query milestones, slices, tasks, evidence", () => { - const dbPath = tempDbPath(); - openDatabase(dbPath); + // getTask verifies all fields + const task = getTask('M001', 'S01', 'T01'); + assertTrue(task !== null, 'task should not be null'); + assertEq(task!.id, 'T01', 'task id'); + assertEq(task!.slice_id, 'S01', 'task slice_id'); + assertEq(task!.milestone_id, 'M001', 'task milestone_id'); + assertEq(task!.title, 'Test Task', 'task title'); + assertEq(task!.status, 'complete', 'task status'); + assertEq(task!.one_liner, 'Did the thing', 'task one_liner'); + assertEq(task!.narrative, 'Full story here.', 'task narrative'); + assertEq(task!.verification_result, 'passed', 'task verification_result'); + assertEq(task!.blocker_discovered, false, 'task blocker_discovered'); + assertEq(task!.key_files, ['file1.ts', 'file2.ts'], 'task key_files JSON round-trip'); + assertEq(task!.key_decisions, ['D001'], 'task key_decisions JSON round-trip'); + assertEq(task!.full_summary_md, '# Summary', 'task full_summary_md'); - // Insert milestone - insertMilestone({ id: 'M001', title: 'Test Milestone' }); - const adapter = _getAdapter()!; - const mRow = adapter.prepare("SELECT * FROM milestones WHERE id = 'M001'").get(); - assert.strictEqual(mRow?.['id'], 'M001', 'milestone id should be M001'); - assert.strictEqual(mRow?.['title'], 'Test Milestone', 'milestone title should match'); + // getTask returns null for non-existent + const noTask = getTask('M001', 'S01', 'T99'); + assertEq(noTask, null, 'non-existent task should return null'); - // Insert slice - insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Test Slice', risk: 'high' }); - const sRow = adapter.prepare("SELECT * FROM slices WHERE id = 'S01' AND milestone_id = 'M001'").get(); - assert.strictEqual(sRow?.['id'], 'S01', 'slice id should be S01'); - assert.strictEqual(sRow?.['risk'], 'high', 'slice risk should be high'); + // Insert verification evidence + insertVerificationEvidence({ + taskId: 'T01', + sliceId: 'S01', + milestoneId: 'M001', + command: 'npm test', + exitCode: 0, + verdict: '✅ pass', + durationMs: 3000, + }); + const evRows = adapter.prepare( + "SELECT * FROM verification_evidence WHERE task_id = 'T01' AND slice_id = 'S01' AND milestone_id = 'M001'" + ).all(); + assertEq(evRows.length, 1, 'should have 1 verification evidence row'); + assertEq(evRows[0]['command'], 'npm test', 'evidence command'); + assertEq(evRows[0]['exit_code'], 0, 'evidence exit_code'); + assertEq(evRows[0]['verdict'], '✅ pass', 'evidence verdict'); + assertEq(evRows[0]['duration_ms'], 3000, 'evidence duration_ms'); - // Insert task with all fields - insertTask({ - id: 'T01', - sliceId: 'S01', - milestoneId: 'M001', - title: 'Test Task', - status: 'complete', - oneLiner: 'Did the thing', - narrative: 'Full story here.', - verificationResult: 'passed', - duration: '30m', - blockerDiscovered: false, - deviations: 'None', - knownIssues: 'None', - keyFiles: ['file1.ts', 'file2.ts'], - keyDecisions: ['D001'], - fullSummaryMd: '# Summary', - }); + // getSliceTasks returns array + const sliceTasks = getSliceTasks('M001', 'S01'); + assertEq(sliceTasks.length, 1, 'getSliceTasks should return 1 task'); + assertEq(sliceTasks[0].id, 'T01', 'getSliceTasks first task id'); - // getTask verifies all fields - const task = getTask('M001', 'S01', 'T01'); - assert.ok(task !== null, 'task should not be null'); - assert.strictEqual(task!.id, 'T01', 'task id'); - assert.strictEqual(task!.slice_id, 'S01', 'task slice_id'); - assert.strictEqual(task!.milestone_id, 'M001', 'task milestone_id'); - assert.strictEqual(task!.title, 'Test Task', 'task title'); - assert.strictEqual(task!.status, 'complete', 'task status'); - assert.strictEqual(task!.one_liner, 'Did the thing', 'task one_liner'); - assert.strictEqual(task!.narrative, 'Full story here.', 'task narrative'); - assert.strictEqual(task!.verification_result, 'passed', 'task verification_result'); - assert.strictEqual(task!.blocker_discovered, false, 'task blocker_discovered'); - assert.deepStrictEqual(task!.key_files, ['file1.ts', 'file2.ts'], 'task key_files JSON round-trip'); - assert.deepStrictEqual(task!.key_decisions, ['D001'], 'task key_decisions JSON round-trip'); - assert.strictEqual(task!.full_summary_md, '# Summary', 'task full_summary_md'); + // updateTaskStatus changes status + updateTaskStatus('M001', 'S01', 'T01', 'failed', new Date().toISOString()); + const updatedTask = getTask('M001', 'S01', 'T01'); + assertEq(updatedTask!.status, 'failed', 'task status should be updated to failed'); + assertTrue(updatedTask!.completed_at !== null, 'completed_at should be set after status update'); - // getTask returns null for non-existent - const noTask = getTask('M001', 'S01', 'T99'); - assert.strictEqual(noTask, null, 'non-existent task should return null'); + cleanup(dbPath); +} - // Insert verification evidence +// ═══════════════════════════════════════════════════════════════════════════ +// complete-task: Accessor stale-state error +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== complete-task: accessor stale-state error ==='); +{ + // No DB open — accessors should throw GSD_STALE_STATE + closeDatabase(); + let threw = false; + try { + insertMilestone({ id: 'M001' }); + } catch (err: any) { + threw = true; + assertTrue(err.code === 'GSD_STALE_STATE' || err.message.includes('No database open'), + 'should throw GSD_STALE_STATE when no DB open'); + } + assertTrue(threw, 'insertMilestone should throw when no DB open'); + + threw = false; + try { + insertSlice({ id: 'S01', milestoneId: 'M001' }); + } catch (err: any) { + threw = true; + assertTrue(err.code === 'GSD_STALE_STATE' || err.message.includes('No database open'), + 'insertSlice should throw GSD_STALE_STATE'); + } + assertTrue(threw, 'insertSlice should throw when no DB open'); + + threw = false; + try { + insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001' }); + } catch (err: any) { + threw = true; + assertTrue(err.code === 'GSD_STALE_STATE' || err.message.includes('No database open'), + 'insertTask should throw GSD_STALE_STATE'); + } + assertTrue(threw, 'insertTask should throw when no DB open'); + + threw = false; + try { insertVerificationEvidence({ - taskId: 'T01', - sliceId: 'S01', - milestoneId: 'M001', - command: 'npm test', - exitCode: 0, - verdict: '✅ pass', - durationMs: 3000, - }); - const evRows = adapter.prepare( - "SELECT * FROM verification_evidence WHERE task_id = 'T01' AND slice_id = 'S01' AND milestone_id = 'M001'" - ).all(); - assert.strictEqual(evRows.length, 1, 'should have 1 verification evidence row'); - assert.strictEqual(evRows[0]['command'], 'npm test', 'evidence command'); - assert.strictEqual(evRows[0]['exit_code'], 0, 'evidence exit_code'); - assert.strictEqual(evRows[0]['verdict'], '✅ pass', 'evidence verdict'); - assert.strictEqual(evRows[0]['duration_ms'], 3000, 'evidence duration_ms'); - - // getSliceTasks returns array - const sliceTasks = getSliceTasks('M001', 'S01'); - assert.strictEqual(sliceTasks.length, 1, 'getSliceTasks should return 1 task'); - assert.strictEqual(sliceTasks[0].id, 'T01', 'getSliceTasks first task id'); - - // updateTaskStatus changes status - updateTaskStatus('M001', 'S01', 'T01', 'failed', new Date().toISOString()); - const updatedTask = getTask('M001', 'S01', 'T01'); - assert.strictEqual(updatedTask!.status, 'failed', 'task status should be updated to failed'); - assert.ok(updatedTask!.completed_at !== null, 'completed_at should be set after status update'); - - cleanup(dbPath); - }); -}); - -describe("complete-task: accessor stale-state error", () => { - test("accessors throw when no DB open", () => { - closeDatabase(); - - assert.throws(() => insertMilestone({ id: 'M001' }), - (err: any) => err.code === 'GSD_STALE_STATE' || err.message.includes('No database open'), - 'insertMilestone should throw when no DB open'); - - assert.throws(() => insertSlice({ id: 'S01', milestoneId: 'M001' }), - (err: any) => err.code === 'GSD_STALE_STATE' || err.message.includes('No database open'), - 'insertSlice should throw when no DB open'); - - assert.throws(() => insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001' }), - (err: any) => err.code === 'GSD_STALE_STATE' || err.message.includes('No database open'), - 'insertTask should throw when no DB open'); - - assert.throws(() => insertVerificationEvidence({ taskId: 'T01', sliceId: 'S01', milestoneId: 'M001', command: 'test', exitCode: 0, verdict: 'pass', durationMs: 0, - }), - (err: any) => err.code === 'GSD_STALE_STATE' || err.message.includes('No database open'), - 'insertVerificationEvidence should throw when no DB open'); - }); -}); + }); + } catch (err: any) { + threw = true; + assertTrue(err.code === 'GSD_STALE_STATE' || err.message.includes('No database open'), + 'insertVerificationEvidence should throw GSD_STALE_STATE'); + } + assertTrue(threw, 'insertVerificationEvidence should throw when no DB open'); +} -describe("complete-task: handler", () => { - test("happy path", async () => { - const dbPath = tempDbPath(); - openDatabase(dbPath); +// ═══════════════════════════════════════════════════════════════════════════ +// complete-task: Handler happy path +// ═══════════════════════════════════════════════════════════════════════════ - const { basePath, planPath } = createTempProject(); +console.log('\n=== complete-task: handler happy path ==='); +{ + const dbPath = tempDbPath(); + openDatabase(dbPath); - const params = makeValidParams(); - const result = await handleCompleteTask(params, basePath); + const { basePath, planPath } = createTempProject(); - assert.ok(!('error' in result), 'handler should succeed without error'); - if (!('error' in result)) { - assert.strictEqual(result.taskId, 'T01', 'result taskId'); - assert.strictEqual(result.sliceId, 'S01', 'result sliceId'); - assert.strictEqual(result.milestoneId, 'M001', 'result milestoneId'); - assert.ok(result.summaryPath.endsWith('T01-SUMMARY.md'), 'summaryPath should end with T01-SUMMARY.md'); + // Seed milestone + slice + both tasks so projection renders T01 ([x]) and T02 ([ ]) + insertMilestone({ id: 'M001', title: 'Test Milestone' }); + insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Test Slice' }); + insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', status: 'pending', title: 'Second task' }); - // (a) Verify task row in DB with status 'complete' - const task = getTask('M001', 'S01', 'T01'); - assert.ok(task !== null, 'task should exist in DB after handler'); - assert.strictEqual(task!.status, 'complete', 'task status should be complete'); - assert.strictEqual(task!.one_liner, 'Added test functionality', 'task one_liner in DB'); - assert.deepStrictEqual(task!.key_files, ['src/test.ts', 'src/test.test.ts'], 'task key_files in DB'); + const params = makeValidParams(); + const result = await handleCompleteTask(params, basePath); - // (b) Verify verification_evidence rows in DB - const adapter = _getAdapter()!; - const evRows = adapter.prepare( - "SELECT * FROM verification_evidence WHERE task_id = 'T01' AND milestone_id = 'M001'" - ).all(); - assert.strictEqual(evRows.length, 1, 'should have 1 verification evidence row after handler'); - assert.strictEqual(evRows[0]['command'], 'npm run test:unit', 'evidence command from handler'); + assertTrue(!('error' in result), 'handler should succeed without error'); + if (!('error' in result)) { + assertEq(result.taskId, 'T01', 'result taskId'); + assertEq(result.sliceId, 'S01', 'result sliceId'); + assertEq(result.milestoneId, 'M001', 'result milestoneId'); + assertTrue(result.summaryPath.endsWith('T01-SUMMARY.md'), 'summaryPath should end with T01-SUMMARY.md'); - // (c) Verify T01-SUMMARY.md file on disk with correct YAML frontmatter - assert.ok(fs.existsSync(result.summaryPath), 'summary file should exist on disk'); - const summaryContent = fs.readFileSync(result.summaryPath, 'utf-8'); - assert.match(summaryContent, /^---\n/, 'summary should start with YAML frontmatter'); - assert.match(summaryContent, /id: T01/, 'summary should contain id: T01'); - assert.match(summaryContent, /parent: S01/, 'summary should contain parent: S01'); - assert.match(summaryContent, /milestone: M001/, 'summary should contain milestone: M001'); - assert.match(summaryContent, /blocker_discovered: false/, 'summary should contain blocker_discovered'); - assert.match(summaryContent, /# T01:/, 'summary should have H1 with task ID'); - assert.match(summaryContent, /\*\*Added test functionality\*\*/, 'summary should have one-liner in bold'); - assert.match(summaryContent, /## What Happened/, 'summary should have What Happened section'); - assert.match(summaryContent, /## Verification Evidence/, 'summary should have Verification Evidence section'); - assert.match(summaryContent, /npm run test:unit/, 'summary evidence should contain command'); + // (a) Verify task row in DB with status 'complete' + const task = getTask('M001', 'S01', 'T01'); + assertTrue(task !== null, 'task should exist in DB after handler'); + assertEq(task!.status, 'complete', 'task status should be complete'); + assertEq(task!.one_liner, 'Added test functionality', 'task one_liner in DB'); + assertEq(task!.key_files, ['src/test.ts', 'src/test.test.ts'], 'task key_files in DB'); - // (d) Verify plan checkbox changed to [x] - const planContent = fs.readFileSync(planPath, 'utf-8'); - assert.match(planContent, /\[x\]\s+\*\*T01:/, 'T01 should be checked in plan'); - // T02 should still be unchecked - assert.match(planContent, /\[ \]\s+\*\*T02:/, 'T02 should still be unchecked in plan'); + // (b) Verify verification_evidence rows in DB + const adapter = _getAdapter()!; + const evRows = adapter.prepare( + "SELECT * FROM verification_evidence WHERE task_id = 'T01' AND milestone_id = 'M001'" + ).all(); + assertEq(evRows.length, 1, 'should have 1 verification evidence row after handler'); + assertEq(evRows[0]['command'], 'npm run test:unit', 'evidence command from handler'); - // (e) Verify full_summary_md stored in DB for D004 recovery - const taskAfter = getTask('M001', 'S01', 'T01'); - assert.ok(taskAfter!.full_summary_md.length > 0, 'full_summary_md should be non-empty in DB'); - assert.match(taskAfter!.full_summary_md, /id: T01/, 'full_summary_md should contain frontmatter'); - } + // (c) Verify T01-SUMMARY.md file on disk with correct YAML frontmatter + assertTrue(fs.existsSync(result.summaryPath), 'summary file should exist on disk'); + const summaryContent = fs.readFileSync(result.summaryPath, 'utf-8'); + assertMatch(summaryContent, /^---\n/, 'summary should start with YAML frontmatter'); + assertMatch(summaryContent, /id: T01/, 'summary should contain id: T01'); + assertMatch(summaryContent, /parent: S01/, 'summary should contain parent: S01'); + assertMatch(summaryContent, /milestone: M001/, 'summary should contain milestone: M001'); + assertMatch(summaryContent, /blocker_discovered: false/, 'summary should contain blocker_discovered'); + assertMatch(summaryContent, /# T01:/, 'summary should have H1 with task ID'); + assertMatch(summaryContent, /\*\*Added test functionality\*\*/, 'summary should have one-liner in bold'); + assertMatch(summaryContent, /## What Happened/, 'summary should have What Happened section'); + assertMatch(summaryContent, /## Verification Evidence/, 'summary should have Verification Evidence section'); + assertMatch(summaryContent, /npm run test:unit/, 'summary evidence should contain command'); - cleanupDir(basePath); - cleanup(dbPath); - }); + // (d) Verify plan checkbox changed to [x] + const planContent = fs.readFileSync(planPath, 'utf-8'); + assertMatch(planContent, /\[x\]\s+\*\*T01:/, 'T01 should be checked in plan'); + // T02 should still be unchecked + assertMatch(planContent, /\[ \]\s+\*\*T02:/, 'T02 should still be unchecked in plan'); - test("validation errors", async () => { - const dbPath = tempDbPath(); - openDatabase(dbPath); + // (e) Verify full_summary_md stored in DB for D004 recovery + const taskAfter = getTask('M001', 'S01', 'T01'); + assertTrue(taskAfter!.full_summary_md.length > 0, 'full_summary_md should be non-empty in DB'); + assertMatch(taskAfter!.full_summary_md, /id: T01/, 'full_summary_md should contain frontmatter'); + } - const params = makeValidParams(); + cleanupDir(basePath); + cleanup(dbPath); +} - // Empty taskId - const r1 = await handleCompleteTask({ ...params, taskId: '' }, '/tmp/fake'); - assert.ok('error' in r1, 'should return error for empty taskId'); - if ('error' in r1) { - assert.match(r1.error, /taskId/, 'error should mention taskId'); - } +// ═══════════════════════════════════════════════════════════════════════════ +// complete-task: Handler validation errors +// ═══════════════════════════════════════════════════════════════════════════ - // Empty milestoneId - const r2 = await handleCompleteTask({ ...params, milestoneId: '' }, '/tmp/fake'); - assert.ok('error' in r2, 'should return error for empty milestoneId'); - if ('error' in r2) { - assert.match(r2.error, /milestoneId/, 'error should mention milestoneId'); - } +console.log('\n=== complete-task: handler validation errors ==='); +{ + const dbPath = tempDbPath(); + openDatabase(dbPath); - // Empty sliceId - const r3 = await handleCompleteTask({ ...params, sliceId: '' }, '/tmp/fake'); - assert.ok('error' in r3, 'should return error for empty sliceId'); - if ('error' in r3) { - assert.match(r3.error, /sliceId/, 'error should mention sliceId'); - } + const params = makeValidParams(); - cleanup(dbPath); - }); + // Empty taskId + const r1 = await handleCompleteTask({ ...params, taskId: '' }, '/tmp/fake'); + assertTrue('error' in r1, 'should return error for empty taskId'); + if ('error' in r1) { + assertMatch(r1.error, /taskId/, 'error should mention taskId'); + } - test("idempotency", async () => { - const dbPath = tempDbPath(); - openDatabase(dbPath); + // Empty milestoneId + const r2 = await handleCompleteTask({ ...params, milestoneId: '' }, '/tmp/fake'); + assertTrue('error' in r2, 'should return error for empty milestoneId'); + if ('error' in r2) { + assertMatch(r2.error, /milestoneId/, 'error should mention milestoneId'); + } - const { basePath, planPath } = createTempProject(); + // Empty sliceId + const r3 = await handleCompleteTask({ ...params, sliceId: '' }, '/tmp/fake'); + assertTrue('error' in r3, 'should return error for empty sliceId'); + if ('error' in r3) { + assertMatch(r3.error, /sliceId/, 'error should mention sliceId'); + } - const params = makeValidParams(); + cleanup(dbPath); +} - // First call - const r1 = await handleCompleteTask(params, basePath); - assert.ok(!('error' in r1), 'first call should succeed'); +// ═══════════════════════════════════════════════════════════════════════════ +// complete-task: Handler idempotency +// ═══════════════════════════════════════════════════════════════════════════ - // Second call with same params — should not crash (INSERT OR REPLACE) - const r2 = await handleCompleteTask(params, basePath); - assert.ok(!('error' in r2), 'second call should succeed (idempotent)'); +console.log('\n=== complete-task: handler idempotency ==='); +{ + const dbPath = tempDbPath(); + openDatabase(dbPath); - // Verify only 1 task row (upserted, not duplicated) - const tasks = getSliceTasks('M001', 'S01'); - assert.strictEqual(tasks.length, 1, 'should have exactly 1 task row after 2 calls (upsert)'); + const { basePath, planPath } = createTempProject(); - // File should still exist - if (!('error' in r2)) { - assert.ok(fs.existsSync(r2.summaryPath), 'summary should still exist after second call'); - } + // Seed milestone + slice so state machine guards pass + insertMilestone({ id: 'M001', title: 'Test Milestone' }); + insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Test Slice' }); - cleanupDir(basePath); - cleanup(dbPath); - }); + const params = makeValidParams(); - test("missing plan file (graceful)", async () => { - const dbPath = tempDbPath(); - openDatabase(dbPath); + // First call should succeed + const r1 = await handleCompleteTask(params, basePath); + assertTrue(!('error' in r1), 'first call should succeed'); - // Create a temp dir WITHOUT a plan file - const basePath = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-no-plan-')); - const tasksDir = path.join(basePath, '.gsd', 'milestones', 'M001', 'slices', 'S01', 'tasks'); - fs.mkdirSync(tasksDir, { recursive: true }); + // Verify only 1 task row + const tasks = getSliceTasks('M001', 'S01'); + assertEq(tasks.length, 1, 'should have exactly 1 task row after first call'); - const params = makeValidParams(); - const result = await handleCompleteTask(params, basePath); + // Second call with same params — state machine guard rejects (task is already complete) + const r2 = await handleCompleteTask(params, basePath); + assertTrue('error' in r2, 'second call should return error (task already complete)'); + if ('error' in r2) { + assertMatch(r2.error, /already complete/, 'error should mention already complete'); + } - // Should succeed even without plan file — just skip checkbox toggle - assert.ok(!('error' in result), 'handler should succeed without plan file'); - if (!('error' in result)) { - assert.ok(fs.existsSync(result.summaryPath), 'summary should be written even without plan file'); - } + // Still only 1 task row (no duplication from rejected second call) + const tasksAfter = getSliceTasks('M001', 'S01'); + assertEq(tasksAfter.length, 1, 'should still have exactly 1 task row after rejected second call'); - cleanupDir(basePath); - cleanup(dbPath); - }); -}); + cleanupDir(basePath); + cleanup(dbPath); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// complete-task: Handler with missing plan file (graceful) +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== complete-task: handler with missing plan file ==='); +{ + const dbPath = tempDbPath(); + openDatabase(dbPath); + + // Create a temp dir WITHOUT a plan file + const basePath = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-no-plan-')); + const tasksDir = path.join(basePath, '.gsd', 'milestones', 'M001', 'slices', 'S01', 'tasks'); + fs.mkdirSync(tasksDir, { recursive: true }); + + // Seed milestone + slice so state machine guards pass + insertMilestone({ id: 'M001', title: 'Test Milestone' }); + insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Test Slice' }); + + const params = makeValidParams(); + const result = await handleCompleteTask(params, basePath); + + // Should succeed even without plan file — just skip checkbox toggle + assertTrue(!('error' in result), 'handler should succeed without plan file'); + if (!('error' in result)) { + assertTrue(fs.existsSync(result.summaryPath), 'summary should be written even without plan file'); + } + + cleanupDir(basePath); + cleanup(dbPath); +} + +// ═══════════════════════════════════════════════════════════════════════════ + +report(); diff --git a/src/resources/extensions/gsd/tools/complete-milestone.ts b/src/resources/extensions/gsd/tools/complete-milestone.ts index ae27f4a37..32aae5890 100644 --- a/src/resources/extensions/gsd/tools/complete-milestone.ts +++ b/src/resources/extensions/gsd/tools/complete-milestone.ts @@ -11,7 +11,9 @@ import { mkdirSync } from "node:fs"; import { transaction, + getMilestone, getMilestoneSlices, + getSliceTasks, _getAdapter, } from "../gsd-db.js"; import { resolveMilestonePath, clearPathCache } from "../paths.js"; @@ -34,6 +36,10 @@ export interface CompleteMilestoneParams { lessonsLearned: string[]; followUps: string; deviations: string; + /** Optional caller-provided identity for audit trail */ + actorName?: string; + /** Optional caller-provided reason this action was triggered */ + triggerReason?: string; } export interface CompleteMilestoneResult { @@ -111,6 +117,15 @@ export async function handleCompleteMilestone( return { error: "title is required and must be a non-empty string" }; } + // ── State machine preconditions ───────────────────────────────────────── + const milestone = getMilestone(params.milestoneId); + if (!milestone) { + return { error: `milestone not found: ${params.milestoneId}` }; + } + if (milestone.status === "complete" || milestone.status === "done") { + return { error: `milestone ${params.milestoneId} is already complete` }; + } + // ── Verify all slices are complete ─────────────────────────────────────── const slices = getMilestoneSlices(params.milestoneId); if (slices.length === 0) { @@ -123,6 +138,16 @@ export async function handleCompleteMilestone( return { error: `incomplete slices: ${incompleteIds}` }; } + // ── Deep check: verify all tasks in all slices are complete ────────────── + for (const slice of slices) { + const tasks = getSliceTasks(params.milestoneId, slice.id); + const incompleteTasks = tasks.filter(t => t.status !== "complete" && t.status !== "done"); + if (incompleteTasks.length > 0) { + const ids = incompleteTasks.map(t => `${t.id} (status: ${t.status})`).join(", "); + return { error: `slice ${slice.id} has incomplete tasks: ${ids}` }; + } + } + // ── DB writes inside a transaction ────────────────────────────────────── const completedAt = new Date().toISOString(); @@ -181,6 +206,8 @@ export async function handleCompleteMilestone( params: { milestoneId: params.milestoneId }, ts: new Date().toISOString(), actor: "agent", + actor_name: params.actorName, + trigger_reason: params.triggerReason, }); } catch (hookErr) { process.stderr.write( diff --git a/src/resources/extensions/gsd/tools/complete-slice.ts b/src/resources/extensions/gsd/tools/complete-slice.ts index 6f0c92d28..e7701707b 100644 --- a/src/resources/extensions/gsd/tools/complete-slice.ts +++ b/src/resources/extensions/gsd/tools/complete-slice.ts @@ -15,11 +15,14 @@ import { transaction, insertMilestone, insertSlice, + getSlice, getSliceTasks, + getMilestone, updateSliceStatus, _getAdapter, } from "../gsd-db.js"; import { resolveSliceFile, resolveSlicePath, clearPathCache } from "../paths.js"; +import { checkOwnership, sliceUnitKey } from "../unit-ownership.js"; import { saveFile, clearParseCache } from "../files.js"; import { invalidateStateCache } from "../state.js"; import { renderRoadmapCheckboxes } from "../markdown-renderer.js"; @@ -203,6 +206,33 @@ export async function handleCompleteSlice( return { error: "milestoneId is required and must be a non-empty string" }; } + // ── State machine preconditions ───────────────────────────────────────── + const milestone = getMilestone(params.milestoneId); + if (!milestone) { + return { error: `milestone not found: ${params.milestoneId}` }; + } + if (milestone.status === "complete" || milestone.status === "done") { + return { error: `cannot complete slice in a closed milestone: ${params.milestoneId} (status: ${milestone.status})` }; + } + + const slice = getSlice(params.milestoneId, params.sliceId); + if (!slice) { + return { error: `slice not found: ${params.milestoneId}/${params.sliceId}` }; + } + if (slice.status === "complete" || slice.status === "done") { + return { error: `slice ${params.sliceId} is already complete — use gsd_slice_reopen first if you need to redo it` }; + } + + // ── Ownership check (opt-in: only enforced when claim file exists) ────── + const ownershipErr = checkOwnership( + basePath, + sliceUnitKey(params.milestoneId, params.sliceId), + params.actorName, + ); + if (ownershipErr) { + return { error: ownershipErr }; + } + // ── Verify all tasks are complete ─────────────────────────────────────── const tasks = getSliceTasks(params.milestoneId, params.sliceId); if (tasks.length === 0) { @@ -303,6 +333,8 @@ export async function handleCompleteSlice( params: { milestoneId: params.milestoneId, sliceId: params.sliceId }, ts: new Date().toISOString(), actor: "agent", + actor_name: params.actorName, + trigger_reason: params.triggerReason, }); } catch (hookErr) { process.stderr.write( diff --git a/src/resources/extensions/gsd/tools/complete-task.ts b/src/resources/extensions/gsd/tools/complete-task.ts index e20366edc..25f4c1860 100644 --- a/src/resources/extensions/gsd/tools/complete-task.ts +++ b/src/resources/extensions/gsd/tools/complete-task.ts @@ -17,9 +17,13 @@ import { insertSlice, insertTask, insertVerificationEvidence, + getMilestone, + getSlice, + getTask, _getAdapter, } from "../gsd-db.js"; import { resolveSliceFile, resolveTasksDir, clearPathCache } from "../paths.js"; +import { checkOwnership, taskUnitKey } from "../unit-ownership.js"; import { saveFile, clearParseCache } from "../files.js"; import { invalidateStateCache } from "../state.js"; import { renderPlanCheckboxes } from "../markdown-renderer.js"; @@ -134,6 +138,38 @@ export async function handleCompleteTask( return { error: "milestoneId is required and must be a non-empty string" }; } + // ── State machine preconditions ───────────────────────────────────────── + const milestone = getMilestone(params.milestoneId); + if (!milestone) { + return { error: `milestone not found: ${params.milestoneId}` }; + } + if (milestone.status === "complete" || milestone.status === "done") { + return { error: `cannot complete task in a closed milestone: ${params.milestoneId} (status: ${milestone.status})` }; + } + + const slice = getSlice(params.milestoneId, params.sliceId); + if (!slice) { + return { error: `slice not found: ${params.milestoneId}/${params.sliceId}` }; + } + if (slice.status === "complete" || slice.status === "done") { + return { error: `cannot complete task in a closed slice: ${params.sliceId} (status: ${slice.status})` }; + } + + const existingTask = getTask(params.milestoneId, params.sliceId, params.taskId); + if (existingTask && (existingTask.status === "complete" || existingTask.status === "done")) { + return { error: `task ${params.taskId} is already complete — use gsd_task_reopen first if you need to redo it` }; + } + + // ── Ownership check (opt-in: only enforced when claim file exists) ────── + const ownershipErr = checkOwnership( + basePath, + taskUnitKey(params.milestoneId, params.sliceId, params.taskId), + params.actorName, + ); + if (ownershipErr) { + return { error: ownershipErr }; + } + // ── DB writes inside a transaction ────────────────────────────────────── const completedAt = new Date().toISOString(); @@ -248,6 +284,8 @@ export async function handleCompleteTask( params: { milestoneId: params.milestoneId, sliceId: params.sliceId, taskId: params.taskId }, ts: new Date().toISOString(), actor: "agent", + actor_name: params.actorName, + trigger_reason: params.triggerReason, }); } catch (hookErr) { process.stderr.write( diff --git a/src/resources/extensions/gsd/tools/plan-milestone.ts b/src/resources/extensions/gsd/tools/plan-milestone.ts index c9d536c03..95bc2ede8 100644 --- a/src/resources/extensions/gsd/tools/plan-milestone.ts +++ b/src/resources/extensions/gsd/tools/plan-milestone.ts @@ -1,6 +1,7 @@ import { clearParseCache } from "../files.js"; import { transaction, + getMilestone, insertMilestone, insertSlice, upsertMilestonePlanning, @@ -31,6 +32,10 @@ export interface PlanMilestoneParams { title: string; status?: string; dependsOn?: string[]; + /** Optional caller-provided identity for audit trail */ + actorName?: string; + /** Optional caller-provided reason this action was triggered */ + triggerReason?: string; vision: string; successCriteria: string[]; keyRisks: Array<{ risk: string; whyItMatters: string }>; @@ -184,6 +189,25 @@ export async function handlePlanMilestone( return { error: `validation failed: ${(err as Error).message}` }; } + // ── State machine preconditions ───────────────────────────────────────── + const existingMilestone = getMilestone(params.milestoneId); + if (existingMilestone && (existingMilestone.status === "complete" || existingMilestone.status === "done")) { + return { error: `cannot re-plan milestone ${params.milestoneId}: it is already complete` }; + } + + // Validate depends_on: all dependencies must exist and be complete + if (params.dependsOn && params.dependsOn.length > 0) { + for (const depId of params.dependsOn) { + const dep = getMilestone(depId); + if (!dep) { + return { error: `depends_on references unknown milestone: ${depId}` }; + } + if (dep.status !== "complete" && dep.status !== "done") { + return { error: `depends_on milestone ${depId} is not yet complete (status: ${dep.status})` }; + } + } + } + try { transaction(() => { insertMilestone({ @@ -254,6 +278,8 @@ export async function handlePlanMilestone( params: { milestoneId: params.milestoneId }, ts: new Date().toISOString(), actor: "agent", + actor_name: params.actorName, + trigger_reason: params.triggerReason, }); } catch (hookErr) { process.stderr.write( diff --git a/src/resources/extensions/gsd/tools/plan-slice.ts b/src/resources/extensions/gsd/tools/plan-slice.ts index d46be8d6d..3f2951a22 100644 --- a/src/resources/extensions/gsd/tools/plan-slice.ts +++ b/src/resources/extensions/gsd/tools/plan-slice.ts @@ -1,6 +1,7 @@ import { clearParseCache } from "../files.js"; import { transaction, + getMilestone, getSlice, insertTask, upsertSlicePlanning, @@ -35,6 +36,10 @@ export interface PlanSliceParams { integrationClosure: string; observabilityImpact: string; tasks: PlanSliceTaskInput[]; + /** Optional caller-provided identity for audit trail */ + actorName?: string; + /** Optional caller-provided reason this action was triggered */ + triggerReason?: string; } export interface PlanSliceResult { @@ -139,10 +144,21 @@ export async function handlePlanSlice( return { error: `validation failed: ${(err as Error).message}` }; } + const parentMilestone = getMilestone(params.milestoneId); + if (!parentMilestone) { + return { error: `milestone not found: ${params.milestoneId}` }; + } + if (parentMilestone.status === "complete" || parentMilestone.status === "done") { + return { error: `cannot plan slice in a closed milestone: ${params.milestoneId} (status: ${parentMilestone.status})` }; + } + const parentSlice = getSlice(params.milestoneId, params.sliceId); if (!parentSlice) { return { error: `missing parent slice: ${params.milestoneId}/${params.sliceId}` }; } + if (parentSlice.status === "complete" || parentSlice.status === "done") { + return { error: `cannot re-plan slice ${params.sliceId}: it is already complete — use gsd_slice_reopen first` }; + } try { transaction(() => { @@ -193,6 +209,8 @@ export async function handlePlanSlice( params: { milestoneId: params.milestoneId, sliceId: params.sliceId }, ts: new Date().toISOString(), actor: "agent", + actor_name: params.actorName, + trigger_reason: params.triggerReason, }); } catch (hookErr) { process.stderr.write( diff --git a/src/resources/extensions/gsd/tools/plan-task.ts b/src/resources/extensions/gsd/tools/plan-task.ts index 429115212..c640ee22d 100644 --- a/src/resources/extensions/gsd/tools/plan-task.ts +++ b/src/resources/extensions/gsd/tools/plan-task.ts @@ -19,6 +19,10 @@ export interface PlanTaskParams { expectedOutput: string[]; observabilityImpact?: string; fullPlanMd?: string; + /** Optional caller-provided identity for audit trail */ + actorName?: string; + /** Optional caller-provided reason this action was triggered */ + triggerReason?: string; } export interface PlanTaskResult { @@ -77,10 +81,18 @@ export async function handlePlanTask( if (!parentSlice) { return { error: `missing parent slice: ${params.milestoneId}/${params.sliceId}` }; } + if (parentSlice.status === "complete" || parentSlice.status === "done") { + return { error: `cannot plan task in a closed slice: ${params.sliceId} (status: ${parentSlice.status})` }; + } + + const existingTask = getTask(params.milestoneId, params.sliceId, params.taskId); + if (existingTask && (existingTask.status === "complete" || existingTask.status === "done")) { + return { error: `cannot re-plan task ${params.taskId}: it is already complete — use gsd_task_reopen first` }; + } try { transaction(() => { - if (!getTask(params.milestoneId, params.sliceId, params.taskId)) { + if (!existingTask) { insertTask({ id: params.taskId, sliceId: params.sliceId, @@ -119,6 +131,8 @@ export async function handlePlanTask( params: { milestoneId: params.milestoneId, sliceId: params.sliceId, taskId: params.taskId }, ts: new Date().toISOString(), actor: "agent", + actor_name: params.actorName, + trigger_reason: params.triggerReason, }); } catch (hookErr) { process.stderr.write( diff --git a/src/resources/extensions/gsd/tools/reassess-roadmap.ts b/src/resources/extensions/gsd/tools/reassess-roadmap.ts index b4f61e2a8..db916bea9 100644 --- a/src/resources/extensions/gsd/tools/reassess-roadmap.ts +++ b/src/resources/extensions/gsd/tools/reassess-roadmap.ts @@ -3,6 +3,7 @@ import { transaction, getMilestone, getMilestoneSlices, + getSlice, insertSlice, updateSliceFields, insertAssessment, @@ -33,6 +34,10 @@ export interface ReassessRoadmapParams { added: SliceChangeInput[]; removed: string[]; }; + /** Optional caller-provided identity for audit trail */ + actorName?: string; + /** Optional caller-provided reason this action was triggered */ + triggerReason?: string; } export interface ReassessRoadmapResult { @@ -99,11 +104,23 @@ export async function handleReassessRoadmap( return { error: `validation failed: ${(err as Error).message}` }; } - // ── Verify milestone exists ─────────────────────────────────────── + // ── Verify milestone exists and is active ──────────────────────── const milestone = getMilestone(params.milestoneId); if (!milestone) { return { error: `milestone not found: ${params.milestoneId}` }; } + if (milestone.status === "complete" || milestone.status === "done") { + return { error: `cannot reassess a closed milestone: ${params.milestoneId} (status: ${milestone.status})` }; + } + + // ── Verify completedSliceId is actually complete ────────────────── + const completedSlice = getSlice(params.milestoneId, params.completedSliceId); + if (!completedSlice) { + return { error: `completedSliceId not found: ${params.milestoneId}/${params.completedSliceId}` }; + } + if (completedSlice.status !== "complete" && completedSlice.status !== "done") { + return { error: `completedSliceId ${params.completedSliceId} is not complete (status: ${completedSlice.status}) — reassess can only be called after a slice finishes` }; + } // ── Structural enforcement ──────────────────────────────────────── const existingSlices = getMilestoneSlices(params.milestoneId); @@ -203,6 +220,8 @@ export async function handleReassessRoadmap( params: { milestoneId: params.milestoneId, completedSliceId: params.completedSliceId }, ts: new Date().toISOString(), actor: "agent", + actor_name: params.actorName, + trigger_reason: params.triggerReason, }); } catch (hookErr) { process.stderr.write( diff --git a/src/resources/extensions/gsd/tools/reopen-slice.ts b/src/resources/extensions/gsd/tools/reopen-slice.ts new file mode 100644 index 000000000..b9fa05a09 --- /dev/null +++ b/src/resources/extensions/gsd/tools/reopen-slice.ts @@ -0,0 +1,113 @@ +/** + * reopen-slice handler — the core operation behind gsd_slice_reopen. + * + * Resets a completed slice back to "in_progress" and resets ALL of its + * tasks back to "pending". This is intentional — if you're reopening a + * slice, you're re-doing the work. Partial resets create ambiguous state. + * + * The parent milestone must still be open (not complete). + */ + +// GSD — reopen-slice tool handler +// Copyright (c) 2026 Jeremy McSpadden + +import { + getMilestone, + getSlice, + getSliceTasks, + updateSliceStatus, + updateTaskStatus, + transaction, +} from "../gsd-db.js"; +import { invalidateStateCache } from "../state.js"; +import { renderAllProjections } from "../workflow-projections.js"; +import { writeManifest } from "../workflow-manifest.js"; +import { appendEvent } from "../workflow-events.js"; + +export interface ReopenSliceParams { + milestoneId: string; + sliceId: string; + reason?: string; + /** Optional caller-provided identity for audit trail */ + actorName?: string; + /** Optional caller-provided reason this action was triggered */ + triggerReason?: string; +} + +export interface ReopenSliceResult { + milestoneId: string; + sliceId: string; + tasksReset: number; +} + +export async function handleReopenSlice( + params: ReopenSliceParams, + basePath: string, +): Promise { + // ── Validate required fields ──────────────────────────────────────────── + if (!params.sliceId || typeof params.sliceId !== "string" || params.sliceId.trim() === "") { + return { error: "sliceId is required and must be a non-empty string" }; + } + if (!params.milestoneId || typeof params.milestoneId !== "string" || params.milestoneId.trim() === "") { + return { error: "milestoneId is required and must be a non-empty string" }; + } + + // ── State machine preconditions ───────────────────────────────────────── + const milestone = getMilestone(params.milestoneId); + if (!milestone) { + return { error: `milestone not found: ${params.milestoneId}` }; + } + if (milestone.status === "complete" || milestone.status === "done") { + return { error: `cannot reopen slice inside a closed milestone: ${params.milestoneId} (status: ${milestone.status})` }; + } + + const slice = getSlice(params.milestoneId, params.sliceId); + if (!slice) { + return { error: `slice not found: ${params.milestoneId}/${params.sliceId}` }; + } + if (slice.status !== "complete" && slice.status !== "done") { + return { error: `slice ${params.sliceId} is not complete (status: ${slice.status}) — nothing to reopen` }; + } + + // ── Reset slice + all tasks in a transaction ──────────────────────────── + const tasks = getSliceTasks(params.milestoneId, params.sliceId); + + transaction(() => { + updateSliceStatus(params.milestoneId, params.sliceId, "in_progress"); + for (const task of tasks) { + updateTaskStatus(params.milestoneId, params.sliceId, task.id, "pending"); + } + }); + + // ── Invalidate caches ──────────────────────────────────────────────────── + invalidateStateCache(); + + // ── Post-mutation hook ─────────────────────────────────────────────────── + try { + await renderAllProjections(basePath, params.milestoneId); + writeManifest(basePath); + appendEvent(basePath, { + cmd: "reopen-slice", + params: { + milestoneId: params.milestoneId, + sliceId: params.sliceId, + reason: params.reason ?? null, + tasksReset: tasks.length, + }, + ts: new Date().toISOString(), + actor: "agent", + actor_name: params.actorName, + trigger_reason: params.triggerReason, + }); + } catch (hookErr) { + process.stderr.write( + `gsd: reopen-slice post-mutation hook warning: ${(hookErr as Error).message}\n`, + ); + } + + return { + milestoneId: params.milestoneId, + sliceId: params.sliceId, + tasksReset: tasks.length, + }; +} diff --git a/src/resources/extensions/gsd/tools/reopen-task.ts b/src/resources/extensions/gsd/tools/reopen-task.ts new file mode 100644 index 000000000..b25dbc7e2 --- /dev/null +++ b/src/resources/extensions/gsd/tools/reopen-task.ts @@ -0,0 +1,115 @@ +/** + * reopen-task handler — the core operation behind gsd_task_reopen. + * + * Resets a completed task back to "pending" so it can be re-done + * without manual SQL surgery. The parent slice and milestone must + * still be open (not complete) — you cannot reopen tasks inside a + * closed slice. + */ + +// GSD — reopen-task tool handler +// Copyright (c) 2026 Jeremy McSpadden + +import { + getMilestone, + getSlice, + getTask, + updateTaskStatus, +} from "../gsd-db.js"; +import { invalidateStateCache } from "../state.js"; +import { renderAllProjections } from "../workflow-projections.js"; +import { writeManifest } from "../workflow-manifest.js"; +import { appendEvent } from "../workflow-events.js"; + +export interface ReopenTaskParams { + milestoneId: string; + sliceId: string; + taskId: string; + reason?: string; + /** Optional caller-provided identity for audit trail */ + actorName?: string; + /** Optional caller-provided reason this action was triggered */ + triggerReason?: string; +} + +export interface ReopenTaskResult { + milestoneId: string; + sliceId: string; + taskId: string; +} + +export async function handleReopenTask( + params: ReopenTaskParams, + basePath: string, +): Promise { + // ── Validate required fields ──────────────────────────────────────────── + if (!params.taskId || typeof params.taskId !== "string" || params.taskId.trim() === "") { + return { error: "taskId is required and must be a non-empty string" }; + } + if (!params.sliceId || typeof params.sliceId !== "string" || params.sliceId.trim() === "") { + return { error: "sliceId is required and must be a non-empty string" }; + } + if (!params.milestoneId || typeof params.milestoneId !== "string" || params.milestoneId.trim() === "") { + return { error: "milestoneId is required and must be a non-empty string" }; + } + + // ── State machine preconditions ───────────────────────────────────────── + const milestone = getMilestone(params.milestoneId); + if (!milestone) { + return { error: `milestone not found: ${params.milestoneId}` }; + } + if (milestone.status === "complete" || milestone.status === "done") { + return { error: `cannot reopen task in a closed milestone: ${params.milestoneId} (status: ${milestone.status})` }; + } + + const slice = getSlice(params.milestoneId, params.sliceId); + if (!slice) { + return { error: `slice not found: ${params.milestoneId}/${params.sliceId}` }; + } + if (slice.status === "complete" || slice.status === "done") { + return { error: `cannot reopen task inside a closed slice: ${params.sliceId} (status: ${slice.status}) — use gsd_slice_reopen first` }; + } + + const task = getTask(params.milestoneId, params.sliceId, params.taskId); + if (!task) { + return { error: `task not found: ${params.milestoneId}/${params.sliceId}/${params.taskId}` }; + } + if (task.status !== "complete" && task.status !== "done") { + return { error: `task ${params.taskId} is not complete (status: ${task.status}) — nothing to reopen` }; + } + + // ── Reset task status ──────────────────────────────────────────────────── + updateTaskStatus(params.milestoneId, params.sliceId, params.taskId, "pending"); + + // ── Invalidate caches ──────────────────────────────────────────────────── + invalidateStateCache(); + + // ── Post-mutation hook ─────────────────────────────────────────────────── + try { + await renderAllProjections(basePath, params.milestoneId); + writeManifest(basePath); + appendEvent(basePath, { + cmd: "reopen-task", + params: { + milestoneId: params.milestoneId, + sliceId: params.sliceId, + taskId: params.taskId, + reason: params.reason ?? null, + }, + ts: new Date().toISOString(), + actor: "agent", + actor_name: params.actorName, + trigger_reason: params.triggerReason, + }); + } catch (hookErr) { + process.stderr.write( + `gsd: reopen-task post-mutation hook warning: ${(hookErr as Error).message}\n`, + ); + } + + return { + milestoneId: params.milestoneId, + sliceId: params.sliceId, + taskId: params.taskId, + }; +} diff --git a/src/resources/extensions/gsd/tools/replan-slice.ts b/src/resources/extensions/gsd/tools/replan-slice.ts index e68a9e501..f96474825 100644 --- a/src/resources/extensions/gsd/tools/replan-slice.ts +++ b/src/resources/extensions/gsd/tools/replan-slice.ts @@ -35,6 +35,10 @@ export interface ReplanSliceParams { whatChanged: string; updatedTasks: ReplanSliceTaskInput[]; removedTaskIds: string[]; + /** Optional caller-provided identity for audit trail */ + actorName?: string; + /** Optional caller-provided reason this action was triggered */ + triggerReason?: string; } export interface ReplanSliceResult { @@ -86,11 +90,23 @@ export async function handleReplanSlice( return { error: `validation failed: ${(err as Error).message}` }; } - // ── Verify parent slice exists ──────────────────────────────────── + // ── Verify parent slice exists and is not closed ───────────────── const parentSlice = getSlice(params.milestoneId, params.sliceId); if (!parentSlice) { return { error: `missing parent slice: ${params.milestoneId}/${params.sliceId}` }; } + if (parentSlice.status === "complete" || parentSlice.status === "done") { + return { error: `cannot replan a closed slice: ${params.sliceId} (status: ${parentSlice.status})` }; + } + + // ── Verify blocker task exists and is complete ──────────────────── + const blockerTask = getTask(params.milestoneId, params.sliceId, params.blockerTaskId); + if (!blockerTask) { + return { error: `blockerTaskId not found: ${params.milestoneId}/${params.sliceId}/${params.blockerTaskId}` }; + } + if (blockerTask.status !== "complete" && blockerTask.status !== "done") { + return { error: `blockerTaskId ${params.blockerTaskId} is not complete (status: ${blockerTask.status}) — the blocker task must be finished before a replan is triggered` }; + } // ── Structural enforcement ──────────────────────────────────────── const existingTasks = getSliceTasks(params.milestoneId, params.sliceId); @@ -195,6 +211,8 @@ export async function handleReplanSlice( params: { milestoneId: params.milestoneId, sliceId: params.sliceId, blockerTaskId: params.blockerTaskId }, ts: new Date().toISOString(), actor: "agent", + actor_name: params.actorName, + trigger_reason: params.triggerReason, }); } catch (hookErr) { process.stderr.write( diff --git a/src/resources/extensions/gsd/types.ts b/src/resources/extensions/gsd/types.ts index aca13ea6c..66c9c23f5 100644 --- a/src/resources/extensions/gsd/types.ts +++ b/src/resources/extensions/gsd/types.ts @@ -520,6 +520,10 @@ export interface CompleteTaskParams { verdict: string; durationMs: number; }>; + /** Optional caller-provided identity for audit trail */ + actorName?: string; + /** Optional caller-provided reason this action was triggered */ + triggerReason?: string; } // ─── Complete Slice Params (gsd_complete_slice tool input) ─────────────── @@ -548,4 +552,8 @@ export interface CompleteSliceParams { requires: Array<{ slice: string; provides: string }>; affects: string[]; drillDownPaths: string[]; + /** Optional caller-provided identity for audit trail */ + actorName?: string; + /** Optional caller-provided reason this action was triggered */ + triggerReason?: string; } diff --git a/src/resources/extensions/gsd/unit-ownership.ts b/src/resources/extensions/gsd/unit-ownership.ts new file mode 100644 index 000000000..9bbeb4f22 --- /dev/null +++ b/src/resources/extensions/gsd/unit-ownership.ts @@ -0,0 +1,104 @@ +// GSD Extension — Unit Ownership +// Opt-in per-unit ownership claims for multi-agent safety. +// +// An agent can claim a unit (task, slice) before working on it. +// complete-task and complete-slice enforce ownership when claims exist. +// If no claim file is present, ownership is not enforced (backward compatible). +// +// Claim file location: .gsd/unit-claims.json +// Unit key format: +// task: "//" +// slice: "/" +// +// Copyright (c) 2026 Jeremy McSpadden + +import { existsSync, readFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { atomicWriteSync } from "./atomic-write.js"; + +// ─── Types ─────────────────────────────────────────────────────────────── + +export interface UnitClaim { + agent: string; + claimed_at: string; +} + +type ClaimsMap = Record; + +// ─── Key Builders ──────────────────────────────────────────────────────── + +export function taskUnitKey(milestoneId: string, sliceId: string, taskId: string): string { + return `${milestoneId}/${sliceId}/${taskId}`; +} + +export function sliceUnitKey(milestoneId: string, sliceId: string): string { + return `${milestoneId}/${sliceId}`; +} + +// ─── File Path ─────────────────────────────────────────────────────────── + +function claimsPath(basePath: string): string { + return join(basePath, ".gsd", "unit-claims.json"); +} + +// ─── Read Claims ───────────────────────────────────────────────────────── + +function readClaims(basePath: string): ClaimsMap | null { + const path = claimsPath(basePath); + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, "utf-8")) as ClaimsMap; + } catch { + return null; + } +} + +// ─── Public API ────────────────────────────────────────────────────────── + +/** + * Claim a unit for an agent. + * Overwrites any existing claim for this unit (last writer wins). + */ +export function claimUnit(basePath: string, unitKey: string, agentName: string): void { + const claims = readClaims(basePath) ?? {}; + claims[unitKey] = { agent: agentName, claimed_at: new Date().toISOString() }; + const dir = join(basePath, ".gsd"); + mkdirSync(dir, { recursive: true }); + atomicWriteSync(claimsPath(basePath), JSON.stringify(claims, null, 2) + "\n"); +} + +/** + * Release a unit claim (remove it from the claims map). + */ +export function releaseUnit(basePath: string, unitKey: string): void { + const claims = readClaims(basePath); + if (!claims || !(unitKey in claims)) return; + delete claims[unitKey]; + atomicWriteSync(claimsPath(basePath), JSON.stringify(claims, null, 2) + "\n"); +} + +/** + * Get the current owner of a unit, or null if unclaimed / no claims file. + */ +export function getOwner(basePath: string, unitKey: string): string | null { + const claims = readClaims(basePath); + if (!claims) return null; + return claims[unitKey]?.agent ?? null; +} + +/** + * Check if an actor is authorized to operate on a unit. + * Returns null if ownership passes (or is unclaimed / no file). + * Returns an error string if a different agent owns the unit. + */ +export function checkOwnership( + basePath: string, + unitKey: string, + actorName: string | undefined, +): string | null { + if (!actorName) return null; // no actor identity provided — opt-in, so allow + const owner = getOwner(basePath, unitKey); + if (owner === null) return null; // unit unclaimed or no claims file + if (owner === actorName) return null; // actor is the owner + return `Unit ${unitKey} is owned by ${owner}, not ${actorName}`; +} diff --git a/src/resources/extensions/gsd/workflow-events.ts b/src/resources/extensions/gsd/workflow-events.ts index 3ba08a430..87bac5efb 100644 --- a/src/resources/extensions/gsd/workflow-events.ts +++ b/src/resources/extensions/gsd/workflow-events.ts @@ -1,8 +1,20 @@ -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { appendFileSync, readFileSync, existsSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { atomicWriteSync } from "./atomic-write.js"; +// ─── Session ID ─────────────────────────────────────────────────────────── + +/** + * Engine-generated session ID — stable for the lifetime of this process. + * Agents can reference this to correlate all events from one run. + */ +const ENGINE_SESSION_ID: string = randomUUID(); + +export function getSessionId(): string { + return ENGINE_SESSION_ID; +} + // ─── Event Types ───────────────────────────────────────────────────────── export interface WorkflowEvent { @@ -11,25 +23,32 @@ export interface WorkflowEvent { ts: string; // ISO 8601 hash: string; // content hash (hex, 16 chars) actor: "agent" | "system"; + actor_name?: string; // e.g. "executor-agent-01" — caller-provided identity + trigger_reason?: string; // e.g. "plan-phase complete" — caller-provided causation + session_id: string; // engine-generated UUID, stable per process lifetime } // ─── appendEvent ───────────────────────────────────────────────────────── /** * Append one event to .gsd/event-log.jsonl. - * Computes a content hash from cmd+params (deterministic, independent of ts/actor). + * Computes a content hash from cmd+params (deterministic, independent of ts/actor/session). * Creates .gsd directory if needed. */ export function appendEvent( basePath: string, - event: Omit, + event: Omit & { actor_name?: string; trigger_reason?: string }, ): void { const hash = createHash("sha256") .update(JSON.stringify({ cmd: event.cmd, params: event.params })) .digest("hex") .slice(0, 16); - const fullEvent: WorkflowEvent = { ...event, hash }; + const fullEvent: WorkflowEvent = { + ...event, + hash, + session_id: ENGINE_SESSION_ID, + }; const dir = join(basePath, ".gsd"); mkdirSync(dir, { recursive: true }); appendFileSync(join(dir, "event-log.jsonl"), JSON.stringify(fullEvent) + "\n", "utf-8"); diff --git a/src/resources/extensions/gsd/workflow-logger.ts b/src/resources/extensions/gsd/workflow-logger.ts index 4add85dd9..35e79bde5 100644 --- a/src/resources/extensions/gsd/workflow-logger.ts +++ b/src/resources/extensions/gsd/workflow-logger.ts @@ -2,6 +2,7 @@ // Centralized warning/error accumulator for the workflow engine pipeline. // Captures structured entries that the auto-loop can drain after each unit // to surface root causes for stuck loops, silent degradation, and blocked writes. +// All entries are also persisted to .gsd/audit-log.jsonl for post-mortem analysis. // // Stderr policy: every logWarning/logError call writes immediately to stderr // for terminal visibility. This is intentional — unlike debug-logger (which is @@ -13,6 +14,9 @@ // the start of each unit to prevent log bleed between units running in the same // Node process. +import { appendFileSync, readFileSync, existsSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; + // ─── Types ────────────────────────────────────────────────────────────── export type LogSeverity = "warn" | "error"; @@ -38,10 +42,20 @@ export interface LogEntry { context?: Record; } -// ─── Buffer ───────────────────────────────────────────────────────────── +// ─── Buffer & Persistent Audit ────────────────────────────────────────── const MAX_BUFFER = 100; let _buffer: LogEntry[] = []; +let _auditBasePath: string | null = null; + +/** + * Set the base path for persistent audit log writes. + * Should be called once at engine init with the project root. + * Until set, log entries are buffered in-memory only. + */ +export function setLogBasePath(basePath: string): void { + _auditBasePath = basePath; +} // ─── Public API ───────────────────────────────────────────────────────── @@ -156,12 +170,36 @@ export function formatForNotification(entries: readonly LogEntry[]): string { .join("\n"); } +/** + * Read all entries from the persistent audit log. + * Returns empty array if no basePath is set or the file doesn't exist. + */ +export function readAuditLog(basePath?: string): LogEntry[] { + const bp = basePath ?? _auditBasePath; + if (!bp) return []; + const auditPath = join(bp, ".gsd", "audit-log.jsonl"); + if (!existsSync(auditPath)) return []; + try { + const content = readFileSync(auditPath, "utf-8"); + return content + .split("\n") + .filter((l) => l.length > 0) + .map((l) => { + try { return JSON.parse(l) as LogEntry; } catch { return null; } + }) + .filter((e): e is LogEntry => e !== null); + } catch { + return []; + } +} + /** * Reset buffer. Call at the start of each auto-loop unit to prevent log bleed * between units running in the same process. Also used in tests via _resetLogs(). */ export function _resetLogs(): void { _buffer = []; + _auditBasePath = null; } // ─── Internal ─────────────────────────────────────────────────────────── @@ -190,4 +228,16 @@ function _push( if (_buffer.length > MAX_BUFFER) { _buffer.shift(); } + + // Persist to .gsd/audit-log.jsonl so entries survive context resets + if (_auditBasePath) { + try { + const auditDir = join(_auditBasePath, ".gsd"); + mkdirSync(auditDir, { recursive: true }); + appendFileSync(join(auditDir, "audit-log.jsonl"), JSON.stringify(entry) + "\n", "utf-8"); + } catch (auditErr) { + // Best-effort — never let audit write failures bubble up + process.stderr.write(`[gsd:audit] failed to persist log entry: ${(auditErr as Error).message}\n`); + } + } } diff --git a/src/resources/extensions/gsd/workflow-projections.ts b/src/resources/extensions/gsd/workflow-projections.ts index 3f1afe35a..3708ede94 100644 --- a/src/resources/extensions/gsd/workflow-projections.ts +++ b/src/resources/extensions/gsd/workflow-projections.ts @@ -35,8 +35,8 @@ export function renderPlanContent(sliceRow: SliceRow, taskRows: TaskRow[]): stri lines.push("## Tasks"); for (const task of taskRows) { - const checkbox = task.status === "done" ? "[x]" : "[ ]"; - lines.push(`- ${checkbox} **${task.id}:** ${task.title} \u2014 ${task.description}`); + const checkbox = task.status === "done" || task.status === "complete" ? "[x]" : "[ ]"; + lines.push(`- ${checkbox} **${task.id}: ${task.title}** \u2014 ${task.description}`); // Estimate subline (always present if non-empty) if (task.estimate) { @@ -104,7 +104,7 @@ export function renderRoadmapContent(milestoneRow: MilestoneRow, sliceRows: Slic lines.push("|----|-------|------|---------|------|------------|"); for (const slice of sliceRows) { - const done = slice.status === "done" ? "\u2705" : "\u2B1C"; + const done = slice.status === "done" || slice.status === "complete" ? "\u2705" : "\u2B1C"; // depends is already parsed to string[] by rowToSlice let depends = "\u2014";