gsd-pi 2.65.0-dev.6cc5110 → 2.65.0-dev.d0517ff

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +7 -2
  2. package/dist/resources/extensions/gsd/bootstrap/write-gate.js +31 -1
  3. package/dist/resources/extensions/gsd/gsd-db.js +36 -2
  4. package/dist/resources/extensions/gsd/index.js +1 -1
  5. package/dist/resources/extensions/gsd/prompts/discuss.md +2 -0
  6. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +2 -0
  7. package/dist/resources/extensions/gsd/prompts/queue.md +2 -0
  8. package/dist/resources/extensions/gsd/state.js +3 -6
  9. package/dist/resources/extensions/gsd/workflow-events.js +1 -0
  10. package/dist/resources/extensions/gsd/workflow-projections.js +3 -2
  11. package/dist/web/standalone/.next/BUILD_ID +1 -1
  12. package/dist/web/standalone/.next/app-path-routes-manifest.json +17 -17
  13. package/dist/web/standalone/.next/build-manifest.json +2 -2
  14. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  15. package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
  16. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  17. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  18. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  19. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  20. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  21. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  22. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  24. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/index.html +1 -1
  32. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app-paths-manifest.json +17 -17
  39. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  40. package/dist/web/standalone/.next/server/pages/500.html +2 -2
  41. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  42. package/package.json +1 -1
  43. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +7 -2
  44. package/src/resources/extensions/gsd/bootstrap/write-gate.ts +36 -1
  45. package/src/resources/extensions/gsd/gsd-db.ts +33 -2
  46. package/src/resources/extensions/gsd/index.ts +1 -0
  47. package/src/resources/extensions/gsd/prompts/discuss.md +2 -0
  48. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +2 -0
  49. package/src/resources/extensions/gsd/prompts/queue.md +2 -0
  50. package/src/resources/extensions/gsd/state.ts +3 -6
  51. package/src/resources/extensions/gsd/tests/wave5-consistency-regressions.test.ts +165 -0
  52. package/src/resources/extensions/gsd/tests/write-gate.test.ts +127 -2
  53. package/src/resources/extensions/gsd/workflow-events.ts +5 -3
  54. package/src/resources/extensions/gsd/workflow-projections.ts +3 -2
  55. /package/dist/web/standalone/.next/static/{iueakR5x5bQbax2sGz8Yr → JwdBI3y1H8vtBKiYvWfEK}/_buildManifest.js +0 -0
  56. /package/dist/web/standalone/.next/static/{iueakR5x5bQbax2sGz8Yr → JwdBI3y1H8vtBKiYvWfEK}/_ssgManifest.js +0 -0
@@ -451,6 +451,25 @@ function migrateSchema(db: DbAdapter): void {
451
451
  const currentVersion = row ? (row["v"] as number) : 0;
452
452
  if (currentVersion >= SCHEMA_VERSION) return;
453
453
 
454
+ // Backup database before migration so a mid-migration crash doesn't
455
+ // leave a partially-migrated DB with no recovery path.
456
+ // WAL-safe: checkpoint first to flush WAL into the main DB file, then copy.
457
+ if (currentPath && currentPath !== ":memory:" && existsSync(currentPath)) {
458
+ try {
459
+ const backupPath = `${currentPath}.backup-v${currentVersion}`;
460
+ if (!existsSync(backupPath)) {
461
+ // Flush WAL to main DB file before copying — without this, the backup
462
+ // may be missing committed data that only exists in the -wal file.
463
+ try { db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); } catch { /* checkpoint is best-effort */ }
464
+ copyFileSync(currentPath, backupPath);
465
+ }
466
+ } catch (backupErr) {
467
+ // Log but proceed — blocking migration leaves the DB stuck at an old
468
+ // schema version permanently on read-only or full filesystems.
469
+ logWarning("db", `Pre-migration backup failed: ${backupErr instanceof Error ? backupErr.message : String(backupErr)}`);
470
+ }
471
+ }
472
+
454
473
  db.exec("BEGIN");
455
474
  try {
456
475
  if (currentVersion < 2) {
@@ -999,9 +1018,21 @@ export function _resetProvider(): void {
999
1018
 
1000
1019
  export function upsertDecision(d: Omit<Decision, "seq">): void {
1001
1020
  if (!currentDb) throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
1021
+ // Use ON CONFLICT DO UPDATE instead of INSERT OR REPLACE to preserve the
1022
+ // seq column. INSERT OR REPLACE deletes then reinserts, resetting seq and
1023
+ // corrupting decision ordering in DECISIONS.md after reconcile replay.
1002
1024
  currentDb.prepare(
1003
- `INSERT OR REPLACE INTO decisions (id, when_context, scope, decision, choice, rationale, revisable, made_by, superseded_by)
1004
- VALUES (:id, :when_context, :scope, :decision, :choice, :rationale, :revisable, :made_by, :superseded_by)`,
1025
+ `INSERT INTO decisions (id, when_context, scope, decision, choice, rationale, revisable, made_by, superseded_by)
1026
+ VALUES (:id, :when_context, :scope, :decision, :choice, :rationale, :revisable, :made_by, :superseded_by)
1027
+ ON CONFLICT(id) DO UPDATE SET
1028
+ when_context = excluded.when_context,
1029
+ scope = excluded.scope,
1030
+ decision = excluded.decision,
1031
+ choice = excluded.choice,
1032
+ rationale = excluded.rationale,
1033
+ revisable = excluded.revisable,
1034
+ made_by = excluded.made_by,
1035
+ superseded_by = excluded.superseded_by`,
1005
1036
  ).run({
1006
1037
  ":id": d.id,
1007
1038
  ":when_context": d.when_context,
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI } from "@gsd/pi-coding-agent";
2
2
 
3
3
  export {
4
+ isDepthConfirmationAnswer,
4
5
  isDepthVerified,
5
6
  isQueuePhaseActive,
6
7
  setQueuePhaseActive,
@@ -114,6 +114,8 @@ If they clarify, absorb the correction and re-verify.
114
114
 
115
115
  The depth verification is the required write-gate. Do **not** add another meta "ready to proceed?" checkpoint immediately after it unless there is still material ambiguity.
116
116
 
117
+ **CRITICAL — Non-bypassable gate:** The system mechanically blocks CONTEXT.md writes until the user selects the "(Recommended)" option. If the user declines, cancels, or the tool fails, you MUST re-ask — never rationalize past the block ("tool not responding, I'll proceed" is forbidden). The gate exists to protect the user's work; treat a block as an instruction, not an obstacle to work around.
118
+
117
119
  ## Wrap-up Gate
118
120
 
119
121
  Once the depth checklist is fully satisfied, move directly into requirements and roadmap preview. Do not insert a separate "are you ready to continue?" gate unless the user explicitly wants to keep brainstorming or you still see material ambiguity.
@@ -100,6 +100,8 @@ If they clarify, absorb the correction and re-verify.
100
100
 
101
101
  The depth verification is the only required confirmation gate. Do not add a second "ready to proceed?" gate after it.
102
102
 
103
+ **CRITICAL — Non-bypassable gate:** The system mechanically blocks CONTEXT.md writes until the user selects the "(Recommended)" option. If the user declines, cancels, or the tool fails, you MUST re-ask — never rationalize past the block ("tool not responding, I'll proceed" is forbidden). The gate exists to protect the user's work; treat a block as an instruction, not an obstacle to work around.
104
+
103
105
  ---
104
106
 
105
107
  ## Output
@@ -103,6 +103,8 @@ The user confirms or corrects before you write. One depth verification per miles
103
103
 
104
104
  **If you skip this step, the system will block the CONTEXT.md write and return an error telling you to complete verification first.**
105
105
 
106
+ **CRITICAL — Non-bypassable gate:** The system mechanically blocks CONTEXT.md writes until the user selects the "(Recommended)" option. If the user declines, cancels, or the tool fails, you MUST re-ask — never rationalize past the block ("tool not responding, I'll proceed" is forbidden). The gate exists to protect the user's work; treat a block as an instruction, not an obstacle to work around.
107
+
106
108
  ## Output Phase
107
109
 
108
110
  Once the user is satisfied, in a single pass for **each** new milestone:
@@ -304,12 +304,9 @@ function extractContextTitle(content: string | null, fallback: string): string {
304
304
 
305
305
  // ─── DB-backed State Derivation ────────────────────────────────────────────
306
306
 
307
- /**
308
- * Helper: check if a DB status counts as "done" (handles K002 ambiguity).
309
- */
310
- function isStatusDone(status: string): boolean {
311
- return status === 'complete' || status === 'done' || status === 'skipped';
312
- }
307
+ // isStatusDone replaced by isClosedStatus from status-guards.ts (single source of truth).
308
+ // Alias kept for backward compatibility within this file.
309
+ const isStatusDone = isClosedStatus;
313
310
 
314
311
  /**
315
312
  * Derive GSD state from the milestones/slices/tasks DB tables.
@@ -0,0 +1,165 @@
1
+ // GSD State Machine — Wave 5 Consistency Regression Tests
2
+ // Validates isClosedStatus usage in projections, upsertDecision seq preservation,
3
+ // event schema versioning, and replay round-trip with mixed cmd formats.
4
+
5
+ import { describe, test } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { tmpdir } from "node:os";
10
+ import { isClosedStatus } from "../status-guards.js";
11
+ import { openDatabase, closeDatabase, upsertDecision, _getAdapter, insertMilestone, insertSlice, insertTask, getTask } from "../gsd-db.js";
12
+ import { extractEntityKey } from "../workflow-reconcile.js";
13
+ import type { WorkflowEvent } from "../workflow-events.js";
14
+
15
+ // ── Fix 19: isClosedStatus covers all closed statuses ──
16
+
17
+ describe("isClosedStatus used by projections", () => {
18
+ test("skipped is closed (projections now show checked)", () => {
19
+ assert.ok(isClosedStatus("skipped"));
20
+ });
21
+ test("complete is closed", () => {
22
+ assert.ok(isClosedStatus("complete"));
23
+ });
24
+ test("done is closed", () => {
25
+ assert.ok(isClosedStatus("done"));
26
+ });
27
+ test("in-progress is not closed", () => {
28
+ assert.ok(!isClosedStatus("in-progress"));
29
+ });
30
+ });
31
+
32
+ // ── Fix 20: upsertDecision preserves seq on update ──
33
+
34
+ describe("upsertDecision preserves seq column", () => {
35
+ test("seq is preserved when decision is re-upserted", () => {
36
+ const tmp = mkdtempSync(join(tmpdir(), "gsd-upsert-test-"));
37
+ const dbPath = join(tmp, "gsd.db");
38
+ try {
39
+ openDatabase(dbPath);
40
+ const adapter = _getAdapter();
41
+ assert.ok(adapter, "adapter must be available");
42
+
43
+ // Insert two decisions
44
+ upsertDecision({
45
+ id: "D001", when_context: "ctx1", scope: "s1",
46
+ decision: "d1", choice: "c1", rationale: "r1",
47
+ revisable: "yes", made_by: "agent", superseded_by: null,
48
+ });
49
+ upsertDecision({
50
+ id: "D002", when_context: "ctx2", scope: "s2",
51
+ decision: "d2", choice: "c2", rationale: "r2",
52
+ revisable: "yes", made_by: "agent", superseded_by: null,
53
+ });
54
+
55
+ // Get original seq values
56
+ const rows1 = adapter.prepare("SELECT id, seq FROM decisions ORDER BY seq").all() as Array<{ id: string; seq: number }>;
57
+ assert.strictEqual(rows1[0].id, "D001");
58
+ assert.strictEqual(rows1[1].id, "D002");
59
+ const d001OriginalSeq = rows1[0].seq;
60
+
61
+ // Re-upsert D001 with updated content
62
+ upsertDecision({
63
+ id: "D001", when_context: "updated", scope: "s1",
64
+ decision: "d1-updated", choice: "c1", rationale: "r1",
65
+ revisable: "yes", made_by: "agent", superseded_by: null,
66
+ });
67
+
68
+ // Verify seq is preserved (not moved to end)
69
+ const rows2 = adapter.prepare("SELECT id, seq FROM decisions ORDER BY seq").all() as Array<{ id: string; seq: number }>;
70
+ assert.strictEqual(rows2[0].id, "D001", "D001 should still be first by seq");
71
+ assert.strictEqual(rows2[0].seq, d001OriginalSeq, "D001 seq should be preserved");
72
+ assert.strictEqual(rows2[1].id, "D002", "D002 should still be second");
73
+
74
+ // Verify content was updated
75
+ const updated = adapter.prepare("SELECT decision FROM decisions WHERE id = 'D001'").get() as { decision: string };
76
+ assert.strictEqual(updated.decision, "d1-updated");
77
+
78
+ closeDatabase();
79
+ } finally {
80
+ rmSync(tmp, { recursive: true, force: true });
81
+ }
82
+ });
83
+ });
84
+
85
+ // ── Fix 23: Event schema versioning ──
86
+
87
+ describe("WorkflowEvent v field", () => {
88
+ test("appendEvent includes v:2 in output", async () => {
89
+ const tmp = mkdtempSync(join(tmpdir(), "gsd-event-v-test-"));
90
+ try {
91
+ const { appendEvent } = await import("../workflow-events.js");
92
+ appendEvent(tmp, {
93
+ cmd: "test-event",
94
+ params: { foo: "bar" },
95
+ ts: new Date().toISOString(),
96
+ actor: "system",
97
+ });
98
+
99
+ const logPath = join(tmp, ".gsd", "event-log.jsonl");
100
+ const line = readFileSync(logPath, "utf-8").trim();
101
+ const event = JSON.parse(line);
102
+ assert.strictEqual(event.v, 2, "New events should have v:2");
103
+ assert.strictEqual(event.cmd, "test-event");
104
+ } finally {
105
+ rmSync(tmp, { recursive: true, force: true });
106
+ }
107
+ });
108
+ });
109
+
110
+ // ── Fix 19 (behavior-level): Projection rendering with skipped tasks ──
111
+
112
+ describe("isClosedStatus drives projection checkbox logic", () => {
113
+ test("skipped task produces checked checkbox via isClosedStatus", () => {
114
+ // This tests the behavior contract that projections rely on:
115
+ // workflow-projections.ts uses isClosedStatus() to determine checkbox state.
116
+ // "skipped" tasks must render as [x], not [ ].
117
+ const statuses = ["complete", "done", "skipped"];
118
+ for (const status of statuses) {
119
+ assert.ok(
120
+ isClosedStatus(status),
121
+ `status "${status}" must be closed so projections render [x]`,
122
+ );
123
+ }
124
+ // Non-closed statuses must render as [ ]
125
+ for (const status of ["pending", "in-progress", "blocked", "active"]) {
126
+ assert.ok(
127
+ !isClosedStatus(status),
128
+ `status "${status}" must NOT be closed so projections render [ ]`,
129
+ );
130
+ }
131
+ });
132
+ });
133
+
134
+ // ── extractEntityKey: underscored cmds are recognized (Wave 5 scope) ──
135
+ // Note: hyphenated cmd normalization is in Wave 1. These tests validate
136
+ // the underscored format that Wave 5's extractEntityKey handles directly.
137
+
138
+ describe("extractEntityKey recognizes underscored cmds", () => {
139
+ const base: WorkflowEvent = { cmd: "", params: {}, ts: "", hash: "", actor: "agent", session_id: "" };
140
+
141
+ test("complete_task → task entity", () => {
142
+ const key = extractEntityKey({ ...base, cmd: "complete_task", params: { taskId: "T01" } });
143
+ assert.deepStrictEqual(key, { type: "task", id: "T01" });
144
+ });
145
+
146
+ test("complete_slice → slice entity", () => {
147
+ const key = extractEntityKey({ ...base, cmd: "complete_slice", params: { sliceId: "S01" } });
148
+ assert.deepStrictEqual(key, { type: "slice", id: "S01" });
149
+ });
150
+
151
+ test("plan_slice → slice_plan entity (distinct from complete)", () => {
152
+ const key = extractEntityKey({ ...base, cmd: "plan_slice", params: { sliceId: "S01" } });
153
+ assert.deepStrictEqual(key, { type: "slice_plan", id: "S01" });
154
+ });
155
+
156
+ test("save_decision → decision entity", () => {
157
+ const key = extractEntityKey({ ...base, cmd: "save_decision", params: { scope: "s", decision: "d" } });
158
+ assert.deepStrictEqual(key, { type: "decision", id: "s:d" });
159
+ });
160
+
161
+ test("unknown cmd returns null (not crash)", () => {
162
+ const key = extractEntityKey({ ...base, cmd: "future_unknown_cmd", params: {} });
163
+ assert.strictEqual(key, null);
164
+ });
165
+ });
@@ -12,6 +12,7 @@
12
12
  import test from 'node:test';
13
13
  import assert from 'node:assert/strict';
14
14
  import {
15
+ isDepthConfirmationAnswer,
15
16
  shouldBlockContextWrite,
16
17
  isDepthVerified,
17
18
  isQueuePhaseActive,
@@ -117,9 +118,9 @@ test('write-gate: regex does not match slice context files (S01-CONTEXT.md)', ()
117
118
  assert.strictEqual(result.block, false, 'S01-CONTEXT.md should not be blocked');
118
119
  });
119
120
 
120
- // ─── Scenario 7: Error message contains actionable instruction ──
121
+ // ─── Scenario 7: Error message contains actionable instruction and anti-bypass language ──
121
122
 
122
- test('write-gate: blocked reason contains depth_verification keyword', () => {
123
+ test('write-gate: blocked reason contains depth_verification keyword and anti-bypass language', () => {
123
124
  const result = shouldBlockContextWrite(
124
125
  'write',
125
126
  '.gsd/milestones/M999/M999-CONTEXT.md',
@@ -129,6 +130,8 @@ test('write-gate: blocked reason contains depth_verification keyword', () => {
129
130
  assert.strictEqual(result.block, true);
130
131
  assert.ok(result.reason!.includes('depth_verification'), 'reason should mention depth_verification question id');
131
132
  assert.ok(result.reason!.includes('ask_user_questions'), 'reason should mention ask_user_questions tool');
133
+ assert.ok(result.reason!.includes('MUST NOT'), 'reason should include anti-bypass language');
134
+ assert.ok(result.reason!.includes('(Recommended)'), 'reason should specify the required confirmation option');
132
135
  });
133
136
 
134
137
  // ─── Scenario 8: Queue mode blocks CONTEXT.md write without depth verification ──
@@ -191,3 +194,125 @@ test('write-gate: markDepthVerified unblocks queue-mode writes when milestoneId
191
194
 
192
195
  clearDiscussionFlowState();
193
196
  });
197
+
198
+ // ─── Standard options fixture used across depth confirmation tests ──
199
+
200
+ const STANDARD_OPTIONS = [
201
+ { label: 'Yes, you got it (Recommended)' },
202
+ { label: 'Not quite — let me clarify' },
203
+ ];
204
+
205
+ // ─── Scenario 11: accepts first option (confirmation) with structural validation ──
206
+
207
+ test('write-gate: isDepthConfirmationAnswer accepts first option with options present', () => {
208
+ assert.strictEqual(
209
+ isDepthConfirmationAnswer('Yes, you got it (Recommended)', STANDARD_OPTIONS),
210
+ true,
211
+ 'should accept exact match of first option label',
212
+ );
213
+ });
214
+
215
+ // ─── Scenario 12: rejects second option (decline) ──
216
+
217
+ test('write-gate: isDepthConfirmationAnswer rejects decline option', () => {
218
+ assert.strictEqual(
219
+ isDepthConfirmationAnswer('Not quite — let me clarify', STANDARD_OPTIONS),
220
+ false,
221
+ 'should reject the clarification option',
222
+ );
223
+ });
224
+
225
+ // ─── Scenario 13: rejects "None of the above" ──
226
+
227
+ test('write-gate: isDepthConfirmationAnswer rejects None of the above', () => {
228
+ assert.strictEqual(
229
+ isDepthConfirmationAnswer('None of the above', STANDARD_OPTIONS),
230
+ false,
231
+ 'should reject None of the above',
232
+ );
233
+ });
234
+
235
+ // ─── Scenario 14: rejects garbage/empty input ──
236
+
237
+ test('write-gate: isDepthConfirmationAnswer rejects garbage and edge cases', () => {
238
+ assert.strictEqual(isDepthConfirmationAnswer('discord', STANDARD_OPTIONS), false, 'garbage string');
239
+ assert.strictEqual(isDepthConfirmationAnswer('', STANDARD_OPTIONS), false, 'empty string');
240
+ assert.strictEqual(isDepthConfirmationAnswer(undefined, STANDARD_OPTIONS), false, 'undefined');
241
+ assert.strictEqual(isDepthConfirmationAnswer(null, STANDARD_OPTIONS), false, 'null');
242
+ assert.strictEqual(isDepthConfirmationAnswer(42, STANDARD_OPTIONS), false, 'number');
243
+ });
244
+
245
+ // ─── Scenario 15: handles array-wrapped selection ──
246
+
247
+ test('write-gate: isDepthConfirmationAnswer handles array-wrapped selected value', () => {
248
+ assert.strictEqual(
249
+ isDepthConfirmationAnswer(['Yes, you got it (Recommended)'], STANDARD_OPTIONS),
250
+ true,
251
+ 'should accept array-wrapped confirmation',
252
+ );
253
+ assert.strictEqual(
254
+ isDepthConfirmationAnswer(['Not quite — let me clarify'], STANDARD_OPTIONS),
255
+ false,
256
+ 'should reject array-wrapped decline',
257
+ );
258
+ assert.strictEqual(
259
+ isDepthConfirmationAnswer([], STANDARD_OPTIONS),
260
+ false,
261
+ 'should reject empty array',
262
+ );
263
+ });
264
+
265
+ // ─── Scenario 16: rejects free-form "Other" text that contains "(Recommended)" ──
266
+
267
+ test('write-gate: isDepthConfirmationAnswer rejects free-form text containing Recommended', () => {
268
+ assert.strictEqual(
269
+ isDepthConfirmationAnswer('I think this is fine (Recommended)', STANDARD_OPTIONS),
270
+ false,
271
+ 'free-form text with (Recommended) substring must not unlock gate',
272
+ );
273
+ assert.strictEqual(
274
+ isDepthConfirmationAnswer('(Recommended)', STANDARD_OPTIONS),
275
+ false,
276
+ 'bare (Recommended) string must not unlock gate',
277
+ );
278
+ });
279
+
280
+ // ─── Scenario 17: works with changed label text (decoupled from specific copy) ──
281
+
282
+ test('write-gate: isDepthConfirmationAnswer works with different label text', () => {
283
+ const customOptions = [
284
+ { label: 'Looks good, proceed' },
285
+ { label: 'Needs more discussion' },
286
+ ];
287
+ assert.strictEqual(
288
+ isDepthConfirmationAnswer('Looks good, proceed', customOptions),
289
+ true,
290
+ 'should accept first option regardless of label text',
291
+ );
292
+ assert.strictEqual(
293
+ isDepthConfirmationAnswer('Needs more discussion', customOptions),
294
+ false,
295
+ 'should reject second option',
296
+ );
297
+ // Old label should NOT work with new options
298
+ assert.strictEqual(
299
+ isDepthConfirmationAnswer('Yes, you got it (Recommended)', customOptions),
300
+ false,
301
+ 'old label text should not match new options',
302
+ );
303
+ });
304
+
305
+ // ─── Scenario 18: fallback when options not available ──
306
+
307
+ test('write-gate: isDepthConfirmationAnswer falls back to (Recommended) match without options', () => {
308
+ assert.strictEqual(
309
+ isDepthConfirmationAnswer('Yes, you got it (Recommended)'),
310
+ true,
311
+ 'should accept via fallback when no options provided',
312
+ );
313
+ assert.strictEqual(
314
+ isDepthConfirmationAnswer('Not quite — let me clarify'),
315
+ false,
316
+ 'should reject non-Recommended via fallback',
317
+ );
318
+ });
@@ -19,10 +19,11 @@ export function getSessionId(): string {
19
19
  // ─── Event Types ─────────────────────────────────────────────────────────
20
20
 
21
21
  export interface WorkflowEvent {
22
- cmd: string; // e.g. "complete-task" (canonical: hyphens; legacy: underscores both accepted by replay)
22
+ v?: number; // schema version omitted in v1 (legacy), 2 for current format
23
+ cmd: string; // e.g. "complete-task" (canonical: hyphens; legacy: underscores — both accepted by replay)
23
24
  params: Record<string, unknown>;
24
- ts: string; // ISO 8601
25
- hash: string; // content hash (hex, 16 chars)
25
+ ts: string; // ISO 8601
26
+ hash: string; // content hash (hex, 16 chars)
26
27
  actor: "agent" | "system";
27
28
  actor_name?: string; // e.g. "executor-agent-01" — caller-provided identity
28
29
  trigger_reason?: string; // e.g. "plan-phase complete" — caller-provided causation
@@ -46,6 +47,7 @@ export function appendEvent(
46
47
  .slice(0, 16);
47
48
 
48
49
  const fullEvent: WorkflowEvent = {
50
+ v: 2,
49
51
  ...event,
50
52
  hash,
51
53
  session_id: ENGINE_SESSION_ID,
@@ -16,6 +16,7 @@ import { atomicWriteSync } from "./atomic-write.js";
16
16
  import { join } from "node:path";
17
17
  import { mkdirSync, existsSync } from "node:fs";
18
18
  import { logWarning } from "./workflow-logger.js";
19
+ import { isClosedStatus } from "./status-guards.js";
19
20
  import { deriveState } from "./state.js";
20
21
  import type { GSDState } from "./types.js";
21
22
 
@@ -55,7 +56,7 @@ export function renderPlanContent(sliceRow: SliceRow, taskRows: TaskRow[]): stri
55
56
  lines.push("## Tasks");
56
57
 
57
58
  for (const task of taskRows) {
58
- const checkbox = task.status === "done" || task.status === "complete" ? "[x]" : "[ ]";
59
+ const checkbox = isClosedStatus(task.status) ? "[x]" : "[ ]";
59
60
  lines.push(`- ${checkbox} **${task.id}: ${task.title}** \u2014 ${task.description}`);
60
61
 
61
62
  // Estimate subline (always present if non-empty)
@@ -125,7 +126,7 @@ export function renderRoadmapContent(milestoneRow: MilestoneRow, sliceRows: Slic
125
126
  lines.push("|----|-------|------|---------|------|------------|");
126
127
 
127
128
  for (const slice of sliceRows) {
128
- const done = slice.status === "done" || slice.status === "complete" ? "\u2705" : "\u2B1C";
129
+ const done = isClosedStatus(slice.status) ? "\u2705" : "\u2B1C";
129
130
 
130
131
  // depends is already parsed to string[] by rowToSlice
131
132
  let depends = "\u2014";