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
@@ -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
@@ -6,6 +6,10 @@
6
6
 
7
7
  import type { Roadmap, BoundaryMapEntry, RoadmapSliceEntry, RiskLevel } from './types.js';
8
8
 
9
+ // Issue #453: auto-mode post-turn reconciliation must stay on the stable JS path
10
+ // unless the native parser is explicitly requested.
11
+ const NATIVE_GSD_PARSER_ENABLED = process.env.GSD_ENABLE_NATIVE_GSD_PARSER === "1";
12
+
9
13
  let nativeModule: {
10
14
  parseFrontmatter: (content: string) => { metadata: string; body: string };
11
15
  extractSection: (content: string, heading: string, level?: number) => { content: string; found: boolean };
@@ -29,6 +33,7 @@ let loadAttempted = false;
29
33
  function loadNative(): typeof nativeModule {
30
34
  if (loadAttempted) return nativeModule;
31
35
  loadAttempted = true;
36
+ if (!NATIVE_GSD_PARSER_ENABLED) return nativeModule;
32
37
 
33
38
  try {
34
39
  // Dynamic import to avoid hard dependency - fails gracefully if native module not built