gsd-pi 3.0.0-dev.2e8b124f7 → 3.0.0-dev.6c9a50fd0

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 (87) hide show
  1. package/dist/resources/.managed-resources-content-hash +1 -1
  2. package/dist/resources/extensions/gsd/auto/loop.js +2 -3
  3. package/dist/resources/extensions/gsd/auto/orchestrator.js +2 -2
  4. package/dist/resources/extensions/gsd/auto/phases.js +12 -4
  5. package/dist/resources/extensions/gsd/auto-dispatch.js +34 -4
  6. package/dist/resources/extensions/gsd/auto-recovery.js +1 -0
  7. package/dist/resources/extensions/gsd/auto.js +27 -11
  8. package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +35 -4
  9. package/dist/resources/extensions/gsd/crash-recovery.js +4 -1
  10. package/dist/resources/extensions/gsd/db/auto-workers.js +21 -0
  11. package/dist/resources/extensions/gsd/preferences.js +4 -0
  12. package/dist/resources/extensions/gsd/repo-identity.js +39 -22
  13. package/dist/resources/extensions/gsd/session-lock.js +15 -2
  14. package/dist/resources/extensions/gsd/tools/complete-milestone.js +9 -1
  15. package/dist/resources/extensions/gsd/tools/complete-slice.js +50 -2
  16. package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +66 -40
  17. package/dist/resources/extensions/gsd/worktree-safety.js +10 -3
  18. package/dist/resources/extensions/shared/next-action-ui.js +13 -5
  19. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  20. package/dist/web/standalone/.next/BUILD_ID +1 -1
  21. package/dist/web/standalone/.next/app-path-routes-manifest.json +6 -6
  22. package/dist/web/standalone/.next/build-manifest.json +2 -2
  23. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  24. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  25. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  33. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/index.html +1 -1
  41. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app-paths-manifest.json +6 -6
  48. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  49. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  50. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  51. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  52. package/package.json +1 -1
  53. package/src/resources/extensions/gsd/auto/contracts.ts +2 -0
  54. package/src/resources/extensions/gsd/auto/loop.ts +2 -2
  55. package/src/resources/extensions/gsd/auto/orchestrator.ts +2 -2
  56. package/src/resources/extensions/gsd/auto/phases.ts +14 -4
  57. package/src/resources/extensions/gsd/auto-dispatch.ts +52 -3
  58. package/src/resources/extensions/gsd/auto-recovery.ts +1 -0
  59. package/src/resources/extensions/gsd/auto.ts +63 -18
  60. package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +25 -4
  61. package/src/resources/extensions/gsd/crash-recovery.ts +3 -0
  62. package/src/resources/extensions/gsd/db/auto-workers.ts +25 -0
  63. package/src/resources/extensions/gsd/preferences.ts +4 -0
  64. package/src/resources/extensions/gsd/repo-identity.ts +45 -25
  65. package/src/resources/extensions/gsd/session-lock.ts +15 -2
  66. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +135 -0
  67. package/src/resources/extensions/gsd/tests/auto-orchestrator.test.ts +64 -35
  68. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +17 -15
  69. package/src/resources/extensions/gsd/tests/auto-workers.test.ts +13 -0
  70. package/src/resources/extensions/gsd/tests/complete-milestone.test.ts +51 -1
  71. package/src/resources/extensions/gsd/tests/complete-slice.test.ts +55 -0
  72. package/src/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts +111 -1
  73. package/src/resources/extensions/gsd/tests/integration/state-machine-live-validation.test.ts +15 -0
  74. package/src/resources/extensions/gsd/tests/interrupted-session-auto.test.ts +38 -0
  75. package/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts +28 -1
  76. package/src/resources/extensions/gsd/tests/session-lock-regression.test.ts +35 -0
  77. package/src/resources/extensions/gsd/tests/session-switch-abort-misclassification.test.ts +38 -0
  78. package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +120 -0
  79. package/src/resources/extensions/gsd/tests/worktree-safety.test.ts +44 -0
  80. package/src/resources/extensions/gsd/tools/complete-milestone.ts +10 -0
  81. package/src/resources/extensions/gsd/tools/complete-slice.ts +51 -2
  82. package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +31 -17
  83. package/src/resources/extensions/gsd/worktree-safety.ts +12 -4
  84. package/src/resources/extensions/shared/next-action-ui.ts +11 -5
  85. package/src/resources/extensions/shared/tests/next-action-ui-hasui.test.ts +32 -0
  86. /package/dist/web/standalone/.next/static/{zCegwxH2e6vLp1vEZLLuZ → 8wipfz6TDZ6YWoaQjgqYD}/_buildManifest.js +0 -0
  87. /package/dist/web/standalone/.next/static/{zCegwxH2e6vLp1vEZLLuZ → 8wipfz6TDZ6YWoaQjgqYD}/_ssgManifest.js +0 -0
@@ -10,6 +10,7 @@ import {
10
10
  closeDatabase,
11
11
  _getAdapter,
12
12
  insertGateRow,
13
+ insertAssessment,
13
14
  upsertRequirement,
14
15
  getAllMilestones,
15
16
  } from "../gsd-db.ts";
@@ -26,6 +27,7 @@ import {
26
27
  executeTaskComplete,
27
28
  executeMilestoneStatus,
28
29
  executeSliceComplete,
30
+ executeSliceReopen,
29
31
  executeValidateMilestone,
30
32
  } from "../tools/workflow-tool-executors.ts";
31
33
 
@@ -146,6 +148,117 @@ test("executeTaskComplete coerces string verificationEvidence entries", async ()
146
148
  }
147
149
  });
148
150
 
151
+ test("executeSliceComplete preserves omitted optional requirement arrays", async () => {
152
+ const base = makeTmpBase();
153
+ try {
154
+ openTestDb(base);
155
+ await inProjectDir(base, () => executePlanMilestone({
156
+ milestoneId: "M001",
157
+ title: "Requirement preservation",
158
+ vision: "Ensure omitted arrays are not coerced to empties.",
159
+ slices: [
160
+ {
161
+ sliceId: "S01",
162
+ title: "Slice",
163
+ risk: "medium",
164
+ depends: [],
165
+ demo: "demo",
166
+ goal: "goal",
167
+ successCriteria: "done",
168
+ proofLevel: "integration",
169
+ integrationClosure: "closed",
170
+ observabilityImpact: "covered",
171
+ },
172
+ ],
173
+ }, base));
174
+ await inProjectDir(base, () => executePlanSlice({
175
+ milestoneId: "M001",
176
+ sliceId: "S01",
177
+ goal: "goal",
178
+ tasks: [
179
+ {
180
+ taskId: "T01",
181
+ title: "Task",
182
+ description: "desc",
183
+ estimate: "5m",
184
+ files: ["src/a.ts"],
185
+ verify: "node --test",
186
+ inputs: ["in"],
187
+ expectedOutput: ["out"],
188
+ },
189
+ ],
190
+ }, base));
191
+ await inProjectDir(base, () => executeTaskComplete({
192
+ milestoneId: "M001",
193
+ sliceId: "S01",
194
+ taskId: "T01",
195
+ oneLiner: "done",
196
+ narrative: "done",
197
+ verification: "ok",
198
+ }, base));
199
+
200
+ const result = await inProjectDir(base, () => executeSliceComplete({
201
+ milestoneId: "M001",
202
+ sliceId: "S01",
203
+ sliceTitle: "Slice",
204
+ oneLiner: "done",
205
+ narrative: "done",
206
+ verification: "ok",
207
+ uatContent: "ok",
208
+ requirementsAdvanced: [{ id: "R010", how: "advanced" }],
209
+ requirementsValidated: [{ id: "R010", proof: "validated" }],
210
+ }, base));
211
+
212
+ assert.equal(result.details.operation, "complete_slice");
213
+ const summaryPath = String(result.details.summaryPath);
214
+ const summary = readFileSync(summaryPath, "utf-8");
215
+ assert.match(summary, /R010 — advanced/);
216
+ assert.match(summary, /R010 — validated/);
217
+
218
+ const reopenResult = await inProjectDir(base, () => executeSliceReopen({
219
+ milestoneId: "M001",
220
+ sliceId: "S01",
221
+ reason: "validate idempotent overwrite behavior",
222
+ }, base));
223
+ assert.equal(reopenResult.details.operation, "reopen_slice");
224
+ await inProjectDir(base, () => executeTaskComplete({
225
+ milestoneId: "M001",
226
+ sliceId: "S01",
227
+ taskId: "T01",
228
+ oneLiner: "done (updated)",
229
+ narrative: "done (updated)",
230
+ verification: "ok",
231
+ }, base));
232
+
233
+ const recallResult = await inProjectDir(base, () => executeSliceComplete({
234
+ milestoneId: "M001",
235
+ sliceId: "S01",
236
+ sliceTitle: "Slice",
237
+ oneLiner: "done (updated)",
238
+ narrative: "done (updated)",
239
+ verification: "ok",
240
+ uatContent: "ok",
241
+ }, base));
242
+
243
+ assert.equal(recallResult.details.operation, "complete_slice");
244
+ const recallSummaryPath = String(recallResult.details.summaryPath);
245
+ const recallSummary = readFileSync(recallSummaryPath, "utf-8");
246
+ assert.match(
247
+ recallSummary,
248
+ /R010 — advanced/,
249
+ "requirementsAdvanced should be preserved from first call",
250
+ );
251
+ assert.match(
252
+ recallSummary,
253
+ /R010 — validated/,
254
+ "requirementsValidated should be preserved from first call",
255
+ );
256
+ } finally {
257
+ closeDatabase();
258
+ cleanup(base);
259
+ }
260
+ });
261
+
149
262
  test("executeMilestoneStatus returns milestone metadata and slice counts", async () => {
150
263
  const base = makeTmpBase();
151
264
  try {
@@ -371,6 +484,13 @@ test("executeCompleteMilestone sanitizes raw params and writes milestone summary
371
484
  db!.prepare(
372
485
  "INSERT OR REPLACE INTO tasks (milestone_id, slice_id, id, title, status) VALUES (?, ?, ?, ?, ?)",
373
486
  ).run("M003", "S03", "T03", "Task T03", "complete");
487
+ insertAssessment({
488
+ path: join(".gsd", "milestones", "M003", "M003-VALIDATION.md"),
489
+ milestoneId: "M003",
490
+ status: "pass",
491
+ scope: "milestone-validation",
492
+ fullContent: "---\nverdict: pass\nremediation_round: 0\n---\n\n# Validation\nValidated.",
493
+ });
374
494
 
375
495
  const rawParams = {
376
496
  milestoneId: "M003",
@@ -129,6 +129,50 @@ describe("Worktree Safety module", () => {
129
129
  assert.equal(result.details?.expectedRoot, unitRoot);
130
130
  });
131
131
 
132
+ test("accepts project root for source-writing Unit when isolation mode is none", () => {
133
+ const safety = createWorktreeSafetyModule({
134
+ existsSync: () => true,
135
+ lstatSync: () => ({ isFile: () => true }),
136
+ listRegisteredWorktrees: () => [{ path: projectRoot, branch: "main" }],
137
+ });
138
+
139
+ const result = safety.validateUnitRoot({
140
+ unitType: "execute-task",
141
+ unitId: "M001/S01/T01",
142
+ writeScope: "source-writing",
143
+ projectRoot,
144
+ unitRoot: projectRoot,
145
+ milestoneId: "M001",
146
+ isolationMode: "none",
147
+ });
148
+
149
+ assert.equal(result.ok, true);
150
+ assert.equal(result.kind, "safe");
151
+ });
152
+
153
+ test("rejects non-project root for source-writing Unit when isolation mode is none", () => {
154
+ const safety = createWorktreeSafetyModule({
155
+ existsSync: () => true,
156
+ lstatSync: () => ({ isFile: () => true }),
157
+ listRegisteredWorktrees: () => [{ path: unitRoot, branch: "main" }],
158
+ });
159
+
160
+ const result = safety.validateUnitRoot({
161
+ unitType: "execute-task",
162
+ unitId: "M001/S01/T01",
163
+ writeScope: "source-writing",
164
+ projectRoot,
165
+ unitRoot,
166
+ milestoneId: "M001",
167
+ isolationMode: "none",
168
+ });
169
+
170
+ assert.equal(result.ok, false);
171
+ assert.equal(result.kind, "invalid-root");
172
+ assert.equal(result.details?.expectedRoot, projectRoot);
173
+ assert.equal(result.details?.unitRoot, unitRoot);
174
+ });
175
+
132
176
  test("rejects a standalone repository masquerading as a worktree", () => {
133
177
  unlinkSync(join(unitRoot, ".git"));
134
178
  mkdirSync(join(unitRoot, ".git"), { recursive: true });
@@ -15,6 +15,7 @@ import {
15
15
  getMilestone,
16
16
  getMilestoneSlices,
17
17
  getSliceTasks,
18
+ getLatestAssessmentByScope,
18
19
  updateMilestoneStatus,
19
20
  } from "../gsd-db.js";
20
21
  import { resolveMilestonePath, clearPathCache } from "../paths.js";
@@ -156,6 +157,15 @@ export async function handleCompleteMilestone(
156
157
  return;
157
158
  }
158
159
 
160
+ // Defense-in-depth: only a passing milestone validation permits closeout.
161
+ const validation = getLatestAssessmentByScope(params.milestoneId, "milestone-validation");
162
+ if (validation?.status !== "pass") {
163
+ guardError =
164
+ `Refusing to complete ${params.milestoneId}: latest milestone-validation verdict is ` +
165
+ `"${validation?.status ?? "absent"}". Only verdict=pass permits closeout.`;
166
+ return;
167
+ }
168
+
159
169
  // Verify all slices are complete
160
170
  const slices = getMilestoneSlices(params.milestoneId);
161
171
  if (slices.length === 0) {
@@ -232,6 +232,34 @@ ${params.uatContent}
232
232
  `;
233
233
  }
234
234
 
235
+ function parseRequirementSection(
236
+ summaryMd: string,
237
+ heading: "Requirements Advanced" | "Requirements Validated" | "Requirements Invalidated or Re-scoped",
238
+ field: "how" | "proof" | "what",
239
+ ): Array<{ id: string; how?: string; proof?: string; what?: string }> {
240
+ const headingLine = `## ${heading}\n\n`;
241
+ const start = summaryMd.indexOf(headingLine);
242
+ if (start === -1) return [];
243
+ const contentStart = start + headingLine.length;
244
+ const nextHeading = summaryMd.indexOf("\n\n## ", contentStart);
245
+ const content = nextHeading === -1
246
+ ? summaryMd.slice(contentStart)
247
+ : summaryMd.slice(contentStart, nextHeading);
248
+ return content
249
+ .split("\n")
250
+ .map((line) => line.trim())
251
+ .filter((line) => line.startsWith("- "))
252
+ .map((line) => line.slice(2).trim())
253
+ .map((line) => {
254
+ const pair = line.match(/^(.+?)\s*(?:—|-)\s+(.+)$/);
255
+ const id = pair ? pair[1].trim() : line.trim();
256
+ const detail = pair ? pair[2].trim() : "";
257
+ if (!id || !detail) return null;
258
+ return { id, [field]: detail };
259
+ })
260
+ .filter((entry): entry is { id: string; how?: string; proof?: string; what?: string } => entry !== null);
261
+ }
262
+
235
263
  /**
236
264
  * Handle the complete_slice operation end-to-end.
237
265
  *
@@ -277,6 +305,7 @@ export async function handleCompleteSlice(
277
305
  // ── Guards + DB writes inside a single transaction (prevents TOCTOU) ───
278
306
  const completedAt = new Date().toISOString();
279
307
  let guardError: string | null = null;
308
+ let existingSummaryMd = "";
280
309
 
281
310
  transaction(() => {
282
311
  // State machine preconditions (inside txn for atomicity).
@@ -289,6 +318,7 @@ export async function handleCompleteSlice(
289
318
  }
290
319
 
291
320
  const slice = getSlice(params.milestoneId, params.sliceId);
321
+ existingSummaryMd = slice?.full_summary_md?.trim() ?? "";
292
322
  if (slice && isClosedStatus(slice.status)) {
293
323
  if (isStaleWrite("complete-slice")) {
294
324
  guardError = "__stale_duplicate__";
@@ -347,8 +377,27 @@ export async function handleCompleteSlice(
347
377
  return { error: guardError };
348
378
  }
349
379
 
380
+ const effectiveParams: CompleteSliceParams = { ...params };
381
+ if (existingSummaryMd) {
382
+ // Keep these heading names in lock-step with renderSliceSummaryMarkdown's
383
+ // section titles so omitted CompleteSliceParams requirement fields can be
384
+ // backfilled from previously rendered summary markdown.
385
+ if (effectiveParams.requirementsAdvanced === undefined) {
386
+ const parsed = parseRequirementSection(existingSummaryMd, "Requirements Advanced", "how");
387
+ if (parsed.length > 0) effectiveParams.requirementsAdvanced = parsed as Array<{ id: string; how: string }>;
388
+ }
389
+ if (effectiveParams.requirementsValidated === undefined) {
390
+ const parsed = parseRequirementSection(existingSummaryMd, "Requirements Validated", "proof");
391
+ if (parsed.length > 0) effectiveParams.requirementsValidated = parsed as Array<{ id: string; proof: string }>;
392
+ }
393
+ if (effectiveParams.requirementsInvalidated === undefined) {
394
+ const parsed = parseRequirementSection(existingSummaryMd, "Requirements Invalidated or Re-scoped", "what");
395
+ if (parsed.length > 0) effectiveParams.requirementsInvalidated = parsed as Array<{ id: string; what: string }>;
396
+ }
397
+ }
398
+
350
399
  // Render summary markdown
351
- const summaryMd = renderSliceSummaryMarkdown(params);
400
+ const summaryMd = renderSliceSummaryMarkdown(effectiveParams);
352
401
 
353
402
  // Resolve and write summary to disk
354
403
  let summaryPath: string;
@@ -363,7 +412,7 @@ export async function handleCompleteSlice(
363
412
  summaryPath = join(manualSliceDir, `${params.sliceId}-SUMMARY.md`);
364
413
  }
365
414
 
366
- const uatMd = renderUatMarkdown(params);
415
+ const uatMd = renderUatMarkdown(effectiveParams);
367
416
  const uatPath = summaryPath.replace(/-SUMMARY\.md$/, "-UAT.md");
368
417
  setSliceSummaryMd(params.milestoneId, params.sliceId, summaryMd, uatMd);
369
418
  let projectionStale = false;
@@ -523,39 +523,53 @@ export async function executeSliceComplete(
523
523
  const m = s.match(/^(.+?)\s*(?:—|-)\s+(.+)$/);
524
524
  return m ? [m[1].trim(), m[2].trim()] : [s.trim(), ""];
525
525
  };
526
- const wrapArray = (v: unknown): unknown[] =>
527
- v == null ? [] : Array.isArray(v) ? v : [v];
528
-
529
- const coerced = { ...params } as CompleteSliceParams & Record<string, unknown>;
530
- coerced.provides = wrapArray(params.provides) as string[];
531
- coerced.keyFiles = wrapArray(params.keyFiles) as string[];
532
- coerced.keyDecisions = wrapArray(params.keyDecisions) as string[];
533
- coerced.patternsEstablished = wrapArray(params.patternsEstablished) as string[];
534
- coerced.observabilitySurfaces = wrapArray(params.observabilitySurfaces) as string[];
535
- coerced.requirementsSurfaced = wrapArray(params.requirementsSurfaced) as string[];
536
- coerced.drillDownPaths = wrapArray(params.drillDownPaths) as string[];
537
- coerced.affects = wrapArray(params.affects) as string[];
538
- coerced.filesModified = wrapArray(params.filesModified).map((f) => {
526
+ const wrapOptionalArray = (v: unknown): unknown[] | undefined =>
527
+ v == null ? undefined : Array.isArray(v) ? v : [v];
528
+ const coerced = Object.fromEntries(
529
+ Object.entries(params).filter(([, value]) => value !== undefined && value !== null),
530
+ ) as CompleteSliceParams & Record<string, unknown>;
531
+ const provides = wrapOptionalArray(params.provides);
532
+ if (provides !== undefined) coerced.provides = provides as string[];
533
+ const keyFiles = wrapOptionalArray(params.keyFiles);
534
+ if (keyFiles !== undefined) coerced.keyFiles = keyFiles as string[];
535
+ const keyDecisions = wrapOptionalArray(params.keyDecisions);
536
+ if (keyDecisions !== undefined) coerced.keyDecisions = keyDecisions as string[];
537
+ const patternsEstablished = wrapOptionalArray(params.patternsEstablished);
538
+ if (patternsEstablished !== undefined) coerced.patternsEstablished = patternsEstablished as string[];
539
+ const observabilitySurfaces = wrapOptionalArray(params.observabilitySurfaces);
540
+ if (observabilitySurfaces !== undefined) coerced.observabilitySurfaces = observabilitySurfaces as string[];
541
+ const requirementsSurfaced = wrapOptionalArray(params.requirementsSurfaced);
542
+ if (requirementsSurfaced !== undefined) coerced.requirementsSurfaced = requirementsSurfaced as string[];
543
+ const drillDownPaths = wrapOptionalArray(params.drillDownPaths);
544
+ if (drillDownPaths !== undefined) coerced.drillDownPaths = drillDownPaths as string[];
545
+ const affects = wrapOptionalArray(params.affects);
546
+ if (affects !== undefined) coerced.affects = affects as string[];
547
+ const filesModified = wrapOptionalArray(params.filesModified);
548
+ if (filesModified !== undefined) coerced.filesModified = filesModified.map((f) => {
539
549
  if (typeof f !== "string") return f;
540
550
  const [path, description] = splitPair(f);
541
551
  return { path, description };
542
552
  }) as Array<{ path: string; description: string }>;
543
- coerced.requires = wrapArray(params.requires).map((r) => {
553
+ const requires = wrapOptionalArray(params.requires);
554
+ if (requires !== undefined) coerced.requires = requires.map((r) => {
544
555
  if (typeof r !== "string") return r;
545
556
  const [slice, provides] = splitPair(r);
546
557
  return { slice, provides };
547
558
  }) as Array<{ slice: string; provides: string }>;
548
- coerced.requirementsAdvanced = wrapArray(params.requirementsAdvanced).map((r) => {
559
+ const requirementsAdvanced = wrapOptionalArray(params.requirementsAdvanced);
560
+ if (requirementsAdvanced !== undefined) coerced.requirementsAdvanced = requirementsAdvanced.map((r) => {
549
561
  if (typeof r !== "string") return r;
550
562
  const [id, how] = splitPair(r);
551
563
  return { id, how };
552
564
  }) as Array<{ id: string; how: string }>;
553
- coerced.requirementsValidated = wrapArray(params.requirementsValidated).map((r) => {
565
+ const requirementsValidated = wrapOptionalArray(params.requirementsValidated);
566
+ if (requirementsValidated !== undefined) coerced.requirementsValidated = requirementsValidated.map((r) => {
554
567
  if (typeof r !== "string") return r;
555
568
  const [id, proof] = splitPair(r);
556
569
  return { id, proof };
557
570
  }) as Array<{ id: string; proof: string }>;
558
- coerced.requirementsInvalidated = wrapArray(params.requirementsInvalidated).map((r) => {
571
+ const requirementsInvalidated = wrapOptionalArray(params.requirementsInvalidated);
572
+ if (requirementsInvalidated !== undefined) coerced.requirementsInvalidated = requirementsInvalidated.map((r) => {
559
573
  if (typeof r !== "string") return r;
560
574
  const [id, what] = splitPair(r);
561
575
  return { id, what };
@@ -53,6 +53,7 @@ export interface WorktreeSafetyInput {
53
53
  projectRoot: string;
54
54
  unitRoot: string;
55
55
  milestoneId?: string | null;
56
+ isolationMode?: "none" | "branch" | "worktree";
56
57
  expectedBranch?: string | null;
57
58
  emptyWorktreeWithProjectContent?: boolean;
58
59
  lease?: {
@@ -156,12 +157,19 @@ export function createWorktreeSafetyModule(
156
157
 
157
158
  const projectRoot = resolve(input.projectRoot);
158
159
  const unitRoot = resolve(input.unitRoot);
159
- const expectedRoot = join(projectRoot, ".gsd", "worktrees", milestoneId);
160
+ const isolationMode = input.isolationMode ?? "worktree";
161
+ const expectedRoot = isolationMode === "worktree"
162
+ ? join(projectRoot, ".gsd", "worktrees", milestoneId)
163
+ : projectRoot;
160
164
  if (!samePath(unitRoot, expectedRoot)) {
161
165
  return failure(
162
166
  "invalid-root",
163
- `Unit root ${unitRoot} is not the expected worktree root for ${milestoneId}.`,
164
- "Prepare the Unit in its canonical milestone worktree before allowing source writes.",
167
+ isolationMode === "worktree"
168
+ ? `Unit root ${unitRoot} is not the expected worktree root for ${milestoneId}.`
169
+ : `Unit root ${unitRoot} is not the project root while isolation mode is ${isolationMode}.`,
170
+ isolationMode === "worktree"
171
+ ? "Prepare the Unit in its canonical milestone worktree before allowing source writes."
172
+ : "Run the Unit from the project root when worktree isolation is disabled.",
165
173
  { expectedRoot, unitRoot },
166
174
  );
167
175
  }
@@ -197,7 +205,7 @@ export function createWorktreeSafetyModule(
197
205
  );
198
206
  }
199
207
 
200
- if (!gitMarkerStat.isFile()) {
208
+ if (isolationMode === "worktree" && !gitMarkerStat.isFile()) {
201
209
  return failure(
202
210
  "worktree-git-marker-not-file",
203
211
  `Worktree root ${unitRoot} has a .git directory, not a registered worktree .git file.`,
@@ -118,11 +118,9 @@ export async function showNextAction(
118
118
  }
119
119
  });
120
120
 
121
- // Headless guard: when no UI is bound (noOpUIContext), ctx.ui.custom() resolves
122
- // to undefined immediately, and ctx.ui.select() does the same. Skip both and
123
- // return the safe default so callers don't await two no-op promises before
124
- // reaching a deterministic "not_yet". Lockup #5125 root protection.
125
- if (!ctx.hasUI) {
121
+ // Headless/non-interactive guard: avoid emitting interactive select requests
122
+ // in contexts where no human can answer (no UI, RPC/headless shims).
123
+ if (!isInteractiveUIContext(ctx)) {
126
124
  return "not_yet";
127
125
  }
128
126
 
@@ -218,3 +216,11 @@ export async function showNextAction(
218
216
 
219
217
  return result;
220
218
  }
219
+
220
+ function isInteractiveUIContext(ctx: ExtensionCommandContext): boolean {
221
+ if (!ctx.hasUI) return false;
222
+ if (process.env.GSD_HEADLESS === "1") return false;
223
+ const uiMode = (ctx.ui as { mode?: string } | undefined)?.mode;
224
+ if (uiMode === "rpc" || uiMode === "headless") return false;
225
+ return true;
226
+ }
@@ -81,6 +81,38 @@ describe("showNextAction ctx.hasUI guard (#5125 lockup root protection)", () =>
81
81
  assert.equal(result, "alpha", "fallback should map the picked label back to the chosen action id");
82
82
  });
83
83
 
84
+ it("returns 'not_yet' immediately when UI mode is rpc even if ctx.hasUI is true", async () => {
85
+ let customCalled = 0;
86
+ let selectCalled = 0;
87
+
88
+ const ctx = {
89
+ hasUI: true,
90
+ ui: {
91
+ mode: "rpc",
92
+ custom: async () => {
93
+ customCalled++;
94
+ return undefined as never;
95
+ },
96
+ select: async () => {
97
+ selectCalled++;
98
+ return undefined;
99
+ },
100
+ },
101
+ };
102
+
103
+ const result = await showNextAction(ctx as any, {
104
+ title: "GSD — test",
105
+ actions: [
106
+ { id: "alpha", label: "Alpha", description: "first", recommended: true },
107
+ { id: "beta", label: "Beta", description: "second" },
108
+ ],
109
+ });
110
+
111
+ assert.equal(result, "not_yet", "rpc-backed UI is non-interactive for next-action");
112
+ assert.equal(customCalled, 0, "ctx.ui.custom must not be called in rpc mode");
113
+ assert.equal(selectCalled, 0, "ctx.ui.select must not be called in rpc mode");
114
+ });
115
+
84
116
  it("returns the resolved id when ctx.ui.custom completes normally", async () => {
85
117
  let selectCalled = 0;
86
118