gsd-pi 2.35.0-dev.640d5c7 → 2.35.0-dev.67d0e02

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 (94) hide show
  1. package/README.md +3 -1
  2. package/dist/cli.js +7 -2
  3. package/dist/resource-loader.d.ts +1 -1
  4. package/dist/resource-loader.js +13 -1
  5. package/dist/resources/extensions/async-jobs/await-tool.js +0 -2
  6. package/dist/resources/extensions/async-jobs/job-manager.js +0 -6
  7. package/dist/resources/extensions/bg-shell/output-formatter.js +1 -19
  8. package/dist/resources/extensions/bg-shell/process-manager.js +0 -4
  9. package/dist/resources/extensions/bg-shell/types.js +0 -2
  10. package/dist/resources/extensions/context7/index.js +5 -0
  11. package/dist/resources/extensions/get-secrets-from-user.js +2 -30
  12. package/dist/resources/extensions/google-search/index.js +5 -0
  13. package/dist/resources/extensions/gsd/auto-dispatch.js +43 -1
  14. package/dist/resources/extensions/gsd/auto-loop.js +10 -1
  15. package/dist/resources/extensions/gsd/auto-recovery.js +35 -0
  16. package/dist/resources/extensions/gsd/auto-start.js +35 -2
  17. package/dist/resources/extensions/gsd/auto.js +59 -4
  18. package/dist/resources/extensions/gsd/commands-handlers.js +2 -2
  19. package/dist/resources/extensions/gsd/doctor-environment.js +26 -17
  20. package/dist/resources/extensions/gsd/files.js +9 -1
  21. package/dist/resources/extensions/gsd/gitignore.js +54 -7
  22. package/dist/resources/extensions/gsd/guided-flow.js +1 -1
  23. package/dist/resources/extensions/gsd/health-widget-core.js +96 -0
  24. package/dist/resources/extensions/gsd/health-widget.js +97 -46
  25. package/dist/resources/extensions/gsd/index.js +26 -33
  26. package/dist/resources/extensions/gsd/migrate-external.js +55 -2
  27. package/dist/resources/extensions/gsd/milestone-ids.js +3 -2
  28. package/dist/resources/extensions/gsd/paths.js +74 -7
  29. package/dist/resources/extensions/gsd/post-unit-hooks.js +4 -1
  30. package/dist/resources/extensions/gsd/preferences-validation.js +16 -1
  31. package/dist/resources/extensions/gsd/prompts/complete-milestone.md +2 -0
  32. package/dist/resources/extensions/gsd/prompts/validate-milestone.md +2 -0
  33. package/dist/resources/extensions/gsd/roadmap-mutations.js +55 -0
  34. package/dist/resources/extensions/gsd/session-lock.js +53 -2
  35. package/dist/resources/extensions/gsd/state.js +2 -1
  36. package/dist/resources/extensions/gsd/templates/plan.md +8 -0
  37. package/dist/resources/extensions/gsd/worktree-resolver.js +12 -0
  38. package/dist/resources/extensions/remote-questions/remote-command.js +2 -22
  39. package/dist/resources/extensions/shared/mod.js +1 -1
  40. package/dist/resources/extensions/shared/sanitize.js +30 -0
  41. package/dist/resources/extensions/subagent/index.js +6 -14
  42. package/package.json +2 -1
  43. package/packages/pi-coding-agent/dist/core/resource-loader.d.ts.map +1 -1
  44. package/packages/pi-coding-agent/dist/core/resource-loader.js +13 -2
  45. package/packages/pi-coding-agent/dist/core/resource-loader.js.map +1 -1
  46. package/packages/pi-coding-agent/src/core/resource-loader.ts +13 -2
  47. package/src/resources/extensions/async-jobs/await-tool.ts +0 -2
  48. package/src/resources/extensions/async-jobs/job-manager.ts +0 -7
  49. package/src/resources/extensions/bg-shell/output-formatter.ts +0 -17
  50. package/src/resources/extensions/bg-shell/process-manager.ts +0 -4
  51. package/src/resources/extensions/bg-shell/types.ts +0 -12
  52. package/src/resources/extensions/context7/index.ts +7 -0
  53. package/src/resources/extensions/get-secrets-from-user.ts +2 -35
  54. package/src/resources/extensions/google-search/index.ts +7 -0
  55. package/src/resources/extensions/gsd/auto-dispatch.ts +49 -1
  56. package/src/resources/extensions/gsd/auto-loop.ts +11 -1
  57. package/src/resources/extensions/gsd/auto-recovery.ts +39 -0
  58. package/src/resources/extensions/gsd/auto-start.ts +42 -2
  59. package/src/resources/extensions/gsd/auto.ts +61 -3
  60. package/src/resources/extensions/gsd/commands-handlers.ts +2 -2
  61. package/src/resources/extensions/gsd/doctor-environment.ts +26 -16
  62. package/src/resources/extensions/gsd/files.ts +10 -1
  63. package/src/resources/extensions/gsd/gitignore.ts +54 -7
  64. package/src/resources/extensions/gsd/guided-flow.ts +1 -1
  65. package/src/resources/extensions/gsd/health-widget-core.ts +129 -0
  66. package/src/resources/extensions/gsd/health-widget.ts +103 -59
  67. package/src/resources/extensions/gsd/index.ts +30 -33
  68. package/src/resources/extensions/gsd/migrate-external.ts +47 -2
  69. package/src/resources/extensions/gsd/milestone-ids.ts +3 -2
  70. package/src/resources/extensions/gsd/paths.ts +73 -7
  71. package/src/resources/extensions/gsd/post-unit-hooks.ts +5 -1
  72. package/src/resources/extensions/gsd/preferences-validation.ts +16 -1
  73. package/src/resources/extensions/gsd/prompts/complete-milestone.md +2 -0
  74. package/src/resources/extensions/gsd/prompts/validate-milestone.md +2 -0
  75. package/src/resources/extensions/gsd/roadmap-mutations.ts +66 -0
  76. package/src/resources/extensions/gsd/session-lock.ts +59 -2
  77. package/src/resources/extensions/gsd/state.ts +2 -1
  78. package/src/resources/extensions/gsd/templates/plan.md +8 -0
  79. package/src/resources/extensions/gsd/tests/gitignore-tracked-gsd.test.ts +214 -0
  80. package/src/resources/extensions/gsd/tests/health-widget.test.ts +158 -0
  81. package/src/resources/extensions/gsd/tests/paths.test.ts +113 -0
  82. package/src/resources/extensions/gsd/tests/preferences.test.ts +12 -2
  83. package/src/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +26 -0
  84. package/src/resources/extensions/gsd/tests/test-utils.ts +165 -0
  85. package/src/resources/extensions/gsd/tests/validate-directory.test.ts +15 -0
  86. package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +7 -0
  87. package/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +32 -0
  88. package/src/resources/extensions/gsd/worktree-resolver.ts +11 -0
  89. package/src/resources/extensions/remote-questions/remote-command.ts +2 -23
  90. package/src/resources/extensions/shared/mod.ts +1 -1
  91. package/src/resources/extensions/shared/sanitize.ts +36 -0
  92. package/src/resources/extensions/subagent/index.ts +6 -12
  93. package/dist/resources/extensions/shared/wizard-ui.js +0 -478
  94. package/src/resources/extensions/shared/wizard-ui.ts +0 -551
package/README.md CHANGED
@@ -455,7 +455,9 @@ auto_report: true
455
455
 
456
456
  ### Agent Instructions
457
457
 
458
- Create an `agent-instructions.md` file in your project root to inject persistent per-project behavioral guidance into every agent session. This file is loaded automatically and provides project-specific context the LLM should always have coding standards, architectural decisions, domain terminology, or workflow preferences.
458
+ Place an `AGENTS.md` file in any directory to provide persistent behavioral guidance for that scope. Pi core loads `AGENTS.md` automatically (with `CLAUDE.md` as a fallback) at both user and project levels. Use these files for coding standards, architectural decisions, domain terminology, or workflow preferences.
459
+
460
+ > **Note:** The legacy `agent-instructions.md` format (`~/.gsd/agent-instructions.md` and `.gsd/agent-instructions.md`) is deprecated and no longer loaded. Migrate any existing instructions to `AGENTS.md` or `CLAUDE.md`.
459
461
 
460
462
  ### Debug Mode
461
463
 
package/dist/cli.js CHANGED
@@ -327,7 +327,10 @@ if (isPrintMode) {
327
327
  markStartup('createAgentSession');
328
328
  if (extensionsResult.errors.length > 0) {
329
329
  for (const err of extensionsResult.errors) {
330
- process.stderr.write(`[gsd] Extension load error: ${err.error}\n`);
330
+ // Downgrade conflicts with built-in tools to warnings (#1347)
331
+ const isSuperseded = err.error.includes("supersedes");
332
+ const prefix = isSuperseded ? "Extension conflict" : "Extension load error";
333
+ process.stderr.write(`[gsd] ${prefix}: ${err.error}\n`);
331
334
  }
332
335
  }
333
336
  // Apply --model override if specified
@@ -456,7 +459,9 @@ const { session, extensionsResult } = await createAgentSession({
456
459
  markStartup('createAgentSession');
457
460
  if (extensionsResult.errors.length > 0) {
458
461
  for (const err of extensionsResult.errors) {
459
- process.stderr.write(`[gsd] Extension load error: ${err.error}\n`);
462
+ const isSuperseded = err.error.includes("supersedes");
463
+ const prefix = isSuperseded ? "Extension conflict" : "Extension load error";
464
+ process.stderr.write(`[gsd] ${prefix}: ${err.error}\n`);
460
465
  }
461
466
  }
462
467
  // Restore scoped models from settings on startup.
@@ -9,7 +9,7 @@ export declare function getNewerManagedResourceVersion(agentDir: string, current
9
9
  * - extensions/ → ~/.gsd/agent/extensions/ (overwrite when version changes)
10
10
  * - agents/ → ~/.gsd/agent/agents/ (overwrite when version changes)
11
11
  * - skills/ → ~/.gsd/agent/skills/ (overwrite when version changes)
12
- * - GSD-WORKFLOW.md is read directly from bundled path via GSD_WORKFLOW_PATH env var
12
+ * - GSD-WORKFLOW.md ~/.gsd/agent/GSD-WORKFLOW.md (fallback for env var miss)
13
13
  *
14
14
  * Skips the copy when the managed-resources.json version matches the current
15
15
  * GSD version, avoiding ~128ms of synchronous cpSync on every startup.
@@ -18,6 +18,9 @@ import { loadRegistry, readManifestFromEntryPath, isExtensionEnabled, ensureRegi
18
18
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
19
19
  const distResources = join(packageRoot, 'dist', 'resources');
20
20
  const srcResources = join(packageRoot, 'src', 'resources');
21
+ // Use dist/resources only if it has the full expected structure.
22
+ // A partial build (tsc without copy-resources) creates dist/resources/extensions/
23
+ // but not agents/ or skills/, causing initResources to sync from an incomplete source.
21
24
  const resourcesDir = (existsSync(distResources) && existsSync(join(distResources, 'agents')))
22
25
  ? distResources
23
26
  : srcResources;
@@ -220,7 +223,7 @@ function copyDirRecursive(src, dest) {
220
223
  * - extensions/ → ~/.gsd/agent/extensions/ (overwrite when version changes)
221
224
  * - agents/ → ~/.gsd/agent/agents/ (overwrite when version changes)
222
225
  * - skills/ → ~/.gsd/agent/skills/ (overwrite when version changes)
223
- * - GSD-WORKFLOW.md is read directly from bundled path via GSD_WORKFLOW_PATH env var
226
+ * - GSD-WORKFLOW.md ~/.gsd/agent/GSD-WORKFLOW.md (fallback for env var miss)
224
227
  *
225
228
  * Skips the copy when the managed-resources.json version matches the current
226
229
  * GSD version, avoiding ~128ms of synchronous cpSync on every startup.
@@ -247,6 +250,15 @@ export function initResources(agentDir) {
247
250
  syncResourceDir(bundledExtensionsDir, join(agentDir, 'extensions'));
248
251
  syncResourceDir(join(resourcesDir, 'agents'), join(agentDir, 'agents'));
249
252
  syncResourceDir(join(resourcesDir, 'skills'), join(agentDir, 'skills'));
253
+ // Sync GSD-WORKFLOW.md to agentDir as a fallback for when GSD_WORKFLOW_PATH
254
+ // env var is not set (e.g. fork/dev builds, alternative entry points).
255
+ const workflowSrc = join(resourcesDir, 'GSD-WORKFLOW.md');
256
+ if (existsSync(workflowSrc)) {
257
+ try {
258
+ copyFileSync(workflowSrc, join(agentDir, 'GSD-WORKFLOW.md'));
259
+ }
260
+ catch { /* non-fatal */ }
261
+ }
250
262
  // Ensure all newly copied files are owner-writable so the next run can
251
263
  // overwrite them (covers extensions, agents, and skills in one walk).
252
264
  makeTreeWritable(agentDir);
@@ -52,14 +52,12 @@ export function createAwaitTool(getManager) {
52
52
  const running = watched.filter((j) => j.status === "running");
53
53
  if (running.length === 0) {
54
54
  const result = formatResults(watched);
55
- manager.acknowledgeDeliveries(watched.map((j) => j.id));
56
55
  return { content: [{ type: "text", text: result }], details: undefined };
57
56
  }
58
57
  // Wait for at least one to complete
59
58
  await Promise.race(running.map((j) => j.promise));
60
59
  // Collect all completed results (more may have finished while waiting)
61
60
  const completed = watched.filter((j) => j.status !== "running");
62
- manager.acknowledgeDeliveries(completed.map((j) => j.id));
63
61
  const stillRunning = watched.filter((j) => j.status === "running");
64
62
  let result = formatResults(completed);
65
63
  if (stillRunning.length > 0) {
@@ -101,12 +101,6 @@ export class AsyncJobManager {
101
101
  getAllJobs() {
102
102
  return [...this.jobs.values()];
103
103
  }
104
- /**
105
- * No-op. Retained for API compatibility with await_job tool.
106
- */
107
- acknowledgeDeliveries(_jobIds) {
108
- // Delivery is fire-once; no retries to cancel.
109
- }
110
104
  /**
111
105
  * Cleanup all timers and resources.
112
106
  */
@@ -2,7 +2,7 @@
2
2
  * Output analysis, digest generation, highlights extraction, and output retrieval.
3
3
  */
4
4
  import { truncateHead, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, } from "@gsd/pi-coding-agent";
5
- import { ERROR_PATTERN_UNION, WARNING_PATTERN_UNION, READINESS_PATTERN_UNION, BUILD_COMPLETE_PATTERN_UNION, TEST_RESULT_PATTERN_UNION, URL_PATTERN, PORT_PATTERN_SOURCE, LINE_DEDUP_MAX, } from "./types.js";
5
+ import { ERROR_PATTERN_UNION, WARNING_PATTERN_UNION, READINESS_PATTERN_UNION, BUILD_COMPLETE_PATTERN_UNION, TEST_RESULT_PATTERN_UNION, URL_PATTERN, PORT_PATTERN_SOURCE, } from "./types.js";
6
6
  import { addEvent, pushAlert } from "./process-manager.js";
7
7
  import { transitionToReady } from "./readiness-detector.js";
8
8
  import { formatUptime, formatTimeAgo } from "./utilities.js";
@@ -78,24 +78,6 @@ export function analyzeLine(bg, line, stream) {
78
78
  pushAlert(bg, "recovered — errors cleared");
79
79
  }
80
80
  }
81
- // Dedup tracking — evict oldest entry when map exceeds LINE_DEDUP_MAX (LRU via Map insertion order)
82
- bg.totalRawLines++;
83
- const lineHash = line.trim().slice(0, 100);
84
- const existing = bg.lineDedup.get(lineHash);
85
- if (existing !== undefined) {
86
- // Re-insert to update insertion order (move to tail = most recent)
87
- bg.lineDedup.delete(lineHash);
88
- bg.lineDedup.set(lineHash, existing + 1);
89
- }
90
- else {
91
- if (bg.lineDedup.size >= LINE_DEDUP_MAX) {
92
- // Evict oldest entry (Map iteration order = insertion order = LRU at head)
93
- const oldest = bg.lineDedup.keys().next().value;
94
- if (oldest !== undefined)
95
- bg.lineDedup.delete(oldest);
96
- }
97
- bg.lineDedup.set(lineHash, 1);
98
- }
99
81
  }
100
82
  // ── Digest Generation ──────────────────────────────────────────────────────
101
83
  export function generateDigest(bg, mutate = false) {
@@ -135,12 +135,8 @@ export function startProcess(opts) {
135
135
  group: opts.group || null,
136
136
  lastErrorCount: 0,
137
137
  lastWarningCount: 0,
138
- commandHistory: [],
139
- lineDedup: new Map(),
140
- totalRawLines: 0,
141
138
  stdoutLineCount: 0,
142
139
  stderrLineCount: 0,
143
- envKeys: Object.keys(opts.env || {}),
144
140
  restartCount: 0,
145
141
  startConfig: {
146
142
  command,
@@ -5,8 +5,6 @@
5
5
  export const MAX_BUFFER_LINES = 5000;
6
6
  export const MAX_EVENTS = 200;
7
7
  export const DEAD_PROCESS_TTL = 10 * 60 * 1000;
8
- /** Maximum unique entries in the per-process lineDedup Map before LRU eviction. */
9
- export const LINE_DEDUP_MAX = 500;
10
8
  export const PORT_PROBE_TIMEOUT = 500;
11
9
  export const READY_POLL_INTERVAL = 250;
12
10
  export const DEFAULT_READY_TIMEOUT = 30000;
@@ -327,6 +327,11 @@ export default function (pi) {
327
327
  return new Text(text, 0, 0);
328
328
  },
329
329
  });
330
+ // ── Session cleanup ─────────────────────────────────────────────────────
331
+ pi.on("session_shutdown", async () => {
332
+ searchCache.clear();
333
+ docCache.clear();
334
+ });
330
335
  // ── Startup notification ─────────────────────────────────────────────────
331
336
  pi.on("session_start", async (_event, ctx) => {
332
337
  if (!getApiKey()) {
@@ -8,9 +8,9 @@
8
8
  import { readFile, writeFile } from "node:fs/promises";
9
9
  import { existsSync, statSync } from "node:fs";
10
10
  import { resolve } from "node:path";
11
- import { CURSOR_MARKER, Editor, Key, matchesKey, Text, truncateToWidth, wrapTextWithAnsi } from "@gsd/pi-tui";
11
+ import { Editor, Key, matchesKey, Text, truncateToWidth, wrapTextWithAnsi } from "@gsd/pi-tui";
12
12
  import { Type } from "@sinclair/typebox";
13
- import { makeUI } from "./shared/mod.js";
13
+ import { makeUI, maskEditorLine } from "./shared/mod.js";
14
14
  import { parseSecretsManifest, formatSecretsManifest } from "./gsd/files.js";
15
15
  import { resolveMilestoneFile } from "./gsd/paths.js";
16
16
  // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -21,34 +21,6 @@ function maskPreview(value) {
21
21
  return "*".repeat(value.length);
22
22
  return `${value.slice(0, 4)}${"*".repeat(Math.max(4, value.length - 8))}${value.slice(-4)}`;
23
23
  }
24
- /**
25
- * Replace editor visible text with masked characters while preserving ANSI cursor/sequencer codes.
26
- */
27
- function maskEditorLine(line) {
28
- // Keep border / metadata lines readable.
29
- if (line.startsWith("─")) {
30
- return line;
31
- }
32
- let output = "";
33
- let i = 0;
34
- while (i < line.length) {
35
- if (line.startsWith(CURSOR_MARKER, i)) {
36
- output += CURSOR_MARKER;
37
- i += CURSOR_MARKER.length;
38
- continue;
39
- }
40
- const ansiMatch = /^\x1b\[[0-9;]*m/.exec(line.slice(i));
41
- if (ansiMatch) {
42
- output += ansiMatch[0];
43
- i += ansiMatch[0].length;
44
- continue;
45
- }
46
- const ch = line[i];
47
- output += ch === " " ? " " : "*";
48
- i += 1;
49
- }
50
- return output;
51
- }
52
24
  function shellEscapeSingle(value) {
53
25
  return `'${value.replace(/'/g, `'\\''`)}'`;
54
26
  }
@@ -326,6 +326,11 @@ export default function (pi) {
326
326
  return new Text(text, 0, 0);
327
327
  },
328
328
  });
329
+ // ── Session cleanup ─────────────────────────────────────────────────────
330
+ pi.on("session_shutdown", async () => {
331
+ resultCache.clear();
332
+ client = null;
333
+ });
329
334
  // ── Startup notification ─────────────────────────────────────────────────
330
335
  pi.on("session_start", async (_event, ctx) => {
331
336
  if (process.env.GEMINI_API_KEY)
@@ -8,7 +8,7 @@
8
8
  * data structure that is inspectable, testable per-rule, and extensible
9
9
  * without modifying orchestration code.
10
10
  */
11
- import { loadFile, loadActiveOverrides } from "./files.js";
11
+ import { loadFile, loadActiveOverrides, parseRoadmap } from "./files.js";
12
12
  import { resolveMilestoneFile, resolveMilestonePath, resolveSliceFile, resolveTaskFile, relSliceFile, buildMilestoneFileName, } from "./paths.js";
13
13
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
14
14
  import { join } from "node:path";
@@ -274,6 +274,28 @@ const DISPATCH_RULES = [
274
274
  match: async ({ state, mid, midTitle, basePath, prefs }) => {
275
275
  if (state.phase !== "validating-milestone")
276
276
  return null;
277
+ // Safety guard (#1368): verify all roadmap slices have SUMMARY files before
278
+ // allowing milestone validation. If any slice lacks a summary, the milestone
279
+ // is not genuinely complete — something skipped earlier slices.
280
+ const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP");
281
+ const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
282
+ if (roadmapContent) {
283
+ const roadmap = parseRoadmap(roadmapContent);
284
+ const missingSlices = [];
285
+ for (const slice of roadmap.slices) {
286
+ const summaryPath = resolveSliceFile(basePath, mid, slice.id, "SUMMARY");
287
+ if (!summaryPath || !existsSync(summaryPath)) {
288
+ missingSlices.push(slice.id);
289
+ }
290
+ }
291
+ if (missingSlices.length > 0) {
292
+ return {
293
+ action: "stop",
294
+ reason: `Cannot validate milestone ${mid}: slices ${missingSlices.join(", ")} are missing SUMMARY files. These slices may have been skipped.`,
295
+ level: "error",
296
+ };
297
+ }
298
+ }
277
299
  // Skip preference: write a minimal pass-through VALIDATION file
278
300
  if (prefs?.phases?.skip_milestone_validation) {
279
301
  const mDir = resolveMilestonePath(basePath, mid);
@@ -308,6 +330,26 @@ const DISPATCH_RULES = [
308
330
  match: async ({ state, mid, midTitle, basePath }) => {
309
331
  if (state.phase !== "completing-milestone")
310
332
  return null;
333
+ // Safety guard (#1368): verify all roadmap slices have SUMMARY files.
334
+ const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP");
335
+ const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
336
+ if (roadmapContent) {
337
+ const roadmap = parseRoadmap(roadmapContent);
338
+ const missingSlices = [];
339
+ for (const slice of roadmap.slices) {
340
+ const summaryPath = resolveSliceFile(basePath, mid, slice.id, "SUMMARY");
341
+ if (!summaryPath || !existsSync(summaryPath)) {
342
+ missingSlices.push(slice.id);
343
+ }
344
+ }
345
+ if (missingSlices.length > 0) {
346
+ return {
347
+ action: "stop",
348
+ reason: `Cannot complete milestone ${mid}: slices ${missingSlices.join(", ")} are missing SUMMARY files. Run /gsd doctor to diagnose.`,
349
+ level: "error",
350
+ };
351
+ }
352
+ }
311
353
  return {
312
354
  action: "dispatch",
313
355
  unitType: "complete-milestone",
@@ -161,6 +161,15 @@ export async function runUnit(ctx, pi, s, unitType, unitId, prompt, _prefs) {
161
161
  const unitPromise = new Promise((resolve) => {
162
162
  s.pendingResolve = resolve;
163
163
  });
164
+ // Ensure cwd matches basePath before dispatch (#1389).
165
+ // async_bash and background jobs can drift cwd away from the worktree.
166
+ // Realigning here prevents commits from landing on the wrong branch.
167
+ try {
168
+ if (process.cwd() !== s.basePath) {
169
+ process.chdir(s.basePath);
170
+ }
171
+ }
172
+ catch { /* non-fatal — chdir may fail if dir was removed */ }
164
173
  // ── Send the prompt ──
165
174
  debugLog("runUnit", { phase: "send-message", unitType, unitId });
166
175
  pi.sendMessage({ customType: "gsd-auto", content: prompt, display: s.verbose }, { triggerTurn: true });
@@ -498,7 +507,7 @@ export async function autoLoop(ctx, pi, s, deps) {
498
507
  }
499
508
  // Secrets re-check gate
500
509
  try {
501
- const manifestStatus = await deps.getManifestStatus(s.basePath, mid);
510
+ const manifestStatus = await deps.getManifestStatus(s.basePath, mid, s.originalBasePath);
502
511
  if (manifestStatus && manifestStatus.pending.length > 0) {
503
512
  const result = await deps.collectSecretsFromManifest(s.basePath, mid, ctx);
504
513
  if (result &&
@@ -6,11 +6,13 @@
6
6
  * Pure functions that receive all needed state as parameters — no module-level
7
7
  * globals or AutoContext dependency.
8
8
  */
9
+ import { parseUnitId } from "./unit-id.js";
9
10
  import { clearUnitRuntimeRecord } from "./unit-runtime.js";
10
11
  import { clearParseCache, parseRoadmap, parsePlan } from "./files.js";
11
12
  import { isValidationTerminal } from "./state.js";
12
13
  import { nativeConflictFiles, nativeCommit, nativeCheckoutTheirs, nativeAddPaths, nativeMergeAbort, nativeResetHard, } from "./native-git-bridge.js";
13
14
  import { resolveMilestonePath, resolveSlicePath, resolveSliceFile, resolveTasksDir, relMilestoneFile, relSliceFile, relSlicePath, relTaskFile, buildMilestoneFileName, buildSliceFileName, buildTaskFileName, resolveMilestoneFile, clearPathCache, resolveGsdRootFile, } from "./paths.js";
15
+ import { markSliceDoneInRoadmap } from "./roadmap-mutations.js";
14
16
  import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, } from "node:fs";
15
17
  import { dirname, join } from "node:path";
16
18
  // ─── Artifact Resolution & Verification ───────────────────────────────────────
@@ -432,6 +434,39 @@ export async function selfHealRuntimeRecords(base, ctx) {
432
434
  const now = Date.now();
433
435
  for (const record of records) {
434
436
  const { unitType, unitId } = record;
437
+ // Case 0: complete-slice with SUMMARY + UAT but unchecked roadmap (#1350).
438
+ // If a complete-slice was interrupted after writing artifacts but before
439
+ // flipping the roadmap checkbox, the verification fails and the dispatch
440
+ // loop relaunches the same unit forever. Auto-fix the checkbox.
441
+ if (unitType === "complete-slice") {
442
+ const { milestone: mid, slice: sid } = parseUnitId(unitId);
443
+ if (mid && sid) {
444
+ const dir = resolveSlicePath(base, mid, sid);
445
+ if (dir) {
446
+ const summaryPath = join(dir, buildSliceFileName(sid, "SUMMARY"));
447
+ const uatPath = join(dir, buildSliceFileName(sid, "UAT"));
448
+ if (existsSync(summaryPath) && existsSync(uatPath)) {
449
+ const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP");
450
+ if (roadmapFile && existsSync(roadmapFile)) {
451
+ try {
452
+ const roadmapContent = readFileSync(roadmapFile, "utf-8");
453
+ const roadmap = parseRoadmap(roadmapContent);
454
+ const slice = (roadmap.slices ?? []).find(s => s.id === sid);
455
+ if (slice && !slice.done) {
456
+ // Auto-fix: flip the checkbox using shared utility
457
+ if (markSliceDoneInRoadmap(base, mid, sid)) {
458
+ ctx.ui.notify(`Self-heal: marked ${sid} done in roadmap (SUMMARY + UAT exist but checkbox was stale).`, "info");
459
+ }
460
+ }
461
+ }
462
+ catch {
463
+ // Roadmap parse failure — don't block self-heal
464
+ }
465
+ }
466
+ }
467
+ }
468
+ }
469
+ }
435
470
  // Clear stale dispatched records (dispatched > 1h ago, process crashed)
436
471
  const age = now - (record.startedAt ?? 0);
437
472
  if (record.phase === "dispatched" && age > STALE_THRESHOLD_MS) {
@@ -11,6 +11,8 @@
11
11
  import { deriveState } from "./state.js";
12
12
  import { loadFile, getManifestStatus } from "./files.js";
13
13
  import { loadEffectiveGSDPreferences, resolveSkillDiscoveryMode, getIsolationMode, } from "./preferences.js";
14
+ import { ensureGsdSymlink } from "./repo-identity.js";
15
+ import { migrateToExternalState, recoverFailedMigration } from "./migrate-external.js";
14
16
  import { collectSecretsFromManifest } from "../get-secrets-from-user.js";
15
17
  import { gsdRoot, resolveMilestoneFile } from "./paths.js";
16
18
  import { invalidateAllCaches } from "./cache.js";
@@ -42,6 +44,12 @@ import { sep as pathSep } from "node:path";
42
44
  * Returns false if the bootstrap aborted (e.g., guided flow returned,
43
45
  * concurrent session detected). Returns true when ready to dispatch.
44
46
  */
47
+ /** Guard: tracks consecutive bootstrap attempts that found phase === "complete".
48
+ * Prevents the recursive dialog loop described in #1348 where
49
+ * bootstrapAutoSession → showSmartEntry → checkAutoStartAfterDiscuss → startAuto
50
+ * cycles indefinitely when the discuss workflow doesn't produce a milestone. */
51
+ let _consecutiveCompleteBootstraps = 0;
52
+ const MAX_CONSECUTIVE_COMPLETE_BOOTSTRAPS = 2;
45
53
  export async function bootstrapAutoSession(s, ctx, pi, base, verboseMode, requestedStepMode, deps) {
46
54
  const { shouldUseWorktreeIsolation, registerSigtermHandler, lockBase, buildResolver, } = deps;
47
55
  const lockResult = acquireSessionLock(base);
@@ -60,7 +68,19 @@ export async function bootstrapAutoSession(s, ctx, pi, base, verboseMode, reques
60
68
  const mainBranch = loadEffectiveGSDPreferences()?.preferences?.git?.main_branch || "main";
61
69
  nativeInit(base, mainBranch);
62
70
  }
63
- // Ensure .gitignore has baseline patterns
71
+ // Migrate legacy in-project .gsd/ to external state directory.
72
+ // Migration MUST run before ensureGitignore to avoid adding ".gsd" to
73
+ // .gitignore when .gsd/ is git-tracked (data-loss bug #1364).
74
+ recoverFailedMigration(base);
75
+ const migration = migrateToExternalState(base);
76
+ if (migration.error) {
77
+ ctx.ui.notify(`External state migration warning: ${migration.error}`, "warning");
78
+ }
79
+ // Ensure symlink exists (handles fresh projects and post-migration)
80
+ ensureGsdSymlink(base);
81
+ // Ensure .gitignore has baseline patterns.
82
+ // ensureGitignore checks for git-tracked .gsd/ files and skips the
83
+ // ".gsd" pattern if the project intentionally tracks .gsd/ in git.
64
84
  const gitPrefs = loadEffectiveGSDPreferences()?.preferences?.git;
65
85
  const commitDocs = gitPrefs?.commit_docs;
66
86
  const manageGitignore = gitPrefs?.manage_gitignore;
@@ -186,6 +206,16 @@ export async function bootstrapAutoSession(s, ctx, pi, base, verboseMode, reques
186
206
  if (!hasSurvivorBranch) {
187
207
  // No active work — start a new milestone via discuss flow
188
208
  if (!state.activeMilestone || state.phase === "complete") {
209
+ // Guard against recursive dialog loop (#1348):
210
+ // If we've entered this branch multiple times in quick succession,
211
+ // the discuss workflow isn't producing a milestone. Break the cycle.
212
+ _consecutiveCompleteBootstraps++;
213
+ if (_consecutiveCompleteBootstraps > MAX_CONSECUTIVE_COMPLETE_BOOTSTRAPS) {
214
+ _consecutiveCompleteBootstraps = 0;
215
+ ctx.ui.notify("All milestones are complete and the discussion didn't produce a new one. " +
216
+ "Run /gsd to start a new milestone manually.", "warning");
217
+ return releaseLockAndReturn();
218
+ }
189
219
  const { showSmartEntry } = await import("./guided-flow.js");
190
220
  await showSmartEntry(ctx, pi, base, { step: requestedStepMode });
191
221
  invalidateAllCaches();
@@ -193,6 +223,7 @@ export async function bootstrapAutoSession(s, ctx, pi, base, verboseMode, reques
193
223
  if (postState.activeMilestone &&
194
224
  postState.phase !== "complete" &&
195
225
  postState.phase !== "pre-planning") {
226
+ _consecutiveCompleteBootstraps = 0; // Successfully advanced past "complete"
196
227
  state = postState;
197
228
  }
198
229
  else if (postState.activeMilestone &&
@@ -237,6 +268,8 @@ export async function bootstrapAutoSession(s, ctx, pi, base, verboseMode, reques
237
268
  await showSmartEntry(ctx, pi, base, { step: requestedStepMode });
238
269
  return releaseLockAndReturn();
239
270
  }
271
+ // Successfully resolved an active milestone — reset the re-entry guard
272
+ _consecutiveCompleteBootstraps = 0;
240
273
  // ── Initialize session state ──
241
274
  s.active = true;
242
275
  s.stepMode = requestedStepMode;
@@ -345,7 +378,7 @@ export async function bootstrapAutoSession(s, ctx, pi, base, verboseMode, reques
345
378
  // Secrets collection gate
346
379
  const mid = state.activeMilestone.id;
347
380
  try {
348
- const manifestStatus = await getManifestStatus(base, mid);
381
+ const manifestStatus = await getManifestStatus(base, mid, s.originalBasePath || base);
349
382
  if (manifestStatus && manifestStatus.pending.length > 0) {
350
383
  const result = await collectSecretsFromManifest(base, mid, ctx);
351
384
  if (result &&
@@ -36,7 +36,7 @@ import { clearSkillSnapshot } from "./skill-discovery.js";
36
36
  import { captureAvailableSkills, resetSkillTelemetry, } from "./skill-telemetry.js";
37
37
  import { initMetrics, resetMetrics, getLedger, getProjectTotals, formatCost, formatTokenCount, } from "./metrics.js";
38
38
  import { join } from "node:path";
39
- import { readFileSync, existsSync, mkdirSync } from "node:fs";
39
+ import { readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs";
40
40
  import { atomicWriteSync } from "./atomic-write.js";
41
41
  import { autoCommitCurrentBranch, captureIntegrationBranch, detectWorktreeName, getCurrentBranch, getMainBranch, setActiveMilestoneId, } from "./worktree.js";
42
42
  import { GitServiceImpl } from "./git-service.js";
@@ -325,6 +325,13 @@ export async function stopAuto(ctx, pi, reason) {
325
325
  resetHookState();
326
326
  if (s.basePath)
327
327
  clearPersistedHookState(s.basePath);
328
+ // Remove paused-session metadata if present (#1383)
329
+ try {
330
+ const pausedPath = join(gsdRoot(s.originalBasePath || s.basePath), "runtime", "paused-session.json");
331
+ if (existsSync(pausedPath))
332
+ unlinkSync(pausedPath);
333
+ }
334
+ catch { /* non-fatal */ }
328
335
  s.active = false;
329
336
  s.paused = false;
330
337
  s.stepMode = false;
@@ -369,10 +376,28 @@ export async function pauseAuto(ctx, _pi) {
369
376
  return;
370
377
  clearUnitTimeout();
371
378
  s.pausedSessionFile = ctx?.sessionManager?.getSessionFile() ?? null;
372
- if (lockBase())
373
- clearLock(lockBase());
374
- if (lockBase())
379
+ // Persist paused-session metadata so resume survives /exit (#1383).
380
+ // The fresh-start bootstrap checks for this file and restores worktree context.
381
+ try {
382
+ const pausedMeta = {
383
+ milestoneId: s.currentMilestoneId,
384
+ worktreePath: isInAutoWorktree(s.basePath) ? s.basePath : null,
385
+ originalBasePath: s.originalBasePath,
386
+ stepMode: s.stepMode,
387
+ pausedAt: new Date().toISOString(),
388
+ sessionFile: s.pausedSessionFile,
389
+ };
390
+ const runtimeDir = join(gsdRoot(s.originalBasePath || s.basePath), "runtime");
391
+ mkdirSync(runtimeDir, { recursive: true });
392
+ writeFileSync(join(runtimeDir, "paused-session.json"), JSON.stringify(pausedMeta, null, 2), "utf-8");
393
+ }
394
+ catch {
395
+ // Non-fatal — resume will still work via full bootstrap, just without worktree context
396
+ }
397
+ if (lockBase()) {
375
398
  releaseSessionLock(lockBase());
399
+ clearLock(lockBase());
400
+ }
376
401
  deregisterSigtermHandler();
377
402
  s.active = false;
378
403
  s.paused = true;
@@ -520,6 +545,30 @@ export async function startAuto(ctx, pi, base, verboseMode, options) {
520
545
  // Escape stale worktree cwd from a previous milestone (#608).
521
546
  base = escapeStaleWorktree(base);
522
547
  // If resuming from paused state, just re-activate and dispatch next unit.
548
+ // Check persisted paused-session first (#1383) — survives /exit.
549
+ if (!s.paused) {
550
+ try {
551
+ const pausedPath = join(gsdRoot(base), "runtime", "paused-session.json");
552
+ if (existsSync(pausedPath)) {
553
+ const meta = JSON.parse(readFileSync(pausedPath, "utf-8"));
554
+ if (meta.milestoneId) {
555
+ s.currentMilestoneId = meta.milestoneId;
556
+ s.originalBasePath = meta.originalBasePath || base;
557
+ s.stepMode = meta.stepMode ?? requestedStepMode;
558
+ s.paused = true;
559
+ // Clean up the persisted file — we're consuming it
560
+ try {
561
+ unlinkSync(pausedPath);
562
+ }
563
+ catch { /* non-fatal */ }
564
+ ctx.ui.notify(`Resuming paused session for ${meta.milestoneId}${meta.worktreePath ? ` (worktree)` : ""}.`, "info");
565
+ }
566
+ }
567
+ }
568
+ catch {
569
+ // Malformed or missing — proceed with fresh bootstrap
570
+ }
571
+ }
523
572
  if (s.paused) {
524
573
  const resumeLock = acquireSessionLock(base);
525
574
  if (!resumeLock.acquired) {
@@ -754,6 +803,12 @@ export async function dispatchHookUnit(ctx, pi, hookName, triggerUnitType, trigg
754
803
  }, hookHardTimeoutMs);
755
804
  ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto");
756
805
  ctx.ui.notify(`Running post-unit hook: ${hookName}`, "info");
806
+ // Ensure cwd matches basePath before hook dispatch (#1389)
807
+ try {
808
+ if (process.cwd() !== s.basePath)
809
+ process.chdir(s.basePath);
810
+ }
811
+ catch { }
757
812
  debugLog("dispatchHookUnit", {
758
813
  phase: "send-message",
759
814
  promptLength: hookPrompt.length,
@@ -15,7 +15,7 @@ import { isAutoActive } from "./auto.js";
15
15
  import { projectRoot } from "./commands.js";
16
16
  import { loadPrompt } from "./prompt-loader.js";
17
17
  export function dispatchDoctorHeal(pi, scope, reportText, structuredIssues) {
18
- const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".pi", "GSD-WORKFLOW.md");
18
+ const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".gsd", "agent", "GSD-WORKFLOW.md");
19
19
  const workflow = readFileSync(workflowPath, "utf-8");
20
20
  const prompt = loadPrompt("doctor-heal", {
21
21
  doctorSummary: reportText,
@@ -144,7 +144,7 @@ export async function handleTriage(ctx, pi, basePath) {
144
144
  currentPlan: currentPlan || "(no active slice plan)",
145
145
  roadmapContext: roadmapContext || "(no active roadmap)",
146
146
  });
147
- const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".pi", "GSD-WORKFLOW.md");
147
+ const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".gsd", "agent", "GSD-WORKFLOW.md");
148
148
  const workflow = readFileSync(workflowPath, "utf-8");
149
149
  pi.sendMessage({
150
150
  customType: "gsd-triage",