gsd-pi 2.64.0-dev.6fe1e44 → 2.64.0-dev.b3ee078

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/headless.js +3 -1
  2. package/dist/resources/extensions/bg-shell/bg-shell-lifecycle.js +22 -7
  3. package/dist/resources/extensions/bg-shell/process-manager.js +6 -1
  4. package/dist/resources/extensions/gsd/bootstrap/db-tools.js +24 -13
  5. package/dist/resources/extensions/gsd/bootstrap/register-shortcuts.js +1 -0
  6. package/dist/resources/extensions/gsd/bootstrap/system-context.js +20 -0
  7. package/dist/resources/extensions/gsd/commands/handlers/notifications-handler.js +1 -0
  8. package/dist/resources/extensions/gsd/notification-overlay.js +2 -3
  9. package/dist/resources/extensions/gsd/notification-store.js +10 -5
  10. package/dist/web/standalone/.next/BUILD_ID +1 -1
  11. package/dist/web/standalone/.next/app-path-routes-manifest.json +16 -16
  12. package/dist/web/standalone/.next/build-manifest.json +2 -2
  13. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  14. package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
  15. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  16. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  17. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  18. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  19. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  20. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  21. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  22. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  23. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/index.html +1 -1
  31. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app-paths-manifest.json +16 -16
  38. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  39. package/dist/web/standalone/.next/server/pages/500.html +2 -2
  40. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  41. package/package.json +1 -1
  42. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts +1 -0
  43. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  44. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js +8 -0
  45. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js.map +1 -1
  46. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts +6 -0
  47. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  48. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +36 -0
  49. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  50. package/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts +9 -0
  51. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +33 -0
  52. package/packages/pi-tui/dist/__tests__/overlay-layout.test.d.ts +2 -0
  53. package/packages/pi-tui/dist/__tests__/overlay-layout.test.d.ts.map +1 -0
  54. package/packages/pi-tui/dist/__tests__/overlay-layout.test.js +66 -0
  55. package/packages/pi-tui/dist/__tests__/overlay-layout.test.js.map +1 -0
  56. package/packages/pi-tui/dist/components/loader.d.ts +4 -2
  57. package/packages/pi-tui/dist/components/loader.d.ts.map +1 -1
  58. package/packages/pi-tui/dist/components/loader.js +27 -9
  59. package/packages/pi-tui/dist/components/loader.js.map +1 -1
  60. package/packages/pi-tui/dist/components/text.d.ts.map +1 -1
  61. package/packages/pi-tui/dist/components/text.js +2 -0
  62. package/packages/pi-tui/dist/components/text.js.map +1 -1
  63. package/packages/pi-tui/dist/overlay-layout.d.ts.map +1 -1
  64. package/packages/pi-tui/dist/overlay-layout.js +12 -1
  65. package/packages/pi-tui/dist/overlay-layout.js.map +1 -1
  66. package/packages/pi-tui/dist/tui.d.ts +4 -0
  67. package/packages/pi-tui/dist/tui.d.ts.map +1 -1
  68. package/packages/pi-tui/dist/tui.js +35 -0
  69. package/packages/pi-tui/dist/tui.js.map +1 -1
  70. package/packages/pi-tui/src/__tests__/overlay-layout.test.ts +82 -0
  71. package/packages/pi-tui/src/components/loader.ts +27 -10
  72. package/packages/pi-tui/src/components/text.ts +1 -0
  73. package/packages/pi-tui/src/overlay-layout.ts +14 -1
  74. package/packages/pi-tui/src/tui.ts +34 -0
  75. package/src/resources/extensions/bg-shell/bg-shell-lifecycle.ts +19 -7
  76. package/src/resources/extensions/bg-shell/process-manager.ts +8 -2
  77. package/src/resources/extensions/gsd/bootstrap/db-tools.ts +25 -13
  78. package/src/resources/extensions/gsd/bootstrap/register-shortcuts.ts +1 -0
  79. package/src/resources/extensions/gsd/bootstrap/system-context.ts +28 -0
  80. package/src/resources/extensions/gsd/commands/handlers/notifications-handler.ts +1 -0
  81. package/src/resources/extensions/gsd/notification-overlay.ts +2 -3
  82. package/src/resources/extensions/gsd/notification-store.ts +8 -3
  83. package/src/resources/extensions/gsd/tests/complete-slice-string-coercion.test.ts +36 -0
  84. package/src/resources/extensions/gsd/tests/notification-store.test.ts +34 -1
  85. package/src/resources/extensions/gsd/tests/unstructured-continue-context-injection.test.ts +163 -0
  86. /package/dist/web/standalone/.next/static/{KPMt-rZBouivKwIKcIral → l7tiSF0KtXOwxxYn0ZAyF}/_buildManifest.js +0 -0
  87. /package/dist/web/standalone/.next/static/{KPMt-rZBouivKwIKcIral → l7tiSF0KtXOwxxYn0ZAyF}/_ssgManifest.js +0 -0
@@ -16,6 +16,7 @@ import {
16
16
  import {
17
17
  processes,
18
18
  pendingAlerts,
19
+ pushAlert,
19
20
  cleanupAll,
20
21
  cleanupSessionProcesses,
21
22
  persistManifest,
@@ -37,19 +38,30 @@ export function registerBgShellLifecycle(pi: ExtensionAPI, state: BgShellSharedS
37
38
  }
38
39
  }
39
40
 
40
- // Clean up on session shutdown
41
- pi.on("session_shutdown", async () => {
42
- cleanupAll();
43
- });
44
-
45
41
  // Register signal handlers to clean up bg processes on unexpected exit (fixes #428)
46
42
  const signalCleanup = () => {
47
43
  cleanupAll();
44
+ // Also kill bash-tool spawned children that bg-shell doesn't track
45
+ try {
46
+ const { listDescendants } = require("@gsd/native") as typeof import("@gsd/native");
47
+ const descendants = listDescendants(process.pid);
48
+ for (const childPid of descendants) {
49
+ try { process.kill(childPid, "SIGKILL"); } catch {}
50
+ }
51
+ } catch {}
48
52
  };
49
53
  process.on("SIGTERM", signalCleanup);
50
54
  process.on("SIGINT", signalCleanup);
51
55
  process.on("beforeExit", signalCleanup);
52
56
 
57
+ // Clean up on session shutdown — remove signal handlers to prevent accumulation
58
+ pi.on("session_shutdown", async () => {
59
+ process.off("SIGTERM", signalCleanup);
60
+ process.off("SIGINT", signalCleanup);
61
+ process.off("beforeExit", signalCleanup);
62
+ cleanupAll();
63
+ });
64
+
53
65
  // ── Compaction Awareness: Survive Context Resets ───────────────
54
66
 
55
67
  /** Build a compact state summary of all alive processes for context re-injection */
@@ -65,7 +77,7 @@ export function registerBgShellLifecycle(pi: ExtensionAPI, state: BgShellSharedS
65
77
  return ` - id:${p.id} "${p.label}" [${p.processType}] status:${p.status} uptime:${formatUptime(Date.now() - p.startedAt)}${portInfo}${urlInfo}${errInfo}${groupInfo}`;
66
78
  }).join("\n");
67
79
 
68
- pendingAlerts.push(
80
+ pushAlert(null,
69
81
  `${reason} ${alive.length} background process(es) are still running:\n${processSummaries}\nUse bg_shell digest/output/kill with these IDs.`
70
82
  );
71
83
  }
@@ -150,7 +162,7 @@ export function registerBgShellLifecycle(pi: ExtensionAPI, state: BgShellSharedS
150
162
  ` - ${s.id}: ${s.label} (pid ${s.pid}, type: ${s.processType}${s.group ? `, group: ${s.group}` : ""})`
151
163
  ).join("\n");
152
164
 
153
- pendingAlerts.push(
165
+ pushAlert(null,
154
166
  `${surviving.length} background process(es) from previous session still running:\n${summary}\n Note: These processes are outside bg_shell's control. Kill them manually if needed.`
155
167
  );
156
168
  }
@@ -33,6 +33,8 @@ export const processes = new Map<string, BgProcess>();
33
33
  /** Pending alerts to inject into the next agent context */
34
34
  export let pendingAlerts: string[] = [];
35
35
 
36
+ const MAX_PENDING_ALERTS = 50;
37
+
36
38
  /** Replace the pendingAlerts array (used by the extension entry point) */
37
39
  export function setPendingAlerts(alerts: string[]): void {
38
40
  pendingAlerts = alerts;
@@ -58,8 +60,12 @@ export function addEvent(bg: BgProcess, event: Omit<ProcessEvent, "timestamp">):
58
60
  }
59
61
  }
60
62
 
61
- export function pushAlert(bg: BgProcess, message: string): void {
62
- pendingAlerts.push(`[bg:${bg.id} ${bg.label}] ${message}`);
63
+ export function pushAlert(bg: BgProcess | null, message: string): void {
64
+ const prefix = bg ? `[bg:${bg.id} ${bg.label}] ` : "";
65
+ pendingAlerts.push(`${prefix}${message}`);
66
+ if (pendingAlerts.length > MAX_PENDING_ALERTS) {
67
+ pendingAlerts.splice(0, pendingAlerts.length - MAX_PENDING_ALERTS);
68
+ }
63
69
  }
64
70
 
65
71
  export function getInfo(p: BgProcess): BgProcessInfo {
@@ -804,27 +804,39 @@ export function registerDbTools(pi: ExtensionAPI): void {
804
804
  return m ? [m[1].trim(), m[2].trim()] : [s.trim(), ""];
805
805
  };
806
806
  const coerced = { ...params };
807
- coerced.filesModified = (params.filesModified ?? []).map((f: any) => {
807
+ // Coerce simple string-array fields: LLMs sometimes pass a plain string
808
+ // instead of a single-element array (#3585).
809
+ const wrapArray = (v: any): any[] =>
810
+ v == null ? [] : Array.isArray(v) ? v : [v];
811
+ coerced.provides = wrapArray(params.provides);
812
+ coerced.keyFiles = wrapArray(params.keyFiles);
813
+ coerced.keyDecisions = wrapArray(params.keyDecisions);
814
+ coerced.patternsEstablished = wrapArray(params.patternsEstablished);
815
+ coerced.observabilitySurfaces = wrapArray(params.observabilitySurfaces);
816
+ coerced.requirementsSurfaced = wrapArray(params.requirementsSurfaced);
817
+ coerced.drillDownPaths = wrapArray(params.drillDownPaths);
818
+ coerced.affects = wrapArray(params.affects);
819
+ coerced.filesModified = wrapArray(params.filesModified).map((f: any) => {
808
820
  if (typeof f !== "string") return f;
809
821
  const [path, description] = splitPair(f);
810
822
  return { path, description };
811
823
  });
812
- coerced.requires = (params.requires ?? []).map((r: any) => {
824
+ coerced.requires = wrapArray(params.requires).map((r: any) => {
813
825
  if (typeof r !== "string") return r;
814
826
  const [slice, provides] = splitPair(r);
815
827
  return { slice, provides };
816
828
  });
817
- coerced.requirementsAdvanced = (params.requirementsAdvanced ?? []).map((r: any) => {
829
+ coerced.requirementsAdvanced = wrapArray(params.requirementsAdvanced).map((r: any) => {
818
830
  if (typeof r !== "string") return r;
819
831
  const [id, how] = splitPair(r);
820
832
  return { id, how };
821
833
  });
822
- coerced.requirementsValidated = (params.requirementsValidated ?? []).map((r: any) => {
834
+ coerced.requirementsValidated = wrapArray(params.requirementsValidated).map((r: any) => {
823
835
  if (typeof r !== "string") return r;
824
836
  const [id, proof] = splitPair(r);
825
837
  return { id, proof };
826
838
  });
827
- coerced.requirementsInvalidated = (params.requirementsInvalidated ?? []).map((r: any) => {
839
+ coerced.requirementsInvalidated = wrapArray(params.requirementsInvalidated).map((r: any) => {
828
840
  if (typeof r !== "string") return r;
829
841
  const [id, what] = splitPair(r);
830
842
  return { id, what };
@@ -884,14 +896,14 @@ export function registerDbTools(pi: ExtensionAPI): void {
884
896
  deviations: Type.Optional(Type.String({ description: "Deviations from the slice plan, or 'None.'" })),
885
897
  knownLimitations: Type.Optional(Type.String({ description: "Known limitations or gaps, or 'None.'" })),
886
898
  followUps: Type.Optional(Type.String({ description: "Follow-up work discovered during execution, or 'None.'" })),
887
- keyFiles: Type.Optional(Type.Array(Type.String(), { description: "Key files created or modified" })),
888
- keyDecisions: Type.Optional(Type.Array(Type.String(), { description: "Key decisions made during this slice" })),
889
- patternsEstablished: Type.Optional(Type.Array(Type.String(), { description: "Patterns established by this slice" })),
890
- observabilitySurfaces: Type.Optional(Type.Array(Type.String(), { description: "Observability surfaces added" })),
891
- provides: Type.Optional(Type.Array(Type.String(), { description: "What this slice provides to downstream slices" })),
892
- requirementsSurfaced: Type.Optional(Type.Array(Type.String(), { description: "New requirements surfaced" })),
893
- drillDownPaths: Type.Optional(Type.Array(Type.String(), { description: "Paths to task summaries for drill-down" })),
894
- affects: Type.Optional(Type.Array(Type.String(), { description: "Downstream slices affected" })),
899
+ keyFiles: Type.Optional(Type.Union([Type.Array(Type.String()), Type.String()], { description: "Key files created or modified" })),
900
+ keyDecisions: Type.Optional(Type.Union([Type.Array(Type.String()), Type.String()], { description: "Key decisions made during this slice" })),
901
+ patternsEstablished: Type.Optional(Type.Union([Type.Array(Type.String()), Type.String()], { description: "Patterns established by this slice" })),
902
+ observabilitySurfaces: Type.Optional(Type.Union([Type.Array(Type.String()), Type.String()], { description: "Observability surfaces added" })),
903
+ provides: Type.Optional(Type.Union([Type.Array(Type.String()), Type.String()], { description: "What this slice provides to downstream slices" })),
904
+ requirementsSurfaced: Type.Optional(Type.Union([Type.Array(Type.String()), Type.String()], { description: "New requirements surfaced" })),
905
+ drillDownPaths: Type.Optional(Type.Union([Type.Array(Type.String()), Type.String()], { description: "Paths to task summaries for drill-down" })),
906
+ affects: Type.Optional(Type.Union([Type.Array(Type.String()), Type.String()], { description: "Downstream slices affected" })),
895
907
  requirementsAdvanced: Type.Optional(Type.Array(
896
908
  Type.Union([
897
909
  Type.Object({
@@ -44,6 +44,7 @@ export function registerShortcuts(pi: ExtensionAPI): void {
44
44
  minWidth: 60,
45
45
  maxHeight: "88%",
46
46
  anchor: "center",
47
+ backdrop: true,
47
48
  },
48
49
  },
49
50
  );
@@ -264,6 +264,13 @@ function buildWorktreeContextBlock(): string {
264
264
  return "";
265
265
  }
266
266
 
267
+ /**
268
+ * Low-entropy resume intent patterns — short phrases a user types to
269
+ * continue work after a pause, rate limit, or context reset (#3615).
270
+ * Tested against the trimmed, lowercased prompt with trailing punctuation stripped.
271
+ */
272
+ const RESUME_INTENT_PATTERNS = /^(continue|resume|ok|go|go ahead|proceed|keep going|carry on|next|yes|yeah|yep|sure|do it|let's go|pick up where you left off)$/;
273
+
267
274
  async function buildGuidedExecuteContextInjection(prompt: string, basePath: string): Promise<string | null> {
268
275
  const executeMatch = prompt.match(/Execute the next task:\s+(T\d+)\s+\("([^"]+)"\)\s+in slice\s+(S\d+)\s+of milestone\s+(M\d+(?:-[a-z0-9]{6})?)/i);
269
276
  if (executeMatch) {
@@ -280,6 +287,27 @@ async function buildGuidedExecuteContextInjection(prompt: string, basePath: stri
280
287
  }
281
288
  }
282
289
 
290
+ // Fallback: low-entropy resume prompt (e.g., "continue", "ok", "go ahead")
291
+ // during an active executing task — inject task context so the agent
292
+ // doesn't rebuild from scratch (#3615).
293
+ // Intent-gated: only fire for short, resume-like prompts to avoid hijacking
294
+ // control/help/diagnostic prompts with unrelated execution context.
295
+ // Phase-gated: only fire during "executing" to avoid misrouting during
296
+ // replanning, gate evaluation, or other non-execution phases.
297
+ const trimmed = prompt.trim().toLowerCase().replace(/[.!?,]+$/g, "");
298
+ if (RESUME_INTENT_PATTERNS.test(trimmed)) {
299
+ const state = await deriveState(basePath);
300
+ if (state.phase === "executing" && state.activeTask && state.activeMilestone && state.activeSlice) {
301
+ return buildTaskExecutionContextInjection(
302
+ basePath,
303
+ state.activeMilestone.id,
304
+ state.activeSlice.id,
305
+ state.activeTask.id,
306
+ state.activeTask.title,
307
+ );
308
+ }
309
+ }
310
+
283
311
  return null;
284
312
  }
285
313
 
@@ -105,6 +105,7 @@ export async function handleNotificationsCommand(
105
105
  minWidth: 60,
106
106
  maxHeight: "88%",
107
107
  anchor: "center",
108
+ backdrop: true,
108
109
  },
109
110
  },
110
111
  );
@@ -157,9 +157,8 @@ export class GSDNotificationOverlay {
157
157
  }
158
158
 
159
159
  const content = this.buildContentLines(width);
160
- const viewportHeight = Math.max(5, process.stdout.rows ? process.stdout.rows - 8 : 24);
161
- const chromeHeight = 2; // top + bottom border
162
- const visibleContentRows = Math.max(1, viewportHeight - chromeHeight);
160
+ const maxVisibleRows = Math.max(5, process.stdout.rows ? process.stdout.rows - 8 : 24) - 2;
161
+ const visibleContentRows = Math.min(content.length, maxVisibleRows);
163
162
  const maxScroll = Math.max(0, content.length - visibleContentRows);
164
163
  this.scrollOffset = Math.min(this.scrollOffset, maxScroll);
165
164
  const visibleContent = content.slice(this.scrollOffset, this.scrollOffset + visibleContentRows);
@@ -275,14 +275,19 @@ function _withLock<T>(basePath: string, fn: () => T): T {
275
275
  }
276
276
  }
277
277
 
278
+ // Only run the mutation if we actually own the lock
279
+ const ownsLock = fd !== null;
278
280
  try {
279
- // Write our PID timestamp into the lock for stale detection
280
- if (fd !== null) {
281
+ if (ownsLock && fd !== null) {
282
+ // Write our PID timestamp into the lock for stale detection
281
283
  writeFileSync(lockPath, String(Date.now()), "utf-8");
282
284
  closeSync(fd);
283
285
  }
284
286
  return fn();
285
287
  } finally {
286
- try { unlinkSync(lockPath); } catch { /* best-effort cleanup */ }
288
+ // Only delete the lock if we created it never remove another process's lock
289
+ if (ownsLock) {
290
+ try { unlinkSync(lockPath); } catch { /* best-effort cleanup */ }
291
+ }
287
292
  }
288
293
  }
@@ -124,6 +124,42 @@ describe("verificationEvidence sentinel coercion (#3565)", () => {
124
124
  });
125
125
  });
126
126
 
127
+ // ─── wrapArray coercion unit tests (#3585) ──────────────────────────────
128
+
129
+ describe("wrapArray coercion for simple string-array fields (#3585)", () => {
130
+ /**
131
+ * The wrapArray coercion logic extracted from db-tools.ts sliceCompleteExecute.
132
+ * Duplicated here so we can unit-test it directly.
133
+ */
134
+ function wrapArray(v: any): any[] {
135
+ return v == null ? [] : Array.isArray(v) ? v : [v];
136
+ }
137
+
138
+ test("null returns empty array", () => {
139
+ assert.deepEqual(wrapArray(null), []);
140
+ });
141
+
142
+ test("undefined returns empty array", () => {
143
+ assert.deepEqual(wrapArray(undefined), []);
144
+ });
145
+
146
+ test("plain string wraps into single-element array", () => {
147
+ assert.deepEqual(
148
+ wrapArray("Validated Tech UI flows and Portal self-service flows"),
149
+ ["Validated Tech UI flows and Portal self-service flows"],
150
+ );
151
+ });
152
+
153
+ test("array passes through unchanged", () => {
154
+ const arr = ["item1", "item2"];
155
+ assert.deepEqual(wrapArray(arr), arr);
156
+ });
157
+
158
+ test("empty array passes through unchanged", () => {
159
+ assert.deepEqual(wrapArray([]), []);
160
+ });
161
+ });
162
+
127
163
  // ─── Handler integration with coerced params ─────────────────────────────
128
164
 
129
165
  describe("handleCompleteSlice with coerced string arrays (#3565)", () => {
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { describe, test, beforeEach, afterEach } from "node:test";
4
4
  import assert from "node:assert/strict";
5
- import { mkdtempSync, mkdirSync, rmSync, readFileSync, existsSync } from "node:fs";
5
+ import { mkdtempSync, mkdirSync, rmSync, readFileSync, existsSync, writeFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { tmpdir } from "node:os";
8
8
 
@@ -246,4 +246,37 @@ describe("notification-store", () => {
246
246
  assert.equal(getUnreadCount(), 0);
247
247
  assert.equal(getLineCount(), 0);
248
248
  });
249
+
250
+ test("markAllRead does not delete a foreign lock file", () => {
251
+ initNotificationStore(tmp);
252
+ appendNotification("msg1", "info");
253
+
254
+ // Simulate another process holding the lock
255
+ const lockPath = join(tmp, ".gsd", "notifications.lock");
256
+ writeFileSync(lockPath, String(Date.now()), "utf-8");
257
+
258
+ // markAllRead should still work (best-effort) but not delete the foreign lock
259
+ markAllRead();
260
+
261
+ assert.ok(existsSync(lockPath), "foreign lock file should not be deleted");
262
+
263
+ // Clean up the lock so afterEach doesn't leave artifacts
264
+ rmSync(lockPath, { force: true });
265
+ });
266
+
267
+ test("clearNotifications does not delete a foreign lock file", () => {
268
+ initNotificationStore(tmp);
269
+ appendNotification("msg1", "info");
270
+
271
+ // Simulate another process holding the lock
272
+ const lockPath = join(tmp, ".gsd", "notifications.lock");
273
+ writeFileSync(lockPath, String(Date.now()), "utf-8");
274
+
275
+ // clearNotifications should still work but not delete the foreign lock
276
+ clearNotifications();
277
+
278
+ assert.ok(existsSync(lockPath), "foreign lock file should not be deleted");
279
+
280
+ rmSync(lockPath, { force: true });
281
+ });
249
282
  });
@@ -0,0 +1,163 @@
1
+ // GSD-2 — Regression test for #3615: unstructured "continue" must inject task context
2
+ // Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
3
+
4
+ /**
5
+ * Bug #3615: When a user types "continue" (or any bare text) to resume
6
+ * an in-progress session, buildGuidedExecuteContextInjection() only
7
+ * matched two hardcoded regex patterns (auto-dispatch and guided-resume).
8
+ * The function returned null for any other input, so no task context was
9
+ * injected — causing the agent to rebuild everything from scratch and
10
+ * burn ~86k tokens.
11
+ *
12
+ * This test verifies:
13
+ * 1. Structural: the fallback exists with phase + intent guards
14
+ * 2. Behavioral: RESUME_INTENT_PATTERNS matches expected prompts and
15
+ * rejects non-resume prompts (control, help, diagnostic, etc.)
16
+ */
17
+
18
+ import { describe, test } from "node:test";
19
+ import assert from "node:assert/strict";
20
+ import { readFileSync } from "node:fs";
21
+ import { join, dirname } from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+
24
+ const __dirname = dirname(fileURLToPath(import.meta.url));
25
+ const systemContextSource = readFileSync(
26
+ join(__dirname, "..", "bootstrap", "system-context.ts"),
27
+ "utf-8",
28
+ );
29
+
30
+ // ── Structural tests ────────────────────────────────────────────────
31
+
32
+ describe("#3615 — structural: fallback exists with correct guards", () => {
33
+ const fnStart = systemContextSource.indexOf("async function buildGuidedExecuteContextInjection(");
34
+ assert.ok(fnStart >= 0, "should find buildGuidedExecuteContextInjection");
35
+ const fnEnd = systemContextSource.indexOf("\nasync function ", fnStart + 1);
36
+ const fnBody = fnEnd >= 0
37
+ ? systemContextSource.slice(fnStart, fnEnd)
38
+ : systemContextSource.slice(fnStart);
39
+
40
+ test("has a deriveState fallback after the two regex branches", () => {
41
+ const deriveStateCalls = fnBody.match(/deriveState\(basePath\)/g);
42
+ assert.ok(
43
+ deriveStateCalls && deriveStateCalls.length >= 2,
44
+ `expected >=2 deriveState(basePath) calls, got ${deriveStateCalls?.length ?? 0}`,
45
+ );
46
+ });
47
+
48
+ test("fallback is phase-gated to executing only", () => {
49
+ const afterFallback = fnBody.indexOf("// Fallback:");
50
+ assert.ok(afterFallback >= 0, "should have a fallback comment");
51
+ const fallbackSection = fnBody.slice(afterFallback);
52
+ assert.ok(
53
+ fallbackSection.includes('state.phase === "executing"'),
54
+ 'fallback must be gated on state.phase === "executing"',
55
+ );
56
+ });
57
+
58
+ test("fallback is intent-gated via RESUME_INTENT_PATTERNS", () => {
59
+ const afterFallback = fnBody.indexOf("// Fallback:");
60
+ const fallbackSection = fnBody.slice(afterFallback);
61
+ assert.ok(
62
+ fallbackSection.includes("RESUME_INTENT_PATTERNS"),
63
+ "fallback must check RESUME_INTENT_PATTERNS before deriveState",
64
+ );
65
+ });
66
+
67
+ test("fallback calls buildTaskExecutionContextInjection with derived state", () => {
68
+ const afterFallback = fnBody.indexOf("// Fallback:");
69
+ const fallbackSection = fnBody.slice(afterFallback);
70
+ assert.ok(
71
+ fallbackSection.includes("buildTaskExecutionContextInjection") &&
72
+ fallbackSection.includes("state.activeMilestone.id") &&
73
+ fallbackSection.includes("state.activeSlice.id") &&
74
+ fallbackSection.includes("state.activeTask.id"),
75
+ "fallback must call buildTaskExecutionContextInjection with state-derived IDs",
76
+ );
77
+ });
78
+
79
+ test("only one return null at the end", () => {
80
+ const returnNulls = fnBody.match(/return null;/g);
81
+ assert.ok(
82
+ returnNulls && returnNulls.length === 1,
83
+ `expected exactly 1 'return null' (at end after fallback), got ${returnNulls?.length ?? 0}`,
84
+ );
85
+ });
86
+ });
87
+
88
+ // ── Behavioral tests: RESUME_INTENT_PATTERNS ────────────────────────
89
+
90
+ describe("#3615 — behavioral: RESUME_INTENT_PATTERNS matches resume prompts", () => {
91
+ // Extract the regex from source so the test stays in sync
92
+ const patternMatch = systemContextSource.match(/const RESUME_INTENT_PATTERNS\s*=\s*\/(.+)\/;/);
93
+ assert.ok(patternMatch, "should find RESUME_INTENT_PATTERNS definition");
94
+ const pattern = new RegExp(patternMatch[1]);
95
+
96
+ // Helper: normalize prompt the same way the production code does
97
+ const normalize = (s: string) => s.trim().toLowerCase().replace(/[.!?,]+$/g, "");
98
+
99
+ const shouldMatch = [
100
+ "continue",
101
+ "Continue",
102
+ "CONTINUE",
103
+ "continue.",
104
+ "continue!",
105
+ "resume",
106
+ "ok",
107
+ "OK",
108
+ "Ok!",
109
+ "go",
110
+ "go ahead",
111
+ "Go ahead.",
112
+ "proceed",
113
+ "keep going",
114
+ "carry on",
115
+ "next",
116
+ "yes",
117
+ "yeah",
118
+ "yep",
119
+ "sure",
120
+ "do it",
121
+ "let's go",
122
+ "pick up where you left off",
123
+ " continue ", // whitespace padded
124
+ ];
125
+
126
+ const shouldNotMatch = [
127
+ "help",
128
+ "status",
129
+ "/gsd auto",
130
+ "/gsd stats",
131
+ "what's the plan?",
132
+ "show me the logs",
133
+ "abort",
134
+ "stop",
135
+ "cancel",
136
+ "replan this slice",
137
+ "I think we should change the approach",
138
+ "can you explain what you just did?",
139
+ "run the tests",
140
+ "check the build",
141
+ "Execute the next task: T01",
142
+ "what files were changed",
143
+ "",
144
+ ];
145
+
146
+ for (const prompt of shouldMatch) {
147
+ test(`matches resume prompt: "${prompt}"`, () => {
148
+ assert.ok(
149
+ pattern.test(normalize(prompt)),
150
+ `expected RESUME_INTENT_PATTERNS to match "${prompt}" (normalized: "${normalize(prompt)}")`,
151
+ );
152
+ });
153
+ }
154
+
155
+ for (const prompt of shouldNotMatch) {
156
+ test(`rejects non-resume prompt: "${prompt}"`, () => {
157
+ assert.ok(
158
+ !pattern.test(normalize(prompt)),
159
+ `expected RESUME_INTENT_PATTERNS to NOT match "${prompt}" (normalized: "${normalize(prompt)}")`,
160
+ );
161
+ });
162
+ }
163
+ });