gsd-pi 2.16.0 → 2.17.0

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 (89) hide show
  1. package/dist/resources/extensions/gsd/auto-dashboard.ts +4 -0
  2. package/dist/resources/extensions/gsd/auto-dispatch.ts +9 -3
  3. package/dist/resources/extensions/gsd/auto-prompts.ts +71 -41
  4. package/dist/resources/extensions/gsd/auto-recovery.ts +7 -2
  5. package/dist/resources/extensions/gsd/auto.ts +54 -15
  6. package/dist/resources/extensions/gsd/commands.ts +20 -2
  7. package/dist/resources/extensions/gsd/complexity.ts +236 -0
  8. package/dist/resources/extensions/gsd/docs/preferences-reference.md +1 -0
  9. package/dist/resources/extensions/gsd/files.ts +6 -2
  10. package/dist/resources/extensions/gsd/git-service.ts +19 -8
  11. package/dist/resources/extensions/gsd/gitignore.ts +41 -2
  12. package/dist/resources/extensions/gsd/guided-flow.ts +10 -6
  13. package/dist/resources/extensions/gsd/metrics.ts +44 -0
  14. package/dist/resources/extensions/gsd/native-git-bridge.ts +5 -0
  15. package/dist/resources/extensions/gsd/native-parser-bridge.ts +5 -0
  16. package/dist/resources/extensions/gsd/preferences.ts +122 -1
  17. package/dist/resources/extensions/gsd/routing-history.ts +290 -0
  18. package/dist/resources/extensions/gsd/tests/auto-recovery.test.ts +50 -0
  19. package/dist/resources/extensions/gsd/tests/budget-prediction.test.ts +220 -0
  20. package/dist/resources/extensions/gsd/tests/complexity-routing.test.ts +294 -0
  21. package/dist/resources/extensions/gsd/tests/context-compression.test.ts +180 -0
  22. package/dist/resources/extensions/gsd/tests/git-service.test.ts +132 -0
  23. package/dist/resources/extensions/gsd/tests/preferences-git.test.ts +28 -0
  24. package/dist/resources/extensions/gsd/tests/routing-history.test.ts +87 -0
  25. package/dist/resources/extensions/gsd/tests/stop-auto-remote.test.ts +130 -0
  26. package/dist/resources/extensions/gsd/tests/token-profile.test.ts +263 -0
  27. package/dist/resources/extensions/gsd/types.ts +28 -0
  28. package/dist/resources/extensions/gsd/worktree.ts +2 -2
  29. package/package.json +1 -1
  30. package/packages/pi-ai/dist/models.generated.d.ts +493 -13
  31. package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
  32. package/packages/pi-ai/dist/models.generated.js +422 -62
  33. package/packages/pi-ai/dist/models.generated.js.map +1 -1
  34. package/packages/pi-ai/dist/providers/google-shared.d.ts +12 -0
  35. package/packages/pi-ai/dist/providers/google-shared.d.ts.map +1 -1
  36. package/packages/pi-ai/dist/providers/google-shared.js +9 -22
  37. package/packages/pi-ai/dist/providers/google-shared.js.map +1 -1
  38. package/packages/pi-ai/dist/providers/google-shared.test.d.ts +2 -0
  39. package/packages/pi-ai/dist/providers/google-shared.test.d.ts.map +1 -0
  40. package/packages/pi-ai/dist/providers/google-shared.test.js +125 -0
  41. package/packages/pi-ai/dist/providers/google-shared.test.js.map +1 -0
  42. package/packages/pi-ai/src/models.generated.ts +422 -62
  43. package/packages/pi-ai/src/providers/google-shared.test.ts +137 -0
  44. package/packages/pi-ai/src/providers/google-shared.ts +10 -19
  45. package/packages/pi-coding-agent/dist/core/tools/edit-diff.d.ts +7 -7
  46. package/packages/pi-coding-agent/dist/core/tools/edit-diff.d.ts.map +1 -1
  47. package/packages/pi-coding-agent/dist/core/tools/edit-diff.js +209 -13
  48. package/packages/pi-coding-agent/dist/core/tools/edit-diff.js.map +1 -1
  49. package/packages/pi-coding-agent/dist/core/tools/edit-diff.test.d.ts +2 -0
  50. package/packages/pi-coding-agent/dist/core/tools/edit-diff.test.d.ts.map +1 -0
  51. package/packages/pi-coding-agent/dist/core/tools/edit-diff.test.js +67 -0
  52. package/packages/pi-coding-agent/dist/core/tools/edit-diff.test.js.map +1 -0
  53. package/packages/pi-coding-agent/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  54. package/packages/pi-coding-agent/dist/modes/interactive/theme/theme.js +10 -0
  55. package/packages/pi-coding-agent/dist/modes/interactive/theme/theme.js.map +1 -1
  56. package/packages/pi-coding-agent/src/core/tools/edit-diff.test.ts +85 -0
  57. package/packages/pi-coding-agent/src/core/tools/edit-diff.ts +245 -17
  58. package/packages/pi-coding-agent/src/modes/interactive/theme/theme.ts +13 -0
  59. package/pkg/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  60. package/pkg/dist/modes/interactive/theme/theme.js +10 -0
  61. package/pkg/dist/modes/interactive/theme/theme.js.map +1 -1
  62. package/src/resources/extensions/gsd/auto-dashboard.ts +4 -0
  63. package/src/resources/extensions/gsd/auto-dispatch.ts +9 -3
  64. package/src/resources/extensions/gsd/auto-prompts.ts +71 -41
  65. package/src/resources/extensions/gsd/auto-recovery.ts +7 -2
  66. package/src/resources/extensions/gsd/auto.ts +54 -15
  67. package/src/resources/extensions/gsd/commands.ts +20 -2
  68. package/src/resources/extensions/gsd/complexity.ts +236 -0
  69. package/src/resources/extensions/gsd/docs/preferences-reference.md +1 -0
  70. package/src/resources/extensions/gsd/files.ts +6 -2
  71. package/src/resources/extensions/gsd/git-service.ts +19 -8
  72. package/src/resources/extensions/gsd/gitignore.ts +41 -2
  73. package/src/resources/extensions/gsd/guided-flow.ts +10 -6
  74. package/src/resources/extensions/gsd/metrics.ts +44 -0
  75. package/src/resources/extensions/gsd/native-git-bridge.ts +5 -0
  76. package/src/resources/extensions/gsd/native-parser-bridge.ts +5 -0
  77. package/src/resources/extensions/gsd/preferences.ts +122 -1
  78. package/src/resources/extensions/gsd/routing-history.ts +290 -0
  79. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +50 -0
  80. package/src/resources/extensions/gsd/tests/budget-prediction.test.ts +220 -0
  81. package/src/resources/extensions/gsd/tests/complexity-routing.test.ts +294 -0
  82. package/src/resources/extensions/gsd/tests/context-compression.test.ts +180 -0
  83. package/src/resources/extensions/gsd/tests/git-service.test.ts +132 -0
  84. package/src/resources/extensions/gsd/tests/preferences-git.test.ts +28 -0
  85. package/src/resources/extensions/gsd/tests/routing-history.test.ts +87 -0
  86. package/src/resources/extensions/gsd/tests/stop-auto-remote.test.ts +130 -0
  87. package/src/resources/extensions/gsd/tests/token-profile.test.ts +263 -0
  88. package/src/resources/extensions/gsd/types.ts +28 -0
  89. package/src/resources/extensions/gsd/worktree.ts +2 -2
@@ -293,6 +293,41 @@ export function isAutoPaused(): boolean {
293
293
  return paused;
294
294
  }
295
295
 
296
+ /**
297
+ * Return the base path to use for the auto.lock file.
298
+ * Always uses the original project root (not the worktree) so that
299
+ * a second terminal can discover and stop a running auto-mode session.
300
+ */
301
+ function lockBase(): string {
302
+ return originalBasePath || basePath;
303
+ }
304
+
305
+ /**
306
+ * Attempt to stop a running auto-mode session from a different process.
307
+ * Reads the lock file at the project root, checks if the PID is alive,
308
+ * and sends SIGTERM to gracefully stop it.
309
+ *
310
+ * Returns true if a remote session was found and signaled, false otherwise.
311
+ */
312
+ export function stopAutoRemote(projectRoot: string): { found: boolean; pid?: number; error?: string } {
313
+ const lock = readCrashLock(projectRoot);
314
+ if (!lock) return { found: false };
315
+
316
+ if (!isLockProcessAlive(lock)) {
317
+ // Stale lock — clean it up
318
+ clearLock(projectRoot);
319
+ return { found: false };
320
+ }
321
+
322
+ // Send SIGTERM — the auto-mode process has a handler that clears the lock and exits
323
+ try {
324
+ process.kill(lock.pid, "SIGTERM");
325
+ return { found: true, pid: lock.pid };
326
+ } catch (err) {
327
+ return { found: false, error: (err as Error).message };
328
+ }
329
+ }
330
+
296
331
  export function isStepMode(): boolean {
297
332
  return stepMode;
298
333
  }
@@ -371,7 +406,7 @@ function startDispatchGapWatchdog(ctx: ExtensionContext, pi: ExtensionAPI): void
371
406
  export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promise<void> {
372
407
  if (!active && !paused) return;
373
408
  clearUnitTimeout();
374
- if (basePath) clearLock(basePath);
409
+ if (lockBase()) clearLock(lockBase());
375
410
  clearSkillSnapshot();
376
411
  _dispatching = false;
377
412
  _skipDepth = 0;
@@ -454,7 +489,7 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi
454
489
  export async function pauseAuto(ctx?: ExtensionContext, _pi?: ExtensionAPI): Promise<void> {
455
490
  if (!active) return;
456
491
  clearUnitTimeout();
457
- if (basePath) clearLock(basePath);
492
+ if (lockBase()) clearLock(lockBase());
458
493
 
459
494
  // Remove SIGTERM handler registered at auto-mode start
460
495
  deregisterSigtermHandler();
@@ -527,8 +562,8 @@ export async function startAuto(
527
562
  }
528
563
  }
529
564
 
530
- // Re-register SIGTERM handler for the resumed session
531
- registerSigtermHandler(basePath);
565
+ // Re-register SIGTERM handler for the resumed session (use original base for lock)
566
+ registerSigtermHandler(lockBase());
532
567
 
533
568
  ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto");
534
569
  ctx.ui.setFooter(hideFooter);
@@ -557,17 +592,21 @@ export async function startAuto(
557
592
  }
558
593
 
559
594
  // Ensure .gitignore has baseline patterns
560
- ensureGitignore(base);
595
+ const commitDocs = loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs;
596
+ ensureGitignore(base, { commitDocs });
561
597
  untrackRuntimeFiles(base);
562
598
 
563
599
  // Bootstrap .gsd/ if it doesn't exist
564
600
  const gsdDir = join(base, ".gsd");
565
601
  if (!existsSync(gsdDir)) {
566
602
  mkdirSync(join(gsdDir, "milestones"), { recursive: true });
567
- try {
568
- nativeAddPaths(base, [".gsd", ".gitignore"]);
569
- nativeCommit(base, "chore: init gsd");
570
- } catch { /* nothing to commit */ }
603
+ // Only commit .gsd/ init when commit_docs is not explicitly false
604
+ if (commitDocs !== false) {
605
+ try {
606
+ nativeAddPaths(base, [".gsd", ".gitignore"]);
607
+ nativeCommit(base, "chore: init gsd");
608
+ } catch { /* nothing to commit */ }
609
+ }
571
610
  }
572
611
 
573
612
  // Initialize GitServiceImpl — basePath is set and git repo confirmed
@@ -658,7 +697,7 @@ export async function startAuto(
658
697
  // of the repo's default (main/master). Idempotent when the branch is the
659
698
  // same; updates the record when started from a different branch (#300).
660
699
  if (currentMilestoneId) {
661
- captureIntegrationBranch(base, currentMilestoneId);
700
+ captureIntegrationBranch(base, currentMilestoneId, { commitDocs });
662
701
  setActiveMilestoneId(base, currentMilestoneId);
663
702
  }
664
703
 
@@ -695,8 +734,8 @@ export async function startAuto(
695
734
  gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
696
735
  ctx.ui.notify(`Created auto-worktree at ${wtPath}`, "info");
697
736
  }
698
- // Re-register SIGTERM handler with the new basePath
699
- registerSigtermHandler(basePath);
737
+ // Re-register SIGTERM handler with the original basePath (lock lives there)
738
+ registerSigtermHandler(originalBasePath);
700
739
  } catch (err) {
701
740
  // Worktree creation is non-fatal — continue in the project root.
702
741
  ctx.ui.notify(
@@ -952,7 +991,7 @@ export async function handleAgentEnd(
952
991
  return;
953
992
  }
954
993
  const sessionFile = ctx.sessionManager.getSessionFile();
955
- writeLock(basePath, hookUnit.unitType, hookUnit.unitId, completedUnits.length, sessionFile);
994
+ writeLock(lockBase(), hookUnit.unitType, hookUnit.unitId, completedUnits.length, sessionFile);
956
995
  // Persist hook state so cycle counts survive crashes
957
996
  persistHookState(basePath);
958
997
 
@@ -1211,7 +1250,7 @@ async function dispatchNextUnit(
1211
1250
  unitRecoveryCount.clear();
1212
1251
  unitLifetimeDispatches.clear();
1213
1252
  // Capture integration branch for the new milestone and update git service
1214
- captureIntegrationBranch(originalBasePath || basePath, mid);
1253
+ captureIntegrationBranch(originalBasePath || basePath, mid, { commitDocs: loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs });
1215
1254
  }
1216
1255
  if (mid) {
1217
1256
  currentMilestoneId = mid;
@@ -1758,7 +1797,7 @@ async function dispatchNextUnit(
1758
1797
  // Pi appends entries incrementally via appendFileSync, so on crash the
1759
1798
  // session file survives with every tool call up to the crash point.
1760
1799
  const sessionFile = ctx.sessionManager.getSessionFile();
1761
- writeLock(basePath, unitType, unitId, completedUnits.length, sessionFile);
1800
+ writeLock(lockBase(), unitType, unitId, completedUnits.length, sessionFile);
1762
1801
 
1763
1802
  // On crash recovery, prepend the full recovery briefing
1764
1803
  // On retry (stuck detection), prepend deep diagnostic from last attempt
@@ -12,7 +12,7 @@ import { fileURLToPath } from "node:url";
12
12
  import { deriveState } from "./state.js";
13
13
  import { GSDDashboardOverlay } from "./dashboard-overlay.js";
14
14
  import { showQueue, showDiscuss } from "./guided-flow.js";
15
- import { startAuto, stopAuto, pauseAuto, isAutoActive, isAutoPaused, isStepMode } from "./auto.js";
15
+ import { startAuto, stopAuto, pauseAuto, isAutoActive, isAutoPaused, isStepMode, stopAutoRemote } from "./auto.js";
16
16
  import {
17
17
  getGlobalGSDPreferencesPath,
18
18
  getLegacyGlobalGSDPreferencesPath,
@@ -178,7 +178,15 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
178
178
 
179
179
  if (trimmed === "stop") {
180
180
  if (!isAutoActive() && !isAutoPaused()) {
181
- ctx.ui.notify("Auto-mode is not running.", "info");
181
+ // Not running in this process — check for a remote auto-mode session
182
+ const result = stopAutoRemote(process.cwd());
183
+ if (result.found) {
184
+ ctx.ui.notify(`Sent stop signal to auto-mode session (PID ${result.pid}). It will shut down gracefully.`, "info");
185
+ } else if (result.error) {
186
+ ctx.ui.notify(`Failed to stop remote auto-mode: ${result.error}`, "error");
187
+ } else {
188
+ ctx.ui.notify("Auto-mode is not running.", "info");
189
+ }
182
190
  return;
183
191
  }
184
192
  await stopAuto(ctx, pi);
@@ -502,6 +510,16 @@ async function handlePrefsWizard(
502
510
  delete git.main_branch;
503
511
  }
504
512
  }
513
+ // ─── Git commit_docs ────────────────────────────────────────────────────
514
+ const currentCommitDocs = git.commit_docs;
515
+ const commitDocsChoice = await ctx.ui.select(
516
+ `Track .gsd/ planning docs in git${currentCommitDocs !== undefined ? ` (current: ${currentCommitDocs})` : ""}:`,
517
+ ["true", "false", "(keep current)"],
518
+ );
519
+ if (commitDocsChoice && commitDocsChoice !== "(keep current)") {
520
+ git.commit_docs = commitDocsChoice === "true";
521
+ }
522
+
505
523
  if (Object.keys(git).length > 0) {
506
524
  prefs.git = git;
507
525
  }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * GSD Task Complexity Classification
3
+ *
4
+ * Classifies task plans and unit types by complexity to enable model routing.
5
+ * Pure heuristics + adaptive learning — no LLM calls, sub-millisecond.
6
+ *
7
+ * Combined approach:
8
+ * - Task plan analysis (step count, file count, description length, signal words)
9
+ * - Unit type defaults (complete-slice → light, replan → heavy, etc.)
10
+ * - Budget pressure thresholds (50/75/90% graduated downgrade)
11
+ * - Adaptive learning via routing-history (optional)
12
+ *
13
+ * Classification output uses our TokenProfile-aligned TaskComplexity type
14
+ * for the simple classifier, and ComplexityTier for the full unit classifier.
15
+ */
16
+
17
+ import { existsSync, readFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import type { ComplexityTier, ClassificationResult, TaskMetadata } from "./types.js";
20
+
21
+ // Re-export for convenience
22
+ export type { ComplexityTier, ClassificationResult, TaskMetadata };
23
+
24
+ // ─── Simple Task Complexity (for task plan analysis) ──────────────────────
25
+
26
+ export type TaskComplexity = "simple" | "standard" | "complex";
27
+
28
+ /** Words that signal non-trivial work requiring full reasoning capacity */
29
+ const COMPLEXITY_SIGNALS = [
30
+ "research", "investigate", "refactor", "migrate", "integrate",
31
+ "complex", "architect", "redesign", "security", "performance",
32
+ "concurrent", "parallel", "distributed", "backward.?compat",
33
+ "migration", "architecture", "concurrency", "compatibility",
34
+ ];
35
+ const COMPLEXITY_PATTERN = new RegExp(COMPLEXITY_SIGNALS.join("|"), "i");
36
+
37
+ /**
38
+ * Classify a task plan by its structural complexity.
39
+ * Used by dispatch to select execution_simple vs execution model.
40
+ */
41
+ export function classifyTaskComplexity(planContent: string): TaskComplexity {
42
+ if (!planContent || planContent.trim().length === 0) return "standard";
43
+
44
+ const stepsMatch = planContent.match(/##\s*Steps\s*\n([\s\S]*?)(?=\n##|\n---|$)/i);
45
+ const stepsSection = stepsMatch?.[1] ?? "";
46
+ const stepCount = (stepsSection.match(/^\s*\d+\.\s/gm) ?? []).length;
47
+
48
+ if (!stepsMatch) return "standard";
49
+
50
+ const stepsIdx = planContent.search(/##\s*Steps/i);
51
+ const descriptionLength = stepsIdx > 0 ? planContent.slice(0, stepsIdx).length : planContent.length;
52
+
53
+ const filePatterns = planContent.match(/`[a-zA-Z0-9_/.-]+\.[a-z]{1,4}`/g) ?? [];
54
+ const uniqueFiles = new Set(filePatterns.map(f => f.replace(/`/g, "")));
55
+ const fileCount = uniqueFiles.size;
56
+
57
+ const hasComplexitySignals = COMPLEXITY_PATTERN.test(planContent);
58
+
59
+ // Count fenced code blocks (from #579 Phase 4)
60
+ const codeBlockCount = (planContent.match(/^```/gm) ?? []).length / 2;
61
+
62
+ if (stepCount >= 8 || fileCount >= 8 || descriptionLength > 2000 || codeBlockCount >= 5) {
63
+ return "complex";
64
+ }
65
+
66
+ if (stepCount <= 3 && descriptionLength < 500 && fileCount <= 3 && !hasComplexitySignals) {
67
+ return "simple";
68
+ }
69
+
70
+ return "standard";
71
+ }
72
+
73
+ // ─── Unit Type → Default Tier Mapping (from #579) ─────────────────────────
74
+
75
+ const UNIT_TYPE_TIERS: Record<string, ComplexityTier> = {
76
+ // Light: structured summaries, completion, UAT
77
+ "complete-slice": "light",
78
+ "run-uat": "light",
79
+
80
+ // Standard: research, routine planning
81
+ "research-milestone": "standard",
82
+ "research-slice": "standard",
83
+ "plan-milestone": "standard",
84
+ "plan-slice": "standard",
85
+
86
+ // Heavy: execution default (upgraded by metadata), replanning
87
+ "execute-task": "standard",
88
+ "replan-slice": "heavy",
89
+ "reassess-roadmap": "heavy",
90
+ "complete-milestone": "standard",
91
+ };
92
+
93
+ /**
94
+ * Classify unit complexity for model routing.
95
+ * Uses unit type defaults, task metadata analysis, and budget pressure.
96
+ *
97
+ * @param unitType The type of unit being dispatched
98
+ * @param unitId The unit ID (e.g. "M001/S01/T01")
99
+ * @param basePath Project base path (for reading task plans)
100
+ * @param budgetPct Current budget usage as fraction (0.0-1.0+), or undefined
101
+ * @param metadata Optional pre-parsed task metadata
102
+ */
103
+ export function classifyUnitComplexity(
104
+ unitType: string,
105
+ unitId: string,
106
+ basePath: string,
107
+ budgetPct?: number,
108
+ metadata?: TaskMetadata,
109
+ ): ClassificationResult {
110
+ // Hook units default to light
111
+ if (unitType.startsWith("hook/")) {
112
+ return applyBudgetPressure({ tier: "light", reason: "hook unit", downgraded: false }, budgetPct);
113
+ }
114
+
115
+ // Triage/capture units default to light
116
+ if (unitType === "triage-captures" || unitType.startsWith("quick-task")) {
117
+ return applyBudgetPressure({ tier: "light", reason: `${unitType} unit`, downgraded: false }, budgetPct);
118
+ }
119
+
120
+ let tier = UNIT_TYPE_TIERS[unitType] ?? "standard";
121
+ let reason = `unit type: ${unitType}`;
122
+
123
+ // For execute-task, analyze task metadata for complexity signals
124
+ if (unitType === "execute-task") {
125
+ const analysis = analyzeTaskFromPlan(unitId, basePath, metadata);
126
+ if (analysis) {
127
+ tier = analysis.tier;
128
+ reason = analysis.reason;
129
+ }
130
+ }
131
+
132
+ return applyBudgetPressure({ tier, reason, downgraded: false }, budgetPct);
133
+ }
134
+
135
+ // ─── Tier Helpers ─────────────────────────────────────────────────────────
136
+
137
+ export function tierLabel(tier: ComplexityTier): string {
138
+ switch (tier) {
139
+ case "light": return "L";
140
+ case "standard": return "S";
141
+ case "heavy": return "H";
142
+ }
143
+ }
144
+
145
+ export function tierOrdinal(tier: ComplexityTier): number {
146
+ switch (tier) {
147
+ case "light": return 0;
148
+ case "standard": return 1;
149
+ case "heavy": return 2;
150
+ }
151
+ }
152
+
153
+ export function escalateTier(currentTier: ComplexityTier): ComplexityTier | null {
154
+ switch (currentTier) {
155
+ case "light": return "standard";
156
+ case "standard": return "heavy";
157
+ case "heavy": return null;
158
+ }
159
+ }
160
+
161
+ // ─── Budget Pressure (from #579 — graduated thresholds) ───────────────────
162
+
163
+ function applyBudgetPressure(
164
+ result: ClassificationResult,
165
+ budgetPct?: number,
166
+ ): ClassificationResult {
167
+ if (budgetPct === undefined || budgetPct < 0.5) return result;
168
+
169
+ const original = result.tier;
170
+
171
+ if (budgetPct >= 0.9) {
172
+ // >90%: almost everything goes to light
173
+ if (result.tier !== "heavy") {
174
+ result.tier = "light";
175
+ } else {
176
+ result.tier = "standard";
177
+ }
178
+ } else if (budgetPct >= 0.75) {
179
+ // 75-90%: only heavy stays, standard → light
180
+ if (result.tier === "standard") {
181
+ result.tier = "light";
182
+ }
183
+ } else {
184
+ // 50-75%: standard → light
185
+ if (result.tier === "standard") {
186
+ result.tier = "light";
187
+ }
188
+ }
189
+
190
+ if (result.tier !== original) {
191
+ result.downgraded = true;
192
+ result.reason = `${result.reason} (budget pressure: ${Math.round(budgetPct * 100)}%)`;
193
+ }
194
+
195
+ return result;
196
+ }
197
+
198
+ // ─── Task Plan Analysis ───────────────────────────────────────────────────
199
+
200
+ interface TaskAnalysis {
201
+ tier: ComplexityTier;
202
+ reason: string;
203
+ }
204
+
205
+ function analyzeTaskFromPlan(
206
+ unitId: string,
207
+ basePath: string,
208
+ metadata?: TaskMetadata,
209
+ ): TaskAnalysis | null {
210
+ // Try to read the task plan for analysis
211
+ const parts = unitId.split("/");
212
+ if (parts.length < 3) return null;
213
+
214
+ const [mid, sid, tid] = parts;
215
+ const planPath = join(basePath, ".gsd", "milestones", mid, "slices", sid, "tasks", `${tid}-PLAN.md`);
216
+
217
+ let planContent = "";
218
+ try {
219
+ if (existsSync(planPath)) {
220
+ planContent = readFileSync(planPath, "utf-8");
221
+ }
222
+ } catch {
223
+ return null;
224
+ }
225
+
226
+ if (!planContent) return null;
227
+
228
+ const taskComplexity = classifyTaskComplexity(planContent);
229
+
230
+ // Map TaskComplexity to ComplexityTier
231
+ switch (taskComplexity) {
232
+ case "simple": return { tier: "light", reason: "task plan: simple (few steps, small scope)" };
233
+ case "complex": return { tier: "heavy", reason: "task plan: complex (many steps/files or signal words)" };
234
+ default: return { tier: "standard", reason: "task plan: standard complexity" };
235
+ }
236
+ }
@@ -108,6 +108,7 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea
108
108
  - `pre_merge_check`: boolean or `"auto"` — run pre-merge checks before merging a worktree back to the integration branch. `true` always runs, `false` never runs, `"auto"` runs when CI is detected. Default: `false`.
109
109
  - `commit_type`: string — override the conventional commit type prefix. Must be one of: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, `ci`, `build`, `style`. Default: inferred from diff content.
110
110
  - `main_branch`: string — the primary branch name for new git repos (e.g., `"main"`, `"master"`, `"trunk"`). Also used by `getMainBranch()` as the preferred branch when auto-detection is ambiguous. Default: `"main"`.
111
+ - `commit_docs`: boolean — when `false`, prevents GSD from committing `.gsd/` planning artifacts to git. The `.gsd/` folder is added to `.gitignore` and kept local-only. Useful for teams where only some members use GSD, or when company policy requires a clean repository. Default: `true`.
111
112
 
112
113
  - `unique_milestone_ids`: boolean — when `true`, generates milestone IDs in `M{seq}-{rand6}` format (e.g. `M001-eh88as`) instead of plain sequential `M001`. Prevents ID collisions in team workflows where multiple contributors create milestones concurrently. Both formats coexist — existing `M001`-style milestones remain valid. Default: `false`.
113
114
 
@@ -26,12 +26,16 @@ import { nativeParseRoadmap, nativeExtractSection, nativeParsePlanFile, nativePa
26
26
 
27
27
  const CACHE_MAX = 50;
28
28
 
29
- /** Fast composite key: length + first/last 100 chars. Unique enough for distinct markdown files. */
29
+ /** Fast composite key: length + first/mid/last 100 chars. The middle sample
30
+ * prevents collisions when only a few characters change in the interior of
31
+ * a file (e.g., a checkbox [ ] → [x] that doesn't alter length or endpoints). */
30
32
  function cacheKey(content: string): string {
31
33
  const len = content.length;
32
34
  const head = content.slice(0, 100);
35
+ const midStart = Math.max(0, Math.floor(len / 2) - 50);
36
+ const mid = len > 200 ? content.slice(midStart, midStart + 100) : '';
33
37
  const tail = len > 100 ? content.slice(-100) : '';
34
- return `${len}:${head}:${tail}`;
38
+ return `${len}:${head}:${mid}:${tail}`;
35
39
  }
36
40
 
37
41
  const _parseCache = new Map<string, unknown>();
@@ -47,6 +47,11 @@ export interface GitPreferences {
47
47
  * - "branch": works directly in the project root (for submodule-heavy repos)
48
48
  */
49
49
  isolation?: "worktree" | "branch";
50
+ /** When false, prevents GSD from committing .gsd/ planning artifacts to git.
51
+ * The .gsd/ folder is added to .gitignore and kept local-only.
52
+ * Default: true (planning docs are tracked in git).
53
+ */
54
+ commit_docs?: boolean;
50
55
  }
51
56
 
52
57
  export const VALID_BRANCH_NAME = /^[a-zA-Z0-9_\-\/.]+$/;
@@ -152,7 +157,7 @@ export function readIntegrationBranch(basePath: string, milestoneId: string): st
152
157
  *
153
158
  * The file is committed immediately so the metadata is persisted in git.
154
159
  */
155
- export function writeIntegrationBranch(basePath: string, milestoneId: string, branch: string): void {
160
+ export function writeIntegrationBranch(basePath: string, milestoneId: string, branch: string, options?: { commitDocs?: boolean }): void {
156
161
  // Don't record slice branches as the integration target
157
162
  if (SLICE_BRANCH_RE.test(branch)) return;
158
163
  // Validate
@@ -178,12 +183,15 @@ export function writeIntegrationBranch(basePath: string, milestoneId: string, br
178
183
  writeFileSync(metaFile, JSON.stringify(existing, null, 2) + "\n", "utf-8");
179
184
 
180
185
  // Commit immediately so the metadata is persisted in git.
181
- try {
182
- nativeAddPaths(basePath, [metaFile]);
183
- nativeCommit(basePath, `chore(${milestoneId}): record integration branch`, { allowEmpty: false });
184
- } catch {
185
- // Non-fatal file is on disk even if commit fails (e.g. nothing to commit
186
- // because the file was already tracked with identical content)
186
+ // Skip when commit_docs is explicitly false — .gsd/ is local-only.
187
+ if (options?.commitDocs !== false) {
188
+ try {
189
+ nativeAddPaths(basePath, [metaFile]);
190
+ nativeCommit(basePath, `chore(${milestoneId}): record integration branch`, { allowEmpty: false });
191
+ } catch {
192
+ // Non-fatal — file is on disk even if commit fails (e.g. nothing to commit
193
+ // because the file was already tracked with identical content)
194
+ }
187
195
  }
188
196
  }
189
197
 
@@ -284,7 +292,10 @@ export class GitServiceImpl {
284
292
  * @param extraExclusions Additional pathspec exclusions beyond RUNTIME_EXCLUSION_PATHS.
285
293
  */
286
294
  private smartStage(extraExclusions: readonly string[] = []): void {
287
- const allExclusions = [...RUNTIME_EXCLUSION_PATHS, ...extraExclusions];
295
+ // When commit_docs is false, exclude the entire .gsd/ directory from staging
296
+ const commitDocsDisabled = this.prefs.commit_docs === false;
297
+ const gsdExclusion = commitDocsDisabled ? [".gsd/"] : [];
298
+ const allExclusions = [...RUNTIME_EXCLUSION_PATHS, ...gsdExclusion, ...extraExclusions];
288
299
 
289
300
  // One-time cleanup: if runtime files are already tracked in the index
290
301
  // (from older versions where the fallback bug staged them), untrack them
@@ -78,15 +78,26 @@ const BASELINE_PATTERNS = [
78
78
  * Ensure basePath/.gitignore contains all baseline patterns.
79
79
  * Creates the file if missing; appends only missing lines if it exists.
80
80
  * Returns true if the file was created or modified, false if already complete.
81
+ *
82
+ * When `commitDocs` is false, the entire `.gsd/` directory is added to
83
+ * .gitignore instead of individual runtime patterns, keeping all GSD
84
+ * artifacts local-only.
81
85
  */
82
- export function ensureGitignore(basePath: string): boolean {
86
+ export function ensureGitignore(basePath: string, options?: { commitDocs?: boolean }): boolean {
83
87
  const gitignorePath = join(basePath, ".gitignore");
88
+ const commitDocs = options?.commitDocs !== false; // default true
84
89
 
85
90
  let existing = "";
86
91
  if (existsSync(gitignorePath)) {
87
92
  existing = readFileSync(gitignorePath, "utf-8");
88
93
  }
89
94
 
95
+ // When commit_docs is false, ensure blanket ".gsd/" is in .gitignore
96
+ // and skip the self-heal that would remove it.
97
+ if (!commitDocs) {
98
+ return ensureBlanketGsdIgnore(gitignorePath, existing);
99
+ }
100
+
90
101
  // Self-heal: remove blanket ".gsd/" lines from pre-v2.14.0 projects.
91
102
  // The blanket ignore prevented planning artifacts (.gsd/milestones/) from
92
103
  // being tracked in git, causing artifacts to vanish in worktrees and
@@ -203,7 +214,7 @@ See \`~/.gsd/agent/extensions/gsd/docs/preferences-reference.md\` for full field
203
214
  - \`models\`: Model preferences for specific task types
204
215
  - \`skill_discovery\`: Automatic skill detection preferences
205
216
  - \`auto_supervisor\`: Supervision and gating rules for autonomous modes
206
- - \`git\`: Git preferences — \`main_branch\` (default branch name for new repos, e.g., "main", "master", "trunk"), \`auto_push\`, \`snapshots\`, etc.
217
+ - \`git\`: Git preferences — \`main_branch\` (default branch name for new repos, e.g., "main", "master", "trunk"), \`auto_push\`, \`snapshots\`, \`commit_docs\` (set to \`false\` to keep .gsd/ local-only), etc.
207
218
 
208
219
  ## Examples
209
220
 
@@ -224,3 +235,31 @@ custom_instructions:
224
235
  return true;
225
236
  }
226
237
 
238
+ /**
239
+ * When commit_docs is false, ensure `.gsd/` is in .gitignore as a blanket
240
+ * pattern. This keeps all GSD artifacts local-only.
241
+ * Returns true if the file was modified, false if already complete.
242
+ */
243
+ function ensureBlanketGsdIgnore(gitignorePath: string, existing: string): boolean {
244
+ const existingLines = new Set(
245
+ existing
246
+ .split("\n")
247
+ .map((l) => l.trim())
248
+ .filter((l) => l && !l.startsWith("#")),
249
+ );
250
+
251
+ // Already has blanket .gsd/ ignore
252
+ if (existingLines.has(".gsd/") || existingLines.has(".gsd")) return false;
253
+
254
+ const block = [
255
+ "",
256
+ "# ── GSD (local-only, commit_docs: false) ──",
257
+ ".gsd/",
258
+ "",
259
+ ].join("\n");
260
+
261
+ const prefix = existing && !existing.endsWith("\n") ? "\n" : "";
262
+ writeFileSync(gitignorePath, existing + prefix + block, "utf-8");
263
+ return true;
264
+ }
265
+
@@ -712,7 +712,8 @@ export async function showSmartEntry(
712
712
  }
713
713
 
714
714
  // ── Ensure .gitignore has baseline patterns ──────────────────────────
715
- ensureGitignore(basePath);
715
+ const commitDocs = loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs;
716
+ ensureGitignore(basePath, { commitDocs });
716
717
  untrackRuntimeFiles(basePath);
717
718
 
718
719
  // ── No GSD project OR no milestone → Create first/next milestone ────
@@ -723,11 +724,14 @@ export async function showSmartEntry(
723
724
 
724
725
  // ── Create PREFERENCES.md template ────────────────────────────────
725
726
  ensurePreferences(basePath);
726
- try {
727
- nativeAddPaths(basePath, [".gsd", ".gitignore"]);
728
- nativeCommit(basePath, "chore: init gsd");
729
- } catch {
730
- // nothing to commit — that's fine
727
+ // Only commit .gsd/ init when commit_docs is not explicitly false
728
+ if (commitDocs !== false) {
729
+ try {
730
+ nativeAddPaths(basePath, [".gsd", ".gitignore"]);
731
+ nativeCommit(basePath, "chore: init gsd");
732
+ } catch {
733
+ // nothing to commit — that's fine
734
+ }
731
735
  }
732
736
  }
733
737
 
@@ -303,6 +303,50 @@ export function formatCost(cost: number): string {
303
303
  return `$${n.toFixed(2)}`;
304
304
  }
305
305
 
306
+ // ─── Budget Prediction ────────────────────────────────────────────────────────
307
+
308
+ /**
309
+ * Calculate average cost per unit type from completed units.
310
+ * Returns a Map from unit type to average cost in USD.
311
+ */
312
+ export function getAverageCostPerUnitType(units: UnitMetrics[]): Map<string, number> {
313
+ const sums = new Map<string, { total: number; count: number }>();
314
+ for (const u of units) {
315
+ const entry = sums.get(u.type) ?? { total: 0, count: 0 };
316
+ entry.total += u.cost;
317
+ entry.count += 1;
318
+ sums.set(u.type, entry);
319
+ }
320
+ const avgs = new Map<string, number>();
321
+ for (const [type, { total, count }] of sums) {
322
+ avgs.set(type, total / count);
323
+ }
324
+ return avgs;
325
+ }
326
+
327
+ /**
328
+ * Estimate remaining cost given average costs and remaining unit counts.
329
+ * @param avgCosts - Average cost per unit type
330
+ * @param remainingUnits - Array of unit types still to dispatch
331
+ * @param fallbackAvg - Fallback average if unit type not seen before
332
+ * @returns Estimated remaining cost in USD
333
+ */
334
+ export function predictRemainingCost(
335
+ avgCosts: Map<string, number>,
336
+ remainingUnits: string[],
337
+ fallbackAvg?: number,
338
+ ): number {
339
+ // If no averages available, use overall average as fallback
340
+ const allAvgs = [...avgCosts.values()];
341
+ const overallAvg = fallbackAvg ?? (allAvgs.length > 0 ? allAvgs.reduce((a, b) => a + b, 0) / allAvgs.length : 0);
342
+
343
+ let total = 0;
344
+ for (const unitType of remainingUnits) {
345
+ total += avgCosts.get(unitType) ?? overallAvg;
346
+ }
347
+ return total;
348
+ }
349
+
306
350
  /**
307
351
  * Compute a projected remaining cost based on completed slice averages.
308
352
  *
@@ -17,6 +17,10 @@ const GIT_NO_PROMPT_ENV = {
17
17
  GIT_SVN_ID: "",
18
18
  };
19
19
 
20
+ // Issue #453: keep auto-mode bookkeeping on the stable git CLI path unless a
21
+ // caller explicitly opts into the native helper.
22
+ const NATIVE_GSD_GIT_ENABLED = process.env.GSD_ENABLE_NATIVE_GSD_GIT === "1";
23
+
20
24
  // ─── Native Module Types ──────────────────────────────────────────────────
21
25
 
22
26
  interface GitDiffStat {
@@ -116,6 +120,7 @@ let loadAttempted = false;
116
120
  function loadNative(): typeof nativeModule {
117
121
  if (loadAttempted) return nativeModule;
118
122
  loadAttempted = true;
123
+ if (!NATIVE_GSD_GIT_ENABLED) return nativeModule;
119
124
 
120
125
  try {
121
126
  // eslint-disable-next-line @typescript-eslint/no-require-imports