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.
- package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +7 -2
- package/dist/resources/extensions/gsd/bootstrap/write-gate.js +31 -1
- package/dist/resources/extensions/gsd/gsd-db.js +36 -2
- package/dist/resources/extensions/gsd/index.js +1 -1
- package/dist/resources/extensions/gsd/prompts/discuss.md +2 -0
- package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +2 -0
- package/dist/resources/extensions/gsd/prompts/queue.md +2 -0
- package/dist/resources/extensions/gsd/state.js +3 -6
- package/dist/resources/extensions/gsd/workflow-events.js +1 -0
- package/dist/resources/extensions/gsd/workflow-projections.js +3 -2
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +17 -17
- package/dist/web/standalone/.next/build-manifest.json +2 -2
- package/dist/web/standalone/.next/prerender-manifest.json +3 -3
- package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +17 -17
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +2 -2
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/package.json +1 -1
- package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +7 -2
- package/src/resources/extensions/gsd/bootstrap/write-gate.ts +36 -1
- package/src/resources/extensions/gsd/gsd-db.ts +33 -2
- package/src/resources/extensions/gsd/index.ts +1 -0
- package/src/resources/extensions/gsd/prompts/discuss.md +2 -0
- package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +2 -0
- package/src/resources/extensions/gsd/prompts/queue.md +2 -0
- package/src/resources/extensions/gsd/state.ts +3 -6
- package/src/resources/extensions/gsd/tests/wave5-consistency-regressions.test.ts +165 -0
- package/src/resources/extensions/gsd/tests/write-gate.test.ts +127 -2
- package/src/resources/extensions/gsd/workflow-events.ts +5 -3
- package/src/resources/extensions/gsd/workflow-projections.ts +3 -2
- /package/dist/web/standalone/.next/static/{iueakR5x5bQbax2sGz8Yr → JwdBI3y1H8vtBKiYvWfEK}/_buildManifest.js +0 -0
- /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
|
|
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,
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|
|
25
|
-
hash: string;
|
|
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
|
|
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
|
|
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";
|
|
File without changes
|
|
File without changes
|