gsd-pi 2.75.0-dev.b6ad8c5f7 → 2.75.0-dev.e41b70b10

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 (181) hide show
  1. package/dist/resources/extensions/gsd/auto/phases.js +2 -0
  2. package/dist/resources/extensions/gsd/auto-dashboard.js +22 -1
  3. package/dist/resources/extensions/gsd/auto-direct-dispatch.js +8 -2
  4. package/dist/resources/extensions/gsd/auto-dispatch.js +11 -11
  5. package/dist/resources/extensions/gsd/auto-model-selection.js +3 -1
  6. package/dist/resources/extensions/gsd/auto-prompts.js +19 -9
  7. package/dist/resources/extensions/gsd/auto-worktree.js +16 -1
  8. package/dist/resources/extensions/gsd/bootstrap/memory-tools.js +128 -0
  9. package/dist/resources/extensions/gsd/bootstrap/register-extension.js +2 -0
  10. package/dist/resources/extensions/gsd/bootstrap/system-context.js +17 -4
  11. package/dist/resources/extensions/gsd/commands/handlers/onboarding.js +52 -92
  12. package/dist/resources/extensions/gsd/commands/handlers/ops.js +5 -0
  13. package/dist/resources/extensions/gsd/commands-memory.js +462 -0
  14. package/dist/resources/extensions/gsd/doctor-git-checks.js +22 -2
  15. package/dist/resources/extensions/gsd/gsd-db.js +237 -4
  16. package/dist/resources/extensions/gsd/memory-embeddings.js +219 -0
  17. package/dist/resources/extensions/gsd/memory-extractor.js +78 -27
  18. package/dist/resources/extensions/gsd/memory-ingest.js +218 -0
  19. package/dist/resources/extensions/gsd/memory-relations.js +189 -0
  20. package/dist/resources/extensions/gsd/memory-source-store.js +113 -0
  21. package/dist/resources/extensions/gsd/memory-store.js +299 -6
  22. package/dist/resources/extensions/gsd/pre-execution-checks.js +12 -8
  23. package/dist/resources/extensions/gsd/prompts/add-tests.md +1 -0
  24. package/dist/resources/extensions/gsd/prompts/execute-task.md +1 -1
  25. package/dist/resources/extensions/gsd/prompts/plan-slice.md +1 -0
  26. package/dist/resources/extensions/gsd/tools/memory-tools.js +306 -0
  27. package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +14 -0
  28. package/dist/resources/extensions/search-the-web/command-search-provider.js +4 -1
  29. package/dist/resources/extensions/search-the-web/native-search.js +13 -2
  30. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  31. package/dist/web/standalone/.next/BUILD_ID +1 -1
  32. package/dist/web/standalone/.next/app-path-routes-manifest.json +10 -10
  33. package/dist/web/standalone/.next/build-manifest.json +2 -2
  34. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  35. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  36. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  44. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/index.html +1 -1
  52. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  55. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  58. package/dist/web/standalone/.next/server/app-paths-manifest.json +10 -10
  59. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  60. package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
  61. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  62. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  63. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  64. package/package.json +1 -1
  65. package/packages/mcp-server/dist/server.d.ts.map +1 -1
  66. package/packages/mcp-server/dist/server.js +12 -10
  67. package/packages/mcp-server/dist/server.js.map +1 -1
  68. package/packages/mcp-server/dist/session-manager.d.ts.map +1 -1
  69. package/packages/mcp-server/dist/session-manager.js +8 -1
  70. package/packages/mcp-server/dist/session-manager.js.map +1 -1
  71. package/packages/mcp-server/dist/workflow-tools.d.ts +1 -0
  72. package/packages/mcp-server/dist/workflow-tools.d.ts.map +1 -1
  73. package/packages/mcp-server/dist/workflow-tools.js +207 -71
  74. package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
  75. package/packages/mcp-server/src/mcp-server.test.ts +40 -4
  76. package/packages/mcp-server/src/server.ts +12 -10
  77. package/packages/mcp-server/src/session-manager.ts +10 -3
  78. package/packages/mcp-server/src/workflow-tools.test.ts +346 -1
  79. package/packages/mcp-server/src/workflow-tools.ts +228 -75
  80. package/packages/mcp-server/tsconfig.tsbuildinfo +1 -1
  81. package/packages/pi-agent-core/tsconfig.tsbuildinfo +1 -1
  82. package/packages/pi-ai/dist/index.d.ts +1 -0
  83. package/packages/pi-ai/dist/index.d.ts.map +1 -1
  84. package/packages/pi-ai/dist/index.js +1 -0
  85. package/packages/pi-ai/dist/index.js.map +1 -1
  86. package/packages/pi-ai/dist/providers/api-family.d.ts +27 -0
  87. package/packages/pi-ai/dist/providers/api-family.d.ts.map +1 -0
  88. package/packages/pi-ai/dist/providers/api-family.js +47 -0
  89. package/packages/pi-ai/dist/providers/api-family.js.map +1 -0
  90. package/packages/pi-ai/dist/providers/api-family.test.d.ts +2 -0
  91. package/packages/pi-ai/dist/providers/api-family.test.d.ts.map +1 -0
  92. package/packages/pi-ai/dist/providers/api-family.test.js +101 -0
  93. package/packages/pi-ai/dist/providers/api-family.test.js.map +1 -0
  94. package/packages/pi-ai/src/index.ts +1 -0
  95. package/packages/pi-ai/src/providers/api-family.test.ts +129 -0
  96. package/packages/pi-ai/src/providers/api-family.ts +57 -0
  97. package/packages/pi-ai/tsconfig.tsbuildinfo +1 -1
  98. package/packages/pi-coding-agent/dist/cli/args.d.ts +6 -0
  99. package/packages/pi-coding-agent/dist/cli/args.d.ts.map +1 -1
  100. package/packages/pi-coding-agent/dist/cli/args.js +14 -4
  101. package/packages/pi-coding-agent/dist/cli/args.js.map +1 -1
  102. package/packages/pi-coding-agent/dist/cli/args.test.d.ts +2 -0
  103. package/packages/pi-coding-agent/dist/cli/args.test.d.ts.map +1 -0
  104. package/packages/pi-coding-agent/dist/cli/args.test.js +38 -0
  105. package/packages/pi-coding-agent/dist/cli/args.test.js.map +1 -0
  106. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts +1 -0
  107. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -1
  108. package/packages/pi-coding-agent/dist/core/extensions/runner.js.map +1 -1
  109. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +2 -1
  110. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
  111. package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
  112. package/packages/pi-coding-agent/dist/core/retry-handler.d.ts.map +1 -1
  113. package/packages/pi-coding-agent/dist/core/retry-handler.js +4 -1
  114. package/packages/pi-coding-agent/dist/core/retry-handler.js.map +1 -1
  115. package/packages/pi-coding-agent/dist/core/sdk.d.ts +10 -0
  116. package/packages/pi-coding-agent/dist/core/sdk.d.ts.map +1 -1
  117. package/packages/pi-coding-agent/dist/core/sdk.js +7 -1
  118. package/packages/pi-coding-agent/dist/core/sdk.js.map +1 -1
  119. package/packages/pi-coding-agent/dist/main.d.ts.map +1 -1
  120. package/packages/pi-coding-agent/dist/main.js +3 -0
  121. package/packages/pi-coding-agent/dist/main.js.map +1 -1
  122. package/packages/pi-coding-agent/src/cli/args.test.ts +44 -0
  123. package/packages/pi-coding-agent/src/cli/args.ts +21 -6
  124. package/packages/pi-coding-agent/src/core/extensions/runner.ts +4 -1
  125. package/packages/pi-coding-agent/src/core/extensions/types.ts +2 -2
  126. package/packages/pi-coding-agent/src/core/retry-handler.ts +4 -1
  127. package/packages/pi-coding-agent/src/core/sdk.ts +17 -1
  128. package/packages/pi-coding-agent/src/main.ts +4 -0
  129. package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -1
  130. package/src/resources/extensions/gsd/auto/loop-deps.ts +2 -10
  131. package/src/resources/extensions/gsd/auto/phases.ts +3 -0
  132. package/src/resources/extensions/gsd/auto-dashboard.ts +25 -1
  133. package/src/resources/extensions/gsd/auto-direct-dispatch.ts +15 -2
  134. package/src/resources/extensions/gsd/auto-dispatch.ts +21 -7
  135. package/src/resources/extensions/gsd/auto-model-selection.ts +3 -1
  136. package/src/resources/extensions/gsd/auto-prompts.ts +33 -9
  137. package/src/resources/extensions/gsd/auto-worktree.ts +16 -1
  138. package/src/resources/extensions/gsd/bootstrap/memory-tools.ts +158 -0
  139. package/src/resources/extensions/gsd/bootstrap/register-extension.ts +2 -0
  140. package/src/resources/extensions/gsd/bootstrap/system-context.ts +20 -4
  141. package/src/resources/extensions/gsd/commands/handlers/onboarding.ts +65 -131
  142. package/src/resources/extensions/gsd/commands/handlers/ops.ts +5 -0
  143. package/src/resources/extensions/gsd/commands-memory.ts +551 -0
  144. package/src/resources/extensions/gsd/doctor-git-checks.ts +23 -2
  145. package/src/resources/extensions/gsd/gsd-db.ts +273 -4
  146. package/src/resources/extensions/gsd/memory-embeddings.ts +235 -0
  147. package/src/resources/extensions/gsd/memory-extractor.ts +100 -34
  148. package/src/resources/extensions/gsd/memory-ingest.ts +286 -0
  149. package/src/resources/extensions/gsd/memory-relations.ts +240 -0
  150. package/src/resources/extensions/gsd/memory-source-store.ts +138 -0
  151. package/src/resources/extensions/gsd/memory-store.ts +351 -7
  152. package/src/resources/extensions/gsd/pre-execution-checks.ts +12 -8
  153. package/src/resources/extensions/gsd/prompts/add-tests.md +1 -0
  154. package/src/resources/extensions/gsd/prompts/execute-task.md +1 -1
  155. package/src/resources/extensions/gsd/prompts/plan-slice.md +1 -0
  156. package/src/resources/extensions/gsd/tests/auto-dashboard.test.ts +49 -0
  157. package/src/resources/extensions/gsd/tests/complete-slice.test.ts +2 -2
  158. package/src/resources/extensions/gsd/tests/complete-task.test.ts +2 -2
  159. package/src/resources/extensions/gsd/tests/escalation.test.ts +2 -2
  160. package/src/resources/extensions/gsd/tests/gsd-db.test.ts +1 -1
  161. package/src/resources/extensions/gsd/tests/integration/doctor-git-symlink-cwd.test.ts +79 -0
  162. package/src/resources/extensions/gsd/tests/md-importer.test.ts +1 -1
  163. package/src/resources/extensions/gsd/tests/memory-embeddings.test.ts +213 -0
  164. package/src/resources/extensions/gsd/tests/memory-ingest.test.ts +153 -0
  165. package/src/resources/extensions/gsd/tests/memory-maintenance.test.ts +107 -0
  166. package/src/resources/extensions/gsd/tests/memory-relations.test.ts +175 -0
  167. package/src/resources/extensions/gsd/tests/memory-store.test.ts +2 -2
  168. package/src/resources/extensions/gsd/tests/memory-tools.test.ts +295 -0
  169. package/src/resources/extensions/gsd/tests/pre-execution-checks.test.ts +66 -0
  170. package/src/resources/extensions/gsd/tests/prompt-budget-enforcement.test.ts +132 -8
  171. package/src/resources/extensions/gsd/tests/prompts-no-gitignored-test-refs.test.ts +56 -0
  172. package/src/resources/extensions/gsd/tests/stash-queued-context-files.test.ts +54 -0
  173. package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +97 -0
  174. package/src/resources/extensions/gsd/tools/memory-tools.ts +380 -0
  175. package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +14 -0
  176. package/src/resources/extensions/gsd/workflow-logger.ts +3 -1
  177. package/src/resources/extensions/search-the-web/command-search-provider.ts +4 -1
  178. package/src/resources/extensions/search-the-web/native-search.ts +13 -3
  179. package/src/resources/extensions/gsd/tests/onboarding-handler-loader.test.ts +0 -41
  180. /package/dist/web/standalone/.next/static/{J2z3GMC9QtSLr7gyoM38c → By_yegSJ-AA1OP0QjYbSl}/_buildManifest.js +0 -0
  181. /package/dist/web/standalone/.next/static/{J2z3GMC9QtSLr7gyoM38c → By_yegSJ-AA1OP0QjYbSl}/_ssgManifest.js +0 -0
@@ -23,7 +23,7 @@ import { readKnowledge } from './readers/knowledge.js';
23
23
  import { buildGraph, writeGraph, writeSnapshot, graphStatus, graphQuery, graphDiff } from './readers/graph.js';
24
24
  import { resolveGsdRoot } from './readers/paths.js';
25
25
  import { runDoctorLite } from './readers/doctor-lite.js';
26
- import { registerWorkflowTools } from './workflow-tools.js';
26
+ import { registerWorkflowTools, validateProjectDir } from './workflow-tools.js';
27
27
  import { applySecrets, checkExistingEnvKeys, detectDestination } from './env-writer.js';
28
28
 
29
29
  // ---------------------------------------------------------------------------
@@ -497,7 +497,8 @@ export async function createMcpServer(sessionManager: SessionManager): Promise<{
497
497
  async (args: Record<string, unknown>) => {
498
498
  const { projectDir, query } = args as { projectDir: string; query?: string };
499
499
  try {
500
- const state = await readProjectState(projectDir, query);
500
+ const validated = validateProjectDir(projectDir);
501
+ const state = await readProjectState(validated, query);
501
502
  return jsonContent(state);
502
503
  } catch (err) {
503
504
  return errorContent(err instanceof Error ? err.message : String(err));
@@ -589,7 +590,7 @@ export async function createMcpServer(sessionManager: SessionManager): Promise<{
589
590
  };
590
591
 
591
592
  try {
592
- const resolvedProjectDir = resolve(projectDir);
593
+ const resolvedProjectDir = validateProjectDir(projectDir);
593
594
  const resolvedEnvPath = resolve(resolvedProjectDir, envFilePath ?? '.env');
594
595
 
595
596
  // (1) Check which keys already exist
@@ -696,7 +697,7 @@ export async function createMcpServer(sessionManager: SessionManager): Promise<{
696
697
  async (args: Record<string, unknown>) => {
697
698
  const { projectDir } = args as { projectDir: string };
698
699
  try {
699
- return jsonContent(readProgress(projectDir));
700
+ return jsonContent(readProgress(validateProjectDir(projectDir)));
700
701
  } catch (err) {
701
702
  return errorContent(err instanceof Error ? err.message : String(err));
702
703
  }
@@ -716,7 +717,7 @@ export async function createMcpServer(sessionManager: SessionManager): Promise<{
716
717
  async (args: Record<string, unknown>) => {
717
718
  const { projectDir, milestoneId } = args as { projectDir: string; milestoneId?: string };
718
719
  try {
719
- return jsonContent(readRoadmap(projectDir, milestoneId));
720
+ return jsonContent(readRoadmap(validateProjectDir(projectDir), milestoneId));
720
721
  } catch (err) {
721
722
  return errorContent(err instanceof Error ? err.message : String(err));
722
723
  }
@@ -736,7 +737,7 @@ export async function createMcpServer(sessionManager: SessionManager): Promise<{
736
737
  async (args: Record<string, unknown>) => {
737
738
  const { projectDir, limit } = args as { projectDir: string; limit?: number };
738
739
  try {
739
- return jsonContent(readHistory(projectDir, limit));
740
+ return jsonContent(readHistory(validateProjectDir(projectDir), limit));
740
741
  } catch (err) {
741
742
  return errorContent(err instanceof Error ? err.message : String(err));
742
743
  }
@@ -756,7 +757,7 @@ export async function createMcpServer(sessionManager: SessionManager): Promise<{
756
757
  async (args: Record<string, unknown>) => {
757
758
  const { projectDir, scope } = args as { projectDir: string; scope?: string };
758
759
  try {
759
- return jsonContent(runDoctorLite(projectDir, scope));
760
+ return jsonContent(runDoctorLite(validateProjectDir(projectDir), scope));
760
761
  } catch (err) {
761
762
  return errorContent(err instanceof Error ? err.message : String(err));
762
763
  }
@@ -776,7 +777,7 @@ export async function createMcpServer(sessionManager: SessionManager): Promise<{
776
777
  async (args: Record<string, unknown>) => {
777
778
  const { projectDir, filter } = args as { projectDir: string; filter?: 'all' | 'pending' | 'actionable' };
778
779
  try {
779
- return jsonContent(readCaptures(projectDir, filter ?? 'all'));
780
+ return jsonContent(readCaptures(validateProjectDir(projectDir), filter ?? 'all'));
780
781
  } catch (err) {
781
782
  return errorContent(err instanceof Error ? err.message : String(err));
782
783
  }
@@ -795,7 +796,7 @@ export async function createMcpServer(sessionManager: SessionManager): Promise<{
795
796
  async (args: Record<string, unknown>) => {
796
797
  const { projectDir } = args as { projectDir: string };
797
798
  try {
798
- return jsonContent(readKnowledge(projectDir));
799
+ return jsonContent(readKnowledge(validateProjectDir(projectDir)));
799
800
  } catch (err) {
800
801
  return errorContent(err instanceof Error ? err.message : String(err));
801
802
  }
@@ -836,7 +837,7 @@ export async function createMcpServer(sessionManager: SessionManager): Promise<{
836
837
  snapshot: z.boolean().optional().describe('Write snapshot before build (for future diff)'),
837
838
  },
838
839
  async (args: Record<string, unknown>) => {
839
- const { projectDir, mode, term, budget, snapshot } = args as {
840
+ const { projectDir: rawProjectDir, mode, term, budget, snapshot } = args as {
840
841
  projectDir: string;
841
842
  mode: 'build' | 'query' | 'status' | 'diff';
842
843
  term?: string;
@@ -845,6 +846,7 @@ export async function createMcpServer(sessionManager: SessionManager): Promise<{
845
846
  };
846
847
 
847
848
  try {
849
+ const projectDir = validateProjectDir(rawProjectDir);
848
850
  const gsdRoot = resolveGsdRoot(projectDir);
849
851
 
850
852
  switch (mode) {
@@ -71,9 +71,16 @@ export class SessionManager {
71
71
 
72
72
  const existing = this.sessions.get(resolvedDir);
73
73
  if (existing) {
74
- throw new Error(
75
- `Session already active for ${resolvedDir} (sessionId: ${existing.sessionId}, status: ${existing.status})`
76
- );
74
+ // Only block when a genuinely active session is running. Terminal
75
+ // states (error, completed, cancelled) are evicted so the caller can
76
+ // start a fresh session for the same projectDir.
77
+ if (existing.status === 'starting' || existing.status === 'running' || existing.status === 'blocked') {
78
+ throw new Error(
79
+ `Session already active for ${resolvedDir} (sessionId: ${existing.sessionId}, status: ${existing.status})`
80
+ );
81
+ }
82
+ existing.unsubscribe?.();
83
+ this.sessions.delete(resolvedDir);
77
84
  }
78
85
 
79
86
  const cliPath = options.cliPath ?? SessionManager.resolveCLIPath();
@@ -5,8 +5,10 @@ import { join } from "node:path";
5
5
  import { tmpdir } from "node:os";
6
6
  import { randomUUID } from "node:crypto";
7
7
 
8
+ import { symlinkSync, realpathSync } from "node:fs";
9
+
8
10
  import { _getAdapter, closeDatabase } from "../../../src/resources/extensions/gsd/gsd-db.ts";
9
- import { registerWorkflowTools, WORKFLOW_TOOL_NAMES } from "./workflow-tools.ts";
11
+ import { registerWorkflowTools, WORKFLOW_TOOL_NAMES, validateProjectDir } from "./workflow-tools.ts";
10
12
 
11
13
  function makeTmpBase(): string {
12
14
  const base = join(tmpdir(), `gsd-mcp-workflow-${randomUUID()}`);
@@ -384,6 +386,261 @@ describe("workflow MCP tools", () => {
384
386
  }
385
387
  });
386
388
 
389
+ it("other workflow tools reject empty required strings at the schema layer", async () => {
390
+ const base = makeTmpBase();
391
+ try {
392
+ const server = makeMockServer();
393
+ registerWorkflowTools(server as any);
394
+
395
+ const expectRejection = async (toolName: string, args: Record<string, unknown>, expectedField: string) => {
396
+ const tool = server.tools.find((t) => t.name === toolName);
397
+ assert.ok(tool, `${toolName} should be registered`);
398
+ let caught: unknown;
399
+ try {
400
+ await tool!.handler(args);
401
+ } catch (err) {
402
+ caught = err;
403
+ }
404
+ assert.ok(caught, `${toolName} should reject empty ${expectedField}`);
405
+ const message = caught instanceof Error ? caught.message : String(caught);
406
+ assert.ok(
407
+ message.includes(expectedField),
408
+ `${toolName} error should mention ${expectedField}, got: ${message}`,
409
+ );
410
+ };
411
+
412
+ // Empty sliceId top-level
413
+ await expectRejection("gsd_plan_slice", {
414
+ projectDir: base,
415
+ milestoneId: "M001",
416
+ sliceId: "",
417
+ goal: "Persist slice plan.",
418
+ tasks: [],
419
+ }, "sliceId");
420
+
421
+ // Empty task verify inside tasks array
422
+ await expectRejection("gsd_plan_slice", {
423
+ projectDir: base,
424
+ milestoneId: "M001",
425
+ sliceId: "S01",
426
+ goal: "Persist slice plan.",
427
+ tasks: [
428
+ {
429
+ taskId: "T01",
430
+ title: "Add bridge",
431
+ description: "Implement bridge.",
432
+ estimate: "15m",
433
+ files: ["src/x.ts"],
434
+ verify: "",
435
+ inputs: ["ROADMAP.md"],
436
+ expectedOutput: ["S01-PLAN.md"],
437
+ },
438
+ ],
439
+ }, "verify");
440
+
441
+ // Empty element inside files[] array
442
+ await expectRejection("gsd_plan_slice", {
443
+ projectDir: base,
444
+ milestoneId: "M001",
445
+ sliceId: "S01",
446
+ goal: "Persist slice plan.",
447
+ tasks: [
448
+ {
449
+ taskId: "T01",
450
+ title: "Add bridge",
451
+ description: "Implement bridge.",
452
+ estimate: "15m",
453
+ files: ["src/x.ts", " "],
454
+ verify: "node --test",
455
+ inputs: ["ROADMAP.md"],
456
+ expectedOutput: ["S01-PLAN.md"],
457
+ },
458
+ ],
459
+ }, "files");
460
+
461
+ // Empty milestoneId on gsd_plan_task
462
+ await expectRejection("gsd_plan_task", {
463
+ projectDir: base,
464
+ milestoneId: "",
465
+ sliceId: "S01",
466
+ taskId: "T01",
467
+ title: "t",
468
+ description: "d",
469
+ estimate: "1m",
470
+ files: [],
471
+ verify: "v",
472
+ inputs: [],
473
+ expectedOutput: [],
474
+ }, "milestoneId");
475
+
476
+ // Empty observabilityImpact explicitly rejected (optional-but-non-empty)
477
+ await expectRejection("gsd_plan_task", {
478
+ projectDir: base,
479
+ milestoneId: "M001",
480
+ sliceId: "S01",
481
+ taskId: "T01",
482
+ title: "t",
483
+ description: "d",
484
+ estimate: "1m",
485
+ files: [],
486
+ verify: "v",
487
+ inputs: [],
488
+ expectedOutput: [],
489
+ observabilityImpact: " ",
490
+ }, "observabilityImpact");
491
+
492
+ // Empty assessment on gsd_reassess_roadmap
493
+ await expectRejection("gsd_reassess_roadmap", {
494
+ projectDir: base,
495
+ milestoneId: "M001",
496
+ completedSliceId: "S01",
497
+ verdict: "roadmap-confirmed",
498
+ assessment: "",
499
+ sliceChanges: { modified: [], added: [], removed: [] },
500
+ }, "assessment");
501
+
502
+ // Empty keyRisks[i].risk on gsd_plan_milestone top-level arrays
503
+ await expectRejection("gsd_plan_milestone", {
504
+ projectDir: base,
505
+ milestoneId: "M001",
506
+ title: "T",
507
+ vision: "V",
508
+ slices: [],
509
+ keyRisks: [{ risk: "", whyItMatters: "because." }],
510
+ }, "risk");
511
+
512
+ // Empty blockerDescription on gsd_replan_slice
513
+ await expectRejection("gsd_replan_slice", {
514
+ projectDir: base,
515
+ milestoneId: "M001",
516
+ sliceId: "S01",
517
+ blockerTaskId: "T01",
518
+ blockerDescription: "",
519
+ whatChanged: "x",
520
+ updatedTasks: [],
521
+ removedTaskIds: [],
522
+ }, "blockerDescription");
523
+
524
+ // Empty milestoneId on gsd_task_complete
525
+ await expectRejection("gsd_task_complete", {
526
+ projectDir: base,
527
+ taskId: "T01",
528
+ sliceId: "S01",
529
+ milestoneId: "",
530
+ oneLiner: "ol",
531
+ narrative: "n",
532
+ verification: "v",
533
+ }, "milestoneId");
534
+ } finally {
535
+ cleanup(base);
536
+ }
537
+ });
538
+
539
+ it("gsd_plan_milestone rejects empty slice fields up front with all violations", async () => {
540
+ const base = makeTmpBase();
541
+ try {
542
+ const server = makeMockServer();
543
+ registerWorkflowTools(server as any);
544
+ const milestoneTool = server.tools.find((t) => t.name === "gsd_plan_milestone");
545
+ assert.ok(milestoneTool, "milestone planning tool should be registered");
546
+
547
+ let caught: unknown;
548
+ try {
549
+ await milestoneTool!.handler({
550
+ projectDir: base,
551
+ milestoneId: "M001",
552
+ title: "Workflow MCP planning",
553
+ vision: "Plan milestone over MCP.",
554
+ slices: [
555
+ {
556
+ sliceId: "S01",
557
+ title: "Bridge planning",
558
+ risk: "medium",
559
+ depends: [],
560
+ demo: "Milestone plan persists through MCP.",
561
+ goal: "Persist roadmap state.",
562
+ successCriteria: "",
563
+ proofLevel: "",
564
+ integrationClosure: " ",
565
+ observabilityImpact: "",
566
+ },
567
+ ],
568
+ });
569
+ } catch (err) {
570
+ caught = err;
571
+ }
572
+ assert.ok(caught, "empty slice fields should be rejected");
573
+ const message = caught instanceof Error ? caught.message : String(caught);
574
+ for (const field of ["successCriteria", "proofLevel", "integrationClosure", "observabilityImpact"]) {
575
+ assert.ok(
576
+ message.includes(field),
577
+ `parse error should mention ${field}, got: ${message}`,
578
+ );
579
+ }
580
+ } finally {
581
+ cleanup(base);
582
+ }
583
+ });
584
+
585
+ it("gsd_plan_milestone requires sketchScope when isSketch=true and skips heavy fields", async () => {
586
+ const base = makeTmpBase();
587
+ try {
588
+ const server = makeMockServer();
589
+ registerWorkflowTools(server as any);
590
+ const milestoneTool = server.tools.find((t) => t.name === "gsd_plan_milestone");
591
+ assert.ok(milestoneTool, "milestone planning tool should be registered");
592
+
593
+ let caught: unknown;
594
+ try {
595
+ await milestoneTool!.handler({
596
+ projectDir: base,
597
+ milestoneId: "M001",
598
+ title: "Sketch milestone",
599
+ vision: "Sketch first, refine later.",
600
+ slices: [
601
+ {
602
+ sliceId: "S01",
603
+ title: "Sketch slice",
604
+ risk: "low",
605
+ depends: [],
606
+ demo: "Stub demo.",
607
+ goal: "Stub goal.",
608
+ isSketch: true,
609
+ sketchScope: "",
610
+ },
611
+ ],
612
+ });
613
+ } catch (err) {
614
+ caught = err;
615
+ }
616
+ assert.ok(caught, "empty sketchScope should be rejected when isSketch=true");
617
+ const message = caught instanceof Error ? caught.message : String(caught);
618
+ assert.ok(message.includes("sketchScope"), `expected sketchScope error, got: ${message}`);
619
+
620
+ const sketchResult = await milestoneTool!.handler({
621
+ projectDir: base,
622
+ milestoneId: "M001",
623
+ title: "Sketch milestone",
624
+ vision: "Sketch first, refine later.",
625
+ slices: [
626
+ {
627
+ sliceId: "S01",
628
+ title: "Sketch slice",
629
+ risk: "low",
630
+ depends: [],
631
+ demo: "Stub demo.",
632
+ goal: "Stub goal.",
633
+ isSketch: true,
634
+ sketchScope: "Defer heavy planning fields until refine-slice.",
635
+ },
636
+ ],
637
+ });
638
+ assert.match((sketchResult as any).content[0].text as string, /Planned milestone M001/);
639
+ } finally {
640
+ cleanup(base);
641
+ }
642
+ });
643
+
387
644
  it("gsd_requirement_save opens the DB before inline requirement writes", async () => {
388
645
  const base = makeTmpBase();
389
646
  try {
@@ -1091,3 +1348,91 @@ describe("URL scheme regex — Windows drive letter safety", () => {
1091
1348
  assert.ok(!urlSchemeRegex.test("../parent/path.js"), "parent relative path should not match");
1092
1349
  });
1093
1350
  });
1351
+
1352
+ // ---------------------------------------------------------------------------
1353
+ // validateProjectDir — symlink containment hardening (#4476)
1354
+ // ---------------------------------------------------------------------------
1355
+ //
1356
+ // The regression: a symlink inside the allowed root could point outside it,
1357
+ // and a lexical-only containment check would happily admit the path. The fix
1358
+ // realpath()s the candidate (and the allowed root) before checking
1359
+ // containment, falling back to the lexical path only when the candidate
1360
+ // itself does not exist (a legitimate brand-new-worktree case).
1361
+
1362
+ describe("validateProjectDir", () => {
1363
+ it("rejects a symlink inside the allowed root that points outside it", () => {
1364
+ const allowedRoot = makeTmpBase();
1365
+ const outside = makeTmpBase();
1366
+ const linkInside = join(allowedRoot, "escape-link");
1367
+ symlinkSync(outside, linkInside, "dir");
1368
+
1369
+ const prevRoot = process.env.GSD_WORKFLOW_PROJECT_ROOT;
1370
+ try {
1371
+ process.env.GSD_WORKFLOW_PROJECT_ROOT = allowedRoot;
1372
+ assert.throws(
1373
+ () => validateProjectDir(linkInside),
1374
+ /configured workflow project root/,
1375
+ "symlink-to-outside must not bypass the containment check",
1376
+ );
1377
+ } finally {
1378
+ if (prevRoot === undefined) {
1379
+ delete process.env.GSD_WORKFLOW_PROJECT_ROOT;
1380
+ } else {
1381
+ process.env.GSD_WORKFLOW_PROJECT_ROOT = prevRoot;
1382
+ }
1383
+ cleanup(allowedRoot);
1384
+ cleanup(outside);
1385
+ }
1386
+ });
1387
+
1388
+ it("accepts a non-existent path inside the allowed root (new worktree case)", () => {
1389
+ const allowedRoot = makeTmpBase();
1390
+ // Use the realpath form so that on platforms where /tmp resolves through a
1391
+ // symlink (macOS /var → /private/var) the lexical fallback for ENOENT
1392
+ // candidates still lines up with the allowed root.
1393
+ const canonicalRoot = realpathSync(allowedRoot);
1394
+ const futureWorktree = join(canonicalRoot, "worktrees", "M999-not-yet-created");
1395
+
1396
+ const prevRoot = process.env.GSD_WORKFLOW_PROJECT_ROOT;
1397
+ try {
1398
+ process.env.GSD_WORKFLOW_PROJECT_ROOT = canonicalRoot;
1399
+ const result = validateProjectDir(futureWorktree);
1400
+ assert.equal(result, futureWorktree, "ENOENT should fall back to the lexical path, not throw");
1401
+ } finally {
1402
+ if (prevRoot === undefined) {
1403
+ delete process.env.GSD_WORKFLOW_PROJECT_ROOT;
1404
+ } else {
1405
+ process.env.GSD_WORKFLOW_PROJECT_ROOT = prevRoot;
1406
+ }
1407
+ cleanup(allowedRoot);
1408
+ }
1409
+ });
1410
+
1411
+ it("accepts a real directory inside the allowed root", () => {
1412
+ const allowedRoot = makeTmpBase();
1413
+ const child = join(allowedRoot, "child");
1414
+ mkdirSync(child, { recursive: true });
1415
+
1416
+ const prevRoot = process.env.GSD_WORKFLOW_PROJECT_ROOT;
1417
+ try {
1418
+ process.env.GSD_WORKFLOW_PROJECT_ROOT = allowedRoot;
1419
+ const result = validateProjectDir(child);
1420
+ // realpath may canonicalize macOS /var → /private/var; assert it ends with our child segment.
1421
+ assert.ok(result.endsWith("child"), `expected resolved path to end with 'child', got ${result}`);
1422
+ } finally {
1423
+ if (prevRoot === undefined) {
1424
+ delete process.env.GSD_WORKFLOW_PROJECT_ROOT;
1425
+ } else {
1426
+ process.env.GSD_WORKFLOW_PROJECT_ROOT = prevRoot;
1427
+ }
1428
+ cleanup(allowedRoot);
1429
+ }
1430
+ });
1431
+
1432
+ it("rejects relative paths", () => {
1433
+ assert.throws(
1434
+ () => validateProjectDir("relative/path"),
1435
+ /must be an absolute path/,
1436
+ );
1437
+ });
1438
+ });