@opengsd/gsd-pi 1.3.0-dev.965d1788 → 1.3.0-dev.cca9c184

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 (168) hide show
  1. package/dist/cli.js +17 -4
  2. package/dist/headless-answers.js +1 -1
  3. package/dist/headless-ui.d.ts +1 -1
  4. package/dist/headless-ui.js +1 -1
  5. package/dist/headless.js +1 -1
  6. package/dist/resources/.managed-resources-content-hash +1 -1
  7. package/dist/resources/extensions/claude-code-cli/models.js +9 -0
  8. package/dist/resources/extensions/gsd/auto/session.js +8 -0
  9. package/dist/resources/extensions/gsd/auto-budget.js +5 -1
  10. package/dist/resources/extensions/gsd/auto-dispatch.js +14 -1
  11. package/dist/resources/extensions/gsd/auto-prompts.js +3 -3
  12. package/dist/resources/extensions/gsd/auto-recovery.js +3 -3
  13. package/dist/resources/extensions/gsd/auto.js +19 -10
  14. package/dist/resources/extensions/gsd/bootstrap/core-session-tools.js +1 -0
  15. package/dist/resources/extensions/gsd/bootstrap/db-tools.js +5 -4
  16. package/dist/resources/extensions/gsd/bootstrap/register-extension.js +2 -0
  17. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +25 -2
  18. package/dist/resources/extensions/gsd/bootstrap/tool-call-loop-guard.js +32 -4
  19. package/dist/resources/extensions/gsd/bootstrap/write-gate.js +15 -4
  20. package/dist/resources/extensions/gsd/commands/handlers/auto.js +1 -1
  21. package/dist/resources/extensions/gsd/commands-prefs-wizard.js +1 -1
  22. package/dist/resources/extensions/gsd/commands-usage.js +1 -1
  23. package/dist/resources/extensions/gsd/db/engine.js +4 -1
  24. package/dist/resources/extensions/gsd/db-memory-fts-schema.js +34 -0
  25. package/dist/resources/extensions/gsd/delegation-policy.js +1 -1
  26. package/dist/resources/extensions/gsd/docs/preferences-reference.md +2 -2
  27. package/dist/resources/extensions/gsd/doctor-engine-checks.js +192 -4
  28. package/dist/resources/extensions/gsd/exec-sandbox.js +8 -2
  29. package/dist/resources/extensions/gsd/git-service.js +56 -0
  30. package/dist/resources/extensions/gsd/gitignore.js +1 -1
  31. package/dist/resources/extensions/gsd/migrate-external.js +11 -2
  32. package/dist/resources/extensions/gsd/prompts/plan-milestone.md +2 -2
  33. package/dist/resources/extensions/gsd/prompts/plan-slice.md +3 -3
  34. package/dist/resources/extensions/gsd/root-write-leak-guard.js +7 -1
  35. package/dist/resources/extensions/gsd/safety/destructive-guard.js +8 -1
  36. package/dist/resources/extensions/gsd/safety/evidence-collector.js +16 -1
  37. package/dist/resources/extensions/gsd/tools/plan-slice.js +59 -44
  38. package/dist/resources/extensions/gsd/tools/plan-task.js +28 -5
  39. package/dist/resources/extensions/gsd/unit-registry.js +4 -2
  40. package/dist/resources/extensions/gsd/workflow-manifest.js +122 -12
  41. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  42. package/dist/web/standalone/.next/BUILD_ID +1 -1
  43. package/dist/web/standalone/.next/app-path-routes-manifest.json +11 -11
  44. package/dist/web/standalone/.next/build-manifest.json +2 -2
  45. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  46. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  47. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  55. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  58. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  59. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  60. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  61. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  62. package/dist/web/standalone/.next/server/app/index.html +1 -1
  63. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  64. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  65. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  66. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  67. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  68. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  69. package/dist/web/standalone/.next/server/app-paths-manifest.json +11 -11
  70. package/dist/web/standalone/.next/server/chunks/8357.js +1 -1
  71. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  72. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  73. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  74. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  75. package/package.json +1 -1
  76. package/packages/cloud-mcp-gateway/package.json +2 -2
  77. package/packages/contracts/package.json +1 -1
  78. package/packages/daemon/package.json +4 -4
  79. package/packages/gsd-agent-core/package.json +5 -5
  80. package/packages/gsd-agent-modes/dist/modes/rpc/rpc-client.d.ts +2 -0
  81. package/packages/gsd-agent-modes/dist/modes/rpc/rpc-client.d.ts.map +1 -1
  82. package/packages/gsd-agent-modes/dist/modes/rpc/rpc-client.js +23 -5
  83. package/packages/gsd-agent-modes/dist/modes/rpc/rpc-client.js.map +1 -1
  84. package/packages/gsd-agent-modes/package.json +7 -7
  85. package/packages/mcp-server/dist/workflow-tools.js +1 -1
  86. package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
  87. package/packages/mcp-server/package.json +4 -4
  88. package/packages/native/package.json +1 -1
  89. package/packages/pi-agent-core/dist/agent-loop.js +3 -2
  90. package/packages/pi-agent-core/dist/agent-loop.js.map +1 -1
  91. package/packages/pi-agent-core/package.json +1 -1
  92. package/packages/pi-ai/dist/models.generated.d.ts +46 -0
  93. package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
  94. package/packages/pi-ai/dist/models.generated.js +38 -0
  95. package/packages/pi-ai/dist/models.generated.js.map +1 -1
  96. package/packages/pi-ai/package.json +1 -1
  97. package/packages/pi-coding-agent/package.json +7 -7
  98. package/packages/pi-tui/package.json +2 -2
  99. package/packages/rpc-client/package.json +2 -2
  100. package/pkg/package.json +1 -1
  101. package/src/resources/extensions/claude-code-cli/models.ts +9 -0
  102. package/src/resources/extensions/claude-code-cli/tests/index.test.ts +14 -0
  103. package/src/resources/extensions/gsd/auto/loop-deps.ts +1 -0
  104. package/src/resources/extensions/gsd/auto/session.ts +8 -0
  105. package/src/resources/extensions/gsd/auto-budget.ts +12 -1
  106. package/src/resources/extensions/gsd/auto-dispatch.ts +14 -1
  107. package/src/resources/extensions/gsd/auto-prompts.ts +3 -3
  108. package/src/resources/extensions/gsd/auto-recovery.ts +3 -3
  109. package/src/resources/extensions/gsd/auto.ts +23 -8
  110. package/src/resources/extensions/gsd/bootstrap/core-session-tools.ts +1 -0
  111. package/src/resources/extensions/gsd/bootstrap/db-tools.ts +5 -4
  112. package/src/resources/extensions/gsd/bootstrap/register-extension.ts +2 -0
  113. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +32 -2
  114. package/src/resources/extensions/gsd/bootstrap/tests/write-gate-basepath.test.ts +49 -1
  115. package/src/resources/extensions/gsd/bootstrap/tool-call-loop-guard.ts +34 -4
  116. package/src/resources/extensions/gsd/bootstrap/write-gate.ts +16 -4
  117. package/src/resources/extensions/gsd/commands/handlers/auto.ts +1 -1
  118. package/src/resources/extensions/gsd/commands-prefs-wizard.ts +1 -1
  119. package/src/resources/extensions/gsd/commands-usage.ts +1 -1
  120. package/src/resources/extensions/gsd/db/engine.ts +8 -1
  121. package/src/resources/extensions/gsd/db-memory-fts-schema.ts +45 -0
  122. package/src/resources/extensions/gsd/delegation-policy.ts +1 -1
  123. package/src/resources/extensions/gsd/docs/preferences-reference.md +2 -2
  124. package/src/resources/extensions/gsd/doctor-engine-checks.ts +233 -5
  125. package/src/resources/extensions/gsd/doctor-types.ts +3 -0
  126. package/src/resources/extensions/gsd/exec-sandbox.ts +7 -2
  127. package/src/resources/extensions/gsd/git-service.ts +63 -0
  128. package/src/resources/extensions/gsd/gitignore.ts +1 -1
  129. package/src/resources/extensions/gsd/migrate-external.ts +8 -2
  130. package/src/resources/extensions/gsd/prompts/plan-milestone.md +2 -2
  131. package/src/resources/extensions/gsd/prompts/plan-slice.md +3 -3
  132. package/src/resources/extensions/gsd/root-write-leak-guard.ts +13 -1
  133. package/src/resources/extensions/gsd/safety/destructive-guard.ts +9 -1
  134. package/src/resources/extensions/gsd/safety/evidence-collector.ts +16 -1
  135. package/src/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +14 -5
  136. package/src/resources/extensions/gsd/tests/auto-pause-double-entry-guard.test.ts +36 -0
  137. package/src/resources/extensions/gsd/tests/auto-paused-ui-cleanup.test.ts +182 -4
  138. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +35 -0
  139. package/src/resources/extensions/gsd/tests/auto-retry-mcp-churn-fixes.test.ts +30 -0
  140. package/src/resources/extensions/gsd/tests/db-memory-fts-schema.test.ts +58 -3
  141. package/src/resources/extensions/gsd/tests/destructive-guard.test.ts +44 -0
  142. package/src/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts +143 -0
  143. package/src/resources/extensions/gsd/tests/doctor-scope-db-unavailable.test.ts +239 -2
  144. package/src/resources/extensions/gsd/tests/exec-sandbox.test.ts +16 -0
  145. package/src/resources/extensions/gsd/tests/memory-store.test.ts +48 -0
  146. package/src/resources/extensions/gsd/tests/migrate-external-worktree.test.ts +57 -0
  147. package/src/resources/extensions/gsd/tests/parsers-legacy-importers.test.ts +2 -0
  148. package/src/resources/extensions/gsd/tests/plan-slice.test.ts +103 -4
  149. package/src/resources/extensions/gsd/tests/plan-task.test.ts +44 -1
  150. package/src/resources/extensions/gsd/tests/post-mutation-hook.test.ts +4 -2
  151. package/src/resources/extensions/gsd/tests/prompt-budget-enforcement.test.ts +58 -0
  152. package/src/resources/extensions/gsd/tests/prompt-contracts.test.ts +8 -6
  153. package/src/resources/extensions/gsd/tests/register-hooks-loop-guard-auto.test.ts +103 -0
  154. package/src/resources/extensions/gsd/tests/root-write-leak-guard.test.ts +20 -1
  155. package/src/resources/extensions/gsd/tests/runtime-invariant-modules.test.ts +8 -3
  156. package/src/resources/extensions/gsd/tests/tool-call-loop-guard.test.ts +62 -5
  157. package/src/resources/extensions/gsd/tests/tool-param-optionality.test.ts +6 -3
  158. package/src/resources/extensions/gsd/tests/uok-gitops-turn-action.test.ts +38 -0
  159. package/src/resources/extensions/gsd/tests/workflow-manifest.test.ts +44 -17
  160. package/src/resources/extensions/gsd/tests/workflow-mcp-preflight.test.ts +1 -0
  161. package/src/resources/extensions/gsd/tests/workflow-mcp.test.ts +8 -4
  162. package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +6 -5
  163. package/src/resources/extensions/gsd/tools/plan-slice.ts +60 -47
  164. package/src/resources/extensions/gsd/tools/plan-task.ts +30 -5
  165. package/src/resources/extensions/gsd/unit-registry.ts +4 -2
  166. package/src/resources/extensions/gsd/workflow-manifest.ts +137 -6
  167. /package/dist/web/standalone/.next/static/{-ji77QJck2IKIwrGza1Bs → Eud_8lddsyi_13HIlB2i7}/_buildManifest.js +0 -0
  168. /package/dist/web/standalone/.next/static/{-ji77QJck2IKIwrGza1Bs → Eud_8lddsyi_13HIlB2i7}/_ssgManifest.js +0 -0
@@ -24,6 +24,7 @@ import {
24
24
  } from '../gsd-db.ts';
25
25
  import {
26
26
  writeManifest,
27
+ flushManifest,
27
28
  readManifest,
28
29
  snapshotState,
29
30
  bootstrapFromManifest,
@@ -42,6 +43,11 @@ function cleanupDir(dirPath: string): void {
42
43
  try { fs.rmSync(dirPath, { recursive: true, force: true }); } catch { /* best effort */ }
43
44
  }
44
45
 
46
+ async function writeManifestAndFlush(base: string): Promise<void> {
47
+ writeManifest(base);
48
+ await flushManifest(base);
49
+ }
50
+
45
51
  function insertMemoryBackedDecision(id: string): void {
46
52
  const now = '2026-01-01T00:00:00.000Z';
47
53
  insertMemoryRow({
@@ -83,12 +89,14 @@ test('workflow-manifest: readManifest returns null when file does not exist', ()
83
89
 
84
90
  // ─── writeManifest + readManifest round-trip ─────────────────────────────
85
91
 
86
- test('workflow-manifest: writeManifest creates state-manifest.json with version 1', () => {
92
+ test('workflow-manifest: writeManifest creates state-manifest.json with version 1 after flush', async () => {
87
93
  const base = tempDir();
88
94
  openDatabase(tempDbPath(base));
89
95
  try {
90
96
  writeManifest(base);
91
97
  const manifestPath = path.join(base, '.gsd', 'state-manifest.json');
98
+ assert.equal(fs.existsSync(manifestPath), false, 'writeManifest should not synchronously write state-manifest.json');
99
+ await flushManifest(base);
92
100
  assert.ok(fs.existsSync(manifestPath), 'state-manifest.json should exist');
93
101
  const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
94
102
  assert.strictEqual(raw.version, 1);
@@ -98,11 +106,11 @@ test('workflow-manifest: writeManifest creates state-manifest.json with version
98
106
  }
99
107
  });
100
108
 
101
- test('workflow-manifest: readManifest parses manifest written by writeManifest', () => {
109
+ test('workflow-manifest: readManifest parses manifest written by writeManifest', async () => {
102
110
  const base = tempDir();
103
111
  openDatabase(tempDbPath(base));
104
112
  try {
105
- writeManifest(base);
113
+ await writeManifestAndFlush(base);
106
114
  const manifest = readManifest(base);
107
115
  assert.ok(manifest !== null);
108
116
  assert.strictEqual(manifest!.version, 1);
@@ -124,6 +132,25 @@ test('workflow-manifest: readManifest parses manifest written by writeManifest',
124
132
  }
125
133
  });
126
134
 
135
+ test('workflow-manifest: flushManifest writes the latest queued payload', async () => {
136
+ const base = tempDir();
137
+ openDatabase(tempDbPath(base));
138
+ try {
139
+ insertMilestone({ id: 'M001', title: 'First Milestone' });
140
+ writeManifest(base);
141
+ insertMilestone({ id: 'M002', title: 'Latest Milestone' });
142
+ writeManifest(base);
143
+
144
+ await flushManifest(base);
145
+
146
+ const manifest = readManifest(base);
147
+ assert.ok(manifest?.milestones.some((m) => m.id === 'M002'), 'latest queued write should be durable');
148
+ } finally {
149
+ closeDatabase();
150
+ cleanupDir(base);
151
+ }
152
+ });
153
+
127
154
  // ─── snapshotState: captures DB rows ─────────────────────────────────────
128
155
 
129
156
  test('workflow-manifest: snapshotState includes inserted milestone', () => {
@@ -181,7 +208,7 @@ test('workflow-manifest: snapshotState captures memory-backed decisions', () =>
181
208
  }
182
209
  });
183
210
 
184
- test('workflow-manifest: bootstrapFromManifest restores extended correctness rows', () => {
211
+ test('workflow-manifest: bootstrapFromManifest restores extended correctness rows', async () => {
185
212
  const base = tempDir();
186
213
  openDatabase(tempDbPath(base));
187
214
  try {
@@ -256,7 +283,7 @@ test('workflow-manifest: bootstrapFromManifest restores extended correctness row
256
283
  createdAt: '2026-06-05T00:00:00.000Z',
257
284
  });
258
285
 
259
- writeManifest(base);
286
+ await writeManifestAndFlush(base);
260
287
  closeDatabase();
261
288
 
262
289
  const newDbPath = path.join(base, 'new.db');
@@ -306,7 +333,7 @@ test('workflow-manifest: bootstrapFromManifest returns false when no manifest fi
306
333
  }
307
334
  });
308
335
 
309
- test('workflow-manifest: bootstrapFromManifest restores DB from manifest (round-trip)', () => {
336
+ test('workflow-manifest: bootstrapFromManifest restores DB from manifest (round-trip)', async () => {
310
337
  const base = tempDir();
311
338
  openDatabase(tempDbPath(base));
312
339
  try {
@@ -326,7 +353,7 @@ test('workflow-manifest: bootstrapFromManifest restores DB from manifest (round-
326
353
  status: 'complete',
327
354
  planning: { fullPlanMd: '# Full Task Plan\n\nKeep this body.', targetRepositories: ['project'] },
328
355
  });
329
- writeManifest(base);
356
+ await writeManifestAndFlush(base);
330
357
  closeDatabase();
331
358
 
332
359
  // Open a fresh DB and bootstrap from manifest
@@ -356,14 +383,14 @@ test('workflow-manifest: bootstrapFromManifest restores DB from manifest (round-
356
383
  }
357
384
  });
358
385
 
359
- test('workflow-manifest: bootstrapFromManifest preserves target_repositories', () => {
386
+ test('workflow-manifest: bootstrapFromManifest preserves target_repositories', async () => {
360
387
  const base = tempDir();
361
388
  openDatabase(tempDbPath(base));
362
389
  try {
363
390
  insertMilestone({ id: 'M001', title: 'Restored Milestone' });
364
391
  insertSlice({ id: 'S01', milestoneId: 'M001', planning: { targetRepositories: ['frontend'] } });
365
392
  insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', planning: { targetRepositories: ['backend'] } });
366
- writeManifest(base);
393
+ await writeManifestAndFlush(base);
367
394
  closeDatabase();
368
395
 
369
396
  const newDbPath = path.join(base, 'new.db');
@@ -380,7 +407,7 @@ test('workflow-manifest: bootstrapFromManifest preserves target_repositories', (
380
407
  }
381
408
  });
382
409
 
383
- test('workflow-manifest: bootstrapFromManifest does not truncate projections when artifact content is empty', () => {
410
+ test('workflow-manifest: bootstrapFromManifest does not truncate projections when artifact content is empty', async () => {
384
411
  const base = tempDir();
385
412
  openDatabase(tempDbPath(base));
386
413
  try {
@@ -401,7 +428,7 @@ test('workflow-manifest: bootstrapFromManifest does not truncate projections whe
401
428
  task_id: null,
402
429
  full_content: '# Manifest Validation',
403
430
  });
404
- writeManifest(base);
431
+ await writeManifestAndFlush(base);
405
432
 
406
433
  const planPath = path.join(base, '.gsd', 'milestones', 'M001', 'M001-PLAN.md');
407
434
  const validationPath = path.join(base, '.gsd', 'milestones', 'M001', 'M001-VALIDATION.md');
@@ -434,14 +461,14 @@ test('workflow-manifest: bootstrapFromManifest does not truncate projections whe
434
461
  }
435
462
  });
436
463
 
437
- test('workflow-manifest: bootstrapFromManifest preserves optional rows from old manifests', () => {
464
+ test('workflow-manifest: bootstrapFromManifest preserves optional rows from old manifests', async () => {
438
465
  const base = tempDir();
439
466
  openDatabase(tempDbPath(base));
440
467
  try {
441
468
  insertMilestone({ id: 'M001', title: 'Old Manifest Milestone' });
442
469
  insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Old Manifest Slice' });
443
470
  insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'Old Manifest Task' });
444
- writeManifest(base);
471
+ await writeManifestAndFlush(base);
445
472
 
446
473
  const manifestPath = path.join(base, '.gsd', 'state-manifest.json');
447
474
  const oldManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Record<string, unknown>;
@@ -504,12 +531,12 @@ test('workflow-manifest: bootstrapFromManifest preserves optional rows from old
504
531
  }
505
532
  });
506
533
 
507
- test('workflow-manifest: bootstrapFromManifest restores memory-backed decisions', () => {
534
+ test('workflow-manifest: bootstrapFromManifest restores memory-backed decisions', async () => {
508
535
  const base = tempDir();
509
536
  openDatabase(tempDbPath(base));
510
537
  try {
511
538
  insertMemoryBackedDecision('D901');
512
- writeManifest(base);
539
+ await writeManifestAndFlush(base);
513
540
  closeDatabase();
514
541
 
515
542
  const newDbPath = path.join(base, 'new.db');
@@ -526,12 +553,12 @@ test('workflow-manifest: bootstrapFromManifest restores memory-backed decisions'
526
553
  }
527
554
  });
528
555
 
529
- test('workflow-manifest: bootstrapFromManifest replaces stale memory-backed decisions', () => {
556
+ test('workflow-manifest: bootstrapFromManifest replaces stale memory-backed decisions', async () => {
530
557
  const base = tempDir();
531
558
  openDatabase(tempDbPath(base));
532
559
  try {
533
560
  insertMemoryBackedDecision('D902');
534
- writeManifest(base);
561
+ await writeManifestAndFlush(base);
535
562
  closeDatabase();
536
563
 
537
564
  const newDbPath = path.join(base, 'new.db');
@@ -29,6 +29,7 @@ test("preflight uses inline workflow MCP config when no project config is persis
29
29
  `const { StdioServerTransport } = await import(${JSON.stringify(stdioModuleUrl)});`,
30
30
  'const server = new McpServer({ name: "fake", version: "1.0.0" }, { capabilities: { tools: {} } });',
31
31
  'server.tool("gsd_plan_slice", "Plan slice", {}, async () => ({ content: [{ type: "text", text: "ok" }] }));',
32
+ 'server.tool("gsd_plan_task", "Plan task", {}, async () => ({ content: [{ type: "text", text: "ok" }] }));',
32
33
  'server.tool("gsd_reassess_roadmap", "Reassess roadmap", {}, async () => ({ content: [{ type: "text", text: "ok" }] }));',
33
34
  'await server.connect(new StdioServerTransport());',
34
35
  ].join("\n"),
@@ -65,14 +65,18 @@ test("auto execute-task requires canonical task completion tool", () => {
65
65
  assert.deepEqual(getRequiredWorkflowToolsForAutoUnit("execute-task"), expected);
66
66
  });
67
67
 
68
- test("plan-slice requires planning and roadmap reassessment tools", () => {
69
- const expected = ["gsd_plan_slice", "gsd_reassess_roadmap"];
68
+ test("plan-slice requires slice + incremental task planning and roadmap reassessment tools", () => {
69
+ // Incremental planning (#1027): gsd_plan_slice persists metadata, then
70
+ // gsd_plan_task adds tasks one at a time, so both must be on the surface.
71
+ const expected = ["gsd_plan_slice", "gsd_plan_task", "gsd_reassess_roadmap"];
70
72
  assert.deepEqual(getRequiredWorkflowToolsForGuidedUnit("plan-slice"), expected);
71
73
  assert.deepEqual(getRequiredWorkflowToolsForAutoUnit("plan-slice"), expected);
72
74
  });
73
75
 
74
- test("plan-milestone requires status, roadmap, and single-slice planning tools", () => {
75
- const expected = ["gsd_milestone_status", "gsd_plan_milestone", "gsd_plan_slice"];
76
+ test("plan-milestone requires status, roadmap, and slice + task planning tools", () => {
77
+ // gsd_plan_task is required alongside gsd_plan_slice for incremental
78
+ // milestone planning (#1027).
79
+ const expected = ["gsd_milestone_status", "gsd_plan_milestone", "gsd_plan_slice", "gsd_plan_task"];
76
80
  assert.deepEqual(getRequiredWorkflowToolsForGuidedUnit("plan-milestone"), expected);
77
81
  assert.deepEqual(getRequiredWorkflowToolsForAutoUnit("plan-milestone"), expected);
78
82
  });
@@ -781,22 +781,23 @@ test("executePlanSlice writes task planning state and rendered plan artifacts",
781
781
  }
782
782
  });
783
783
 
784
- test("executePlanSlice marks validation failures with isError", async () => {
784
+ test("executePlanSlice accepts metadata-only incremental planning payloads", async () => {
785
785
  const base = makeTmpBase();
786
786
  try {
787
787
  openTestDb(base);
788
+ await inProjectDir(base, () => executePlanMilestone(validMilestonePlan(), base));
788
789
 
789
790
  const result = await inProjectDir(base, () => executePlanSlice({
790
791
  milestoneId: "M001",
791
792
  sliceId: "S01",
792
- goal: "Trigger validation failure for empty tasks.",
793
+ goal: "Persist slice metadata before tasks.",
793
794
  tasks: [],
794
795
  }, base));
795
796
 
796
- assert.equal(result.isError, true);
797
797
  assert.equal(result.details.operation, "plan_slice");
798
- assert.match(String(result.details.error), /validation failed: tasks must be a non-empty array/);
799
- assert.match(result.content[0].text, /Error planning slice:/);
798
+ assert.equal(result.details.sliceId, "S01");
799
+ assert.equal(result.isError, undefined);
800
+ assert.match(result.content[0].text, /Planned slice S01/);
800
801
  } finally {
801
802
  closeDatabase();
802
803
  cleanup(base);
@@ -51,7 +51,7 @@ export interface PlanSliceParams {
51
51
  milestoneId: string;
52
52
  sliceId: string;
53
53
  goal: string;
54
- tasks: PlanSliceTaskInput[];
54
+ tasks?: PlanSliceTaskInput[];
55
55
  /** @optional — omitted fields render as conservative defaults */
56
56
  successCriteria?: string;
57
57
  /** @optional — omitted fields render as conservative defaults */
@@ -86,10 +86,12 @@ function validateRepositoryTargetIds(
86
86
  return deduped;
87
87
  }
88
88
 
89
- function validateTasks(value: unknown): PlanSliceTaskInput[] {
90
- if (!Array.isArray(value) || value.length === 0) {
91
- throw new Error("tasks must be a non-empty array");
89
+ function validateTasks(value: unknown): PlanSliceTaskInput[] | undefined {
90
+ if (value === undefined) return undefined;
91
+ if (!Array.isArray(value)) {
92
+ throw new Error("tasks must be an array");
92
93
  }
94
+ if (value.length === 0) return undefined;
93
95
 
94
96
  const seen = new Set<string>();
95
97
  return value.map((entry, index) => {
@@ -200,7 +202,7 @@ function validateReferencedRepositories(
200
202
  };
201
203
 
202
204
  for (const id of params.targetRepositories ?? defaultTargets) noteMissing(id);
203
- for (const task of params.tasks) {
205
+ for (const task of params.tasks ?? []) {
204
206
  for (const id of task.targetRepositories ?? params.targetRepositories ?? defaultTargets) noteMissing(id);
205
207
  }
206
208
 
@@ -211,7 +213,7 @@ function validateReferencedRepositories(
211
213
  function resolveAllowedRootsForPathScope(params: PlanSliceParams, registry: RepositoryRegistry, defaultTargets: string[]): string[] {
212
214
  const requested = new Set<string>();
213
215
  for (const id of params.targetRepositories ?? defaultTargets) requested.add(id);
214
- for (const task of params.tasks) {
216
+ for (const task of params.tasks ?? []) {
215
217
  for (const id of task.targetRepositories ?? params.targetRepositories ?? defaultTargets) requested.add(id);
216
218
  }
217
219
  if (requested.size === 0) return [registry.projectRoot];
@@ -222,7 +224,7 @@ function resolveAllowedRootsForPathScope(params: PlanSliceParams, registry: Repo
222
224
  }
223
225
 
224
226
  function toTaskRows(params: PlanSliceParams, defaultTargets: string[]): TaskRow[] {
225
- return params.tasks.map((task, index) => ({
227
+ return (params.tasks ?? []).map((task, index) => ({
226
228
  milestone_id: params.milestoneId,
227
229
  slice_id: params.sliceId,
228
230
  id: task.taskId,
@@ -309,6 +311,8 @@ export async function handlePlanSlice(
309
311
  return { error: `validation failed: ${message}` };
310
312
  }
311
313
  const defaultTargets = defaultRepositoryTargets(repositoryRegistry);
314
+ const taskPayload = params.tasks ?? [];
315
+ const hasTaskPayload = taskPayload.length > 0;
312
316
  const repoValidationError = validateReferencedRepositories(params, repositoryRegistry, defaultTargets);
313
317
  if (repoValidationError) {
314
318
  return { error: `validation failed: ${repoValidationError}` };
@@ -317,7 +321,7 @@ export async function handlePlanSlice(
317
321
  const allowedAbsoluteRoots = resolveAllowedRootsForPathScope(params, repositoryRegistry, defaultTargets);
318
322
 
319
323
  const pathOnlyError = validatePathOnlyPlanningFields(
320
- params.tasks.map((task, index) => ({
324
+ taskPayload.map((task, index) => ({
321
325
  field: `tasks[${index}].expectedOutput`,
322
326
  values: task.expectedOutput,
323
327
  })),
@@ -328,7 +332,7 @@ export async function handlePlanSlice(
328
332
 
329
333
  const pathScopeError = validatePlanningPathScope(
330
334
  basePath,
331
- params.tasks.flatMap((task, index) => [
335
+ taskPayload.flatMap((task, index) => [
332
336
  { field: `tasks[${index}].files`, values: task.files },
333
337
  { field: `tasks[${index}].inputs`, values: task.inputs },
334
338
  { field: `tasks[${index}].expectedOutput`, values: task.expectedOutput },
@@ -372,16 +376,18 @@ export async function handlePlanSlice(
372
376
  return;
373
377
  }
374
378
 
375
- const newTaskIds = new Set(params.tasks.map((task) => task.taskId));
379
+ const newTaskIds = new Set(taskPayload.map((task) => task.taskId));
376
380
  const existingTasks = getSliceTasks(params.milestoneId, params.sliceId);
377
- omittedTaskIds = existingTasks
378
- .filter((task) => !newTaskIds.has(task.id))
379
- .map((task) => task.id);
380
-
381
- for (const task of existingTasks) {
382
- if (!newTaskIds.has(task.id) && isClosedStatus(task.status)) {
383
- guardError = `cannot remove completed task ${task.id}`;
384
- return;
381
+ if (hasTaskPayload) {
382
+ omittedTaskIds = existingTasks
383
+ .filter((task) => !newTaskIds.has(task.id))
384
+ .map((task) => task.id);
385
+
386
+ for (const task of existingTasks) {
387
+ if (!newTaskIds.has(task.id) && isClosedStatus(task.status)) {
388
+ guardError = `cannot remove completed task ${task.id}`;
389
+ return;
390
+ }
385
391
  }
386
392
  }
387
393
 
@@ -398,35 +404,37 @@ export async function handlePlanSlice(
398
404
  targetRepositories: params.targetRepositories ?? defaultTargets,
399
405
  });
400
406
 
401
- for (const taskId of omittedTaskIds) {
402
- deleteTask(params.milestoneId, params.sliceId, taskId);
403
- }
407
+ if (hasTaskPayload) {
408
+ for (const taskId of omittedTaskIds) {
409
+ deleteTask(params.milestoneId, params.sliceId, taskId);
410
+ }
404
411
 
405
- const existingTaskById = new Map(existingTasks.map((task) => [task.id, task]));
406
- for (const task of params.tasks) {
407
- const planning = {
408
- title: task.title,
409
- description: task.description,
410
- estimate: task.estimate,
411
- files: task.files,
412
- verify: task.verify,
413
- inputs: task.inputs,
414
- expectedOutput: task.expectedOutput,
415
- observabilityImpact: task.observabilityImpact ?? "",
416
- fullPlanMd: task.fullPlanMd,
417
- targetRepositories: task.targetRepositories ?? params.targetRepositories ?? defaultTargets,
418
- };
419
- const existingTask = existingTaskById.get(task.taskId);
420
- if (!existingTask || !isClosedStatus(existingTask.status)) {
421
- insertTask({
422
- id: task.taskId,
423
- sliceId: params.sliceId,
424
- milestoneId: params.milestoneId,
412
+ const existingTaskById = new Map(existingTasks.map((task) => [task.id, task]));
413
+ for (const task of taskPayload) {
414
+ const planning = {
425
415
  title: task.title,
426
- status: "pending",
427
- });
416
+ description: task.description,
417
+ estimate: task.estimate,
418
+ files: task.files,
419
+ verify: task.verify,
420
+ inputs: task.inputs,
421
+ expectedOutput: task.expectedOutput,
422
+ observabilityImpact: task.observabilityImpact ?? "",
423
+ fullPlanMd: task.fullPlanMd,
424
+ targetRepositories: task.targetRepositories ?? params.targetRepositories ?? defaultTargets,
425
+ };
426
+ const existingTask = existingTaskById.get(task.taskId);
427
+ if (!existingTask || !isClosedStatus(existingTask.status)) {
428
+ insertTask({
429
+ id: task.taskId,
430
+ sliceId: params.sliceId,
431
+ milestoneId: params.milestoneId,
432
+ title: task.title,
433
+ status: "pending",
434
+ });
435
+ }
436
+ upsertTaskPlanning(params.milestoneId, params.sliceId, task.taskId, planning);
428
437
  }
429
- upsertTaskPlanning(params.milestoneId, params.sliceId, task.taskId, planning);
430
438
  }
431
439
 
432
440
  // Seed quality gate rows inside the transaction — all-or-nothing with
@@ -436,7 +444,7 @@ export async function handlePlanSlice(
436
444
  insertGateRow({ milestoneId: params.milestoneId, sliceId: params.sliceId, gateId: gid, scope: "slice" });
437
445
  }
438
446
  const taskGates = resolveTaskGates(gateEvaluation);
439
- for (const task of params.tasks) {
447
+ for (const task of taskPayload) {
440
448
  for (const gid of taskGates) {
441
449
  insertGateRow({ milestoneId: params.milestoneId, sliceId: params.sliceId, gateId: gid, scope: "task", taskId: task.taskId });
442
450
  }
@@ -460,8 +468,13 @@ export async function handlePlanSlice(
460
468
  deleteArtifactByPath(artifactPath);
461
469
  }
462
470
 
463
- const renderResult = await renderPlanFromDb(basePath, params.milestoneId, params.sliceId);
464
- setSliceSketchFlag(params.milestoneId, params.sliceId, false);
471
+ const sliceTasks = getSliceTasks(params.milestoneId, params.sliceId);
472
+ const renderResult = sliceTasks.length === 0
473
+ ? { planPath: "", taskPlanPaths: [] as string[] }
474
+ : await renderPlanFromDb(basePath, params.milestoneId, params.sliceId);
475
+ if (sliceTasks.length > 0) {
476
+ setSliceSketchFlag(params.milestoneId, params.sliceId, false);
477
+ }
465
478
  invalidateStateCache();
466
479
  clearParseCache();
467
480
 
@@ -1,15 +1,18 @@
1
1
  import { clearParseCache } from "../files.js";
2
2
  import { isClosedStatus } from "../status-guards.js";
3
3
  import { isNonEmptyString, validateStringArray } from "../validation.js";
4
- import { transaction, getSlice, getTask, insertTask, upsertTaskPlanning } from "../gsd-db.js";
4
+ import { getGateIdsForTurn } from "../gate-registry.js";
5
+ import { transaction, getSlice, getTask, insertTask, upsertTaskPlanning, insertGateRow, setSliceSketchFlag } from "../gsd-db.js";
5
6
  import { invalidateStateCache } from "../state.js";
6
7
  import { renderTaskPlanFromDb, renderPlanFromDb } from "../markdown-renderer.js";
7
- import { resolveSliceFile, resolveTasksDir } from "../paths.js";
8
+ import { resolveTasksDir } from "../paths.js";
8
9
  import { flushWorkflowProjections } from "../projection-flush.js";
9
10
  import { writeManifest } from "../workflow-manifest.js";
10
11
  import { appendEvent } from "../workflow-events.js";
11
12
  import { logWarning } from "../workflow-logger.js";
13
+ import { loadEffectiveGSDPreferences } from "../preferences.js";
12
14
  import { validatePathOnlyPlanningFields, validatePlanningPathScope } from "../planning-path-scope.js";
15
+ import type { GateId } from "../types.js";
13
16
 
14
17
  export interface PlanTaskParams {
15
18
  milestoneId: string;
@@ -57,6 +60,12 @@ function validateParams(params: PlanTaskParams): PlanTaskParams {
57
60
  };
58
61
  }
59
62
 
63
+ function resolveTaskGates(basePath: string): GateId[] {
64
+ const loaded = loadEffectiveGSDPreferences(basePath);
65
+ if (loaded?.preferences?.gate_evaluation?.task_gates === false) return [];
66
+ return [...getGateIdsForTurn("execute-task")];
67
+ }
68
+
60
69
  export async function handlePlanTask(
61
70
  rawParams: PlanTaskParams,
62
71
  basePath: string,
@@ -84,6 +93,14 @@ export async function handlePlanTask(
84
93
  return { error: `validation failed: ${pathScopeError}` };
85
94
  }
86
95
 
96
+ let taskGates: GateId[];
97
+ try {
98
+ taskGates = resolveTaskGates(basePath);
99
+ } catch (err) {
100
+ const message = err instanceof Error ? err.message : String(err);
101
+ return { error: `validation failed: ${message}` };
102
+ }
103
+
87
104
  // ── Guards + DB writes inside a single transaction (prevents TOCTOU) ───
88
105
  // Guards must be inside the transaction so the state they check cannot
89
106
  // change between the read and the write (#2723).
@@ -127,6 +144,9 @@ export async function handlePlanTask(
127
144
  observabilityImpact: params.observabilityImpact ?? "",
128
145
  fullPlanMd: params.fullPlanMd,
129
146
  });
147
+ for (const gid of taskGates) {
148
+ insertGateRow({ milestoneId: params.milestoneId, sliceId: params.sliceId, gateId: gid, scope: "task", taskId: params.taskId });
149
+ }
130
150
  });
131
151
  } catch (err) {
132
152
  return { error: `db write failed: ${(err as Error).message}` };
@@ -142,17 +162,22 @@ export async function handlePlanTask(
142
162
  // Flat-phase: tasks live as checkboxes in the slice plan's <tasks> block,
143
163
  // not as standalone TID-PLAN.md files. Re-render the slice plan so the
144
164
  // new/updated task appears in the plan file that gsd-core reads.
145
- // Guard: resolveTasksDir is null in flat-phase (no tasks/ subdir exists);
146
- // only sync if a slice plan already exists (guards pre-plan-slice calls).
165
+ // Guard: resolveTasksDir is null in flat-phase (no tasks/ subdir exists).
166
+ let slicePlanSynced = false;
147
167
  try {
148
168
  const tDir = resolveTasksDir(basePath, params.milestoneId, params.sliceId);
149
- if (!tDir && resolveSliceFile(basePath, params.milestoneId, params.sliceId, "PLAN")) {
169
+ if (!tDir) {
150
170
  await renderPlanFromDb(basePath, params.milestoneId, params.sliceId);
171
+ slicePlanSynced = true;
151
172
  }
152
173
  } catch (syncErr) {
153
174
  logWarning("tool", `plan-task: slice-plan sync failed: ${(syncErr as Error).message}`);
154
175
  }
155
176
 
177
+ if (slicePlanSynced) {
178
+ setSliceSketchFlag(params.milestoneId, params.sliceId, false);
179
+ }
180
+
156
181
  invalidateStateCache();
157
182
  clearParseCache();
158
183
 
@@ -117,10 +117,11 @@ export const UNIT_REGISTRY = {
117
117
  "gsd_milestone_status",
118
118
  "gsd_plan_milestone",
119
119
  "gsd_plan_slice",
120
+ "gsd_plan_task",
120
121
  "gsd_decision_save",
121
122
  "gsd_requirement_update",
122
123
  ],
123
- requiredWorkflowTools: ["gsd_milestone_status", "gsd_plan_milestone", "gsd_plan_slice"],
124
+ requiredWorkflowTools: ["gsd_milestone_status", "gsd_plan_milestone", "gsd_plan_slice", "gsd_plan_task"],
124
125
  },
125
126
  },
126
127
  "discuss-milestone": {
@@ -219,13 +220,14 @@ export const UNIT_REGISTRY = {
219
220
  allowedGsdTools: [
220
221
  "gsd_milestone_status",
221
222
  "gsd_plan_slice",
223
+ "gsd_plan_task",
222
224
  "gsd_reassess_roadmap",
223
225
  "gsd_decision_save",
224
226
  "gsd_exec",
225
227
  "gsd_exec_search",
226
228
  "gsd_resume",
227
229
  ],
228
- requiredWorkflowTools: ["gsd_plan_slice", "gsd_reassess_roadmap"],
230
+ requiredWorkflowTools: ["gsd_plan_slice", "gsd_plan_task", "gsd_reassess_roadmap"],
229
231
  },
230
232
  },
231
233
  "refine-slice": {