diff --git a/.gsd/milestones/M001/slices/S05/S05-PLAN.md b/.gsd/milestones/M001/slices/S05/S05-PLAN.md index 93ba92d58..632ee64cf 100644 --- a/.gsd/milestones/M001/slices/S05/S05-PLAN.md +++ b/.gsd/milestones/M001/slices/S05/S05-PLAN.md @@ -42,7 +42,7 @@ ## Tasks -- [ ] **T01: Schema v10 + flag-file DB migration in deriveStateFromDb** `est:45m` +- [x] **T01: Schema v10 + flag-file DB migration in deriveStateFromDb** `est:45m` - Why: The architecturally novel piece — REPLAN.md and REPLAN-TRIGGER.md detection in `deriveStateFromDb()` must use DB queries instead of disk-file checks. Schema v10 adds the `replan_triggered_at` column. Triage-resolution must also write the column. - Files: `src/resources/extensions/gsd/gsd-db.ts`, `src/resources/extensions/gsd/state.ts`, `src/resources/extensions/gsd/triage-resolution.ts`, `src/resources/extensions/gsd/tests/flag-file-db.test.ts` - Do: (1) Bump SCHEMA_VERSION to 10, add `replan_triggered_at TEXT DEFAULT NULL` to slices CREATE TABLE DDL and v10 migration block. (2) Update `SliceRow` interface and `rowToSlice()`. (3) In `deriveStateFromDb()`, replace `resolveSliceFile(... "REPLAN")` with `getReplanHistory(mid, sid).length > 0` check, replace `resolveSliceFile(... "REPLAN-TRIGGER")` with checking `getSlice(mid, sid)?.replan_triggered_at`. (4) In `triage-resolution.ts` `executeReplan()`, after writing the disk file, also write the `replan_triggered_at` column via `UPDATE slices SET replan_triggered_at = :ts`. (5) Write `flag-file-db.test.ts` testing: blocker→replan detection via DB (no disk file), REPLAN-TRIGGER via DB column (no disk file), loop protection (replan_history exists = no replanning phase). diff --git a/.gsd/milestones/M001/slices/S05/tasks/T01-SUMMARY.md b/.gsd/milestones/M001/slices/S05/tasks/T01-SUMMARY.md new file mode 100644 index 000000000..74b14a4bb --- /dev/null +++ b/.gsd/milestones/M001/slices/S05/tasks/T01-SUMMARY.md @@ -0,0 +1,92 @@ +--- +id: T01 +parent: S05 +milestone: M001 +key_files: + - src/resources/extensions/gsd/gsd-db.ts + - src/resources/extensions/gsd/state.ts + - src/resources/extensions/gsd/triage-resolution.ts + - src/resources/extensions/gsd/tests/flag-file-db.test.ts + - src/resources/extensions/gsd/tests/derive-state-db.test.ts +key_decisions: + - deriveStateFromDb uses getReplanHistory().length for loop protection instead of disk REPLAN.md check + - deriveStateFromDb uses getSlice().replan_triggered_at for trigger detection instead of disk REPLAN-TRIGGER.md check + - triage-resolution.ts DB write is best-effort with silent catch — disk file remains primary for _deriveStateImpl fallback + - Updated existing Test 16 in derive-state-db.test.ts to seed DB column since the DB path no longer reads disk flag files +duration: "" +verification_result: passed +completed_at: 2026-03-23T17:46:00.398Z +blocker_discovered: false +--- + +# T01: Schema v10 adds replan_triggered_at column; deriveStateFromDb uses DB queries for REPLAN/REPLAN-TRIGGER detection instead of disk files + +**Schema v10 adds replan_triggered_at column; deriveStateFromDb uses DB queries for REPLAN/REPLAN-TRIGGER detection instead of disk files** + +## What Happened + +Implemented schema v10 and migrated flag-file detection from disk-based to DB-based in deriveStateFromDb(). + +**Schema v10 in gsd-db.ts:** +- Bumped SCHEMA_VERSION from 9 to 10 +- Added `replan_triggered_at TEXT DEFAULT NULL` column to slices CREATE TABLE DDL (after `sequence`) +- Added `if (currentVersion < 10)` migration block using `ensureColumn()` for existing DBs +- Updated `SliceRow` interface with `replan_triggered_at: string | null` +- Updated `rowToSlice()` to read the column + +**deriveStateFromDb() in state.ts:** +- Replaced `resolveSliceFile(... "REPLAN")` loop protection with `getReplanHistory(mid, sid).length > 0` — checks if replan was already completed via DB instead of checking for REPLAN.md on disk +- Replaced `resolveSliceFile(... "REPLAN-TRIGGER")` detection with `getSlice(mid, sid)?.replan_triggered_at` non-null check — detects triage-initiated replan trigger from DB column instead of REPLAN-TRIGGER.md on disk +- Added `getReplanHistory` and `getSlice` to the gsd-db.js import +- Left `_deriveStateImpl()` fallback path completely untouched — it still uses disk-based detection +- Left CONTINUE.md detection untouched per D003 + +**triage-resolution.ts executeReplan():** +- After writing the disk REPLAN-TRIGGER.md file (kept for fallback path), also writes `replan_triggered_at` column via `UPDATE slices SET replan_triggered_at = :ts` +- Uses lazy `createRequire(import.meta.url)` pattern (consistent with codebase convention) with `isDbAvailable()` gate +- DB write is best-effort — catches errors silently since disk file is primary for fallback path + +**derive-state-db.test.ts fix:** +- Test 16 ("replanning-slice via DB") was seeding only a REPLAN-TRIGGER.md disk file without setting `replan_triggered_at` in DB. Updated to also seed the DB column so the DB-backed detection works correctly. + +**flag-file-db.test.ts (new, 6 test cases):** +1. blocker_discovered + no replan_history → phase is replanning-slice +2. blocker_discovered + replan_history exists → loop protection, phase is executing +3. replan_triggered_at set + no replan_history → phase is replanning-slice +4. replan_triggered_at set + replan_history exists → loop protection, phase is executing +5. no blocker, no trigger → phase is executing (baseline) +6. Diagnostic: replan_triggered_at column is queryable (observability surface verification) + +## Verification + +All three verification suites pass with zero failures: +- flag-file-db.test.ts: 14 assertions passed across 6 test cases (including diagnostic) +- derive-state-db.test.ts: 105 assertions passed (0 regressions after Test 16 fix) +- derive-state-crossval.test.ts: 189 assertions passed (0 regressions) +- schema-v9-sequence.test.ts: 7 tests passed (v9 migration still works under v10) + +## Verification Evidence + +| # | Command | Exit Code | Verdict | Duration | +|---|---------|-----------|---------|----------| +| 1 | `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/flag-file-db.test.ts` | 0 | ✅ pass | 2400ms | +| 2 | `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/derive-state-db.test.ts` | 0 | ✅ pass | 2400ms | +| 3 | `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/derive-state-crossval.test.ts` | 0 | ✅ pass | 2400ms | +| 4 | `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/schema-v9-sequence.test.ts` | 0 | ✅ pass | 2800ms | + + +## Deviations + +Updated derive-state-db.test.ts Test 16 to seed replan_triggered_at DB column — the test was relying on disk-based REPLAN-TRIGGER.md detection which is now replaced by DB queries in deriveStateFromDb(). Added a 6th diagnostic test case in flag-file-db.test.ts beyond the 5 specified in the plan to verify observability surface (column queryability). + +## Known Issues + +None. + +## Files Created/Modified + +- `src/resources/extensions/gsd/gsd-db.ts` +- `src/resources/extensions/gsd/state.ts` +- `src/resources/extensions/gsd/triage-resolution.ts` +- `src/resources/extensions/gsd/tests/flag-file-db.test.ts` +- `src/resources/extensions/gsd/tests/derive-state-db.test.ts` diff --git a/src/resources/extensions/gsd/gsd-db.ts b/src/resources/extensions/gsd/gsd-db.ts index aa19f26bd..abebb95dd 100644 --- a/src/resources/extensions/gsd/gsd-db.ts +++ b/src/resources/extensions/gsd/gsd-db.ts @@ -145,7 +145,7 @@ function openRawDb(path: string): unknown { return new Database(path); } -const SCHEMA_VERSION = 9; +const SCHEMA_VERSION = 10; function initSchema(db: DbAdapter, fileBacked: boolean): void { if (fileBacked) db.exec("PRAGMA journal_mode=WAL"); @@ -268,6 +268,7 @@ function initSchema(db: DbAdapter, fileBacked: boolean): void { integration_closure TEXT NOT NULL DEFAULT '', observability_impact TEXT NOT NULL DEFAULT '', sequence INTEGER DEFAULT 0, + replan_triggered_at TEXT DEFAULT NULL, PRIMARY KEY (milestone_id, id), FOREIGN KEY (milestone_id) REFERENCES milestones(id) ) @@ -604,6 +605,15 @@ function migrateSchema(db: DbAdapter): void { }); } + if (currentVersion < 10) { + ensureColumn(db, "slices", "replan_triggered_at", `ALTER TABLE slices ADD COLUMN replan_triggered_at TEXT DEFAULT NULL`); + + db.prepare("INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)").run({ + ":version": 10, + ":applied_at": new Date().toISOString(), + }); + } + db.exec("COMMIT"); } catch (err) { db.exec("ROLLBACK"); @@ -1150,6 +1160,7 @@ export interface SliceRow { integration_closure: string; observability_impact: string; sequence: number; + replan_triggered_at: string | null; } function rowToSlice(row: Record): SliceRow { @@ -1171,6 +1182,7 @@ function rowToSlice(row: Record): SliceRow { integration_closure: (row["integration_closure"] as string) ?? "", observability_impact: (row["observability_impact"] as string) ?? "", sequence: (row["sequence"] as number) ?? 0, + replan_triggered_at: (row["replan_triggered_at"] as string) ?? null, }; } diff --git a/src/resources/extensions/gsd/state.ts b/src/resources/extensions/gsd/state.ts index ef0f6622d..5b70699aa 100644 --- a/src/resources/extensions/gsd/state.ts +++ b/src/resources/extensions/gsd/state.ts @@ -43,6 +43,8 @@ import { getAllMilestones, getMilestoneSlices, getSliceTasks, + getReplanHistory, + getSlice, type MilestoneRow, type SliceRow, type TaskRow, @@ -639,8 +641,10 @@ export async function deriveStateFromDb(basePath: string): Promise { } if (blockerTaskId) { - const replanFile = resolveSliceFile(basePath, activeMilestone.id, activeSlice.id, "REPLAN"); - if (!replanFile) { + // Loop protection: if replan_history has entries for this slice, a replan + // was already performed — don't re-enter replanning phase. + const replanHistory = getReplanHistory(activeMilestone.id, activeSlice.id); + if (replanHistory.length === 0) { return { activeMilestone, activeSlice, activeTask, phase: 'replanning-slice', @@ -656,10 +660,11 @@ export async function deriveStateFromDb(basePath: string): Promise { // ── REPLAN-TRIGGER detection ───────────────────────────────────────── if (!blockerTaskId) { - const replanTriggerFile = resolveSliceFile(basePath, activeMilestone.id, activeSlice.id, "REPLAN-TRIGGER"); - if (replanTriggerFile) { - const replanFile = resolveSliceFile(basePath, activeMilestone.id, activeSlice.id, "REPLAN"); - if (!replanFile) { + const sliceRow = getSlice(activeMilestone.id, activeSlice.id); + if (sliceRow?.replan_triggered_at) { + // Loop protection: if replan_history has entries, replan was already done + const replanHistory = getReplanHistory(activeMilestone.id, activeSlice.id); + if (replanHistory.length === 0) { return { activeMilestone, activeSlice, activeTask, phase: 'replanning-slice', diff --git a/src/resources/extensions/gsd/tests/derive-state-db.test.ts b/src/resources/extensions/gsd/tests/derive-state-db.test.ts index 8d29d1098..ab59d0325 100644 --- a/src/resources/extensions/gsd/tests/derive-state-db.test.ts +++ b/src/resources/extensions/gsd/tests/derive-state-db.test.ts @@ -738,6 +738,13 @@ async function main(): Promise { insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'First Task', status: 'pending' }); insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', title: 'Done Task', status: 'complete' }); + // Seed the replan_triggered_at column — DB path uses column instead of disk file + const { _getAdapter } = await import('../gsd-db.ts'); + const adapter = _getAdapter(); + adapter!.prepare( + "UPDATE slices SET replan_triggered_at = :ts WHERE milestone_id = :mid AND id = :sid", + ).run({ ":ts": new Date().toISOString(), ":mid": "M001", ":sid": "S01" }); + invalidateStateCache(); const dbState = await deriveStateFromDb(base); diff --git a/src/resources/extensions/gsd/tests/flag-file-db.test.ts b/src/resources/extensions/gsd/tests/flag-file-db.test.ts new file mode 100644 index 000000000..3110bca6d --- /dev/null +++ b/src/resources/extensions/gsd/tests/flag-file-db.test.ts @@ -0,0 +1,290 @@ +/** + * flag-file-db.test.ts — Verify that REPLAN.md and REPLAN-TRIGGER.md + * flag-file detection in deriveStateFromDb() works from DB-only data + * (no disk flag files needed when DB is seeded). + * + * Semantics: + * - blocker_discovered on a completed task → replanning-slice (unless loop-protected) + * - replan_triggered_at column on slice → replanning-slice (unless loop-protected) + * - Loop protection: replan_history entries for the slice → skip replanning + */ + +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { deriveStateFromDb, invalidateStateCache } from '../state.ts'; +import { + openDatabase, + closeDatabase, + isDbAvailable, + insertMilestone, + insertSlice, + insertTask, + insertReplanHistory, + _getAdapter, +} from '../gsd-db.ts'; +import { createTestContext } from './test-helpers.ts'; + +const { assertEq, assertTrue, report } = createTestContext(); + +// ─── Fixture Helpers ─────────────────────────────────────────────────────── + +function createFixtureBase(): string { + const base = mkdtempSync(join(tmpdir(), 'gsd-flag-file-db-')); + mkdirSync(join(base, '.gsd', 'milestones'), { recursive: true }); + return base; +} + +function writeFile(base: string, relativePath: string, content: string): void { + const full = join(base, '.gsd', relativePath); + mkdirSync(join(full, '..'), { recursive: true }); + writeFileSync(full, content); +} + +function cleanup(base: string): void { + rmSync(base, { recursive: true, force: true }); +} + +const ROADMAP_CONTENT = `# M001: Flag-File DB Test + +**Vision:** Test flag-file detection via DB. + +## Slices + +- [ ] **S01: Test Slice** \`risk:low\` \`depends:[]\` + > After this: done. +`; + +const PLAN_CONTENT = `# S01: Test Slice + +**Goal:** Test replanning detection. +**Demo:** Tests pass. + +## Tasks + +- [x] **T01: Done Task** \`est:10m\` + Already done. + +- [ ] **T02: Active Task** \`est:10m\` + Current task. +`; + +// Minimal task plan file content — deriveStateFromDb checks the tasks dir has .md files +const TASK_PLAN_STUB = `# T02: Active Task\n\nDo stuff.\n`; +const TASK_SUMMARY_STUB = `---\nblocker_discovered: false\n---\n# T01 Summary\nDone.\n`; + +// ═══════════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════════ + +async function main(): Promise { + + // ─── Test 1: blocker_discovered + no replan_history → replanning-slice ── + console.log('\n=== flag-file-db: blocker + no history → replanning ==='); + { + const base = createFixtureBase(); + try { + // Write disk files needed by deriveStateFromDb (roadmap check, task dir check) + writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT); + writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT); + writeFile(base, 'milestones/M001/slices/S01/tasks/T02-PLAN.md', TASK_PLAN_STUB); + + openDatabase(':memory:'); + assertTrue(isDbAvailable(), 'test1: DB is available'); + + insertMilestone({ id: 'M001', title: 'Flag-File DB Test', status: 'active' }); + insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Test Slice', status: 'active', risk: 'low', depends: [] }); + insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'Done Task', status: 'complete', blockerDiscovered: true }); + insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', title: 'Active Task', status: 'pending' }); + + // No replan_history entries, no disk REPLAN.md — should trigger replanning + invalidateStateCache(); + const state = await deriveStateFromDb(base); + + assertEq(state.phase, 'replanning-slice', 'test1: phase is replanning-slice'); + assertTrue(state.blockers.length > 0, 'test1: has blockers'); + assertTrue(state.blockers[0]?.includes('blocker'), 'test1: blocker message mentions blocker'); + + closeDatabase(); + } finally { + closeDatabase(); + cleanup(base); + } + } + + // ─── Test 2: blocker_discovered + replan_history exists → loop protection → executing ── + console.log('\n=== flag-file-db: blocker + history → loop protection ==='); + { + const base = createFixtureBase(); + try { + writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT); + writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT); + writeFile(base, 'milestones/M001/slices/S01/tasks/T02-PLAN.md', TASK_PLAN_STUB); + + openDatabase(':memory:'); + + insertMilestone({ id: 'M001', title: 'Flag-File DB Test', status: 'active' }); + insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Test Slice', status: 'active', risk: 'low', depends: [] }); + insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'Done Task', status: 'complete', blockerDiscovered: true }); + insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', title: 'Active Task', status: 'pending' }); + + // Insert replan_history entry — loop protection should kick in + insertReplanHistory({ + milestoneId: 'M001', + sliceId: 'S01', + summary: 'Replan already completed for this slice', + }); + + invalidateStateCache(); + const state = await deriveStateFromDb(base); + + assertEq(state.phase, 'executing', 'test2: phase is executing (loop protection)'); + + closeDatabase(); + } finally { + closeDatabase(); + cleanup(base); + } + } + + // ─── Test 3: replan_triggered_at set + no replan_history → replanning-slice ── + console.log('\n=== flag-file-db: trigger column + no history → replanning ==='); + { + const base = createFixtureBase(); + try { + writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT); + writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT); + writeFile(base, 'milestones/M001/slices/S01/tasks/T02-PLAN.md', TASK_PLAN_STUB); + + openDatabase(':memory:'); + + insertMilestone({ id: 'M001', title: 'Flag-File DB Test', status: 'active' }); + insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Test Slice', status: 'active', risk: 'low', depends: [] }); + insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'Done Task', status: 'complete' }); + insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', title: 'Active Task', status: 'pending' }); + + // Set replan_triggered_at directly via SQL (simulating triage-resolution.ts writing it) + const adapter = _getAdapter(); + adapter!.prepare( + "UPDATE slices SET replan_triggered_at = :ts WHERE milestone_id = :mid AND id = :sid", + ).run({ ":ts": new Date().toISOString(), ":mid": "M001", ":sid": "S01" }); + + invalidateStateCache(); + const state = await deriveStateFromDb(base); + + assertEq(state.phase, 'replanning-slice', 'test3: phase is replanning-slice'); + assertTrue(state.blockers.length > 0, 'test3: has blockers'); + assertTrue(state.blockers[0]?.includes('Triage replan trigger'), 'test3: blocker message mentions triage trigger'); + + closeDatabase(); + } finally { + closeDatabase(); + cleanup(base); + } + } + + // ─── Test 4: replan_triggered_at set + replan_history exists → loop protection ── + console.log('\n=== flag-file-db: trigger column + history → loop protection ==='); + { + const base = createFixtureBase(); + try { + writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT); + writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT); + writeFile(base, 'milestones/M001/slices/S01/tasks/T02-PLAN.md', TASK_PLAN_STUB); + + openDatabase(':memory:'); + + insertMilestone({ id: 'M001', title: 'Flag-File DB Test', status: 'active' }); + insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Test Slice', status: 'active', risk: 'low', depends: [] }); + insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'Done Task', status: 'complete' }); + insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', title: 'Active Task', status: 'pending' }); + + // Set trigger column + const adapter = _getAdapter(); + adapter!.prepare( + "UPDATE slices SET replan_triggered_at = :ts WHERE milestone_id = :mid AND id = :sid", + ).run({ ":ts": new Date().toISOString(), ":mid": "M001", ":sid": "S01" }); + + // Also add replan_history — loop protection should prevent replanning + insertReplanHistory({ + milestoneId: 'M001', + sliceId: 'S01', + summary: 'Replan already done', + }); + + invalidateStateCache(); + const state = await deriveStateFromDb(base); + + assertEq(state.phase, 'executing', 'test4: phase is executing (loop protection)'); + + closeDatabase(); + } finally { + closeDatabase(); + cleanup(base); + } + } + + // ─── Test 5: no blocker, no trigger → phase is executing ────────────── + console.log('\n=== flag-file-db: no blocker, no trigger → executing ==='); + { + const base = createFixtureBase(); + try { + writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT); + writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT); + writeFile(base, 'milestones/M001/slices/S01/tasks/T02-PLAN.md', TASK_PLAN_STUB); + + openDatabase(':memory:'); + + insertMilestone({ id: 'M001', title: 'Flag-File DB Test', status: 'active' }); + insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Test Slice', status: 'active', risk: 'low', depends: [] }); + insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'Done Task', status: 'complete' }); + insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', title: 'Active Task', status: 'pending' }); + + // No blocker, no trigger, no replan_history — normal executing + invalidateStateCache(); + const state = await deriveStateFromDb(base); + + assertEq(state.phase, 'executing', 'test5: phase is executing'); + assertEq(state.activeTask?.id, 'T02', 'test5: activeTask is T02'); + assertEq(state.blockers.length, 0, 'test5: no blockers'); + + closeDatabase(); + } finally { + closeDatabase(); + cleanup(base); + } + } + + // ─── Diagnostic test: DB column inspection ────────────────────────── + console.log('\n=== flag-file-db: replan_triggered_at column is queryable ==='); + { + openDatabase(':memory:'); + + insertMilestone({ id: 'M001', title: 'Diagnostic', status: 'active' }); + insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Test', status: 'active', risk: 'low', depends: [] }); + + // Initially null + const adapter = _getAdapter(); + const before = adapter!.prepare( + "SELECT id, replan_triggered_at FROM slices WHERE milestone_id = :mid", + ).get({ ":mid": "M001" }) as Record; + assertEq(before["replan_triggered_at"], null, 'diagnostic: replan_triggered_at initially null'); + + // After setting + adapter!.prepare( + "UPDATE slices SET replan_triggered_at = :ts WHERE milestone_id = :mid AND id = :sid", + ).run({ ":ts": "2025-01-01T00:00:00Z", ":mid": "M001", ":sid": "S01" }); + + const after = adapter!.prepare( + "SELECT id, replan_triggered_at FROM slices WHERE milestone_id = :mid", + ).get({ ":mid": "M001" }) as Record; + assertEq(after["replan_triggered_at"], "2025-01-01T00:00:00Z", 'diagnostic: replan_triggered_at is set'); + + closeDatabase(); + } + + report(); +} + +main(); diff --git a/src/resources/extensions/gsd/triage-resolution.ts b/src/resources/extensions/gsd/triage-resolution.ts index 61e959077..eefb2caa8 100644 --- a/src/resources/extensions/gsd/triage-resolution.ts +++ b/src/resources/extensions/gsd/triage-resolution.ts @@ -12,6 +12,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { createRequire } from "node:module"; import { gsdRoot, milestonesDir } from "./paths.js"; import { MILESTONE_ID_RE } from "./milestone-ids.js"; import type { Classification, CaptureEntry } from "./captures.js"; @@ -90,19 +91,37 @@ export function executeReplan( const triggerPath = join( basePath, ".gsd", "milestones", mid, "slices", sid, `${sid}-REPLAN-TRIGGER.md`, ); + const ts = new Date().toISOString(); const content = [ `# Replan Trigger`, ``, `**Source:** Capture ${capture.id}`, `**Capture:** ${capture.text}`, `**Rationale:** ${capture.rationale ?? "User-initiated replan via capture triage"}`, - `**Triggered:** ${new Date().toISOString()}`, + `**Triggered:** ${ts}`, ``, `This file was created by the triage pipeline. The next dispatch cycle`, `will detect it and enter the replanning-slice phase.`, ].join("\n"); writeFileSync(triggerPath, content, "utf-8"); + + // Also write replan_triggered_at column for DB-backed detection + try { + const req = createRequire(import.meta.url); + const { isDbAvailable, _getAdapter } = req("./gsd-db.js"); + if (isDbAvailable()) { + const adapter = _getAdapter(); + if (adapter) { + adapter.prepare( + "UPDATE slices SET replan_triggered_at = :ts WHERE milestone_id = :mid AND id = :sid", + ).run({ ":ts": ts, ":mid": mid, ":sid": sid }); + } + } + } catch { + // DB write is best-effort — disk file is the primary trigger for fallback path + } + return true; } catch { return false;