gsd-pi 2.38.0-dev.96dc7fb → 2.38.0-dev.bc2e21e

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 (78) hide show
  1. package/dist/app-paths.js +1 -1
  2. package/dist/extension-registry.js +2 -2
  3. package/dist/remote-questions-config.js +2 -2
  4. package/dist/resources/extensions/env-utils.js +29 -0
  5. package/dist/resources/extensions/get-secrets-from-user.js +5 -24
  6. package/dist/resources/extensions/gsd/auto/session.js +3 -0
  7. package/dist/resources/extensions/gsd/auto-dispatch.js +7 -8
  8. package/dist/resources/extensions/gsd/auto-loop.js +54 -30
  9. package/dist/resources/extensions/gsd/auto-post-unit.js +75 -71
  10. package/dist/resources/extensions/gsd/auto-worktree-sync.js +2 -1
  11. package/dist/resources/extensions/gsd/auto.js +10 -26
  12. package/dist/resources/extensions/gsd/commands-extensions.js +3 -2
  13. package/dist/resources/extensions/gsd/commands.js +2 -1
  14. package/dist/resources/extensions/gsd/detection.js +1 -2
  15. package/dist/resources/extensions/gsd/export.js +1 -1
  16. package/dist/resources/extensions/gsd/files.js +2 -2
  17. package/dist/resources/extensions/gsd/forensics.js +1 -1
  18. package/dist/resources/extensions/gsd/index.js +2 -1
  19. package/dist/resources/extensions/gsd/migrate/parsers.js +1 -1
  20. package/dist/resources/extensions/gsd/preferences-validation.js +1 -1
  21. package/dist/resources/extensions/gsd/preferences.js +4 -3
  22. package/dist/resources/extensions/gsd/prompts/discuss.md +11 -14
  23. package/dist/resources/extensions/gsd/prompts/execute-task.md +2 -2
  24. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +11 -12
  25. package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +8 -10
  26. package/dist/resources/extensions/gsd/prompts/guided-resume-task.md +1 -1
  27. package/dist/resources/extensions/gsd/prompts/queue.md +4 -8
  28. package/dist/resources/extensions/gsd/prompts/reactive-execute.md +11 -8
  29. package/dist/resources/extensions/gsd/prompts/run-uat.md +25 -10
  30. package/dist/resources/extensions/gsd/prompts/workflow-start.md +2 -2
  31. package/dist/resources/extensions/gsd/repo-identity.js +2 -1
  32. package/dist/resources/extensions/gsd/resource-version.js +2 -1
  33. package/dist/resources/extensions/gsd/state.js +1 -1
  34. package/dist/resources/extensions/gsd/visualizer-data.js +1 -1
  35. package/dist/resources/extensions/remote-questions/status.js +2 -1
  36. package/dist/resources/extensions/remote-questions/store.js +2 -1
  37. package/dist/resources/extensions/search-the-web/provider.js +2 -1
  38. package/dist/resources/extensions/subagent/isolation.js +2 -1
  39. package/dist/resources/extensions/ttsr/rule-loader.js +2 -1
  40. package/package.json +1 -1
  41. package/src/resources/extensions/env-utils.ts +31 -0
  42. package/src/resources/extensions/get-secrets-from-user.ts +5 -24
  43. package/src/resources/extensions/gsd/auto/session.ts +5 -1
  44. package/src/resources/extensions/gsd/auto-dispatch.ts +6 -8
  45. package/src/resources/extensions/gsd/auto-loop.ts +70 -63
  46. package/src/resources/extensions/gsd/auto-post-unit.ts +52 -42
  47. package/src/resources/extensions/gsd/auto-worktree-sync.ts +3 -1
  48. package/src/resources/extensions/gsd/auto.ts +14 -29
  49. package/src/resources/extensions/gsd/commands-extensions.ts +4 -2
  50. package/src/resources/extensions/gsd/commands.ts +3 -1
  51. package/src/resources/extensions/gsd/detection.ts +2 -2
  52. package/src/resources/extensions/gsd/export.ts +1 -1
  53. package/src/resources/extensions/gsd/files.ts +2 -2
  54. package/src/resources/extensions/gsd/forensics.ts +1 -1
  55. package/src/resources/extensions/gsd/index.ts +3 -1
  56. package/src/resources/extensions/gsd/migrate/parsers.ts +1 -1
  57. package/src/resources/extensions/gsd/preferences-validation.ts +1 -1
  58. package/src/resources/extensions/gsd/preferences.ts +5 -3
  59. package/src/resources/extensions/gsd/prompts/discuss.md +11 -14
  60. package/src/resources/extensions/gsd/prompts/execute-task.md +2 -2
  61. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +11 -12
  62. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +8 -10
  63. package/src/resources/extensions/gsd/prompts/guided-resume-task.md +1 -1
  64. package/src/resources/extensions/gsd/prompts/queue.md +4 -8
  65. package/src/resources/extensions/gsd/prompts/reactive-execute.md +11 -8
  66. package/src/resources/extensions/gsd/prompts/run-uat.md +25 -10
  67. package/src/resources/extensions/gsd/prompts/workflow-start.md +2 -2
  68. package/src/resources/extensions/gsd/repo-identity.ts +3 -1
  69. package/src/resources/extensions/gsd/resource-version.ts +3 -1
  70. package/src/resources/extensions/gsd/state.ts +1 -1
  71. package/src/resources/extensions/gsd/tests/prompt-contracts.test.ts +59 -0
  72. package/src/resources/extensions/gsd/tests/run-uat.test.ts +11 -3
  73. package/src/resources/extensions/gsd/visualizer-data.ts +1 -1
  74. package/src/resources/extensions/remote-questions/status.ts +3 -1
  75. package/src/resources/extensions/remote-questions/store.ts +3 -1
  76. package/src/resources/extensions/search-the-web/provider.ts +2 -1
  77. package/src/resources/extensions/subagent/isolation.ts +3 -1
  78. package/src/resources/extensions/ttsr/rule-loader.ts +3 -1
package/dist/app-paths.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { homedir } from 'os';
2
2
  import { join } from 'path';
3
- export const appRoot = join(homedir(), '.gsd');
3
+ export const appRoot = process.env.GSD_HOME || join(homedir(), '.gsd');
4
4
  export const agentDir = join(appRoot, 'agent');
5
5
  export const sessionsDir = join(appRoot, 'sessions');
6
6
  export const authFilePath = join(agentDir, 'auth.json');
@@ -6,7 +6,7 @@
6
6
  * The only way an extension stops loading is an explicit `gsd extensions disable <id>`.
7
7
  */
8
8
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
9
- import { homedir } from "node:os";
9
+ import { appRoot } from "./app-paths.js";
10
10
  import { dirname, join } from "node:path";
11
11
  // ─── Validation ─────────────────────────────────────────────────────────────
12
12
  function isRegistry(data) {
@@ -26,7 +26,7 @@ function isManifest(data) {
26
26
  }
27
27
  // ─── Registry Path ──────────────────────────────────────────────────────────
28
28
  export function getRegistryPath() {
29
- return join(homedir(), ".gsd", "extensions", "registry.json");
29
+ return join(appRoot, "extensions", "registry.json");
30
30
  }
31
31
  // ─── Registry I/O ───────────────────────────────────────────────────────────
32
32
  function defaultRegistry() {
@@ -9,12 +9,12 @@
9
9
  */
10
10
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
11
11
  import { dirname, join } from "node:path";
12
- import { homedir } from "node:os";
12
+ import { appRoot } from "./app-paths.js";
13
13
  // Inlined from preferences.ts to avoid crossing the compiled/uncompiled
14
14
  // boundary — this file is compiled by tsc, but preferences.ts is loaded
15
15
  // via jiti at runtime. Importing it as .js fails because no .js exists
16
16
  // in dist/. See #592, #1110.
17
- const GLOBAL_PREFERENCES_PATH = join(homedir(), ".gsd", "preferences.md");
17
+ const GLOBAL_PREFERENCES_PATH = join(appRoot, "preferences.md");
18
18
  export function saveRemoteQuestionsConfig(channel, channelId) {
19
19
  const prefsPath = GLOBAL_PREFERENCES_PATH;
20
20
  const block = [
@@ -0,0 +1,29 @@
1
+ // GSD Extension — Environment variable utilities
2
+ // Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
3
+ //
4
+ // Pure utility for checking existing env keys in .env files and process.env.
5
+ // Extracted from get-secrets-from-user.ts to avoid pulling in @gsd/pi-tui
6
+ // when only env-checking is needed (e.g. from files.ts during report generation).
7
+ import { readFile } from "node:fs/promises";
8
+ /**
9
+ * Check which keys already exist in a .env file or process.env.
10
+ * Returns the subset of `keys` that are already set.
11
+ */
12
+ export async function checkExistingEnvKeys(keys, envFilePath) {
13
+ let fileContent = "";
14
+ try {
15
+ fileContent = await readFile(envFilePath, "utf8");
16
+ }
17
+ catch {
18
+ // ENOENT or other read error — proceed with empty content
19
+ }
20
+ const existing = [];
21
+ for (const key of keys) {
22
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
23
+ const regex = new RegExp(`^${escaped}\\s*=`, "m");
24
+ if (regex.test(fileContent) || key in process.env) {
25
+ existing.push(key);
26
+ }
27
+ }
28
+ return existing;
29
+ }
@@ -46,30 +46,11 @@ async function writeEnvKey(filePath, key, value) {
46
46
  await writeFile(filePath, content, "utf8");
47
47
  }
48
48
  // ─── Exported utilities ───────────────────────────────────────────────────────
49
- /**
50
- * Check which keys already exist in the .env file or process.env.
51
- * Returns the subset of `keys` that are already set.
52
- * Handles ENOENT gracefully (still checks process.env).
53
- * Empty-string values count as existing.
54
- */
55
- export async function checkExistingEnvKeys(keys, envFilePath) {
56
- let fileContent = "";
57
- try {
58
- fileContent = await readFile(envFilePath, "utf8");
59
- }
60
- catch {
61
- // ENOENT or other read error — proceed with empty content
62
- }
63
- const existing = [];
64
- for (const key of keys) {
65
- const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
66
- const regex = new RegExp(`^${escaped}\\s*=`, "m");
67
- if (regex.test(fileContent) || key in process.env) {
68
- existing.push(key);
69
- }
70
- }
71
- return existing;
72
- }
49
+ // Re-export from env-utils.ts so existing consumers still work.
50
+ // The implementation lives in env-utils.ts to avoid pulling @gsd/pi-tui
51
+ // into modules that only need env-checking (e.g. files.ts during reports).
52
+ import { checkExistingEnvKeys } from "./env-utils.js";
53
+ export { checkExistingEnvKeys };
73
54
  /**
74
55
  * Detect the write destination based on project files in basePath.
75
56
  * Priority: vercel.json → convex/ dir → fallback "dotenv".
@@ -60,6 +60,8 @@ export class AutoSession {
60
60
  lastStateRebuildAt = 0;
61
61
  // ── Sidecar queue ─────────────────────────────────────────────────────
62
62
  sidecarQueue = [];
63
+ // ── Dispatch circuit breakers ──────────────────────────────────────
64
+ rewriteAttemptCount = 0;
63
65
  // ── Metrics ──────────────────────────────────────────────────────────────
64
66
  autoStartTime = 0;
65
67
  lastPromptCharCount;
@@ -160,6 +162,7 @@ export class AutoSession {
160
162
  this.lastBaselineCharCount = undefined;
161
163
  this.pendingQuickTasks = [];
162
164
  this.sidecarQueue = [];
165
+ this.rewriteAttemptCount = 0;
163
166
  // Signal handler
164
167
  this.sigtermHandler = null;
165
168
  // Loop promise state
@@ -22,25 +22,24 @@ function missingSliceStop(mid, phase) {
22
22
  }
23
23
  // ─── Rewrite Circuit Breaker ──────────────────────────────────────────────
24
24
  const MAX_REWRITE_ATTEMPTS = 3;
25
- let rewriteAttemptCount = 0;
26
- export function resetRewriteCircuitBreaker() {
27
- rewriteAttemptCount = 0;
28
- }
29
25
  // ─── Rules ────────────────────────────────────────────────────────────────
30
26
  const DISPATCH_RULES = [
31
27
  {
32
28
  name: "rewrite-docs (override gate)",
33
- match: async ({ mid, midTitle, state, basePath }) => {
29
+ match: async ({ mid, midTitle, state, basePath, session }) => {
34
30
  const pendingOverrides = await loadActiveOverrides(basePath);
35
31
  if (pendingOverrides.length === 0)
36
32
  return null;
37
- if (rewriteAttemptCount >= MAX_REWRITE_ATTEMPTS) {
33
+ const count = session?.rewriteAttemptCount ?? 0;
34
+ if (count >= MAX_REWRITE_ATTEMPTS) {
38
35
  const { resolveAllOverrides } = await import("./files.js");
39
36
  await resolveAllOverrides(basePath);
40
- rewriteAttemptCount = 0;
37
+ if (session)
38
+ session.rewriteAttemptCount = 0;
41
39
  return null;
42
40
  }
43
- rewriteAttemptCount++;
41
+ if (session)
42
+ session.rewriteAttemptCount++;
44
43
  const unitId = state.activeSlice ? `${mid}/${state.activeSlice.id}` : mid;
45
44
  return {
46
45
  action: "dispatch",
@@ -18,6 +18,13 @@ import { debugLog } from "./debug-logger.js";
18
18
  * generous headroom including retries and sidecar work.
19
19
  */
20
20
  const MAX_LOOP_ITERATIONS = 500;
21
+ /** Data-driven budget threshold notifications (75/80/90%). The 100% case is
22
+ * handled inline because it requires break/pause/stop control flow. */
23
+ const BUDGET_THRESHOLDS = [
24
+ { pct: 90, label: "Budget 90%", notifyLevel: "warning", cmuxLevel: "warning" },
25
+ { pct: 80, label: "Approaching budget ceiling — 80%", notifyLevel: "warning", cmuxLevel: "warning" },
26
+ { pct: 75, label: "Budget 75%", notifyLevel: "info", cmuxLevel: "progress" },
27
+ ];
21
28
  // ─── Session-scoped promise state ───────────────────────────────────────────
22
29
  //
23
30
  // pendingResolve and pendingAgentEndQueue live on AutoSession (not module-level)
@@ -57,9 +64,10 @@ export function resolveAgentEnd(event) {
57
64
  debugLog("resolveAgentEnd", {
58
65
  status: "queued",
59
66
  queueLength: s.pendingAgentEndQueue.length + 1,
67
+ unitId: s.currentUnit?.id,
60
68
  warning: "agent_end arrived between loop iterations — queued for next runUnit",
61
69
  });
62
- s.pendingAgentEndQueue.push(event);
70
+ s.pendingAgentEndQueue.push({ ...event, unitId: s.currentUnit?.id });
63
71
  }
64
72
  }
65
73
  export function isSessionSwitchInFlight() {
@@ -111,14 +119,35 @@ export async function runUnit(ctx, pi, s, unitType, unitId, prompt, _prefs) {
111
119
  ];
112
120
  }
113
121
  if (s.pendingAgentEndQueue.length > 0) {
114
- const queued = s.pendingAgentEndQueue.shift();
122
+ // Find an event matching this unit; discard stale events from other units
123
+ const matchIdx = s.pendingAgentEndQueue.findIndex((e) => !e.unitId || e.unitId === unitId);
124
+ if (matchIdx >= 0) {
125
+ // Discard any stale events before the match
126
+ if (matchIdx > 0) {
127
+ debugLog("runUnit", {
128
+ phase: "discarded-stale-events",
129
+ count: matchIdx,
130
+ unitType,
131
+ unitId,
132
+ });
133
+ }
134
+ const queued = s.pendingAgentEndQueue.splice(0, matchIdx + 1).pop();
135
+ debugLog("runUnit", {
136
+ phase: "drained-queued-event",
137
+ unitType,
138
+ unitId,
139
+ queueRemaining: s.pendingAgentEndQueue.length,
140
+ });
141
+ return { status: "completed", event: queued };
142
+ }
143
+ // No matching event — discard all stale events and proceed to new session
115
144
  debugLog("runUnit", {
116
- phase: "drained-queued-event",
145
+ phase: "discarded-all-stale-events",
146
+ count: s.pendingAgentEndQueue.length,
117
147
  unitType,
118
148
  unitId,
119
- queueRemaining: s.pendingAgentEndQueue.length,
120
149
  });
121
- return { status: "completed", event: queued };
150
+ s.pendingAgentEndQueue = [];
122
151
  }
123
152
  // ── Session creation with timeout ──
124
153
  debugLog("runUnit", { phase: "session-create", unitType, unitId });
@@ -493,29 +522,20 @@ export async function autoLoop(ctx, pi, s, deps) {
493
522
  deps.sendDesktopNotification("GSD", msg, "warning", "budget");
494
523
  deps.logCmuxEvent(prefs, msg, "warning");
495
524
  }
496
- else if (newBudgetAlertLevel === 90) {
497
- s.lastBudgetAlertLevel =
498
- newBudgetAlertLevel;
499
- ctx.ui.notify(`Budget 90%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`, "warning");
500
- deps.sendDesktopNotification("GSD", `Budget 90%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`, "warning", "budget");
501
- deps.logCmuxEvent(prefs, `Budget 90%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`, "warning");
502
- }
503
- else if (newBudgetAlertLevel === 80) {
504
- s.lastBudgetAlertLevel =
505
- newBudgetAlertLevel;
506
- ctx.ui.notify(`Approaching budget ceiling — 80%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`, "warning");
507
- deps.sendDesktopNotification("GSD", `Approaching budget ceiling — 80%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`, "warning", "budget");
508
- deps.logCmuxEvent(prefs, `Budget 80%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`, "warning");
509
- }
510
- else if (newBudgetAlertLevel === 75) {
511
- s.lastBudgetAlertLevel =
512
- newBudgetAlertLevel;
513
- ctx.ui.notify(`Budget 75%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`, "info");
514
- deps.sendDesktopNotification("GSD", `Budget 75%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`, "info", "budget");
515
- deps.logCmuxEvent(prefs, `Budget 75%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`, "progress");
516
- }
517
- else if (budgetAlertLevel === 0) {
518
- s.lastBudgetAlertLevel = 0;
525
+ else {
526
+ // Data-driven 75/80/90% threshold notifications
527
+ const threshold = BUDGET_THRESHOLDS.find((t) => newBudgetAlertLevel === t.pct);
528
+ if (threshold) {
529
+ s.lastBudgetAlertLevel =
530
+ newBudgetAlertLevel;
531
+ const msg = `${threshold.label}: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`;
532
+ ctx.ui.notify(msg, threshold.notifyLevel);
533
+ deps.sendDesktopNotification("GSD", msg, threshold.notifyLevel, "budget");
534
+ deps.logCmuxEvent(prefs, msg, threshold.cmuxLevel);
535
+ }
536
+ else if (budgetAlertLevel === 0) {
537
+ s.lastBudgetAlertLevel = 0;
538
+ }
519
539
  }
520
540
  }
521
541
  else {
@@ -563,6 +583,7 @@ export async function autoLoop(ctx, pi, s, deps) {
563
583
  midTitle: midTitle,
564
584
  state,
565
585
  prefs,
586
+ session: s,
566
587
  });
567
588
  if (dispatchResult.action === "stop") {
568
589
  if (s.currentUnit) {
@@ -922,8 +943,11 @@ export async function autoLoop(ctx, pi, s, deps) {
922
943
  sidecarBroke = true;
923
944
  break;
924
945
  }
925
- // Run pre-verification for the sidecar unit
926
- const sidecarPreResult = await deps.postUnitPreVerification(postUnitCtx);
946
+ // Run pre-verification for the sidecar unit (lightweight path)
947
+ const sidecarPreOpts = item.kind === "hook"
948
+ ? { skipSettleDelay: true, skipDoctor: true, skipStateRebuild: true, skipWorktreeSync: true }
949
+ : { skipSettleDelay: true, skipStateRebuild: true };
950
+ const sidecarPreResult = await deps.postUnitPreVerification(postUnitCtx, sidecarPreOpts);
927
951
  if (sidecarPreResult === "dispatched") {
928
952
  // Pre-verification caused stop/pause
929
953
  debugLog("autoLoop", {
@@ -22,7 +22,6 @@ import { writeUnitRuntimeRecord, clearUnitRuntimeRecord } from "./unit-runtime.j
22
22
  import { runGSDDoctor, rebuildState, summarizeDoctorIssues } from "./doctor.js";
23
23
  import { recordHealthSnapshot, checkHealEscalation } from "./doctor-proactive.js";
24
24
  import { syncStateToProjectRoot } from "./auto-worktree-sync.js";
25
- import { resetRewriteCircuitBreaker } from "./auto-dispatch.js";
26
25
  import { isDbAvailable } from "./gsd-db.js";
27
26
  import { consumeSignal } from "./session-status-io.js";
28
27
  import { checkPostUnitHooks, isRetryPending, consumeRetryTrigger, persistHookState, } from "./post-unit-hooks.js";
@@ -36,7 +35,7 @@ const STATE_REBUILD_MIN_INTERVAL_MS = 30_000;
36
35
  *
37
36
  * Returns "dispatched" if a signal caused stop/pause, "continue" to proceed.
38
37
  */
39
- export async function postUnitPreVerification(pctx) {
38
+ export async function postUnitPreVerification(pctx, opts) {
40
39
  const { s, ctx, pi, buildSnapshotOpts, stopAuto, pauseAuto } = pctx;
41
40
  // ── Parallel worker signal check ──
42
41
  const milestoneLock = process.env.GSD_MILESTONE_LOCK;
@@ -55,8 +54,10 @@ export async function postUnitPreVerification(pctx) {
55
54
  }
56
55
  // Invalidate all caches
57
56
  invalidateAllCaches();
58
- // Small delay to let files settle
59
- await new Promise(r => setTimeout(r, 500));
57
+ // Small delay to let files settle (skipped for sidecars where latency matters more)
58
+ if (!opts?.skipSettleDelay) {
59
+ await new Promise(r => setTimeout(r, 100));
60
+ }
60
61
  // Auto-commit
61
62
  if (s.currentUnit) {
62
63
  try {
@@ -79,8 +80,8 @@ export async function postUnitPreVerification(pctx) {
79
80
  };
80
81
  }
81
82
  }
82
- catch {
83
- // Non-fatal
83
+ catch (e) {
84
+ debugLog("postUnit", { phase: "task-summary-parse", error: String(e) });
84
85
  }
85
86
  }
86
87
  }
@@ -90,57 +91,60 @@ export async function postUnitPreVerification(pctx) {
90
91
  ctx.ui.notify(`Committed: ${commitMsg.split("\n")[0]}`, "info");
91
92
  }
92
93
  }
93
- catch {
94
- // Non-fatal
94
+ catch (e) {
95
+ debugLog("postUnit", { phase: "auto-commit", error: String(e) });
95
96
  }
96
- // Doctor: fix mechanical bookkeeping
97
- try {
98
- const scopeParts = s.currentUnit.id.split("/").slice(0, 2);
99
- const doctorScope = scopeParts.join("/");
100
- const sliceTerminalUnits = new Set(["complete-slice", "run-uat"]);
101
- const effectiveFixLevel = sliceTerminalUnits.has(s.currentUnit.type) ? "all" : "task";
102
- const report = await runGSDDoctor(s.basePath, { fix: true, scope: doctorScope, fixLevel: effectiveFixLevel });
103
- if (report.fixesApplied.length > 0) {
104
- ctx.ui.notify(`Post-hook: applied ${report.fixesApplied.length} fix(es).`, "info");
105
- }
106
- // Proactive health tracking
107
- const summary = summarizeDoctorIssues(report.issues);
108
- recordHealthSnapshot(summary.errors, summary.warnings, report.fixesApplied.length);
109
- // Check if we should escalate to LLM-assisted heal
110
- if (summary.errors > 0) {
111
- const unresolvedErrors = report.issues
112
- .filter(i => i.severity === "error" && !i.fixable)
113
- .map(i => ({ code: i.code, message: i.message, unitId: i.unitId }));
114
- const escalation = checkHealEscalation(summary.errors, unresolvedErrors);
115
- if (escalation.shouldEscalate) {
116
- ctx.ui.notify(`Doctor heal escalation: ${escalation.reason}. Dispatching LLM-assisted heal.`, "warning");
117
- try {
118
- const { formatDoctorIssuesForPrompt, formatDoctorReport } = await import("./doctor.js");
119
- const { dispatchDoctorHeal } = await import("./commands-handlers.js");
120
- const actionable = report.issues.filter(i => i.severity === "error");
121
- const reportText = formatDoctorReport(report, { scope: doctorScope, includeWarnings: true });
122
- const structuredIssues = formatDoctorIssuesForPrompt(actionable);
123
- dispatchDoctorHeal(pi, doctorScope, reportText, structuredIssues);
124
- }
125
- catch {
126
- // Non-fatal
97
+ // Doctor: fix mechanical bookkeeping (skipped for lightweight sidecars)
98
+ if (!opts?.skipDoctor)
99
+ try {
100
+ const scopeParts = s.currentUnit.id.split("/").slice(0, 2);
101
+ const doctorScope = scopeParts.join("/");
102
+ const sliceTerminalUnits = new Set(["complete-slice", "run-uat"]);
103
+ const effectiveFixLevel = sliceTerminalUnits.has(s.currentUnit.type) ? "all" : "task";
104
+ const report = await runGSDDoctor(s.basePath, { fix: true, scope: doctorScope, fixLevel: effectiveFixLevel });
105
+ if (report.fixesApplied.length > 0) {
106
+ ctx.ui.notify(`Post-hook: applied ${report.fixesApplied.length} fix(es).`, "info");
107
+ }
108
+ // Proactive health tracking
109
+ const summary = summarizeDoctorIssues(report.issues);
110
+ recordHealthSnapshot(summary.errors, summary.warnings, report.fixesApplied.length);
111
+ // Check if we should escalate to LLM-assisted heal
112
+ if (summary.errors > 0) {
113
+ const unresolvedErrors = report.issues
114
+ .filter(i => i.severity === "error" && !i.fixable)
115
+ .map(i => ({ code: i.code, message: i.message, unitId: i.unitId }));
116
+ const escalation = checkHealEscalation(summary.errors, unresolvedErrors);
117
+ if (escalation.shouldEscalate) {
118
+ ctx.ui.notify(`Doctor heal escalation: ${escalation.reason}. Dispatching LLM-assisted heal.`, "warning");
119
+ try {
120
+ const { formatDoctorIssuesForPrompt, formatDoctorReport } = await import("./doctor.js");
121
+ const { dispatchDoctorHeal } = await import("./commands-handlers.js");
122
+ const actionable = report.issues.filter(i => i.severity === "error");
123
+ const reportText = formatDoctorReport(report, { scope: doctorScope, includeWarnings: true });
124
+ const structuredIssues = formatDoctorIssuesForPrompt(actionable);
125
+ dispatchDoctorHeal(pi, doctorScope, reportText, structuredIssues);
126
+ }
127
+ catch (e) {
128
+ debugLog("postUnit", { phase: "doctor-heal-dispatch", error: String(e) });
129
+ }
127
130
  }
128
131
  }
129
132
  }
130
- }
131
- catch {
132
- // Non-fatal
133
- }
134
- // Throttled STATE.md rebuild
135
- const now = Date.now();
136
- if (now - s.lastStateRebuildAt >= STATE_REBUILD_MIN_INTERVAL_MS) {
137
- try {
138
- await rebuildState(s.basePath);
139
- s.lastStateRebuildAt = now;
140
- autoCommitCurrentBranch(s.basePath, "state-rebuild", s.currentUnit.id);
133
+ catch (e) {
134
+ debugLog("postUnit", { phase: "doctor", error: String(e) });
141
135
  }
142
- catch {
143
- // Non-fatal
136
+ // Throttled STATE.md rebuild (skipped for lightweight sidecars)
137
+ if (!opts?.skipStateRebuild) {
138
+ const now = Date.now();
139
+ if (now - s.lastStateRebuildAt >= STATE_REBUILD_MIN_INTERVAL_MS) {
140
+ try {
141
+ await rebuildState(s.basePath);
142
+ s.lastStateRebuildAt = now;
143
+ autoCommitCurrentBranch(s.basePath, "state-rebuild", s.currentUnit.id);
144
+ }
145
+ catch (e) {
146
+ debugLog("postUnit", { phase: "state-rebuild", error: String(e) });
147
+ }
144
148
  }
145
149
  }
146
150
  // Prune dead bg-shell processes
@@ -148,27 +152,27 @@ export async function postUnitPreVerification(pctx) {
148
152
  const { pruneDeadProcesses } = await import("../bg-shell/process-manager.js");
149
153
  pruneDeadProcesses();
150
154
  }
151
- catch {
152
- // Non-fatal
155
+ catch (e) {
156
+ debugLog("postUnit", { phase: "prune-bg-shell", error: String(e) });
153
157
  }
154
- // Sync worktree state back to project root
155
- if (s.originalBasePath && s.originalBasePath !== s.basePath) {
158
+ // Sync worktree state back to project root (skipped for lightweight sidecars)
159
+ if (!opts?.skipWorktreeSync && s.originalBasePath && s.originalBasePath !== s.basePath) {
156
160
  try {
157
161
  syncStateToProjectRoot(s.basePath, s.originalBasePath, s.currentMilestoneId);
158
162
  }
159
- catch {
160
- // Non-fatal
163
+ catch (e) {
164
+ debugLog("postUnit", { phase: "worktree-sync", error: String(e) });
161
165
  }
162
166
  }
163
167
  // Rewrite-docs completion
164
168
  if (s.currentUnit.type === "rewrite-docs") {
165
169
  try {
166
170
  await resolveAllOverrides(s.basePath);
167
- resetRewriteCircuitBreaker();
171
+ s.rewriteAttemptCount = 0;
168
172
  ctx.ui.notify("Override(s) resolved — rewrite-docs completed.", "info");
169
173
  }
170
- catch {
171
- // Non-fatal
174
+ catch (e) {
175
+ debugLog("postUnit", { phase: "rewrite-docs-resolve", error: String(e) });
172
176
  }
173
177
  }
174
178
  // Reactive state cleanup on slice completion
@@ -181,8 +185,8 @@ export async function postUnitPreVerification(pctx) {
181
185
  clearReactiveState(s.basePath, mid, sid);
182
186
  }
183
187
  }
184
- catch {
185
- // Non-fatal
188
+ catch (e) {
189
+ debugLog("postUnit", { phase: "reactive-state-cleanup", error: String(e) });
186
190
  }
187
191
  }
188
192
  // Post-triage: execute actionable resolutions
@@ -224,8 +228,8 @@ export async function postUnitPreVerification(pctx) {
224
228
  invalidateAllCaches();
225
229
  }
226
230
  }
227
- catch {
228
- // Non-fatal
231
+ catch (e) {
232
+ debugLog("postUnit", { phase: "artifact-verify", error: String(e) });
229
233
  }
230
234
  }
231
235
  else {
@@ -238,8 +242,8 @@ export async function postUnitPreVerification(pctx) {
238
242
  });
239
243
  clearUnitRuntimeRecord(s.basePath, s.currentUnit.type, s.currentUnit.id);
240
244
  }
241
- catch {
242
- // Non-fatal
245
+ catch (e) {
246
+ debugLog("postUnit", { phase: "hook-finalize", error: String(e) });
243
247
  }
244
248
  }
245
249
  }
@@ -352,8 +356,8 @@ export async function postUnitPostVerification(pctx) {
352
356
  }
353
357
  }
354
358
  }
355
- catch {
356
- // Triage check failure is non-fatal
359
+ catch (e) {
360
+ debugLog("postUnit", { phase: "triage-check", error: String(e) });
357
361
  }
358
362
  }
359
363
  // ── Quick-task dispatch ──
@@ -387,8 +391,8 @@ export async function postUnitPostVerification(pctx) {
387
391
  ctx.ui.notify(`Executing quick-task: ${capture.id} — "${capture.text}"`, "info");
388
392
  return "continue";
389
393
  }
390
- catch {
391
- // Non-fatal proceed to normal dispatch
394
+ catch (e) {
395
+ debugLog("postUnit", { phase: "quick-task-dispatch", error: String(e) });
392
396
  }
393
397
  }
394
398
  // Step mode → show wizard instead of dispatch
@@ -13,6 +13,7 @@ import { existsSync, readFileSync, unlinkSync, readdirSync, } from "node:fs";
13
13
  import { join, sep as pathSep } from "node:path";
14
14
  import { homedir } from "node:os";
15
15
  import { safeCopy, safeCopyRecursive } from "./safe-fs.js";
16
+ const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd");
16
17
  // ─── Project Root → Worktree Sync ─────────────────────────────────────────
17
18
  /**
18
19
  * Sync milestone artifacts from project root INTO worktree before deriveState.
@@ -75,7 +76,7 @@ export function syncStateToProjectRoot(worktreePath, projectRoot, milestoneId) {
75
76
  * doesn't falsely trigger staleness (#804).
76
77
  */
77
78
  export function readResourceVersion() {
78
- const agentDir = process.env.GSD_CODING_AGENT_DIR || join(homedir(), ".gsd", "agent");
79
+ const agentDir = process.env.GSD_CODING_AGENT_DIR || join(gsdHome, "agent");
79
80
  const manifestPath = join(agentDir, "managed-resources.json");
80
81
  try {
81
82
  const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
@@ -374,39 +374,23 @@ export async function stopAuto(ctx, pi, reason) {
374
374
  unlinkSync(pausedPath);
375
375
  }
376
376
  catch { /* non-fatal */ }
377
- s.active = false;
378
- s.paused = false;
379
- s.stepMode = false;
380
- s.unitDispatchCount.clear();
381
- s.unitRecoveryCount.clear();
377
+ // Restore original model before reset() clears the IDs
378
+ if (pi && ctx && s.originalModelId && s.originalModelProvider) {
379
+ const original = ctx.modelRegistry.find(s.originalModelProvider, s.originalModelId);
380
+ if (original)
381
+ await pi.setModel(original);
382
+ }
383
+ // External cleanup (not covered by session reset)
382
384
  clearInFlightTools();
383
- s.lastBudgetAlertLevel = 0;
384
- s.lastStateRebuildAt = 0;
385
- s.unitLifetimeDispatches.clear();
386
- s.currentUnit = null;
387
- s.autoModeStartModel = null;
388
- s.currentMilestoneId = null;
389
- s.originalBasePath = "";
390
- s.completedUnits = [];
391
- s.pendingQuickTasks = [];
392
385
  clearSliceProgressCache();
393
386
  clearActivityLogState();
394
387
  resetProactiveHealing();
395
- s.pendingCrashRecovery = null;
396
- s.pendingVerificationRetry = null;
397
- s.verificationRetryCount.clear();
398
- s.pausedSessionFile = null;
388
+ // UI cleanup
399
389
  ctx?.ui.setStatus("gsd-auto", undefined);
400
390
  ctx?.ui.setWidget("gsd-progress", undefined);
401
391
  ctx?.ui.setFooter(undefined);
402
- if (pi && ctx && s.originalModelId && s.originalModelProvider) {
403
- const original = ctx.modelRegistry.find(s.originalModelProvider, s.originalModelId);
404
- if (original)
405
- await pi.setModel(original);
406
- s.originalModelId = null;
407
- s.originalModelProvider = null;
408
- }
409
- s.cmdCtx = null;
392
+ // Reset all session state in one call
393
+ s.reset();
410
394
  }
411
395
  /**
412
396
  * Pause auto-mode without destroying state. Context is preserved.
@@ -8,12 +8,13 @@
8
8
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
9
9
  import { dirname, join } from "node:path";
10
10
  import { homedir } from "node:os";
11
+ const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd");
11
12
  // ─── Registry I/O ───────────────────────────────────────────────────────────
12
13
  function getRegistryPath() {
13
- return join(homedir(), ".gsd", "extensions", "registry.json");
14
+ return join(gsdHome, "extensions", "registry.json");
14
15
  }
15
16
  function getAgentExtensionsDir() {
16
- return join(homedir(), ".gsd", "agent", "extensions");
17
+ return join(gsdHome, "agent", "extensions");
17
18
  }
18
19
  function loadRegistry() {
19
20
  const filePath = getRegistryPath();
@@ -7,6 +7,7 @@ import { existsSync, readFileSync, readdirSync, unlinkSync } from "node:fs";
7
7
  import { homedir } from "node:os";
8
8
  import { join } from "node:path";
9
9
  import { gsdRoot } from "./paths.js";
10
+ const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd");
10
11
  import { enableDebug } from "./debug-logger.js";
11
12
  import { deriveState } from "./state.js";
12
13
  import { GSDDashboardOverlay } from "./dashboard-overlay.js";
@@ -437,7 +438,7 @@ export function registerGSDCommand(pi) {
437
438
  if (parts.length === 3 && ["enable", "disable", "info"].includes(parts[1])) {
438
439
  const idPrefix = parts[2] ?? "";
439
440
  try {
440
- const extDir = join(homedir(), ".gsd", "agent", "extensions");
441
+ const extDir = join(gsdHome, "agent", "extensions");
441
442
  const ids = [];
442
443
  for (const entry of readdirSync(extDir, { withFileTypes: true })) {
443
444
  if (!entry.isDirectory())