gsd-pi 2.80.0-dev.488219eb6 → 2.80.0-dev.4edec4620

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 (71) hide show
  1. package/dist/resources/.managed-resources-content-hash +1 -1
  2. package/dist/resources/extensions/claude-code-cli/stream-adapter.js +11 -3
  3. package/dist/resources/extensions/gsd/auto-dashboard.js +18 -0
  4. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +21 -10
  5. package/dist/resources/extensions/gsd/bootstrap/write-gate.js +19 -2
  6. package/dist/resources/extensions/gsd/guided-flow.js +6 -0
  7. package/dist/resources/extensions/gsd/health-widget.js +3 -0
  8. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  9. package/dist/web/standalone/.next/BUILD_ID +1 -1
  10. package/dist/web/standalone/.next/app-path-routes-manifest.json +10 -10
  11. package/dist/web/standalone/.next/build-manifest.json +2 -2
  12. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  13. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  14. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  15. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  16. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  17. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  18. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  19. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  20. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  21. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  22. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/index.html +1 -1
  30. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app-paths-manifest.json +10 -10
  37. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  38. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  39. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  40. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  41. package/package.json +1 -1
  42. package/packages/pi-agent-core/tsconfig.tsbuildinfo +1 -1
  43. package/packages/pi-ai/dist/types.d.ts +2 -0
  44. package/packages/pi-ai/dist/types.d.ts.map +1 -1
  45. package/packages/pi-ai/dist/types.js.map +1 -1
  46. package/packages/pi-ai/src/types.ts +3 -0
  47. package/packages/pi-ai/tsconfig.tsbuildinfo +1 -1
  48. package/packages/pi-coding-agent/dist/core/agent-session-tool-refresh.test.js +12 -1
  49. package/packages/pi-coding-agent/dist/core/agent-session-tool-refresh.test.js.map +1 -1
  50. package/packages/pi-coding-agent/dist/core/agent-session.d.ts +5 -0
  51. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  52. package/packages/pi-coding-agent/dist/core/agent-session.js +8 -0
  53. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  54. package/packages/pi-coding-agent/dist/core/sdk.d.ts.map +1 -1
  55. package/packages/pi-coding-agent/dist/core/sdk.js +7 -2
  56. package/packages/pi-coding-agent/dist/core/sdk.js.map +1 -1
  57. package/packages/pi-coding-agent/src/core/agent-session-tool-refresh.test.ts +15 -1
  58. package/packages/pi-coding-agent/src/core/agent-session.ts +11 -0
  59. package/packages/pi-coding-agent/src/core/sdk.ts +7 -1
  60. package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -1
  61. package/src/resources/extensions/claude-code-cli/stream-adapter.ts +12 -3
  62. package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +41 -0
  63. package/src/resources/extensions/gsd/auto-dashboard.ts +20 -0
  64. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +20 -10
  65. package/src/resources/extensions/gsd/bootstrap/write-gate.ts +19 -2
  66. package/src/resources/extensions/gsd/guided-flow.ts +6 -0
  67. package/src/resources/extensions/gsd/health-widget.ts +3 -0
  68. package/src/resources/extensions/gsd/tests/tui-header-lifecycle.test.ts +210 -0
  69. package/src/resources/extensions/gsd/tests/write-gate.test.ts +40 -1
  70. /package/dist/web/standalone/.next/static/{G68KoLv5claox_Dyd4pZn → eeMbUJZnoHp7l3SZHeXY9}/_buildManifest.js +0 -0
  71. /package/dist/web/standalone/.next/static/{G68KoLv5claox_Dyd4pZn → eeMbUJZnoHp7l3SZHeXY9}/_ssgManifest.js +0 -0
@@ -1,3 +1,4 @@
1
+ // GSD2 - Claude Code CLI provider stream adapter
1
2
  /**
2
3
  * Stream adapter: bridges the Claude Agent SDK into GSD's streamSimple contract.
3
4
  *
@@ -61,6 +62,11 @@ interface ClaudeCodeStreamOptions extends SimpleStreamOptions {
61
62
  extensionUIContext?: ExtensionUIContext;
62
63
  }
63
64
 
65
+ /** Resolve the workspace root for local Claude Code process execution. */
66
+ export function resolveClaudeCodeCwd(options?: SimpleStreamOptions): string {
67
+ return options?.cwd && options.cwd.trim().length > 0 ? options.cwd : process.cwd();
68
+ }
69
+
64
70
  /** A single selectable option within an SDK elicitation schema field. */
65
71
  interface SdkElicitationRequestOption {
66
72
  const?: string;
@@ -1307,8 +1313,9 @@ export function buildSdkOptions(
1307
1313
  overrides?: { permissionMode?: "bypassPermissions" | "acceptEdits" | "default" | "plan" },
1308
1314
  extraOptions: Record<string, unknown> & { reasoning?: ThinkingLevel } = {},
1309
1315
  ): Record<string, unknown> {
1310
- const { reasoning, ...sdkExtraOptions } = extraOptions;
1311
- const mcpServers = buildWorkflowMcpServers();
1316
+ const { reasoning, cwd, ...sdkExtraOptions } = extraOptions;
1317
+ const sdkCwd = typeof cwd === "string" && cwd.trim().length > 0 ? cwd : process.cwd();
1318
+ const mcpServers = buildWorkflowMcpServers(sdkCwd);
1312
1319
  const permissionMode = overrides?.permissionMode ?? "bypassPermissions";
1313
1320
  // Globally unblock the tools GSD expects Claude Code to run. When the
1314
1321
  // workflow MCP server is available, prefer its `ask_user_questions` tool over
@@ -1349,7 +1356,7 @@ export function buildSdkOptions(
1349
1356
  model: modelId,
1350
1357
  includePartialMessages: true,
1351
1358
  persistSession: true,
1352
- cwd: process.cwd(),
1359
+ cwd: sdkCwd,
1353
1360
  permissionMode,
1354
1361
  allowDangerouslySkipPermissions: permissionMode === "bypassPermissions",
1355
1362
  settingSources: ["project"],
@@ -1661,6 +1668,7 @@ async function pumpSdkMessages(
1661
1668
  const queryPrompt = buildSdkQueryPrompt(context, prompt);
1662
1669
  const permissionMode = await resolveClaudePermissionMode();
1663
1670
  const uiContext = (options as ClaudeCodeStreamOptions | undefined)?.extensionUIContext;
1671
+ const cwd = resolveClaudeCodeCwd(options);
1664
1672
  const canUseToolHandler = createClaudeCodeCanUseToolHandler(uiContext);
1665
1673
  // When no UI is available (headless / auto-mode), auto-approve all
1666
1674
  // tool requests. This replaces the old bypassPermissions workaround.
@@ -1672,6 +1680,7 @@ async function pumpSdkMessages(
1672
1680
  prompt,
1673
1681
  { permissionMode },
1674
1682
  {
1683
+ cwd,
1675
1684
  reasoning: options?.reasoning,
1676
1685
  canUseTool: canUseToolFallback,
1677
1686
  ...(uiContext
@@ -1,3 +1,4 @@
1
+ // GSD2 - Claude Code stream adapter regression tests
1
2
  import { describe, test } from "node:test";
2
3
  import assert from "node:assert/strict";
3
4
  import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
@@ -15,6 +16,7 @@ import {
15
16
  buildPromptFromContext,
16
17
  buildSdkQueryPrompt,
17
18
  buildSdkOptions,
19
+ resolveClaudeCodeCwd,
18
20
  createClaudeCodeCanUseToolHandler,
19
21
  buildBashPermissionPattern,
20
22
  buildBashPermissionPatternOptions,
@@ -671,6 +673,45 @@ describe("stream-adapter — session persistence (#2859)", () => {
671
673
  assert.equal(options.model, "claude-sonnet-4-20250514");
672
674
  });
673
675
 
676
+ test("buildSdkOptions prefers explicit cwd over process cwd for local SDK execution", () => {
677
+ const explicitCwd = "/tmp/gsd-session-root";
678
+ const options = buildSdkOptions("claude-sonnet-4-20250514", "hello world", undefined, { cwd: explicitCwd });
679
+ assert.equal(options.cwd, explicitCwd);
680
+ });
681
+
682
+ test("buildSdkOptions uses explicit cwd when auto-detecting workflow MCP launch config", () => {
683
+ const explicitCwd = realpathSync(mkdtempSync(join(tmpdir(), "claude-sdk-cwd-")));
684
+ const restore = setWorkflowMcpEnv({});
685
+ try {
686
+ delete process.env.GSD_WORKFLOW_MCP_COMMAND;
687
+ delete process.env.GSD_WORKFLOW_MCP_NAME;
688
+ delete process.env.GSD_WORKFLOW_MCP_ARGS;
689
+ delete process.env.GSD_WORKFLOW_MCP_ENV;
690
+ delete process.env.GSD_WORKFLOW_MCP_CWD;
691
+
692
+ const distDir = join(explicitCwd, "packages", "mcp-server", "dist");
693
+ mkdirSync(distDir, { recursive: true });
694
+ writeFileSync(join(distDir, "cli.js"), "#!/usr/bin/env node\n");
695
+
696
+ const options = buildSdkOptions("claude-sonnet-4-20250514", "hello world", undefined, { cwd: explicitCwd });
697
+ const mcpServers = options.mcpServers as Record<string, any>;
698
+ assert.equal(mcpServers["gsd-workflow"].cwd, explicitCwd);
699
+ assert.equal(mcpServers["gsd-workflow"].env.GSD_WORKFLOW_PROJECT_ROOT, explicitCwd);
700
+ } finally {
701
+ restore();
702
+ rmSync(explicitCwd, { recursive: true, force: true });
703
+ }
704
+ });
705
+
706
+ test("resolveClaudeCodeCwd falls back to process cwd when no stream cwd is provided", () => {
707
+ assert.equal(resolveClaudeCodeCwd(), process.cwd());
708
+ assert.equal(resolveClaudeCodeCwd({ cwd: " " }), process.cwd());
709
+ });
710
+
711
+ test("resolveClaudeCodeCwd returns stream cwd when provided", () => {
712
+ assert.equal(resolveClaudeCodeCwd({ cwd: "/tmp/current-session" }), "/tmp/current-session");
713
+ });
714
+
674
715
  test("buildSdkOptions enables betas for sonnet models", () => {
675
716
  const sonnetOpts = buildSdkOptions("claude-sonnet-4-20250514", "test");
676
717
  assert.ok(
@@ -602,6 +602,21 @@ export function updateProgressWidget(
602
602
  ): void {
603
603
  if (!ctx.hasUI) return;
604
604
 
605
+ // Welcome header is a startup-only banner — permanently suppress it once
606
+ // auto-mode activates. The dashboard widget owns all status from here.
607
+ // Note: setHeader(undefined) restores the built-in header (logo +
608
+ // instructions). To actually render zero lines, install an empty header.
609
+ if (typeof ctx.ui?.setHeader === "function") {
610
+ ctx.ui.setHeader(() => ({
611
+ render(): string[] { return []; },
612
+ invalidate(): void {},
613
+ }));
614
+ }
615
+ // Clear wizard step badge — auto-mode owns the UI from this point
616
+ if (typeof ctx.ui?.setStatus === "function") {
617
+ ctx.ui.setStatus("gsd-step", undefined);
618
+ }
619
+
605
620
  const verb = unitVerb(unitType);
606
621
  const phaseLabel = unitPhaseLabel(unitType);
607
622
  const mid = state.activeMilestone;
@@ -1028,6 +1043,11 @@ export function updateProgressWidget(
1028
1043
  ? lastCommit.message.slice(0, maxCommitLen - 1) + "…"
1029
1044
  : lastCommit.message
1030
1045
  : "";
1046
+ // Step-mode guidance — shown above keyboard hints when auto is paused
1047
+ if (accessors.isStepMode()) {
1048
+ lines.push(`${pad}${theme.fg("accent", "→")} ${theme.fg("dim", "Ctrl+N to advance to next step · /gsd status for overview")}`);
1049
+ }
1050
+
1031
1051
  // Hints line
1032
1052
  const hintParts: string[] = [];
1033
1053
  hintParts.push("esc pause");
@@ -84,16 +84,26 @@ async function installWelcomeHeader(ctx: ExtensionContext): Promise<void> {
84
84
  if (rc) remoteChannel = rc.channel;
85
85
  } catch { /* non-fatal */ }
86
86
 
87
- ctx.ui.setHeader(() => ({
88
- render(width: number): string[] {
89
- return welcome.buildWelcomeScreenLines({
90
- version: process.env.GSD_VERSION || "0.0.0",
91
- remoteChannel,
92
- width,
93
- });
94
- },
95
- invalidate(): void {},
96
- }));
87
+ ctx.ui.setHeader(() => {
88
+ let cachedLines: string[] | undefined;
89
+ let cachedWidth: number | undefined;
90
+ return {
91
+ render(width: number): string[] {
92
+ if (cachedLines !== undefined && cachedWidth === width) return cachedLines;
93
+ cachedLines = welcome.buildWelcomeScreenLines({
94
+ version: process.env.GSD_VERSION || "0.0.0",
95
+ remoteChannel,
96
+ width,
97
+ });
98
+ cachedWidth = width;
99
+ return cachedLines;
100
+ },
101
+ invalidate(): void {
102
+ cachedLines = undefined;
103
+ cachedWidth = undefined;
104
+ },
105
+ };
106
+ });
97
107
  } catch {
98
108
  /* non-fatal */
99
109
  }
@@ -1,4 +1,5 @@
1
- import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
1
+ // GSD2 - Write gate runtime persistence and policy guards.
2
+ import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
3
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
3
4
 
4
5
  import { minimatch } from "minimatch";
@@ -147,6 +148,22 @@ function writeGateSnapshotPath(basePath: string): string {
147
148
  return join(basePath, ".gsd", "runtime", "write-gate-state.json");
148
149
  }
149
150
 
151
+ function ensureWriteGateSnapshotDirectory(basePath: string): void {
152
+ const gsdPath = join(basePath, ".gsd");
153
+ if (!existsSync(gsdPath)) {
154
+ try {
155
+ const stat = lstatSync(gsdPath);
156
+ if (stat.isSymbolicLink()) {
157
+ const target = readlinkSync(gsdPath);
158
+ mkdirSync(isAbsolute(target) ? target : resolve(basePath, target), { recursive: true });
159
+ }
160
+ } catch {
161
+ // If .gsd truly does not exist, the runtime mkdir below will create it.
162
+ }
163
+ }
164
+ mkdirSync(join(gsdPath, "runtime"), { recursive: true });
165
+ }
166
+
150
167
  function currentWriteGateSnapshot(basePath: string = process.cwd()): WriteGateSnapshot {
151
168
  const state = getWriteGateState(basePath);
152
169
  return {
@@ -160,7 +177,7 @@ function currentWriteGateSnapshot(basePath: string = process.cwd()): WriteGateSn
160
177
  function persistWriteGateSnapshot(basePath: string): void {
161
178
  if (!shouldPersistWriteGateSnapshot()) return;
162
179
  const path = writeGateSnapshotPath(basePath);
163
- mkdirSync(join(basePath, ".gsd", "runtime"), { recursive: true });
180
+ ensureWriteGateSnapshotDirectory(basePath);
164
181
  const tempPath = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
165
182
  writeFileSync(tempPath, JSON.stringify(currentWriteGateSnapshot(basePath), null, 2), "utf-8");
166
183
  try {
@@ -2218,6 +2218,7 @@ export async function showSmartEntry(
2218
2218
 
2219
2219
  if (isFirst) {
2220
2220
  // First ever — skip wizard, just ask directly
2221
+ ctx.ui.setStatus("gsd-step", "New Milestone · answer the questions above to plan");
2221
2222
  setPendingAutoStart(basePath, { ctx, pi, basePath, milestoneId: nextId, step: stepMode });
2222
2223
  await dispatchWorkflow(pi, await prepareAndBuildDiscussPrompt(ctx, pi, nextId,
2223
2224
  `New project, milestone ${nextId}. Do NOT read or explore .gsd/ — it's empty scaffolding.`,
@@ -2246,6 +2247,7 @@ export async function showSmartEntry(
2246
2247
  if (choice === "quick_task") {
2247
2248
  await runQuickTaskChoice(ctx, pi);
2248
2249
  } else if (choice === "new_milestone") {
2250
+ ctx.ui.setStatus("gsd-step", "New Milestone · answer the questions above to plan");
2249
2251
  setPendingAutoStart(basePath, { ctx, pi, basePath, milestoneId: nextId, step: stepMode });
2250
2252
  await dispatchWorkflow(pi, await prepareAndBuildDiscussPrompt(ctx, pi, nextId,
2251
2253
  `New milestone ${nextId}.`,
@@ -2447,6 +2449,7 @@ export async function showSmartEntry(
2447
2449
  if (choice === "quick_task") {
2448
2450
  await runQuickTaskChoice(ctx, pi);
2449
2451
  } else if (choice === "plan") {
2452
+ ctx.ui.setStatus("gsd-step", "Planning Milestone · decomposing into slices");
2450
2453
  setPendingAutoStart(basePath, { ctx, pi, basePath, milestoneId, step: stepMode });
2451
2454
  await dispatchWorkflow(
2452
2455
  pi,
@@ -2579,6 +2582,7 @@ export async function showSmartEntry(
2579
2582
  });
2580
2583
 
2581
2584
  if (choice === "plan") {
2585
+ ctx.ui.setStatus("gsd-step", "Slice Planning · answer the questions above");
2582
2586
  await dispatchWorkflow(
2583
2587
  pi,
2584
2588
  await buildPlanSlicePrompt(milestoneId, milestoneTitle, sliceId, sliceTitle, basePath),
@@ -2641,6 +2645,7 @@ export async function showSmartEntry(
2641
2645
  });
2642
2646
 
2643
2647
  if (choice === "complete") {
2648
+ ctx.ui.setStatus("gsd-step", "Completing Slice · review changes above");
2644
2649
  await dispatchWorkflow(
2645
2650
  pi,
2646
2651
  await buildCompleteSlicePrompt(milestoneId, milestoneTitle, sliceId, sliceTitle, basePath),
@@ -2709,6 +2714,7 @@ export async function showSmartEntry(
2709
2714
  }
2710
2715
 
2711
2716
  if (choice === "execute") {
2717
+ ctx.ui.setStatus("gsd-step", "Executing Task · follow progress above");
2712
2718
  if (hasInterrupted) {
2713
2719
  await dispatchWorkflow(pi, loadPrompt("guided-resume-task", {
2714
2720
  milestoneId,
@@ -135,6 +135,9 @@ export function initHealthWidget(ctx: ExtensionContext): void {
135
135
  render(width: number): string[] {
136
136
  if (!cachedLines || cachedWidth !== width) {
137
137
  cachedLines = buildHealthLines(data, width);
138
+ if (data.projectState === "active") {
139
+ cachedLines = [...cachedLines, _theme.fg("dim", " /gsd auto to run · /gsd status for overview · /gsd help")];
140
+ }
138
141
  cachedWidth = width;
139
142
  }
140
143
  return cachedLines;
@@ -0,0 +1,210 @@
1
+ // Project/App: GSD-2
2
+ // File Purpose: Regression tests for the TUI header lifecycle fixes —
3
+ // header is suppressed (zero lines) when auto-mode activates, the wizard
4
+ // step status badge is cleared, the NEXT-mode footer hint renders when
5
+ // step mode is active, and the health widget appends guidance for active
6
+ // projects.
7
+
8
+ import test from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { mkdirSync, rmSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { tmpdir } from "node:os";
13
+
14
+ import { updateProgressWidget } from "../auto-dashboard.ts";
15
+ import type { GSDState } from "../types.ts";
16
+
17
+ interface CapturedSetHeader {
18
+ factory: ((tui: unknown, theme: unknown) => { render(width: number): string[]; invalidate(): void }) | undefined;
19
+ }
20
+
21
+ function makeTempDir(prefix: string): string {
22
+ return join(
23
+ tmpdir(),
24
+ `gsd-tui-lifecycle-test-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
25
+ );
26
+ }
27
+
28
+ function cleanup(dir: string): void {
29
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ }
30
+ }
31
+
32
+ const baseState: GSDState = {
33
+ phase: "executing",
34
+ activeMilestone: { id: "M001", title: "Milestone" },
35
+ activeSlice: { id: "S01", title: "Slice" },
36
+ activeTask: { id: "T01", title: "Task" },
37
+ } as unknown as GSDState;
38
+
39
+ const baseAccessors = {
40
+ getAutoStartTime: () => 0,
41
+ isStepMode: () => false,
42
+ getCmdCtx: () => null,
43
+ getBasePath: () => "/tmp",
44
+ isVerbose: () => false,
45
+ isSessionSwitching: () => false,
46
+ getCurrentDispatchedModelId: () => null,
47
+ };
48
+
49
+ // ── Header lifecycle ────────────────────────────────────────────────────
50
+
51
+ test("updateProgressWidget installs an EMPTY-rendering header (not undefined) — addresses codex P1 finding that setHeader(undefined) restores the built-in logo+instructions header", (t) => {
52
+ const dir = makeTempDir("empty-header");
53
+ mkdirSync(join(dir, ".gsd"), { recursive: true });
54
+ t.after(() => cleanup(dir));
55
+
56
+ const captured: CapturedSetHeader = { factory: undefined };
57
+ let setHeaderCallCount = 0;
58
+
59
+ updateProgressWidget(
60
+ {
61
+ hasUI: true,
62
+ ui: {
63
+ setWidget() {},
64
+ setHeader(factory: any) {
65
+ setHeaderCallCount++;
66
+ captured.factory = factory;
67
+ },
68
+ setStatus() {},
69
+ },
70
+ } as any,
71
+ "execute-task",
72
+ "M001/S01/T01",
73
+ baseState,
74
+ { ...baseAccessors, getBasePath: () => dir },
75
+ );
76
+
77
+ assert.equal(setHeaderCallCount, 1, "setHeader must be called exactly once when widget installs");
78
+ assert.notEqual(captured.factory, undefined, "factory must NOT be undefined — undefined restores the built-in logo+instructions header (codex P1)");
79
+ assert.equal(typeof captured.factory, "function", "factory must be a component-creating function");
80
+
81
+ const component = captured.factory!(null, null);
82
+ const rendered = component.render(80);
83
+ assert.deepEqual(rendered, [], "empty header component must render zero lines so auto-mode actually suppresses the welcome banner");
84
+ });
85
+
86
+ test("updateProgressWidget clears the gsd-step wizard badge when auto-mode activates", (t) => {
87
+ const dir = makeTempDir("step-badge");
88
+ mkdirSync(join(dir, ".gsd"), { recursive: true });
89
+ t.after(() => cleanup(dir));
90
+
91
+ const statusCalls: Array<[string, string | undefined]> = [];
92
+
93
+ updateProgressWidget(
94
+ {
95
+ hasUI: true,
96
+ ui: {
97
+ setWidget() {},
98
+ setHeader() {},
99
+ setStatus(key: string, value: string | undefined) { statusCalls.push([key, value]); },
100
+ },
101
+ } as any,
102
+ "execute-task",
103
+ "M001/S01/T01",
104
+ baseState,
105
+ { ...baseAccessors, getBasePath: () => dir },
106
+ );
107
+
108
+ assert.ok(
109
+ statusCalls.some(([key, value]) => key === "gsd-step" && value === undefined),
110
+ `expected setStatus("gsd-step", undefined) to be called; got ${JSON.stringify(statusCalls)}`,
111
+ );
112
+ });
113
+
114
+ test("updateProgressWidget gracefully no-ops when ctx.ui lacks setHeader/setStatus (RPC mode)", (t) => {
115
+ const dir = makeTempDir("rpc-mode");
116
+ mkdirSync(join(dir, ".gsd"), { recursive: true });
117
+ t.after(() => cleanup(dir));
118
+
119
+ // ctx.ui without setHeader / setStatus — must not throw.
120
+ assert.doesNotThrow(() => {
121
+ updateProgressWidget(
122
+ {
123
+ hasUI: true,
124
+ ui: { setWidget() {} },
125
+ } as any,
126
+ "execute-task",
127
+ "M001/S01/T01",
128
+ baseState,
129
+ { ...baseAccessors, getBasePath: () => dir },
130
+ );
131
+ });
132
+ });
133
+
134
+ // ── NEXT-mode footer guidance ───────────────────────────────────────────
135
+
136
+ test("auto-dashboard widget render output includes Ctrl+N guidance when isStepMode is true", (t) => {
137
+ const dir = makeTempDir("step-hint");
138
+ mkdirSync(join(dir, ".gsd"), { recursive: true });
139
+ t.after(() => cleanup(dir));
140
+
141
+ let widgetFactory: ((tui: unknown, theme: unknown) => any) | undefined;
142
+
143
+ updateProgressWidget(
144
+ {
145
+ hasUI: true,
146
+ ui: {
147
+ setWidget(_key: string, factory: any) { widgetFactory = factory; },
148
+ setHeader() {},
149
+ setStatus() {},
150
+ },
151
+ } as any,
152
+ "execute-task",
153
+ "M001/S01/T01",
154
+ baseState,
155
+ { ...baseAccessors, getBasePath: () => dir, isStepMode: () => true },
156
+ );
157
+
158
+ assert.ok(widgetFactory, "widget factory must be installed");
159
+
160
+ const fakeTui = { requestRender() {} };
161
+ const fakeTheme = {
162
+ fg: (_color: string, text: string) => text,
163
+ bold: (text: string) => text,
164
+ };
165
+ const component = widgetFactory!(fakeTui, fakeTheme);
166
+ const lines = component.render(120);
167
+
168
+ const hasStepHint = lines.some((line: string) => line.includes("Ctrl+N to advance"));
169
+ assert.ok(hasStepHint, `expected step-mode hint in render output; got:\n${lines.join("\n")}`);
170
+
171
+ if (component.dispose) component.dispose();
172
+ });
173
+
174
+ test("auto-dashboard widget render output omits Ctrl+N guidance when isStepMode is false", (t) => {
175
+ const dir = makeTempDir("no-step-hint");
176
+ mkdirSync(join(dir, ".gsd"), { recursive: true });
177
+ t.after(() => cleanup(dir));
178
+
179
+ let widgetFactory: ((tui: unknown, theme: unknown) => any) | undefined;
180
+
181
+ updateProgressWidget(
182
+ {
183
+ hasUI: true,
184
+ ui: {
185
+ setWidget(_key: string, factory: any) { widgetFactory = factory; },
186
+ setHeader() {},
187
+ setStatus() {},
188
+ },
189
+ } as any,
190
+ "execute-task",
191
+ "M001/S01/T01",
192
+ baseState,
193
+ { ...baseAccessors, getBasePath: () => dir, isStepMode: () => false },
194
+ );
195
+
196
+ assert.ok(widgetFactory);
197
+
198
+ const fakeTui = { requestRender() {} };
199
+ const fakeTheme = {
200
+ fg: (_color: string, text: string) => text,
201
+ bold: (text: string) => text,
202
+ };
203
+ const component = widgetFactory!(fakeTui, fakeTheme);
204
+ const lines = component.render(120);
205
+
206
+ const hasStepHint = lines.some((line: string) => line.includes("Ctrl+N to advance"));
207
+ assert.equal(hasStepHint, false, "step-mode hint must NOT appear when isStepMode is false");
208
+
209
+ if (component.dispose) component.dispose();
210
+ });
@@ -1,3 +1,4 @@
1
+ // GSD2 - Write gate regression tests.
1
2
  /**
2
3
  * Unit tests for the CONTEXT.md write-gate (D031 guard chain).
3
4
  *
@@ -11,7 +12,7 @@
11
12
 
12
13
  import test, { afterEach } from 'node:test';
13
14
  import assert from 'node:assert/strict';
14
- import { mkdirSync, writeFileSync, unlinkSync, existsSync, rmSync } from 'node:fs';
15
+ import { mkdirSync, writeFileSync, unlinkSync, existsSync, rmSync, symlinkSync } from 'node:fs';
15
16
  import { join } from 'node:path';
16
17
  import { tmpdir } from 'node:os';
17
18
  import { randomUUID } from 'node:crypto';
@@ -685,3 +686,41 @@ test('write-gate: loadWriteGateSnapshot returns empty default when persist file
685
686
  } catch { /* swallow */ }
686
687
  }
687
688
  });
689
+
690
+ // ─── Scenario 30: write-gate persistence recreates dangling external .gsd target ──
691
+
692
+ test('write-gate: resetWriteGateState persists through dangling .gsd symlink', () => {
693
+ const base = join(tmpdir(), `gsd-write-gate-dangling-${randomUUID()}`);
694
+ const externalState = join(tmpdir(), `gsd-write-gate-external-${randomUUID()}`);
695
+ const stateFilePath = join(base, '.gsd', 'runtime', 'write-gate-state.json');
696
+ const originalEnv = process.env.GSD_PERSIST_WRITE_GATE_STATE;
697
+
698
+ try {
699
+ process.env.GSD_PERSIST_WRITE_GATE_STATE = '1';
700
+ mkdirSync(base, { recursive: true });
701
+ symlinkSync(externalState, join(base, '.gsd'), 'junction');
702
+ assert.strictEqual(existsSync(join(base, '.gsd')), false, 'precondition: .gsd symlink target is missing');
703
+
704
+ resetWriteGateState(base);
705
+
706
+ assert.ok(existsSync(externalState), 'missing external state target was recreated');
707
+ assert.ok(existsSync(stateFilePath), 'write-gate snapshot persisted under .gsd/runtime');
708
+ assert.deepEqual(loadWriteGateSnapshot(base), {
709
+ verifiedDepthMilestones: [],
710
+ verifiedApprovalGates: [],
711
+ activeQueuePhase: false,
712
+ pendingGateId: null,
713
+ });
714
+ } finally {
715
+ if (originalEnv === undefined) {
716
+ delete process.env.GSD_PERSIST_WRITE_GATE_STATE;
717
+ } else {
718
+ process.env.GSD_PERSIST_WRITE_GATE_STATE = originalEnv;
719
+ }
720
+ clearDiscussionFlowState(base);
721
+ try {
722
+ rmSync(base, { recursive: true, force: true });
723
+ rmSync(externalState, { recursive: true, force: true });
724
+ } catch { /* swallow */ }
725
+ }
726
+ });