@opengsd/gsd-pi 1.3.0-dev.72e3af2a → 1.3.0-dev.738160b9

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 (213) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.js +17 -4
  3. package/dist/headless-answers.js +1 -1
  4. package/dist/headless-ui.d.ts +1 -1
  5. package/dist/headless-ui.js +1 -1
  6. package/dist/headless.js +1 -1
  7. package/dist/resources/.managed-resources-content-hash +1 -1
  8. package/dist/resources/extensions/claude-code-cli/models.js +9 -0
  9. package/dist/resources/extensions/gsd/auto/session.js +8 -0
  10. package/dist/resources/extensions/gsd/auto/verification-retry-policy.js +1 -1
  11. package/dist/resources/extensions/gsd/auto-budget.js +5 -1
  12. package/dist/resources/extensions/gsd/auto-dispatch.js +14 -1
  13. package/dist/resources/extensions/gsd/auto-post-unit.js +1 -1
  14. package/dist/resources/extensions/gsd/auto-prompts.js +3 -3
  15. package/dist/resources/extensions/gsd/auto-recovery.js +3 -3
  16. package/dist/resources/extensions/gsd/auto-verification.js +5 -2
  17. package/dist/resources/extensions/gsd/auto.js +19 -10
  18. package/dist/resources/extensions/gsd/bootstrap/core-session-tools.js +1 -0
  19. package/dist/resources/extensions/gsd/bootstrap/db-tools.js +5 -4
  20. package/dist/resources/extensions/gsd/bootstrap/register-extension.js +2 -0
  21. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +31 -2
  22. package/dist/resources/extensions/gsd/bootstrap/tool-call-loop-guard.js +32 -4
  23. package/dist/resources/extensions/gsd/closeout-consistency-gate.js +3 -1
  24. package/dist/resources/extensions/gsd/commands/handlers/auto.js +1 -1
  25. package/dist/resources/extensions/gsd/commands-prefs-wizard.js +1 -1
  26. package/dist/resources/extensions/gsd/commands-usage.js +1 -1
  27. package/dist/resources/extensions/gsd/db/engine.js +4 -1
  28. package/dist/resources/extensions/gsd/db-memory-fts-schema.js +34 -0
  29. package/dist/resources/extensions/gsd/delegation-policy.js +1 -1
  30. package/dist/resources/extensions/gsd/docs/preferences-reference.md +2 -2
  31. package/dist/resources/extensions/gsd/doctor-engine-checks.js +192 -4
  32. package/dist/resources/extensions/gsd/doctor-runtime-checks.js +3 -0
  33. package/dist/resources/extensions/gsd/exec-sandbox.js +8 -2
  34. package/dist/resources/extensions/gsd/flat-phase-migration.js +45 -0
  35. package/dist/resources/extensions/gsd/git-service.js +57 -0
  36. package/dist/resources/extensions/gsd/gitignore.js +4 -1
  37. package/dist/resources/extensions/gsd/json-persistence.js +20 -0
  38. package/dist/resources/extensions/gsd/knowledge-backfill.js +18 -11
  39. package/dist/resources/extensions/gsd/memory-backfill.js +28 -21
  40. package/dist/resources/extensions/gsd/metrics.js +4 -4
  41. package/dist/resources/extensions/gsd/migrate-external.js +11 -2
  42. package/dist/resources/extensions/gsd/orphan-stash-audit.js +92 -16
  43. package/dist/resources/extensions/gsd/prompts/plan-milestone.md +2 -2
  44. package/dist/resources/extensions/gsd/prompts/plan-slice.md +3 -3
  45. package/dist/resources/extensions/gsd/root-write-leak-guard.js +7 -1
  46. package/dist/resources/extensions/gsd/safety/destructive-guard.js +8 -1
  47. package/dist/resources/extensions/gsd/safety/evidence-collector.js +16 -1
  48. package/dist/resources/extensions/gsd/safety/file-change-validator.js +5 -2
  49. package/dist/resources/extensions/gsd/tools/complete-milestone.js +4 -2
  50. package/dist/resources/extensions/gsd/tools/plan-slice.js +59 -44
  51. package/dist/resources/extensions/gsd/tools/plan-task.js +28 -5
  52. package/dist/resources/extensions/gsd/undo.js +43 -6
  53. package/dist/resources/extensions/gsd/unit-registry.js +4 -2
  54. package/dist/resources/extensions/gsd/verification-gate.js +7 -0
  55. package/dist/resources/extensions/gsd/workflow-manifest.js +126 -13
  56. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  57. package/dist/web/standalone/.next/BUILD_ID +1 -1
  58. package/dist/web/standalone/.next/app-path-routes-manifest.json +9 -9
  59. package/dist/web/standalone/.next/build-manifest.json +2 -2
  60. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  61. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  62. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  63. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  64. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  65. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  66. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  67. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  68. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  69. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  70. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  71. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  72. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  73. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  74. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  75. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  76. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  77. package/dist/web/standalone/.next/server/app/index.html +1 -1
  78. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  79. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  80. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  81. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  82. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  83. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  84. package/dist/web/standalone/.next/server/app-paths-manifest.json +9 -9
  85. package/dist/web/standalone/.next/server/chunks/8357.js +1 -1
  86. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  87. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  88. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  89. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  90. package/package.json +1 -1
  91. package/packages/cloud-mcp-gateway/package.json +2 -2
  92. package/packages/contracts/package.json +1 -1
  93. package/packages/daemon/package.json +4 -4
  94. package/packages/gsd-agent-core/package.json +5 -5
  95. package/packages/gsd-agent-modes/dist/modes/rpc/rpc-client.d.ts +2 -0
  96. package/packages/gsd-agent-modes/dist/modes/rpc/rpc-client.d.ts.map +1 -1
  97. package/packages/gsd-agent-modes/dist/modes/rpc/rpc-client.js +23 -5
  98. package/packages/gsd-agent-modes/dist/modes/rpc/rpc-client.js.map +1 -1
  99. package/packages/gsd-agent-modes/package.json +7 -7
  100. package/packages/mcp-server/dist/workflow-tools.js +1 -1
  101. package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
  102. package/packages/mcp-server/package.json +4 -4
  103. package/packages/native/package.json +1 -1
  104. package/packages/pi-agent-core/dist/agent-loop.js +3 -2
  105. package/packages/pi-agent-core/dist/agent-loop.js.map +1 -1
  106. package/packages/pi-agent-core/package.json +1 -1
  107. package/packages/pi-ai/dist/models.generated.d.ts +46 -0
  108. package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
  109. package/packages/pi-ai/dist/models.generated.js +38 -0
  110. package/packages/pi-ai/dist/models.generated.js.map +1 -1
  111. package/packages/pi-ai/package.json +1 -1
  112. package/packages/pi-coding-agent/package.json +7 -7
  113. package/packages/pi-tui/package.json +2 -2
  114. package/packages/rpc-client/package.json +2 -2
  115. package/pkg/package.json +1 -1
  116. package/src/resources/extensions/claude-code-cli/models.ts +9 -0
  117. package/src/resources/extensions/claude-code-cli/tests/index.test.ts +14 -0
  118. package/src/resources/extensions/gsd/auto/loop-deps.ts +1 -0
  119. package/src/resources/extensions/gsd/auto/session.ts +9 -0
  120. package/src/resources/extensions/gsd/auto/verification-retry-policy.ts +3 -1
  121. package/src/resources/extensions/gsd/auto-budget.ts +12 -1
  122. package/src/resources/extensions/gsd/auto-dispatch.ts +14 -1
  123. package/src/resources/extensions/gsd/auto-post-unit.ts +1 -1
  124. package/src/resources/extensions/gsd/auto-prompts.ts +3 -3
  125. package/src/resources/extensions/gsd/auto-recovery.ts +3 -3
  126. package/src/resources/extensions/gsd/auto-verification.ts +5 -1
  127. package/src/resources/extensions/gsd/auto.ts +23 -8
  128. package/src/resources/extensions/gsd/bootstrap/core-session-tools.ts +1 -0
  129. package/src/resources/extensions/gsd/bootstrap/db-tools.ts +5 -4
  130. package/src/resources/extensions/gsd/bootstrap/register-extension.ts +2 -0
  131. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +41 -2
  132. package/src/resources/extensions/gsd/bootstrap/tool-call-loop-guard.ts +34 -4
  133. package/src/resources/extensions/gsd/closeout-consistency-gate.ts +2 -1
  134. package/src/resources/extensions/gsd/commands/handlers/auto.ts +1 -1
  135. package/src/resources/extensions/gsd/commands-prefs-wizard.ts +1 -1
  136. package/src/resources/extensions/gsd/commands-usage.ts +1 -1
  137. package/src/resources/extensions/gsd/db/engine.ts +8 -1
  138. package/src/resources/extensions/gsd/db-memory-fts-schema.ts +45 -0
  139. package/src/resources/extensions/gsd/delegation-policy.ts +1 -1
  140. package/src/resources/extensions/gsd/docs/preferences-reference.md +2 -2
  141. package/src/resources/extensions/gsd/doctor-engine-checks.ts +233 -5
  142. package/src/resources/extensions/gsd/doctor-runtime-checks.ts +3 -0
  143. package/src/resources/extensions/gsd/doctor-types.ts +3 -0
  144. package/src/resources/extensions/gsd/exec-sandbox.ts +7 -2
  145. package/src/resources/extensions/gsd/flat-phase-migration.ts +44 -0
  146. package/src/resources/extensions/gsd/git-service.ts +64 -0
  147. package/src/resources/extensions/gsd/gitignore.ts +4 -1
  148. package/src/resources/extensions/gsd/json-persistence.ts +20 -0
  149. package/src/resources/extensions/gsd/knowledge-backfill.ts +16 -10
  150. package/src/resources/extensions/gsd/memory-backfill.ts +26 -20
  151. package/src/resources/extensions/gsd/metrics.ts +4 -4
  152. package/src/resources/extensions/gsd/migrate-external.ts +8 -2
  153. package/src/resources/extensions/gsd/orphan-stash-audit.ts +108 -20
  154. package/src/resources/extensions/gsd/prompts/plan-milestone.md +2 -2
  155. package/src/resources/extensions/gsd/prompts/plan-slice.md +3 -3
  156. package/src/resources/extensions/gsd/root-write-leak-guard.ts +13 -1
  157. package/src/resources/extensions/gsd/safety/destructive-guard.ts +9 -1
  158. package/src/resources/extensions/gsd/safety/evidence-collector.ts +16 -1
  159. package/src/resources/extensions/gsd/safety/file-change-validator.ts +8 -2
  160. package/src/resources/extensions/gsd/tests/artifact-retry-cap.test.ts +4 -3
  161. package/src/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +14 -5
  162. package/src/resources/extensions/gsd/tests/auto-pause-double-entry-guard.test.ts +36 -0
  163. package/src/resources/extensions/gsd/tests/auto-paused-ui-cleanup.test.ts +182 -4
  164. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +35 -0
  165. package/src/resources/extensions/gsd/tests/auto-retry-mcp-churn-fixes.test.ts +30 -0
  166. package/src/resources/extensions/gsd/tests/complete-milestone.test.ts +28 -0
  167. package/src/resources/extensions/gsd/tests/complete-slice-reopen-handoff.test.ts +69 -0
  168. package/src/resources/extensions/gsd/tests/db-memory-fts-schema.test.ts +58 -3
  169. package/src/resources/extensions/gsd/tests/decisions-projection-from-memories.test.ts +51 -0
  170. package/src/resources/extensions/gsd/tests/destructive-guard.test.ts +44 -0
  171. package/src/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts +143 -0
  172. package/src/resources/extensions/gsd/tests/doctor-scope-db-unavailable.test.ts +239 -2
  173. package/src/resources/extensions/gsd/tests/exec-sandbox.test.ts +16 -0
  174. package/src/resources/extensions/gsd/tests/file-change-validator.test.ts +40 -0
  175. package/src/resources/extensions/gsd/tests/flat-phase-migration.test.ts +37 -2
  176. package/src/resources/extensions/gsd/tests/gitignore-bg-shell-runtime.test.ts +4 -0
  177. package/src/resources/extensions/gsd/tests/integration/git-service.test.ts +11 -2
  178. package/src/resources/extensions/gsd/tests/json-persistence-atomic.test.ts +47 -0
  179. package/src/resources/extensions/gsd/tests/knowledge-backfill-projection.test.ts +35 -0
  180. package/src/resources/extensions/gsd/tests/memory-store.test.ts +48 -0
  181. package/src/resources/extensions/gsd/tests/merge-closeout-consistency-gate.test.ts +31 -1
  182. package/src/resources/extensions/gsd/tests/metrics-prune-cache-invalidation.test.ts +6 -0
  183. package/src/resources/extensions/gsd/tests/metrics.test.ts +1 -0
  184. package/src/resources/extensions/gsd/tests/migrate-external-worktree.test.ts +57 -0
  185. package/src/resources/extensions/gsd/tests/orphan-stash-audit.test.ts +91 -7
  186. package/src/resources/extensions/gsd/tests/parsers-legacy-importers.test.ts +2 -0
  187. package/src/resources/extensions/gsd/tests/plan-slice-prompt.test.ts +10 -0
  188. package/src/resources/extensions/gsd/tests/plan-slice.test.ts +103 -4
  189. package/src/resources/extensions/gsd/tests/plan-task.test.ts +44 -1
  190. package/src/resources/extensions/gsd/tests/post-mutation-hook.test.ts +4 -2
  191. package/src/resources/extensions/gsd/tests/prompt-budget-enforcement.test.ts +58 -0
  192. package/src/resources/extensions/gsd/tests/prompt-contracts.test.ts +8 -6
  193. package/src/resources/extensions/gsd/tests/register-hooks-loop-guard-auto.test.ts +103 -0
  194. package/src/resources/extensions/gsd/tests/root-write-leak-guard.test.ts +20 -1
  195. package/src/resources/extensions/gsd/tests/runtime-invariant-modules.test.ts +8 -3
  196. package/src/resources/extensions/gsd/tests/tool-call-loop-guard.test.ts +62 -5
  197. package/src/resources/extensions/gsd/tests/tool-param-optionality.test.ts +6 -3
  198. package/src/resources/extensions/gsd/tests/undo.test.ts +74 -0
  199. package/src/resources/extensions/gsd/tests/uok-gitops-turn-action.test.ts +38 -0
  200. package/src/resources/extensions/gsd/tests/verification-retry-policy.test.ts +69 -4
  201. package/src/resources/extensions/gsd/tests/workflow-manifest.test.ts +96 -15
  202. package/src/resources/extensions/gsd/tests/workflow-mcp-preflight.test.ts +1 -0
  203. package/src/resources/extensions/gsd/tests/workflow-mcp.test.ts +8 -4
  204. package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +6 -5
  205. package/src/resources/extensions/gsd/tools/complete-milestone.ts +3 -2
  206. package/src/resources/extensions/gsd/tools/plan-slice.ts +60 -47
  207. package/src/resources/extensions/gsd/tools/plan-task.ts +30 -5
  208. package/src/resources/extensions/gsd/undo.ts +47 -6
  209. package/src/resources/extensions/gsd/unit-registry.ts +4 -2
  210. package/src/resources/extensions/gsd/verification-gate.ts +8 -0
  211. package/src/resources/extensions/gsd/workflow-manifest.ts +141 -7
  212. /package/dist/web/standalone/.next/static/{O7xDYXO0r4zFhIzY1hrWV → 2A_WqqCS-EoYZxN9_v3d2}/_buildManifest.js +0 -0
  213. /package/dist/web/standalone/.next/static/{O7xDYXO0r4zFhIzY1hrWV → 2A_WqqCS-EoYZxN9_v3d2}/_ssgManifest.js +0 -0
@@ -19,6 +19,7 @@ import { tmpdir } from "node:os";
19
19
 
20
20
  import {
21
21
  saveJsonFile,
22
+ saveJsonFileCompact,
22
23
  loadJsonFile,
23
24
  writeJsonFileAtomic,
24
25
  } from "../json-persistence.ts";
@@ -56,6 +57,37 @@ test("saveJsonFile creates file with valid JSON content", () => {
56
57
  }
57
58
  });
58
59
 
60
+ test("saveJsonFile keeps pretty-printed output for human-readable state", () => {
61
+ const dir = makeTempDir();
62
+ const filePath = join(dir, "pretty.json");
63
+
64
+ try {
65
+ const data = { foo: "bar", nested: { count: 42 } };
66
+ saveJsonFile(filePath, data);
67
+
68
+ const content = readFileSync(filePath, "utf-8");
69
+ assert.equal(content, JSON.stringify(data, null, 2) + "\n");
70
+ } finally {
71
+ cleanup(dir);
72
+ }
73
+ });
74
+
75
+ test("saveJsonFileCompact writes valid compact JSON content", () => {
76
+ const dir = makeTempDir();
77
+ const filePath = join(dir, "compact.json");
78
+
79
+ try {
80
+ const data = { foo: "bar", nested: { count: 42 } };
81
+ saveJsonFileCompact(filePath, data);
82
+
83
+ const content = readFileSync(filePath, "utf-8");
84
+ assert.equal(content, JSON.stringify(data) + "\n");
85
+ assert.deepEqual(JSON.parse(content), data);
86
+ } finally {
87
+ cleanup(dir);
88
+ }
89
+ });
90
+
59
91
  test("saveJsonFile does not leave .tmp files on success", () => {
60
92
  const dir = makeTempDir();
61
93
  const filePath = join(dir, "clean.json");
@@ -72,6 +104,21 @@ test("saveJsonFile does not leave .tmp files on success", () => {
72
104
  }
73
105
  });
74
106
 
107
+ test("saveJsonFileCompact does not leave .tmp files on success", () => {
108
+ const dir = makeTempDir();
109
+ const filePath = join(dir, "compact-clean.json");
110
+
111
+ try {
112
+ saveJsonFileCompact(filePath, { test: true });
113
+
114
+ const files = readdirSync(dir);
115
+ const tmpFiles = files.filter(f => f.includes(".tmp"));
116
+ assert.equal(tmpFiles.length, 0, `Unexpected .tmp files: ${tmpFiles.join(", ")}`);
117
+ } finally {
118
+ cleanup(dir);
119
+ }
120
+ });
121
+
75
122
  test("saveJsonFile creates parent directories", () => {
76
123
  const dir = makeTempDir();
77
124
  const filePath = join(dir, "deep", "nested", "data.json");
@@ -161,6 +161,41 @@ test("backfill is idempotent — second run on the same file is a no-op", () =>
161
161
  }
162
162
  });
163
163
 
164
+ test("backfill skips a knowledge row whose memory insert throws and continues later rows", () => {
165
+ const base = makeTmpBase();
166
+ try {
167
+ writeKnowledgeMd(base, FIXTURE);
168
+
169
+ const adapter = _getAdapter();
170
+ assert.ok(adapter);
171
+ adapter.exec(`
172
+ CREATE TRIGGER fail_p002_memory_insert
173
+ BEFORE INSERT ON memories
174
+ WHEN json_extract(NEW.structured_fields, '$.sourceKnowledgeId') = 'P002'
175
+ BEGIN
176
+ SELECT RAISE(ABORT, 'poison knowledge P002');
177
+ END
178
+ `);
179
+
180
+ const written = backfillKnowledgeToMemories(base);
181
+ assert.equal(written, 2, "one poisoned row should not abort the batch");
182
+
183
+ const rows = adapter
184
+ .prepare(
185
+ "SELECT structured_fields FROM memories WHERE structured_fields LIKE '%\"sourceKnowledgeId\":\"%' ORDER BY seq",
186
+ )
187
+ .all() as Array<{ structured_fields: string }>;
188
+
189
+ assert.deepEqual(
190
+ rows.map((row) => JSON.parse(row.structured_fields).sourceKnowledgeId),
191
+ ["P001", "L001"],
192
+ "rows after the poisoned knowledge entry should still be migrated",
193
+ );
194
+ } finally {
195
+ cleanup(base);
196
+ }
197
+ });
198
+
164
199
  test("backfill returns 0 when KNOWLEDGE.md is absent", () => {
165
200
  const base = makeTmpBase();
166
201
  try {
@@ -5,6 +5,9 @@ import {
5
5
  SCHEMA_VERSION,
6
6
  _getAdapter,
7
7
  } from '../gsd-db.ts';
8
+ import * as fs from 'node:fs';
9
+ import * as path from 'node:path';
10
+ import * as os from 'node:os';
8
11
  import {
9
12
  _resetLogs,
10
13
  peekLogs,
@@ -22,6 +25,7 @@ import {
22
25
  markUnitProcessed,
23
26
  decayStaleMemories,
24
27
  enforceMemoryCap,
28
+ queryMemoriesRanked,
25
29
  applyMemoryActions,
26
30
  formatMemoriesForPrompt,
27
31
  } from '../memory-store.ts';
@@ -29,6 +33,16 @@ import type { MemoryAction } from '../memory-store.ts';
29
33
  import { describe, test, beforeEach, afterEach } from 'node:test';
30
34
  import assert from 'node:assert/strict';
31
35
 
36
+ function tempDbPath(): string {
37
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-memory-store-test-'));
38
+ return path.join(dir, 'test.db');
39
+ }
40
+
41
+ function cleanupDbPath(dbPath: string): void {
42
+ closeDatabase();
43
+ fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
44
+ }
45
+
32
46
  // ═══════════════════════════════════════════════════════════════════════════
33
47
  // memory-store: fallback when DB not open
34
48
  // ═══════════════════════════════════════════════════════════════════════════
@@ -366,6 +380,40 @@ test('memory-store: schema includes memories table', () => {
366
380
  closeDatabase();
367
381
  });
368
382
 
383
+ test('memory-store: reopening rebuilds FTS index for pre-FTS memories', () => {
384
+ const dbPath = tempDbPath();
385
+ try {
386
+ openDatabase(dbPath);
387
+ const id = createMemory({ category: 'gotcha', content: 'pre FTS auth memory must become searchable' });
388
+ assert.equal(id, 'MEM001');
389
+
390
+ const adapter = _getAdapter()!;
391
+ const fts = adapter.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='memories_fts'").get();
392
+ if (!fts) return;
393
+
394
+ adapter.exec('DROP TRIGGER IF EXISTS memories_ai');
395
+ adapter.exec('DROP TRIGGER IF EXISTS memories_ad');
396
+ adapter.exec('DROP TRIGGER IF EXISTS memories_au');
397
+ adapter.exec('DROP TABLE IF EXISTS memories_fts');
398
+ adapter.prepare(
399
+ "DELETE FROM runtime_kv WHERE scope = 'global' AND scope_id = '' AND key = 'memories_fts_rebuilt_at'",
400
+ ).run();
401
+ closeDatabase();
402
+
403
+ openDatabase(dbPath);
404
+ const reopened = _getAdapter()!;
405
+ const marker = reopened.prepare(
406
+ "SELECT value_json FROM runtime_kv WHERE scope = 'global' AND scope_id = '' AND key = 'memories_fts_rebuilt_at'",
407
+ ).get();
408
+ assert.ok(marker, 'FTS rebuild marker should be set after reopening');
409
+
410
+ const hits = queryMemoriesRanked({ query: 'auth', k: 5 }).map((hit) => hit.memory.id);
411
+ assert.deepEqual(hits, ['MEM001']);
412
+ } finally {
413
+ cleanupDbPath(dbPath);
414
+ }
415
+ });
416
+
369
417
  // ═══════════════════════════════════════════════════════════════════════════
370
418
  // regression #4967 — createMemory must not silently swallow SQL errors
371
419
  // ═══════════════════════════════════════════════════════════════════════════
@@ -9,7 +9,15 @@ import { tmpdir } from "node:os";
9
9
  import { execFileSync } from "node:child_process";
10
10
 
11
11
  import { mergeMilestoneToMain } from "../auto-worktree.ts";
12
- import { closeDatabase, insertMilestone, openDatabase } from "../gsd-db.ts";
12
+ import { checkCloseoutConsistencyGate } from "../closeout-consistency-gate.ts";
13
+ import {
14
+ closeDatabase,
15
+ insertAssessment,
16
+ insertMilestone,
17
+ insertSlice,
18
+ insertTask,
19
+ openDatabase,
20
+ } from "../gsd-db.ts";
13
21
 
14
22
  function git(args: string[], cwd: string): string {
15
23
  return execFileSync("git", args, {
@@ -61,3 +69,25 @@ test("mergeMilestoneToMain blocks when project DB closeout is still open", () =>
61
69
  if (existsSync(repo)) rmSync(repo, { recursive: true, force: true });
62
70
  }
63
71
  });
72
+
73
+ test("closeout consistency treats deferred slices as inactive", () => {
74
+ try {
75
+ assert.equal(openDatabase(":memory:"), true);
76
+ insertMilestone({ id: "M001", title: "Milestone One", status: "complete" });
77
+ insertSlice({ milestoneId: "M001", id: "S01", title: "Done", status: "complete" });
78
+ insertTask({ milestoneId: "M001", sliceId: "S01", id: "T01", title: "Done", status: "complete" });
79
+ insertSlice({ milestoneId: "M001", id: "S02", title: "Deferred", status: "deferred" });
80
+ insertTask({ milestoneId: "M001", sliceId: "S02", id: "T02", title: "Deferred", status: "pending" });
81
+ insertAssessment({
82
+ path: "milestones/M001/M001-VALIDATION.md",
83
+ milestoneId: "M001",
84
+ status: "pass",
85
+ scope: "milestone-validation",
86
+ fullContent: "verdict: pass",
87
+ });
88
+
89
+ assert.deepEqual(checkCloseoutConsistencyGate("M001"), { ok: true });
90
+ } finally {
91
+ closeDatabase();
92
+ }
93
+ });
@@ -112,6 +112,12 @@ describe("pruneMetricsLedger: invalidates scoped ledger cache", () => {
112
112
  // 3. Prune to keepCount=2 — should evict 3 old units from disk AND clear scopedLedgers.
113
113
  const removed = pruneMetricsLedger(tmpDir, 2);
114
114
  assert.equal(removed, 3, "pruneMetricsLedger should report 3 removed units");
115
+ const prunedRaw = readFileSync(metricsPath(tmpDir), "utf-8");
116
+ assert.equal(
117
+ prunedRaw,
118
+ JSON.stringify(JSON.parse(prunedRaw)) + "\n",
119
+ "pruneMetricsLedger should persist compact metrics.json",
120
+ );
115
121
 
116
122
  // 4. Snapshot a new unit via scope. This exercises the lazy-reload path
117
123
  // (scopedLedgers was cleared by prune) and writes the result to disk.
@@ -259,6 +259,7 @@ test("initMetrics creates ledger, snapshotUnitMetrics persists across resets", (
259
259
  // Verify file content
260
260
  const raw = readFileSync(join(tmpBase, ".gsd", "metrics.json"), "utf-8");
261
261
  const parsed: MetricsLedger = JSON.parse(raw);
262
+ assert.equal(raw, JSON.stringify(parsed) + "\n", "metrics.json should be serialized compactly");
262
263
  assert.equal(parsed.version, 1);
263
264
  assert.equal(parsed.units.length, 1);
264
265
 
@@ -7,7 +7,10 @@ import {
7
7
  existsSync,
8
8
  mkdirSync,
9
9
  realpathSync,
10
+ readFileSync,
10
11
  } from "node:fs";
12
+ import fs from "node:fs";
13
+ import { syncBuiltinESMExports } from "node:module";
11
14
  import { join } from "node:path";
12
15
  import { tmpdir } from "node:os";
13
16
  import { execSync } from "node:child_process";
@@ -116,6 +119,60 @@ describe("migrate-external worktree guard (#2970)", () => {
116
119
  });
117
120
  });
118
121
 
122
+ describe("migrateToExternalState copy failure rollback (#1047)", () => {
123
+ test("restores .gsd when a source entry fails to copy", () => {
124
+ const base = realpathSync(mkdtempSync(join(tmpdir(), "gsd-migrate-copy-fail-")));
125
+ const stateDir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-state-copy-fail-")));
126
+ const previousStateDir = process.env.GSD_STATE_DIR;
127
+ const previousProjectId = process.env.GSD_PROJECT_ID;
128
+ const originalCpSync = fs.cpSync;
129
+ process.env.GSD_STATE_DIR = stateDir;
130
+ process.env.GSD_PROJECT_ID = "copy-fail";
131
+
132
+ try {
133
+ run("git init -b main", base);
134
+ mkdirSync(join(base, ".gsd"), { recursive: true });
135
+ writeFileSync(join(base, ".gsd", "PREFERENCES.md"), "# prefs\n", "utf-8");
136
+ writeFileSync(join(base, ".gsd", "gsd.db"), "state", "utf-8");
137
+ const failingSource = join(base, ".gsd.migrating", "gsd.db");
138
+ fs.cpSync = ((...args: Parameters<typeof fs.cpSync>) => {
139
+ const src = args[0];
140
+ if (String(src) === failingSource) {
141
+ const error = new Error("simulated copy failure") as NodeJS.ErrnoException;
142
+ error.code = "EACCES";
143
+ throw error;
144
+ }
145
+ return originalCpSync(...args);
146
+ }) as typeof fs.cpSync;
147
+ syncBuiltinESMExports();
148
+
149
+ const result = migrateToExternalState(base);
150
+
151
+ assert.equal(result.migrated, false, "copy failure must fail the migration");
152
+ assert.match(result.error ?? "", /Migration copy failed/);
153
+ assert.ok(existsSync(join(base, ".gsd", "gsd.db")), "source .gsd must be restored");
154
+ assert.equal(readFileSync(join(base, ".gsd", "PREFERENCES.md"), "utf-8"), "# prefs\n");
155
+ assert.ok(!existsSync(join(base, ".gsd.migrating")), "backup staging dir must not remain after restore");
156
+ assert.ok(!existsSync(join(stateDir, "projects", "copy-fail", "gsd.db")), "failed file must not be reported as migrated");
157
+ } finally {
158
+ fs.cpSync = originalCpSync;
159
+ syncBuiltinESMExports();
160
+ if (previousStateDir === undefined) {
161
+ delete process.env.GSD_STATE_DIR;
162
+ } else {
163
+ process.env.GSD_STATE_DIR = previousStateDir;
164
+ }
165
+ if (previousProjectId === undefined) {
166
+ delete process.env.GSD_PROJECT_ID;
167
+ } else {
168
+ process.env.GSD_PROJECT_ID = previousProjectId;
169
+ }
170
+ rmSync(base, { recursive: true, force: true });
171
+ rmSync(stateDir, { recursive: true, force: true });
172
+ }
173
+ });
174
+ });
175
+
119
176
  // Regression tests for #5571 — recoverFailedMigration handles orphaned .gsd.migrating
120
177
  describe("recoverFailedMigration (#5571)", () => {
121
178
  test("returns false when .gsd.migrating does not exist", () => {
@@ -1,6 +1,7 @@
1
1
  // gsd-pi + src/resources/extensions/gsd/tests/orphan-stash-audit.test.ts
2
- // Regression: orphaned gsd-preflight-stash entries from completed milestones
3
- // must be auto-applied at startup so the user's pre-merge work returns.
2
+ // Regression coverage for orphaned gsd-preflight-stash entries from completed
3
+ // milestones: safe startup recovery for untracked work, manual warnings for
4
+ // tracked or dirty-tree cases that would silently mutate user state.
4
5
 
5
6
  import { describe, test, beforeEach, afterEach } from "node:test";
6
7
  import assert from "node:assert/strict";
@@ -37,6 +38,13 @@ function pushPreflightStash(repo: string, milestoneId: string, fileName: string,
37
38
  return marker;
38
39
  }
39
40
 
41
+ function pushTrackedPreflightStash(repo: string, milestoneId: string, fileName: string, content: string): string {
42
+ writeFileSync(join(repo, fileName), content);
43
+ const marker = `gsd-preflight-stash:${milestoneId}:42:1700000000000:tracked`;
44
+ git(repo, "stash", "push", "-m", `gsd-preflight-stash [${marker}]`);
45
+ return marker;
46
+ }
47
+
40
48
  describe("auditOrphanedPreflightStashes", () => {
41
49
  let repo: string;
42
50
 
@@ -83,6 +91,23 @@ describe("auditOrphanedPreflightStashes", () => {
83
91
  assert.match(list, /gsd-preflight-stash:M002:/);
84
92
  });
85
93
 
94
+ test("does not auto-apply a tracked orphan preflight stash onto a clean working tree", () => {
95
+ pushTrackedPreflightStash(repo, "M006", "seed.txt", "seed\ntracked pre-merge work\n");
96
+ assert.equal(readFileSync(join(repo, "seed.txt"), "utf-8"), "seed\n");
97
+
98
+ const result = auditOrphanedPreflightStashes(repo, () => true);
99
+
100
+ assert.equal(result.applied.length, 0);
101
+ assert.equal(result.warnings.length, 1);
102
+ assert.match(result.warnings[0], /not auto-applied/);
103
+ assert.match(result.warnings[0], /stash modifies tracked files/);
104
+ assert.match(result.warnings[0], /git stash apply stash@\{\d+\}/);
105
+ assert.equal(readFileSync(join(repo, "seed.txt"), "utf-8"), "seed\n");
106
+
107
+ const list = git(repo, "stash", "list");
108
+ assert.match(list, /gsd-preflight-stash:M006:/);
109
+ });
110
+
86
111
  test("ignores stashes whose milestone is not complete", () => {
87
112
  pushPreflightStash(repo, "M003", "wip.txt", "still-working\n");
88
113
 
@@ -115,23 +140,25 @@ describe("auditOrphanedPreflightStashes", () => {
115
140
  assert.match(result.warnings[0], /db unavailable/);
116
141
  });
117
142
 
118
- test("collects a warning when stash apply fails (conflicting working tree)", () => {
119
- // Push a stash containing a change to seed.txt; then dirty seed.txt with
120
- // a conflicting modification before the audit runs so apply fails.
143
+ test("collects a warning instead of applying when the working tree is not clean", () => {
144
+ // Push a stash containing a change to seed.txt; then dirty seed.txt before
145
+ // the audit runs. Startup recovery must leave the tree untouched and ask
146
+ // the user to apply the retained backup stash manually.
121
147
  writeFileSync(join(repo, "seed.txt"), "stashed\n");
122
148
  const marker = `gsd-preflight-stash:M005:42:1700:zz`;
123
149
  git(repo, "stash", "push", "-m", `gsd-preflight-stash [${marker}]`);
124
150
 
125
- // Dirty the working tree so apply will conflict.
126
151
  writeFileSync(join(repo, "seed.txt"), "conflicting modification\n");
127
152
 
128
153
  const result = auditOrphanedPreflightStashes(repo, () => true);
129
154
 
130
155
  assert.equal(result.applied.length, 0);
131
156
  assert.equal(result.warnings.length, 1);
132
- assert.match(result.warnings[0], /Could not apply orphaned preflight stash/);
157
+ assert.match(result.warnings[0], /not auto-applied/);
158
+ assert.match(result.warnings[0], /working tree not clean/);
133
159
  assert.match(result.warnings[0], /M005/);
134
160
  assert.match(result.warnings[0], /git stash apply/);
161
+ assert.equal(readFileSync(join(repo, "seed.txt"), "utf-8"), "conflicting modification\n");
135
162
 
136
163
  const list = git(repo, "stash", "list");
137
164
  assert.match(list, /gsd-preflight-stash:M005:/);
@@ -169,6 +196,63 @@ describe("auditOrphanedPreflightStashes", () => {
169
196
  "second run must NOT warn — files are already restored from first run",
170
197
  );
171
198
  });
199
+
200
+ test("warns instead of silently skipping when only some stash untracked files were restored", () => {
201
+ // Cursor Bugbot caught: isAlreadyRestoredUntrackedStatus treated a dirty
202
+ // tree as fully restored when every *current* untracked path appeared in
203
+ // the stash, without requiring every *stash* untracked path to be present.
204
+ // A partial restore (only some files recovered) was misread as the
205
+ // idempotent steady state, so the audit no-oped and the remaining pre-merge
206
+ // files stayed missing with no warning.
207
+ writeFileSync(join(repo, "first.txt"), "first\n");
208
+ writeFileSync(join(repo, "second.txt"), "second\n");
209
+ const marker = `gsd-preflight-stash:M007:42:1700000000000:partial`;
210
+ git(repo, "stash", "push", "--include-untracked", "-m", `gsd-preflight-stash [${marker}]`);
211
+
212
+ // Both files are stashed away.
213
+ assert.equal(existsSync(join(repo, "first.txt")), false);
214
+ assert.equal(existsSync(join(repo, "second.txt")), false);
215
+
216
+ // Simulate a partial restore: only first.txt came back; second.txt is still
217
+ // missing from the working tree.
218
+ writeFileSync(join(repo, "first.txt"), "first\n");
219
+
220
+ const result = auditOrphanedPreflightStashes(repo, () => true);
221
+
222
+ // Must NOT be treated as already-restored: second.txt is still missing, so
223
+ // the audit must surface a manual-recovery warning rather than no-op.
224
+ assert.equal(result.applied.length, 0);
225
+ assert.equal(result.warnings.length, 1, "partial restore must warn, not silently no-op");
226
+ assert.match(result.warnings[0], /not auto-applied/);
227
+ assert.match(result.warnings[0], /working tree not clean/);
228
+ assert.match(result.warnings[0], /M007/);
229
+ assert.match(result.warnings[0], /git stash apply/);
230
+
231
+ // The stash entry must remain so the user can recover second.txt manually.
232
+ const list = git(repo, "stash", "list");
233
+ assert.match(list, /gsd-preflight-stash:M007:/);
234
+ });
235
+
236
+ test("fully restored multi-file untracked stash stays a silent no-op", () => {
237
+ // Guards the other direction of the set-equality fix: when EVERY stash
238
+ // untracked file is present in the working tree, the genuine idempotent
239
+ // steady state must still be recognized and skipped without warning, even
240
+ // when the stash captured more than one file.
241
+ writeFileSync(join(repo, "alpha.txt"), "alpha\n");
242
+ writeFileSync(join(repo, "beta.txt"), "beta\n");
243
+ const marker = `gsd-preflight-stash:M008:42:1700000000000:multi`;
244
+ git(repo, "stash", "push", "--include-untracked", "-m", `gsd-preflight-stash [${marker}]`);
245
+
246
+ const first = auditOrphanedPreflightStashes(repo, () => true);
247
+ assert.equal(first.applied.length, 1, "first run applies both files");
248
+ assert.equal(first.warnings.length, 0);
249
+ assert.equal(existsSync(join(repo, "alpha.txt")), true);
250
+ assert.equal(existsSync(join(repo, "beta.txt")), true);
251
+
252
+ const second = auditOrphanedPreflightStashes(repo, () => true);
253
+ assert.equal(second.applied.length, 0, "second run skips");
254
+ assert.equal(second.warnings.length, 0, "fully restored multi-file stash must not warn");
255
+ });
172
256
  });
173
257
 
174
258
  test("_isAlreadyRestoredApplyError detects the git stash apply already-exists error", () => {
@@ -58,6 +58,8 @@ const ALLOWED_IMPORTERS = new Set([
58
58
  "gsd/artifact-verification.ts",
59
59
  // diagnostics-only surfaces: report on projections, make no dispatch decisions
60
60
  "gsd/doctor.ts",
61
+ // diagnostics-only: compares PLAN.md task checkboxes against DB status
62
+ "gsd/doctor-engine-checks.ts",
61
63
  // display/telemetry-only surfaces
62
64
  "gsd/workspace-index.ts",
63
65
  "gsd/visualizer-data.ts",
@@ -94,6 +94,16 @@ test("plan-slice prompt: absence checks use negated quiet searches", () => {
94
94
  assert.ok(result.includes("count commands exit 1 when they find zero matches"));
95
95
  });
96
96
 
97
+ test("plan-slice prompt: npm package existence rule is stated during generation", () => {
98
+ const result = loadPrompt("plan-slice", { ...BASE_VARS, commitInstruction: "Do not commit." });
99
+ assert.ok(result.includes("reference only packages that exist on the public npm registry"));
100
+ assert.ok(result.includes("remove or correct any package name that is not real"));
101
+ // The post-unit pre-execution gate parses package names from require/import
102
+ // patterns too, not just install commands, so the rule must cover that scope.
103
+ assert.ok(result.includes("require('...')"));
104
+ assert.ok(result.includes("import ... from '...'"));
105
+ });
106
+
97
107
  test("plan-slice prompt: footer references gsd_plan_slice tool, not direct write", () => {
98
108
  const result = loadPrompt("plan-slice", { ...BASE_VARS, commitInstruction: "Do not commit." });
99
109
  assert.ok(
@@ -8,6 +8,7 @@ import { tmpdir } from 'node:os';
8
8
 
9
9
  import { openDatabase, closeDatabase, insertMilestone, insertSlice, insertTask, getSlice, getSliceTasks, getTask, getGateResults, updateTaskStatus } from '../gsd-db.ts';
10
10
  import { handlePlanSlice } from '../tools/plan-slice.ts';
11
+ import { handlePlanTask } from '../tools/plan-task.ts';
11
12
  import { parsePlan } from '../parsers-legacy.ts';
12
13
  import { deriveState, invalidateStateCache } from '../state.ts';
13
14
 
@@ -511,15 +512,113 @@ test('handlePlanSlice leaves omitted enrichment fields empty instead of renderin
511
512
  }
512
513
  });
513
514
 
514
- test('handlePlanSlice rejects invalid payloads', async () => {
515
+ test('handlePlanSlice accepts metadata-only payloads without deleting existing tasks', async () => {
515
516
  const base = makeTmpBase();
516
517
  openDatabase(join(base, '.gsd', 'gsd.db'));
517
518
 
518
519
  try {
519
520
  seedParentSlice();
520
- const result = await handlePlanSlice({ ...validParams(), tasks: [] }, base);
521
- assert.ok('error' in result);
522
- assert.match(result.error, /validation failed: tasks must be a non-empty array/);
521
+ const first = await handlePlanSlice(validParams(), base);
522
+ assert.ok(!('error' in first), `unexpected error: ${'error' in first ? first.error : ''}`);
523
+ assert.equal(getSliceTasks('M001', 'S02').length, 2);
524
+
525
+ const second = await handlePlanSlice({
526
+ milestoneId: 'M001',
527
+ sliceId: 'S02',
528
+ goal: 'Persist updated slice metadata before incremental tasks.',
529
+ successCriteria: '- Metadata renders before task planning',
530
+ proofLevel: 'unit',
531
+ integrationClosure: 'Task details follow through gsd_plan_task.',
532
+ observabilityImpact: 'Progress survives between tool calls.',
533
+ }, base);
534
+ assert.ok(!('error' in second), `unexpected error: ${'error' in second ? second.error : ''}`);
535
+ assert.equal(getSlice('M001', 'S02')?.goal, 'Persist updated slice metadata before incremental tasks.');
536
+ assert.deepEqual(getSliceTasks('M001', 'S02').map((task) => task.id), ['T01', 'T02']);
537
+
538
+ const third = await handlePlanSlice({ ...validParams(), tasks: [] }, base);
539
+ assert.ok(!('error' in third), `unexpected error: ${'error' in third ? third.error : ''}`);
540
+ assert.deepEqual(getSliceTasks('M001', 'S02').map((task) => task.id), ['T01', 'T02']);
541
+ } finally {
542
+ cleanup(base);
543
+ }
544
+ });
545
+
546
+ test('handlePlanSlice metadata-only on a fresh sketch slice keeps is_sketch set and renders no PLAN.md (#1027)', async () => {
547
+ const base = makeTmpBase();
548
+ openDatabase(join(base, '.gsd', 'gsd.db'));
549
+
550
+ try {
551
+ insertMilestone({ id: 'M001', title: 'Milestone', status: 'active' });
552
+ insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Planning slice', status: 'pending', demo: 'Rendered plans exist.', isSketch: true });
553
+
554
+ // The incremental flow's first call carries metadata only (no tasks). It
555
+ // must NOT error (renderPlanFromDb throws on a task-less slice), and it must
556
+ // NOT clear the sketch flag — clearing it without a PLAN.md would leave the
557
+ // slice out of refining with no plan artifact, breaking sketch/plan signaling.
558
+ const result = await handlePlanSlice({
559
+ milestoneId: 'M001',
560
+ sliceId: 'S02',
561
+ goal: 'Persist slice metadata before any tasks exist.',
562
+ }, base);
563
+ assert.ok(!('error' in result), `unexpected error: ${'error' in result ? result.error : ''}`);
564
+ assert.equal(getSlice('M001', 'S02')?.goal, 'Persist slice metadata before any tasks exist.');
565
+ assert.equal(getSliceTasks('M001', 'S02').length, 0);
566
+ assert.equal(getSlice('M001', 'S02')?.is_sketch, 1, 'sketch flag must stay set until a task-bearing plan renders');
567
+
568
+ const planPath = join(base, '.gsd', 'phases', '01-test', '01-02-PLAN.md');
569
+ assert.ok(!existsSync(planPath), 'no PLAN.md should be rendered until the first task is planned');
570
+
571
+ invalidateStateCache();
572
+ const after = await deriveState(base);
573
+ assert.equal(after.phase, 'refining', 'a task-less sketch slice stays in refining');
574
+ } finally {
575
+ cleanup(base);
576
+ }
577
+ });
578
+
579
+ test('handlePlanTask materializes the slice PLAN.md and clears the sketch flag on the incremental path (#1027)', async () => {
580
+ const base = makeTmpBase();
581
+ openDatabase(join(base, '.gsd', 'gsd.db'));
582
+
583
+ try {
584
+ insertMilestone({ id: 'M001', title: 'Milestone', status: 'active' });
585
+ insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Planning slice', status: 'pending', demo: 'Rendered plans exist.', isSketch: true });
586
+
587
+ const sliceResult = await handlePlanSlice({
588
+ milestoneId: 'M001',
589
+ sliceId: 'S02',
590
+ goal: 'Incrementally planned slice.',
591
+ successCriteria: '- The slice plan is built up one task at a time',
592
+ }, base);
593
+ assert.ok(!('error' in sliceResult), `unexpected error: ${'error' in sliceResult ? sliceResult.error : ''}`);
594
+
595
+ const taskResult = await handlePlanTask({
596
+ milestoneId: 'M001',
597
+ sliceId: 'S02',
598
+ taskId: 'T01',
599
+ title: 'First incremental task',
600
+ description: 'Implement the first task added through gsd_plan_task.',
601
+ estimate: '30m',
602
+ files: ['src/resources/extensions/gsd/tools/plan-task.ts'],
603
+ verify: 'node --test src/resources/extensions/gsd/tests/plan-task.test.ts',
604
+ inputs: ['src/resources/extensions/gsd/tools/plan-milestone.ts'],
605
+ expectedOutput: ['src/resources/extensions/gsd/tools/plan-task.ts'],
606
+ }, base);
607
+ assert.ok(!('error' in taskResult), `unexpected error: ${'error' in taskResult ? taskResult.error : ''}`);
608
+
609
+ // The first incremental task materializes the canonical slice PLAN.md with
610
+ // the task present, and clears the sketch flag now that a plan exists.
611
+ const planPath = join(base, '.gsd', 'phases', '01-test', '01-02-PLAN.md');
612
+ assert.ok(existsSync(planPath), 'slice PLAN.md should exist after the first task is planned');
613
+ const parsedPlan = parsePlan(readFileSync(planPath, 'utf-8'));
614
+ assert.equal(parsedPlan.goal, 'Incrementally planned slice.');
615
+ assert.equal(parsedPlan.tasks.length, 1);
616
+ assert.equal(parsedPlan.tasks[0]?.id, 'T01');
617
+ assert.equal(getSlice('M001', 'S02')?.is_sketch, 0, 'sketch flag must clear once the first task renders a plan');
618
+
619
+ invalidateStateCache();
620
+ const after = await deriveState(base);
621
+ assert.notEqual(after.phase, 'refining', 'a planned slice leaves refining');
523
622
  } finally {
524
623
  cleanup(base);
525
624
  }