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

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 (58) 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/repo-identity.js +2 -1
  23. package/dist/resources/extensions/gsd/resource-version.js +2 -1
  24. package/dist/resources/extensions/gsd/state.js +1 -1
  25. package/dist/resources/extensions/gsd/visualizer-data.js +1 -1
  26. package/dist/resources/extensions/remote-questions/status.js +2 -1
  27. package/dist/resources/extensions/remote-questions/store.js +2 -1
  28. package/dist/resources/extensions/search-the-web/provider.js +2 -1
  29. package/dist/resources/extensions/subagent/isolation.js +2 -1
  30. package/dist/resources/extensions/ttsr/rule-loader.js +2 -1
  31. package/package.json +1 -1
  32. package/src/resources/extensions/env-utils.ts +31 -0
  33. package/src/resources/extensions/get-secrets-from-user.ts +5 -24
  34. package/src/resources/extensions/gsd/auto/session.ts +5 -1
  35. package/src/resources/extensions/gsd/auto-dispatch.ts +6 -8
  36. package/src/resources/extensions/gsd/auto-loop.ts +70 -63
  37. package/src/resources/extensions/gsd/auto-post-unit.ts +52 -42
  38. package/src/resources/extensions/gsd/auto-worktree-sync.ts +3 -1
  39. package/src/resources/extensions/gsd/auto.ts +14 -29
  40. package/src/resources/extensions/gsd/commands-extensions.ts +4 -2
  41. package/src/resources/extensions/gsd/commands.ts +3 -1
  42. package/src/resources/extensions/gsd/detection.ts +2 -2
  43. package/src/resources/extensions/gsd/export.ts +1 -1
  44. package/src/resources/extensions/gsd/files.ts +2 -2
  45. package/src/resources/extensions/gsd/forensics.ts +1 -1
  46. package/src/resources/extensions/gsd/index.ts +3 -1
  47. package/src/resources/extensions/gsd/migrate/parsers.ts +1 -1
  48. package/src/resources/extensions/gsd/preferences-validation.ts +1 -1
  49. package/src/resources/extensions/gsd/preferences.ts +5 -3
  50. package/src/resources/extensions/gsd/repo-identity.ts +3 -1
  51. package/src/resources/extensions/gsd/resource-version.ts +3 -1
  52. package/src/resources/extensions/gsd/state.ts +1 -1
  53. package/src/resources/extensions/gsd/visualizer-data.ts +1 -1
  54. package/src/resources/extensions/remote-questions/status.ts +3 -1
  55. package/src/resources/extensions/remote-questions/store.ts +3 -1
  56. package/src/resources/extensions/search-the-web/provider.ts +2 -1
  57. package/src/resources/extensions/subagent/isolation.ts +3 -1
  58. package/src/resources/extensions/ttsr/rule-loader.ts +3 -1
@@ -9,6 +9,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
  import { homedir } from "node:os";
11
11
  import { gsdRoot } from "./paths.js";
12
+ const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd");
12
13
  // ─── Project File Markers ───────────────────────────────────────────────────────
13
14
  const PROJECT_FILES = [
14
15
  "package.json",
@@ -309,7 +310,6 @@ function detectVerificationCommands(basePath, detectedFiles, packageManager) {
309
310
  * Check if global GSD setup exists (has ~/.gsd/ with preferences).
310
311
  */
311
312
  export function hasGlobalSetup() {
312
- const gsdHome = join(homedir(), ".gsd");
313
313
  return (existsSync(join(gsdHome, "preferences.md")) ||
314
314
  existsSync(join(gsdHome, "PREFERENCES.md")));
315
315
  }
@@ -318,7 +318,6 @@ export function hasGlobalSetup() {
318
318
  * Returns true if ~/.gsd/ doesn't exist or has no preferences or auth.
319
319
  */
320
320
  export function isFirstEverLaunch() {
321
- const gsdHome = join(homedir(), ".gsd");
322
321
  if (!existsSync(gsdHome))
323
322
  return true;
324
323
  // If we have preferences, not first launch
@@ -5,7 +5,7 @@ import { join, basename } from "node:path";
5
5
  import { exec } from "node:child_process";
6
6
  import { getLedger, getProjectTotals, aggregateByPhase, aggregateBySlice, aggregateByModel, formatCost, formatTokenCount, loadLedgerFromDisk, } from "./metrics.js";
7
7
  import { gsdRoot } from "./paths.js";
8
- import { formatDuration, fileLink } from "../shared/mod.js";
8
+ import { formatDuration, fileLink } from "../shared/format-utils.js";
9
9
  import { getErrorMessage } from "./error-utils.js";
10
10
  /**
11
11
  * Open a file in the user's default browser.
@@ -6,8 +6,8 @@ import { promises as fs } from 'node:fs';
6
6
  import { resolve } from 'node:path';
7
7
  import { atomicWriteAsync } from './atomic-write.js';
8
8
  import { resolveMilestoneFile, relMilestoneFile, resolveGsdRootFile } from './paths.js';
9
- import { findMilestoneIds } from './guided-flow.js';
10
- import { checkExistingEnvKeys } from '../get-secrets-from-user.js';
9
+ import { findMilestoneIds } from './milestone-ids.js';
10
+ import { checkExistingEnvKeys } from '../env-utils.js';
11
11
  import { parseRoadmapSlices } from './roadmap-slices.js';
12
12
  import { nativeParseRoadmap, nativeExtractSection, nativeParsePlanFile, nativeParseSummaryFile, NATIVE_UNAVAILABLE } from './native-parser-bridge.js';
13
13
  import { debugTime, debugCount } from './debug-logger.js';
@@ -21,7 +21,7 @@ import { deriveState } from "./state.js";
21
21
  import { isAutoActive } from "./auto.js";
22
22
  import { loadPrompt } from "./prompt-loader.js";
23
23
  import { gsdRoot } from "./paths.js";
24
- import { formatDuration } from "../shared/mod.js";
24
+ import { formatDuration } from "../shared/format-utils.js";
25
25
  import { getAutoWorktreePath } from "./auto-worktree.js";
26
26
  // ─── Entry Point ──────────────────────────────────────────────────────────────
27
27
  export async function handleForensics(args, ctx, pi) {
@@ -41,6 +41,7 @@ import { join } from "node:path";
41
41
  import { existsSync, readFileSync } from "node:fs";
42
42
  import { homedir } from "node:os";
43
43
  import { shortcutDesc } from "../shared/mod.js";
44
+ const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd");
44
45
  import { Text } from "@gsd/pi-tui";
45
46
  import { pauseAutoForProviderError, classifyProviderError } from "./provider-error-pause.js";
46
47
  import { toPosixPath } from "../shared/mod.js";
@@ -52,7 +53,7 @@ import { markCmuxPromptShown, shouldPromptToEnableCmux } from "../cmux/index.js"
52
53
  // Pi core natively supports AGENTS.md (with CLAUDE.md fallback) per directory.
53
54
  function warnDeprecatedAgentInstructions() {
54
55
  const paths = [
55
- join(homedir(), ".gsd", "agent-instructions.md"),
56
+ join(gsdHome, "agent-instructions.md"),
56
57
  join(process.cwd(), ".gsd", "agent-instructions.md"),
57
58
  ];
58
59
  for (const p of paths) {
@@ -2,7 +2,7 @@
2
2
  // Pure functions that take file content (string) and return typed data.
3
3
  // Zero Pi dependencies — uses only exported helpers from files.ts.
4
4
  import { splitFrontmatter, parseFrontmatterMap, extractBoldField } from '../files.js';
5
- import { normalizeStringArray } from '../../shared/mod.js';
5
+ import { normalizeStringArray } from '../../shared/format-utils.js';
6
6
  // Re-export PlanningProjectMeta — not in types.ts yet, use string for project field
7
7
  // Actually PlanningProjectMeta isn't in types.ts — project is stored as string | null.
8
8
  // We'll keep parseOldProject returning a simple shape.
@@ -6,7 +6,7 @@
6
6
  * together with any errors and warnings.
7
7
  */
8
8
  import { VALID_BRANCH_NAME } from "./git-service.js";
9
- import { normalizeStringArray } from "../shared/mod.js";
9
+ import { normalizeStringArray } from "../shared/format-utils.js";
10
10
  import { KNOWN_PREFERENCE_KEYS, KNOWN_UNIT_TYPES, SKILL_ACTIONS, } from "./preferences-types.js";
11
11
  const VALID_TOKEN_PROFILES = new Set(["budget", "balanced", "quality"]);
12
12
  export function validatePreferences(preferences) {
@@ -12,9 +12,10 @@
12
12
  import { existsSync, readFileSync } from "node:fs";
13
13
  import { homedir } from "node:os";
14
14
  import { join } from "node:path";
15
+ const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd");
15
16
  import { gsdRoot } from "./paths.js";
16
17
  import { parse as parseYaml } from "yaml";
17
- import { normalizeStringArray } from "../shared/mod.js";
18
+ import { normalizeStringArray } from "../shared/format-utils.js";
18
19
  import { resolveProfileDefaults as _resolveProfileDefaults } from "./preferences-models.js";
19
20
  import { MODE_DEFAULTS, } from "./preferences-types.js";
20
21
  import { validatePreferences } from "./preferences-validation.js";
@@ -26,14 +27,14 @@ export { resolveAllSkillReferences, resolveSkillDiscoveryMode, resolveSkillStale
26
27
  // ─── Re-exports: models ─────────────────────────────────────────────────────
27
28
  export { resolveModelForUnit, resolveModelWithFallbacksForUnit, getNextFallbackModel, isTransientNetworkError, validateModelId, updatePreferencesModels, resolveDynamicRoutingConfig, resolveAutoSupervisorConfig, resolveProfileDefaults, resolveEffectiveProfile, resolveInlineLevel, resolveCompressionStrategy, resolveContextSelection, resolveSearchProviderFromPreferences, } from "./preferences-models.js";
28
29
  // ─── Path Constants & Getters ───────────────────────────────────────────────
29
- const GLOBAL_PREFERENCES_PATH = join(homedir(), ".gsd", "preferences.md");
30
+ const GLOBAL_PREFERENCES_PATH = join(gsdHome, "preferences.md");
30
31
  const LEGACY_GLOBAL_PREFERENCES_PATH = join(homedir(), ".pi", "agent", "gsd-preferences.md");
31
32
  function projectPreferencesPath() {
32
33
  return join(gsdRoot(process.cwd()), "preferences.md");
33
34
  }
34
35
  // Bootstrap in gitignore.ts historically created PREFERENCES.md (uppercase) by mistake.
35
36
  // Check uppercase as a fallback so those files aren't silently ignored.
36
- const GLOBAL_PREFERENCES_PATH_UPPERCASE = join(homedir(), ".gsd", "PREFERENCES.md");
37
+ const GLOBAL_PREFERENCES_PATH_UPPERCASE = join(gsdHome, "PREFERENCES.md");
37
38
  function projectPreferencesPathUppercase() {
38
39
  return join(gsdRoot(process.cwd()), "PREFERENCES.md");
39
40
  }
@@ -10,6 +10,7 @@ import { execFileSync } from "node:child_process";
10
10
  import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, symlinkSync } from "node:fs";
11
11
  import { homedir } from "node:os";
12
12
  import { join, resolve } from "node:path";
13
+ const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd");
13
14
  // ─── Repo Identity ──────────────────────────────────────────────────────────
14
15
  /**
15
16
  * Get the git remote URL for "origin", or "" if no remote is configured.
@@ -105,7 +106,7 @@ export function repoIdentity(basePath) {
105
106
  * otherwise `~/.gsd/projects/<hash>`.
106
107
  */
107
108
  export function externalGsdRoot(basePath) {
108
- const base = process.env.GSD_STATE_DIR || join(homedir(), ".gsd");
109
+ const base = process.env.GSD_STATE_DIR || gsdHome;
109
110
  return join(base, "projects", repoIdentity(basePath));
110
111
  }
111
112
  // ─── Symlink Management ─────────────────────────────────────────────────────
@@ -9,6 +9,7 @@ import { loadJsonFileOrNull } from "./json-persistence.js";
9
9
  import { join } from "node:path";
10
10
  import { homedir } from "node:os";
11
11
  import { resolveProjectRoot } from "./worktree.js";
12
+ const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd");
12
13
  // ─── Resource Staleness ───────────────────────────────────────────────────
13
14
  /**
14
15
  * Read the resource version (semver) from the managed-resources manifest.
@@ -19,7 +20,7 @@ function isManifestWithVersion(data) {
19
20
  return data !== null && typeof data === "object" && "gsdVersion" in data && typeof data.gsdVersion === "string";
20
21
  }
21
22
  export function readResourceVersion() {
22
- const agentDir = process.env.GSD_CODING_AGENT_DIR || join(homedir(), ".gsd", "agent");
23
+ const agentDir = process.env.GSD_CODING_AGENT_DIR || join(gsdHome, "agent");
23
24
  const manifestPath = join(agentDir, "managed-resources.json");
24
25
  const manifest = loadJsonFileOrNull(manifestPath, isManifestWithVersion);
25
26
  return manifest?.gsdVersion ?? null;
@@ -3,7 +3,7 @@
3
3
  // Pure TypeScript, zero Pi dependencies.
4
4
  import { parseRoadmap, parsePlan, parseSummary, loadFile, parseRequirementCounts, parseContextDependsOn, } from './files.js';
5
5
  import { resolveMilestoneFile, resolveSlicePath, resolveSliceFile, resolveTaskFile, resolveTasksDir, resolveGsdRootFile, gsdRoot, } from './paths.js';
6
- import { findMilestoneIds } from './guided-flow.js';
6
+ import { findMilestoneIds } from './milestone-ids.js';
7
7
  import { nativeBatchParseGsdFiles } from './native-parser-bridge.js';
8
8
  import { join, resolve } from 'path';
9
9
  import { existsSync, readdirSync } from 'node:fs';
@@ -2,7 +2,7 @@
2
2
  import { existsSync, readFileSync, statSync } from 'node:fs';
3
3
  import { deriveState } from './state.js';
4
4
  import { parseRoadmap, parsePlan, parseSummary, loadFile } from './files.js';
5
- import { findMilestoneIds } from './guided-flow.js';
5
+ import { findMilestoneIds } from './milestone-ids.js';
6
6
  import { resolveMilestoneFile, resolveSliceFile, resolveGsdRootFile } from './paths.js';
7
7
  import { getLedger, getProjectTotals, aggregateByPhase, aggregateBySlice, aggregateByModel, aggregateByTier, formatTierSavings, loadLedgerFromDisk, } from './metrics.js';
8
8
  import { loadAllCaptures, countPendingCaptures } from './captures.js';
@@ -5,8 +5,9 @@ import { existsSync, readdirSync } from "node:fs";
5
5
  import { join } from "node:path";
6
6
  import { homedir } from "node:os";
7
7
  import { readPromptRecord } from "./store.js";
8
+ const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd");
8
9
  export function getLatestPromptSummary() {
9
- const runtimeDir = join(homedir(), ".gsd", "runtime", "remote-questions");
10
+ const runtimeDir = join(gsdHome, "runtime", "remote-questions");
10
11
  if (!existsSync(runtimeDir))
11
12
  return null;
12
13
  const files = readdirSync(runtimeDir).filter((f) => f.endsWith(".json"));
@@ -4,8 +4,9 @@
4
4
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { join } from "node:path";
6
6
  import { homedir } from "node:os";
7
+ const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd");
7
8
  function runtimeDir() {
8
- return join(homedir(), ".gsd", "runtime", "remote-questions");
9
+ return join(gsdHome, "runtime", "remote-questions");
9
10
  }
10
11
  function recordPath(id) {
11
12
  return join(runtimeDir(), `${id}.json`);
@@ -15,7 +15,8 @@ import { resolveSearchProviderFromPreferences } from '../gsd/preferences.js';
15
15
  // Compute authFilePath locally instead of importing from app-paths.ts,
16
16
  // because extensions are copied to ~/.gsd/agent/extensions/ at runtime
17
17
  // where the relative import '../../../app-paths.ts' doesn't resolve.
18
- const authFilePath = join(homedir(), '.gsd', 'agent', 'auth.json');
18
+ const gsdHome = process.env.GSD_HOME || join(homedir(), '.gsd');
19
+ const authFilePath = join(gsdHome, 'agent', 'auth.json');
19
20
  const VALID_PREFERENCES = new Set(['tavily', 'brave', 'ollama', 'auto']);
20
21
  const PREFERENCE_KEY = 'search_provider';
21
22
  /** Returns the Tavily API key from the environment, or empty string if not set. */
@@ -17,8 +17,9 @@ const execFile = promisify(execFileCb);
17
17
  function encodeCwd(cwd) {
18
18
  return cwd.replace(/\//g, "--");
19
19
  }
20
+ const gsdHome = process.env.GSD_HOME || path.join(os.homedir(), ".gsd");
20
21
  function getIsolationBaseDir(cwd, taskId) {
21
- return path.join(os.homedir(), ".gsd", "wt", encodeCwd(cwd), taskId);
22
+ return path.join(gsdHome, "wt", encodeCwd(cwd), taskId);
22
23
  }
23
24
  // Track active isolation dirs for cleanup on exit
24
25
  const activeIsolations = new Set();
@@ -8,6 +8,7 @@
8
8
  import { readdirSync, readFileSync, existsSync } from "node:fs";
9
9
  import { join, basename } from "node:path";
10
10
  import { homedir } from "node:os";
11
+ const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd");
11
12
  import { splitFrontmatter, parseFrontmatterMap } from "../shared/frontmatter.js";
12
13
  function parseRuleFile(filePath) {
13
14
  let content;
@@ -56,7 +57,7 @@ function scanDir(dir) {
56
57
  * Project rules override global rules with the same name.
57
58
  */
58
59
  export function loadRules(cwd) {
59
- const globalDir = join(homedir(), ".gsd", "agent", "rules");
60
+ const globalDir = join(gsdHome, "agent", "rules");
60
61
  const projectDir = join(cwd, ".gsd", "rules");
61
62
  const globalRules = scanDir(globalDir);
62
63
  const projectRules = scanDir(projectDir);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd-pi",
3
- "version": "2.38.0-dev.96dc7fb",
3
+ "version": "2.38.0-dev.add4f78",
4
4
  "description": "GSD — Get Shit Done coding agent",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -0,0 +1,31 @@
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
+
8
+ import { readFile } from "node:fs/promises";
9
+
10
+ /**
11
+ * Check which keys already exist in a .env file or process.env.
12
+ * Returns the subset of `keys` that are already set.
13
+ */
14
+ export async function checkExistingEnvKeys(keys: string[], envFilePath: string): Promise<string[]> {
15
+ let fileContent = "";
16
+ try {
17
+ fileContent = await readFile(envFilePath, "utf8");
18
+ } catch {
19
+ // ENOENT or other read error — proceed with empty content
20
+ }
21
+
22
+ const existing: string[] = [];
23
+ for (const key of keys) {
24
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
25
+ const regex = new RegExp(`^${escaped}\\s*=`, "m");
26
+ if (regex.test(fileContent) || key in process.env) {
27
+ existing.push(key);
28
+ }
29
+ }
30
+ return existing;
31
+ }
@@ -67,30 +67,11 @@ async function writeEnvKey(filePath: string, key: string, value: string): Promis
67
67
 
68
68
  // ─── Exported utilities ───────────────────────────────────────────────────────
69
69
 
70
- /**
71
- * Check which keys already exist in the .env file or process.env.
72
- * Returns the subset of `keys` that are already set.
73
- * Handles ENOENT gracefully (still checks process.env).
74
- * Empty-string values count as existing.
75
- */
76
- export async function checkExistingEnvKeys(keys: string[], envFilePath: string): Promise<string[]> {
77
- let fileContent = "";
78
- try {
79
- fileContent = await readFile(envFilePath, "utf8");
80
- } catch {
81
- // ENOENT or other read error — proceed with empty content
82
- }
83
-
84
- const existing: string[] = [];
85
- for (const key of keys) {
86
- const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
87
- const regex = new RegExp(`^${escaped}\\s*=`, "m");
88
- if (regex.test(fileContent) || key in process.env) {
89
- existing.push(key);
90
- }
91
- }
92
- return existing;
93
- }
70
+ // Re-export from env-utils.ts so existing consumers still work.
71
+ // The implementation lives in env-utils.ts to avoid pulling @gsd/pi-tui
72
+ // into modules that only need env-checking (e.g. files.ts during reports).
73
+ import { checkExistingEnvKeys } from "./env-utils.js";
74
+ export { checkExistingEnvKeys };
94
75
 
95
76
  /**
96
77
  * Detect the write destination based on project files in basePath.
@@ -124,6 +124,9 @@ export class AutoSession {
124
124
  // ── Sidecar queue ─────────────────────────────────────────────────────
125
125
  sidecarQueue: SidecarItem[] = [];
126
126
 
127
+ // ── Dispatch circuit breakers ──────────────────────────────────────
128
+ rewriteAttemptCount = 0;
129
+
127
130
  // ── Metrics ──────────────────────────────────────────────────────────────
128
131
  autoStartTime = 0;
129
132
  lastPromptCharCount: number | undefined;
@@ -154,7 +157,7 @@ export class AutoSession {
154
157
  * events between loop iterations. The next runUnit drains this queue
155
158
  * instead of waiting for a new event.
156
159
  */
157
- pendingAgentEndQueue: Array<{ messages: unknown[] }> = [];
160
+ pendingAgentEndQueue: Array<{ messages: unknown[]; unitId?: string }> = [];
158
161
 
159
162
  // ── Methods ──────────────────────────────────────────────────────────────
160
163
 
@@ -228,6 +231,7 @@ export class AutoSession {
228
231
  this.lastBaselineCharCount = undefined;
229
232
  this.pendingQuickTasks = [];
230
233
  this.sidecarQueue = [];
234
+ this.rewriteAttemptCount = 0;
231
235
 
232
236
  // Signal handler
233
237
  this.sigtermHandler = null;
@@ -62,6 +62,7 @@ export interface DispatchContext {
62
62
  midTitle: string;
63
63
  state: GSDState;
64
64
  prefs: GSDPreferences | undefined;
65
+ session?: import("./auto/session.js").AutoSession;
65
66
  }
66
67
 
67
68
  interface DispatchRule {
@@ -82,26 +83,23 @@ function missingSliceStop(mid: string, phase: string): DispatchAction {
82
83
  // ─── Rewrite Circuit Breaker ──────────────────────────────────────────────
83
84
 
84
85
  const MAX_REWRITE_ATTEMPTS = 3;
85
- let rewriteAttemptCount = 0;
86
- export function resetRewriteCircuitBreaker(): void {
87
- rewriteAttemptCount = 0;
88
- }
89
86
 
90
87
  // ─── Rules ────────────────────────────────────────────────────────────────
91
88
 
92
89
  const DISPATCH_RULES: DispatchRule[] = [
93
90
  {
94
91
  name: "rewrite-docs (override gate)",
95
- match: async ({ mid, midTitle, state, basePath }) => {
92
+ match: async ({ mid, midTitle, state, basePath, session }) => {
96
93
  const pendingOverrides = await loadActiveOverrides(basePath);
97
94
  if (pendingOverrides.length === 0) return null;
98
- if (rewriteAttemptCount >= MAX_REWRITE_ATTEMPTS) {
95
+ const count = session?.rewriteAttemptCount ?? 0;
96
+ if (count >= MAX_REWRITE_ATTEMPTS) {
99
97
  const { resolveAllOverrides } = await import("./files.js");
100
98
  await resolveAllOverrides(basePath);
101
- rewriteAttemptCount = 0;
99
+ if (session) session.rewriteAttemptCount = 0;
102
100
  return null;
103
101
  }
104
- rewriteAttemptCount++;
102
+ if (session) session.rewriteAttemptCount++;
105
103
  const unitId = state.activeSlice ? `${mid}/${state.activeSlice.id}` : mid;
106
104
  return {
107
105
  action: "dispatch",
@@ -18,7 +18,7 @@ import type { GSDPreferences } from "./preferences.js";
18
18
  import type { SessionLockStatus } from "./session-lock.js";
19
19
  import type { GSDState } from "./types.js";
20
20
  import type { CloseoutOptions } from "./auto-unit-closeout.js";
21
- import type { PostUnitContext } from "./auto-post-unit.js";
21
+ import type { PostUnitContext, PreVerificationOpts } from "./auto-post-unit.js";
22
22
  import type {
23
23
  VerificationContext,
24
24
  VerificationResult,
@@ -36,6 +36,19 @@ import type { CmuxLogLevel } from "../cmux/index.js";
36
36
  */
37
37
  const MAX_LOOP_ITERATIONS = 500;
38
38
 
39
+ /** Data-driven budget threshold notifications (75/80/90%). The 100% case is
40
+ * handled inline because it requires break/pause/stop control flow. */
41
+ const BUDGET_THRESHOLDS: Array<{
42
+ pct: number;
43
+ label: string;
44
+ notifyLevel: "info" | "warning";
45
+ cmuxLevel: "progress" | "warning";
46
+ }> = [
47
+ { pct: 90, label: "Budget 90%", notifyLevel: "warning", cmuxLevel: "warning" },
48
+ { pct: 80, label: "Approaching budget ceiling — 80%", notifyLevel: "warning", cmuxLevel: "warning" },
49
+ { pct: 75, label: "Budget 75%", notifyLevel: "info", cmuxLevel: "progress" },
50
+ ];
51
+
39
52
  // ─── Types ───────────────────────────────────────────────────────────────────
40
53
 
41
54
  /**
@@ -96,10 +109,11 @@ export function resolveAgentEnd(event: AgentEndEvent): void {
96
109
  debugLog("resolveAgentEnd", {
97
110
  status: "queued",
98
111
  queueLength: s.pendingAgentEndQueue.length + 1,
112
+ unitId: s.currentUnit?.id,
99
113
  warning:
100
114
  "agent_end arrived between loop iterations — queued for next runUnit",
101
115
  });
102
- s.pendingAgentEndQueue.push(event);
116
+ s.pendingAgentEndQueue.push({ ...event, unitId: s.currentUnit?.id });
103
117
  }
104
118
  }
105
119
 
@@ -166,14 +180,37 @@ export async function runUnit(
166
180
  ];
167
181
  }
168
182
  if (s.pendingAgentEndQueue.length > 0) {
169
- const queued = s.pendingAgentEndQueue.shift()!;
183
+ // Find an event matching this unit; discard stale events from other units
184
+ const matchIdx = s.pendingAgentEndQueue.findIndex(
185
+ (e) => !e.unitId || e.unitId === unitId,
186
+ );
187
+ if (matchIdx >= 0) {
188
+ // Discard any stale events before the match
189
+ if (matchIdx > 0) {
190
+ debugLog("runUnit", {
191
+ phase: "discarded-stale-events",
192
+ count: matchIdx,
193
+ unitType,
194
+ unitId,
195
+ });
196
+ }
197
+ const queued = s.pendingAgentEndQueue.splice(0, matchIdx + 1).pop()!;
198
+ debugLog("runUnit", {
199
+ phase: "drained-queued-event",
200
+ unitType,
201
+ unitId,
202
+ queueRemaining: s.pendingAgentEndQueue.length,
203
+ });
204
+ return { status: "completed", event: queued };
205
+ }
206
+ // No matching event — discard all stale events and proceed to new session
170
207
  debugLog("runUnit", {
171
- phase: "drained-queued-event",
208
+ phase: "discarded-all-stale-events",
209
+ count: s.pendingAgentEndQueue.length,
172
210
  unitType,
173
211
  unitId,
174
- queueRemaining: s.pendingAgentEndQueue.length,
175
212
  });
176
- return { status: "completed", event: queued };
213
+ s.pendingAgentEndQueue = [];
177
214
  }
178
215
 
179
216
  // ── Session creation with timeout ──
@@ -383,6 +420,7 @@ export interface LoopDeps {
383
420
  midTitle: string;
384
421
  state: GSDState;
385
422
  prefs: GSDPreferences | undefined;
423
+ session?: AutoSession;
386
424
  }) => Promise<DispatchAction>;
387
425
  runPreDispatchHooks: (
388
426
  unitType: string,
@@ -500,6 +538,7 @@ export interface LoopDeps {
500
538
  // Post-unit processing
501
539
  postUnitPreVerification: (
502
540
  pctx: PostUnitContext,
541
+ opts?: PreVerificationOpts,
503
542
  ) => Promise<"dispatched" | "continue">;
504
543
  runPostUnitVerification: (
505
544
  vctx: VerificationContext,
@@ -977,62 +1016,26 @@ export async function autoLoop(
977
1016
  ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning");
978
1017
  deps.sendDesktopNotification("GSD", msg, "warning", "budget");
979
1018
  deps.logCmuxEvent(prefs, msg, "warning");
980
- } else if (newBudgetAlertLevel === 90) {
981
- s.lastBudgetAlertLevel =
982
- newBudgetAlertLevel as AutoSession["lastBudgetAlertLevel"];
983
- ctx.ui.notify(
984
- `Budget 90%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
985
- "warning",
986
- );
987
- deps.sendDesktopNotification(
988
- "GSD",
989
- `Budget 90%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
990
- "warning",
991
- "budget",
992
- );
993
- deps.logCmuxEvent(
994
- prefs,
995
- `Budget 90%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
996
- "warning",
997
- );
998
- } else if (newBudgetAlertLevel === 80) {
999
- s.lastBudgetAlertLevel =
1000
- newBudgetAlertLevel as AutoSession["lastBudgetAlertLevel"];
1001
- ctx.ui.notify(
1002
- `Approaching budget ceiling — 80%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
1003
- "warning",
1004
- );
1005
- deps.sendDesktopNotification(
1006
- "GSD",
1007
- `Approaching budget ceiling — 80%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
1008
- "warning",
1009
- "budget",
1010
- );
1011
- deps.logCmuxEvent(
1012
- prefs,
1013
- `Budget 80%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
1014
- "warning",
1015
- );
1016
- } else if (newBudgetAlertLevel === 75) {
1017
- s.lastBudgetAlertLevel =
1018
- newBudgetAlertLevel as AutoSession["lastBudgetAlertLevel"];
1019
- ctx.ui.notify(
1020
- `Budget 75%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
1021
- "info",
1022
- );
1023
- deps.sendDesktopNotification(
1024
- "GSD",
1025
- `Budget 75%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
1026
- "info",
1027
- "budget",
1028
- );
1029
- deps.logCmuxEvent(
1030
- prefs,
1031
- `Budget 75%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
1032
- "progress",
1019
+ } else {
1020
+ // Data-driven 75/80/90% threshold notifications
1021
+ const threshold = BUDGET_THRESHOLDS.find(
1022
+ (t) => newBudgetAlertLevel === t.pct,
1033
1023
  );
1034
- } else if (budgetAlertLevel === 0) {
1035
- s.lastBudgetAlertLevel = 0;
1024
+ if (threshold) {
1025
+ s.lastBudgetAlertLevel =
1026
+ newBudgetAlertLevel as AutoSession["lastBudgetAlertLevel"];
1027
+ const msg = `${threshold.label}: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`;
1028
+ ctx.ui.notify(msg, threshold.notifyLevel);
1029
+ deps.sendDesktopNotification(
1030
+ "GSD",
1031
+ msg,
1032
+ threshold.notifyLevel,
1033
+ "budget",
1034
+ );
1035
+ deps.logCmuxEvent(prefs, msg, threshold.cmuxLevel);
1036
+ } else if (budgetAlertLevel === 0) {
1037
+ s.lastBudgetAlertLevel = 0;
1038
+ }
1036
1039
  }
1037
1040
  } else {
1038
1041
  s.lastBudgetAlertLevel = 0;
@@ -1103,6 +1106,7 @@ export async function autoLoop(
1103
1106
  midTitle: midTitle!,
1104
1107
  state,
1105
1108
  prefs,
1109
+ session: s,
1106
1110
  });
1107
1111
 
1108
1112
  if (dispatchResult.action === "stop") {
@@ -1661,9 +1665,12 @@ export async function autoLoop(
1661
1665
  break;
1662
1666
  }
1663
1667
 
1664
- // Run pre-verification for the sidecar unit
1668
+ // Run pre-verification for the sidecar unit (lightweight path)
1669
+ const sidecarPreOpts: PreVerificationOpts = item.kind === "hook"
1670
+ ? { skipSettleDelay: true, skipDoctor: true, skipStateRebuild: true, skipWorktreeSync: true }
1671
+ : { skipSettleDelay: true, skipStateRebuild: true };
1665
1672
  const sidecarPreResult =
1666
- await deps.postUnitPreVerification(postUnitCtx);
1673
+ await deps.postUnitPreVerification(postUnitCtx, sidecarPreOpts);
1667
1674
  if (sidecarPreResult === "dispatched") {
1668
1675
  // Pre-verification caused stop/pause
1669
1676
  debugLog("autoLoop", {