gsd-pi 2.11.0 → 2.13.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.
- package/dist/cli.js +18 -1
- package/dist/onboarding.js +3 -0
- package/dist/resource-loader.d.ts +2 -0
- package/dist/resource-loader.js +36 -1
- package/dist/resources/extensions/bg-shell/index.ts +51 -7
- package/dist/resources/extensions/gsd/auto-worktree.ts +509 -0
- package/dist/resources/extensions/gsd/auto.ts +381 -13
- package/dist/resources/extensions/gsd/commands.ts +9 -3
- package/dist/resources/extensions/gsd/doctor.ts +254 -3
- package/dist/resources/extensions/gsd/git-self-heal.ts +198 -0
- package/dist/resources/extensions/gsd/git-service.ts +11 -0
- package/dist/resources/extensions/gsd/guided-flow.ts +81 -9
- package/dist/resources/extensions/gsd/post-unit-hooks.ts +449 -0
- package/dist/resources/extensions/gsd/preferences.ts +209 -1
- package/dist/resources/extensions/gsd/prompt-loader.ts +28 -1
- package/dist/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
- package/dist/resources/extensions/gsd/prompts/complete-slice.md +1 -3
- package/dist/resources/extensions/gsd/prompts/discuss.md +10 -8
- package/dist/resources/extensions/gsd/prompts/execute-task.md +4 -2
- package/dist/resources/extensions/gsd/prompts/guided-complete-slice.md +3 -1
- package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +3 -1
- package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +3 -1
- package/dist/resources/extensions/gsd/prompts/guided-execute-task.md +3 -1
- package/dist/resources/extensions/gsd/prompts/guided-plan-milestone.md +4 -2
- package/dist/resources/extensions/gsd/prompts/guided-plan-slice.md +3 -1
- package/dist/resources/extensions/gsd/prompts/guided-research-slice.md +3 -1
- package/dist/resources/extensions/gsd/prompts/plan-milestone.md +9 -12
- package/dist/resources/extensions/gsd/prompts/plan-slice.md +1 -3
- package/dist/resources/extensions/gsd/prompts/queue.md +3 -1
- package/dist/resources/extensions/gsd/prompts/research-milestone.md +1 -1
- package/dist/resources/extensions/gsd/prompts/research-slice.md +1 -1
- package/dist/resources/extensions/gsd/prompts/system.md +32 -29
- package/dist/resources/extensions/gsd/templates/context.md +1 -1
- package/dist/resources/extensions/gsd/templates/state.md +3 -3
- package/dist/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +282 -0
- package/dist/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +259 -0
- package/dist/resources/extensions/gsd/tests/auto-worktree.test.ts +147 -0
- package/dist/resources/extensions/gsd/tests/doctor-git.test.ts +246 -0
- package/dist/resources/extensions/gsd/tests/doctor.test.ts +115 -1
- package/dist/resources/extensions/gsd/tests/git-self-heal.test.ts +234 -0
- package/dist/resources/extensions/gsd/tests/isolation-resolver.test.ts +107 -0
- package/dist/resources/extensions/gsd/tests/post-unit-hooks.test.ts +297 -0
- package/dist/resources/extensions/gsd/tests/preferences-git.test.ts +88 -0
- package/dist/resources/extensions/gsd/tests/preferences-hooks.test.ts +226 -0
- package/dist/resources/extensions/gsd/tests/regex-hardening.test.ts +12 -0
- package/dist/resources/extensions/gsd/tests/worktree-e2e.test.ts +315 -0
- package/dist/resources/extensions/gsd/types.ts +109 -0
- package/dist/resources/extensions/gsd/worktree-manager.ts +6 -4
- package/dist/resources/extensions/search-the-web/command-search-provider.ts +8 -4
- package/dist/resources/extensions/search-the-web/native-search.ts +15 -10
- package/dist/resources/extensions/search-the-web/provider.ts +19 -2
- package/dist/resources/extensions/search-the-web/tool-fetch-page.ts +62 -0
- package/dist/resources/extensions/search-the-web/tool-llm-context.ts +62 -3
- package/dist/resources/extensions/search-the-web/tool-search.ts +62 -3
- package/dist/wizard.js +1 -0
- package/package.json +1 -1
- package/packages/pi-agent-core/dist/agent-loop.d.ts.map +1 -1
- package/packages/pi-agent-core/dist/agent-loop.js +169 -55
- package/packages/pi-agent-core/dist/agent-loop.js.map +1 -1
- package/packages/pi-agent-core/dist/agent.d.ts +13 -1
- package/packages/pi-agent-core/dist/agent.d.ts.map +1 -1
- package/packages/pi-agent-core/dist/agent.js +16 -0
- package/packages/pi-agent-core/dist/agent.js.map +1 -1
- package/packages/pi-agent-core/dist/types.d.ts +91 -1
- package/packages/pi-agent-core/dist/types.d.ts.map +1 -1
- package/packages/pi-agent-core/dist/types.js.map +1 -1
- package/packages/pi-agent-core/src/agent-loop.ts +273 -63
- package/packages/pi-agent-core/src/agent.ts +24 -0
- package/packages/pi-agent-core/src/types.ts +98 -0
- package/packages/pi-ai/dist/env-api-keys.js +1 -0
- package/packages/pi-ai/dist/env-api-keys.js.map +1 -1
- package/packages/pi-ai/dist/models.generated.d.ts +314 -0
- package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
- package/packages/pi-ai/dist/models.generated.js +236 -0
- package/packages/pi-ai/dist/models.generated.js.map +1 -1
- package/packages/pi-ai/dist/types.d.ts +1 -1
- package/packages/pi-ai/dist/types.d.ts.map +1 -1
- package/packages/pi-ai/dist/types.js.map +1 -1
- package/packages/pi-ai/src/env-api-keys.ts +1 -0
- package/packages/pi-ai/src/models.generated.ts +236 -0
- package/packages/pi-ai/src/types.ts +2 -1
- package/packages/pi-coding-agent/dist/cli/args.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/cli/args.js +2 -1
- package/packages/pi-coding-agent/dist/cli/args.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/agent-session.d.ts +10 -0
- package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/agent-session.js +69 -8
- package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts +4 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.js +2 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +5 -0
- package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/model-resolver.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/model-resolver.js +1 -0
- package/packages/pi-coding-agent/dist/core/model-resolver.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/sdk.js +3 -3
- package/packages/pi-coding-agent/dist/core/sdk.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/system-prompt.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/system-prompt.js +6 -0
- package/packages/pi-coding-agent/dist/core/system-prompt.js.map +1 -1
- package/packages/pi-coding-agent/src/cli/args.ts +2 -1
- package/packages/pi-coding-agent/src/core/agent-session.ts +76 -7
- package/packages/pi-coding-agent/src/core/extensions/runner.ts +2 -1
- package/packages/pi-coding-agent/src/core/extensions/types.ts +2 -0
- package/packages/pi-coding-agent/src/core/model-resolver.ts +1 -0
- package/packages/pi-coding-agent/src/core/sdk.ts +3 -3
- package/packages/pi-coding-agent/src/core/system-prompt.ts +9 -0
- package/packages/pi-tui/dist/components/editor.d.ts +11 -0
- package/packages/pi-tui/dist/components/editor.d.ts.map +1 -1
- package/packages/pi-tui/dist/components/editor.js +64 -6
- package/packages/pi-tui/dist/components/editor.js.map +1 -1
- package/packages/pi-tui/src/components/editor.ts +71 -6
- package/src/resources/extensions/bg-shell/index.ts +51 -7
- package/src/resources/extensions/gsd/auto-worktree.ts +509 -0
- package/src/resources/extensions/gsd/auto.ts +381 -13
- package/src/resources/extensions/gsd/commands.ts +9 -3
- package/src/resources/extensions/gsd/doctor.ts +254 -3
- package/src/resources/extensions/gsd/git-self-heal.ts +198 -0
- package/src/resources/extensions/gsd/git-service.ts +11 -0
- package/src/resources/extensions/gsd/guided-flow.ts +81 -9
- package/src/resources/extensions/gsd/post-unit-hooks.ts +449 -0
- package/src/resources/extensions/gsd/preferences.ts +209 -1
- package/src/resources/extensions/gsd/prompt-loader.ts +28 -1
- package/src/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
- package/src/resources/extensions/gsd/prompts/complete-slice.md +1 -3
- package/src/resources/extensions/gsd/prompts/discuss.md +10 -8
- package/src/resources/extensions/gsd/prompts/execute-task.md +4 -2
- package/src/resources/extensions/gsd/prompts/guided-complete-slice.md +3 -1
- package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +3 -1
- package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +3 -1
- package/src/resources/extensions/gsd/prompts/guided-execute-task.md +3 -1
- package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +4 -2
- package/src/resources/extensions/gsd/prompts/guided-plan-slice.md +3 -1
- package/src/resources/extensions/gsd/prompts/guided-research-slice.md +3 -1
- package/src/resources/extensions/gsd/prompts/plan-milestone.md +9 -12
- package/src/resources/extensions/gsd/prompts/plan-slice.md +1 -3
- package/src/resources/extensions/gsd/prompts/queue.md +3 -1
- package/src/resources/extensions/gsd/prompts/research-milestone.md +1 -1
- package/src/resources/extensions/gsd/prompts/research-slice.md +1 -1
- package/src/resources/extensions/gsd/prompts/system.md +32 -29
- package/src/resources/extensions/gsd/templates/context.md +1 -1
- package/src/resources/extensions/gsd/templates/state.md +3 -3
- package/src/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +282 -0
- package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +259 -0
- package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +147 -0
- package/src/resources/extensions/gsd/tests/doctor-git.test.ts +246 -0
- package/src/resources/extensions/gsd/tests/doctor.test.ts +115 -1
- package/src/resources/extensions/gsd/tests/git-self-heal.test.ts +234 -0
- package/src/resources/extensions/gsd/tests/isolation-resolver.test.ts +107 -0
- package/src/resources/extensions/gsd/tests/post-unit-hooks.test.ts +297 -0
- package/src/resources/extensions/gsd/tests/preferences-git.test.ts +88 -0
- package/src/resources/extensions/gsd/tests/preferences-hooks.test.ts +226 -0
- package/src/resources/extensions/gsd/tests/regex-hardening.test.ts +12 -0
- package/src/resources/extensions/gsd/tests/worktree-e2e.test.ts +315 -0
- package/src/resources/extensions/gsd/types.ts +109 -0
- package/src/resources/extensions/gsd/worktree-manager.ts +6 -4
- package/src/resources/extensions/search-the-web/command-search-provider.ts +8 -4
- package/src/resources/extensions/search-the-web/native-search.ts +15 -10
- package/src/resources/extensions/search-the-web/provider.ts +19 -2
- package/src/resources/extensions/search-the-web/tool-fetch-page.ts +62 -0
- package/src/resources/extensions/search-the-web/tool-llm-context.ts +62 -3
- package/src/resources/extensions/search-the-web/tool-search.ts +62 -3
|
@@ -53,10 +53,10 @@ function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportT
|
|
|
53
53
|
|
|
54
54
|
export function registerGSDCommand(pi: ExtensionAPI): void {
|
|
55
55
|
pi.registerCommand("gsd", {
|
|
56
|
-
description: "GSD — Get Shit Done: /gsd next|auto|stop|status|queue|prefs|doctor|migrate|remote",
|
|
56
|
+
description: "GSD — Get Shit Done: /gsd next|auto|stop|status|queue|prefs|hooks|doctor|migrate|remote",
|
|
57
57
|
|
|
58
58
|
getArgumentCompletions: (prefix: string) => {
|
|
59
|
-
const subcommands = ["next", "auto", "stop", "status", "queue", "discuss", "prefs", "doctor", "migrate", "remote"];
|
|
59
|
+
const subcommands = ["next", "auto", "stop", "status", "queue", "discuss", "prefs", "hooks", "doctor", "migrate", "remote"];
|
|
60
60
|
const parts = prefix.trim().split(/\s+/);
|
|
61
61
|
|
|
62
62
|
if (parts.length <= 1) {
|
|
@@ -151,6 +151,12 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
|
|
|
151
151
|
return;
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
if (trimmed === "hooks") {
|
|
155
|
+
const { formatHookStatus } = await import("./post-unit-hooks.js");
|
|
156
|
+
ctx.ui.notify(formatHookStatus(), "info");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
154
160
|
if (trimmed === "migrate" || trimmed.startsWith("migrate ")) {
|
|
155
161
|
await handleMigrate(trimmed.replace(/^migrate\s*/, "").trim(), ctx, pi);
|
|
156
162
|
return;
|
|
@@ -168,7 +174,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
|
|
|
168
174
|
}
|
|
169
175
|
|
|
170
176
|
ctx.ui.notify(
|
|
171
|
-
`Unknown: /gsd ${trimmed}. Use /gsd, /gsd next, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs [global|project|status|wizard|setup], /gsd doctor [audit|fix|heal] [M###/S##], /gsd migrate <path>, or /gsd remote [slack|discord|status|disconnect].`,
|
|
177
|
+
`Unknown: /gsd ${trimmed}. Use /gsd, /gsd next, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs [global|project|status|wizard|setup], /gsd hooks, /gsd doctor [audit|fix|heal] [M###/S##], /gsd migrate <path>, or /gsd remote [slack|discord|status|disconnect].`,
|
|
172
178
|
"warning",
|
|
173
179
|
);
|
|
174
180
|
},
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
1
2
|
import { existsSync, mkdirSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
|
|
@@ -5,6 +6,9 @@ import { loadFile, parsePlan, parseRoadmap, parseSummary, saveFile, parseTaskPla
|
|
|
5
6
|
import { resolveMilestoneFile, resolveMilestonePath, resolveSliceFile, resolveSlicePath, resolveTaskFile, resolveTaskFiles, resolveTasksDir, milestonesDir, gsdRoot, relMilestoneFile, relSliceFile, relTaskFile, relSlicePath, relGsdRootFile, resolveGsdRootFile } from "./paths.js";
|
|
6
7
|
import { deriveState, isMilestoneComplete } from "./state.js";
|
|
7
8
|
import { loadEffectiveGSDPreferences, type GSDPreferences } from "./preferences.js";
|
|
9
|
+
import { listWorktrees } from "./worktree-manager.js";
|
|
10
|
+
import { abortAndReset } from "./git-self-heal.js";
|
|
11
|
+
import { RUNTIME_EXCLUSION_PATHS } from "./git-service.js";
|
|
8
12
|
|
|
9
13
|
export type DoctorSeverity = "info" | "warning" | "error";
|
|
10
14
|
export type DoctorIssueCode =
|
|
@@ -22,7 +26,12 @@ export type DoctorIssueCode =
|
|
|
22
26
|
| "task_done_must_haves_not_verified"
|
|
23
27
|
| "active_requirement_missing_owner"
|
|
24
28
|
| "blocked_requirement_missing_reason"
|
|
25
|
-
| "blocker_discovered_no_replan"
|
|
29
|
+
| "blocker_discovered_no_replan"
|
|
30
|
+
| "delimiter_in_title"
|
|
31
|
+
| "orphaned_auto_worktree"
|
|
32
|
+
| "stale_milestone_branch"
|
|
33
|
+
| "corrupt_merge_state"
|
|
34
|
+
| "tracked_runtime_files";
|
|
26
35
|
|
|
27
36
|
export interface DoctorIssue {
|
|
28
37
|
severity: DoctorSeverity;
|
|
@@ -91,15 +100,43 @@ function validatePreferenceShape(preferences: GSDPreferences): string[] {
|
|
|
91
100
|
return issues;
|
|
92
101
|
}
|
|
93
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Characters that are used as delimiters in GSD state management documents
|
|
105
|
+
* and should not appear in milestone or slice titles.
|
|
106
|
+
*
|
|
107
|
+
* - "—" (em dash, U+2014): used as a display separator in STATE.md and other docs.
|
|
108
|
+
* A title containing "—" makes the separator ambiguous, corrupting state display
|
|
109
|
+
* and confusing the LLM agent that reads and writes these files.
|
|
110
|
+
* - "–" (en dash, U+2013): visually similar to em dash; same ambiguity risk.
|
|
111
|
+
* - "/" (forward slash, U+002F): used as the path separator in unit IDs (M001/S01)
|
|
112
|
+
* and git branch names (gsd/M001/S01). A slash in a title can break path resolution.
|
|
113
|
+
*/
|
|
114
|
+
const TITLE_DELIMITER_RE = /[\u2014\u2013\/]/; // em dash, en dash, forward slash
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Check whether a milestone or slice title contains characters that conflict
|
|
118
|
+
* with GSD's state document delimiter conventions.
|
|
119
|
+
* Returns a human-readable description of the problem, or null if the title is safe.
|
|
120
|
+
*/
|
|
121
|
+
export function validateTitle(title: string): string | null {
|
|
122
|
+
if (TITLE_DELIMITER_RE.test(title)) {
|
|
123
|
+
const found: string[] = [];
|
|
124
|
+
if (/[\u2014\u2013]/.test(title)) found.push("em/en dash (\u2014 or \u2013)");
|
|
125
|
+
if (/\//.test(title)) found.push("forward slash (/)");
|
|
126
|
+
return `title contains ${found.join(" and ")}, which conflict with GSD state document delimiters`;
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
94
131
|
function buildStateMarkdown(state: Awaited<ReturnType<typeof deriveState>>): string {
|
|
95
132
|
const lines: string[] = [];
|
|
96
133
|
lines.push("# GSD State", "");
|
|
97
134
|
|
|
98
135
|
const activeMilestone = state.activeMilestone
|
|
99
|
-
? `${state.activeMilestone.id}
|
|
136
|
+
? `${state.activeMilestone.id}: ${state.activeMilestone.title}`
|
|
100
137
|
: "None";
|
|
101
138
|
const activeSlice = state.activeSlice
|
|
102
|
-
? `${state.activeSlice.id}
|
|
139
|
+
? `${state.activeSlice.id}: ${state.activeSlice.title}`
|
|
103
140
|
: "None";
|
|
104
141
|
|
|
105
142
|
lines.push(`**Active Milestone:** ${activeMilestone}`);
|
|
@@ -422,6 +459,189 @@ export function formatDoctorIssuesForPrompt(issues: DoctorIssue[]): string {
|
|
|
422
459
|
}).join("\n");
|
|
423
460
|
}
|
|
424
461
|
|
|
462
|
+
async function checkGitHealth(
|
|
463
|
+
basePath: string,
|
|
464
|
+
issues: DoctorIssue[],
|
|
465
|
+
fixesApplied: string[],
|
|
466
|
+
shouldFix: (code: DoctorIssueCode) => boolean,
|
|
467
|
+
): Promise<void> {
|
|
468
|
+
// Degrade gracefully if not a git repo
|
|
469
|
+
try {
|
|
470
|
+
execSync("git rev-parse --git-dir", { cwd: basePath, stdio: "pipe" });
|
|
471
|
+
} catch {
|
|
472
|
+
return; // Not a git repo — skip all git health checks
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const gitDir = join(basePath, ".git");
|
|
476
|
+
|
|
477
|
+
// ── Orphaned auto-worktrees ──────────────────────────────────────────
|
|
478
|
+
try {
|
|
479
|
+
const worktrees = listWorktrees(basePath);
|
|
480
|
+
const milestoneWorktrees = worktrees.filter(wt => wt.branch.startsWith("milestone/"));
|
|
481
|
+
|
|
482
|
+
// Load roadmap state once for cross-referencing
|
|
483
|
+
const state = await deriveState(basePath);
|
|
484
|
+
|
|
485
|
+
for (const wt of milestoneWorktrees) {
|
|
486
|
+
// Extract milestone ID from branch name "milestone/M001" → "M001"
|
|
487
|
+
const milestoneId = wt.branch.replace(/^milestone\//, "");
|
|
488
|
+
const milestoneEntry = state.registry.find(m => m.id === milestoneId);
|
|
489
|
+
|
|
490
|
+
// Check if milestone is complete via roadmap
|
|
491
|
+
let isComplete = false;
|
|
492
|
+
if (milestoneEntry) {
|
|
493
|
+
const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
|
|
494
|
+
const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null;
|
|
495
|
+
if (roadmapContent) {
|
|
496
|
+
const roadmap = parseRoadmap(roadmapContent);
|
|
497
|
+
isComplete = isMilestoneComplete(roadmap);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (isComplete) {
|
|
502
|
+
issues.push({
|
|
503
|
+
severity: "warning",
|
|
504
|
+
code: "orphaned_auto_worktree",
|
|
505
|
+
scope: "milestone",
|
|
506
|
+
unitId: milestoneId,
|
|
507
|
+
message: `Worktree for completed milestone ${milestoneId} still exists at ${wt.path}`,
|
|
508
|
+
fixable: true,
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
if (shouldFix("orphaned_auto_worktree")) {
|
|
512
|
+
// Never remove a worktree matching current working directory
|
|
513
|
+
const cwd = process.cwd();
|
|
514
|
+
if (wt.path === cwd || cwd.startsWith(wt.path + "/")) {
|
|
515
|
+
fixesApplied.push(`skipped removing worktree at ${wt.path} (is cwd)`);
|
|
516
|
+
} else {
|
|
517
|
+
try {
|
|
518
|
+
execSync(`git worktree remove --force "${wt.path}"`, { cwd: basePath, stdio: "pipe" });
|
|
519
|
+
fixesApplied.push(`removed orphaned worktree ${wt.path}`);
|
|
520
|
+
} catch {
|
|
521
|
+
fixesApplied.push(`failed to remove worktree ${wt.path}`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ── Stale milestone branches ─────────────────────────────────────────
|
|
529
|
+
try {
|
|
530
|
+
const branchOutput = execSync("git branch --list 'milestone/*'", { cwd: basePath, stdio: "pipe" }).toString().trim();
|
|
531
|
+
if (branchOutput) {
|
|
532
|
+
const branches = branchOutput.split("\n").map(b => b.trim().replace(/^\*\s*/, "")).filter(Boolean);
|
|
533
|
+
const worktreeBranches = new Set(milestoneWorktrees.map(wt => wt.branch));
|
|
534
|
+
|
|
535
|
+
for (const branch of branches) {
|
|
536
|
+
// Skip branches that have a worktree (handled above)
|
|
537
|
+
if (worktreeBranches.has(branch)) continue;
|
|
538
|
+
|
|
539
|
+
const milestoneId = branch.replace(/^milestone\//, "");
|
|
540
|
+
const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
|
|
541
|
+
const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null;
|
|
542
|
+
if (!roadmapContent) continue;
|
|
543
|
+
|
|
544
|
+
const roadmap = parseRoadmap(roadmapContent);
|
|
545
|
+
if (isMilestoneComplete(roadmap)) {
|
|
546
|
+
issues.push({
|
|
547
|
+
severity: "info",
|
|
548
|
+
code: "stale_milestone_branch",
|
|
549
|
+
scope: "milestone",
|
|
550
|
+
unitId: milestoneId,
|
|
551
|
+
message: `Branch ${branch} exists for completed milestone ${milestoneId}`,
|
|
552
|
+
fixable: true,
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
if (shouldFix("stale_milestone_branch")) {
|
|
556
|
+
try {
|
|
557
|
+
execSync(`git branch -D "${branch}"`, { cwd: basePath, stdio: "pipe" });
|
|
558
|
+
fixesApplied.push(`deleted stale branch ${branch}`);
|
|
559
|
+
} catch {
|
|
560
|
+
fixesApplied.push(`failed to delete branch ${branch}`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
} catch {
|
|
567
|
+
// git branch list failed — skip stale branch check
|
|
568
|
+
}
|
|
569
|
+
} catch {
|
|
570
|
+
// listWorktrees or deriveState failed — skip worktree/branch checks
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ── Corrupt merge state ────────────────────────────────────────────────
|
|
574
|
+
try {
|
|
575
|
+
const mergeStateFiles = ["MERGE_HEAD", "SQUASH_MSG"];
|
|
576
|
+
const mergeStateDirs = ["rebase-apply", "rebase-merge"];
|
|
577
|
+
const found: string[] = [];
|
|
578
|
+
|
|
579
|
+
for (const f of mergeStateFiles) {
|
|
580
|
+
if (existsSync(join(gitDir, f))) found.push(f);
|
|
581
|
+
}
|
|
582
|
+
for (const d of mergeStateDirs) {
|
|
583
|
+
if (existsSync(join(gitDir, d))) found.push(d);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (found.length > 0) {
|
|
587
|
+
issues.push({
|
|
588
|
+
severity: "error",
|
|
589
|
+
code: "corrupt_merge_state",
|
|
590
|
+
scope: "project",
|
|
591
|
+
unitId: "project",
|
|
592
|
+
message: `Corrupt merge/rebase state detected: ${found.join(", ")}`,
|
|
593
|
+
fixable: true,
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
if (shouldFix("corrupt_merge_state")) {
|
|
597
|
+
const result = abortAndReset(basePath);
|
|
598
|
+
fixesApplied.push(`cleaned merge state: ${result.cleaned.join(", ")}`);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
} catch {
|
|
602
|
+
// Can't check .git dir — skip
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// ── Tracked runtime files ──────────────────────────────────────────────
|
|
606
|
+
try {
|
|
607
|
+
const trackedPaths: string[] = [];
|
|
608
|
+
for (const exclusion of RUNTIME_EXCLUSION_PATHS) {
|
|
609
|
+
try {
|
|
610
|
+
const output = execSync(`git ls-files "${exclusion}"`, { cwd: basePath, stdio: "pipe" }).toString().trim();
|
|
611
|
+
if (output) {
|
|
612
|
+
trackedPaths.push(...output.split("\n").filter(Boolean));
|
|
613
|
+
}
|
|
614
|
+
} catch {
|
|
615
|
+
// Individual ls-files can fail — continue
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (trackedPaths.length > 0) {
|
|
620
|
+
issues.push({
|
|
621
|
+
severity: "warning",
|
|
622
|
+
code: "tracked_runtime_files",
|
|
623
|
+
scope: "project",
|
|
624
|
+
unitId: "project",
|
|
625
|
+
message: `${trackedPaths.length} runtime file(s) are tracked by git: ${trackedPaths.slice(0, 5).join(", ")}${trackedPaths.length > 5 ? "..." : ""}`,
|
|
626
|
+
fixable: true,
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
if (shouldFix("tracked_runtime_files")) {
|
|
630
|
+
try {
|
|
631
|
+
for (const exclusion of RUNTIME_EXCLUSION_PATHS) {
|
|
632
|
+
execSync(`git rm --cached -r --ignore-unmatch "${exclusion}"`, { cwd: basePath, stdio: "pipe" });
|
|
633
|
+
}
|
|
634
|
+
fixesApplied.push(`untracked ${trackedPaths.length} runtime file(s)`);
|
|
635
|
+
} catch {
|
|
636
|
+
fixesApplied.push("failed to untrack runtime files");
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
} catch {
|
|
641
|
+
// git ls-files failed — skip
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
425
645
|
export async function runGSDDoctor(basePath: string, options?: { fix?: boolean; scope?: string; fixLevel?: "task" | "all" }): Promise<DoctorReport> {
|
|
426
646
|
const issues: DoctorIssue[] = [];
|
|
427
647
|
const fixesApplied: string[] = [];
|
|
@@ -462,6 +682,9 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
|
|
|
462
682
|
}
|
|
463
683
|
}
|
|
464
684
|
|
|
685
|
+
// Git health checks (orphaned worktrees, stale branches, corrupt merge state, tracked runtime files)
|
|
686
|
+
await checkGitHealth(basePath, issues, fixesApplied, shouldFix);
|
|
687
|
+
|
|
465
688
|
const milestonesPath = milestonesDir(basePath);
|
|
466
689
|
if (!existsSync(milestonesPath)) {
|
|
467
690
|
return { ok: issues.every(issue => issue.severity !== "error"), basePath, issues, fixesApplied };
|
|
@@ -477,6 +700,20 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
|
|
|
477
700
|
const milestonePath = resolveMilestonePath(basePath, milestoneId);
|
|
478
701
|
if (!milestonePath) continue;
|
|
479
702
|
|
|
703
|
+
// Validate milestone title for delimiter characters that break state documents.
|
|
704
|
+
const milestoneTitleIssue = validateTitle(milestone.title);
|
|
705
|
+
if (milestoneTitleIssue) {
|
|
706
|
+
issues.push({
|
|
707
|
+
severity: "warning",
|
|
708
|
+
code: "delimiter_in_title",
|
|
709
|
+
scope: "milestone",
|
|
710
|
+
unitId: milestoneId,
|
|
711
|
+
message: `Milestone ${milestoneId} ${milestoneTitleIssue}. Rename the milestone to remove these characters to prevent state corruption.`,
|
|
712
|
+
file: relMilestoneFile(basePath, milestoneId, "ROADMAP"),
|
|
713
|
+
fixable: false,
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
|
|
480
717
|
const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
|
|
481
718
|
const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null;
|
|
482
719
|
if (!roadmapContent) continue;
|
|
@@ -486,6 +723,20 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
|
|
|
486
723
|
const unitId = `${milestoneId}/${slice.id}`;
|
|
487
724
|
if (options?.scope && !matchesScope(unitId, options.scope) && options.scope !== milestoneId) continue;
|
|
488
725
|
|
|
726
|
+
// Validate slice title for delimiter characters.
|
|
727
|
+
const sliceTitleIssue = validateTitle(slice.title);
|
|
728
|
+
if (sliceTitleIssue) {
|
|
729
|
+
issues.push({
|
|
730
|
+
severity: "warning",
|
|
731
|
+
code: "delimiter_in_title",
|
|
732
|
+
scope: "slice",
|
|
733
|
+
unitId,
|
|
734
|
+
message: `Slice ${unitId} ${sliceTitleIssue}. Rename the slice to remove these characters to prevent state corruption.`,
|
|
735
|
+
file: relMilestoneFile(basePath, milestoneId, "ROADMAP"),
|
|
736
|
+
fixable: false,
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
|
|
489
740
|
const slicePath = resolveSlicePath(basePath, milestoneId, slice.id);
|
|
490
741
|
if (!slicePath) continue;
|
|
491
742
|
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git-self-heal.ts — Automated git state recovery utilities.
|
|
3
|
+
*
|
|
4
|
+
* Four synchronous functions for recovering from broken git state
|
|
5
|
+
* during auto-mode operations. Uses only `git reset --hard HEAD` —
|
|
6
|
+
* never `git clean` (which would delete untracked .gsd/ dirs).
|
|
7
|
+
*
|
|
8
|
+
* Observability: Each function returns structured results describing
|
|
9
|
+
* what actions were taken. `formatGitError` maps raw git errors to
|
|
10
|
+
* user-friendly messages suggesting `/gsd doctor`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { execSync } from "node:child_process";
|
|
14
|
+
import { existsSync, unlinkSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { MergeConflictError } from "./git-service.js";
|
|
17
|
+
|
|
18
|
+
// Re-export for consumers
|
|
19
|
+
export { MergeConflictError };
|
|
20
|
+
|
|
21
|
+
/** Result from abortAndReset describing what was cleaned up. */
|
|
22
|
+
export interface AbortAndResetResult {
|
|
23
|
+
/** List of actions taken, e.g. ["aborted merge", "removed SQUASH_MSG", "reset to HEAD"] */
|
|
24
|
+
cleaned: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Detect and clean up leftover merge/rebase state, then hard-reset.
|
|
29
|
+
*
|
|
30
|
+
* Checks for: .git/MERGE_HEAD, .git/SQUASH_MSG, .git/rebase-apply.
|
|
31
|
+
* Aborts in-progress merge or rebase if detected. Always finishes
|
|
32
|
+
* with `git reset --hard HEAD`.
|
|
33
|
+
*
|
|
34
|
+
* @returns Structured result listing what was cleaned. Empty `cleaned`
|
|
35
|
+
* array means repo was already in a clean state.
|
|
36
|
+
*/
|
|
37
|
+
export function abortAndReset(cwd: string): AbortAndResetResult {
|
|
38
|
+
const gitDir = join(cwd, ".git");
|
|
39
|
+
const cleaned: string[] = [];
|
|
40
|
+
|
|
41
|
+
// Abort in-progress merge
|
|
42
|
+
if (existsSync(join(gitDir, "MERGE_HEAD"))) {
|
|
43
|
+
try {
|
|
44
|
+
execSync("git merge --abort", { cwd, stdio: "pipe" });
|
|
45
|
+
cleaned.push("aborted merge");
|
|
46
|
+
} catch {
|
|
47
|
+
// merge --abort can fail if state is really broken; continue to reset
|
|
48
|
+
cleaned.push("merge abort attempted (may have failed)");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Remove leftover SQUASH_MSG (squash-merge leaves this without MERGE_HEAD)
|
|
53
|
+
const squashMsgPath = join(gitDir, "SQUASH_MSG");
|
|
54
|
+
if (existsSync(squashMsgPath)) {
|
|
55
|
+
try {
|
|
56
|
+
unlinkSync(squashMsgPath);
|
|
57
|
+
cleaned.push("removed SQUASH_MSG");
|
|
58
|
+
} catch {
|
|
59
|
+
// Not critical
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Abort in-progress rebase
|
|
64
|
+
if (existsSync(join(gitDir, "rebase-apply")) || existsSync(join(gitDir, "rebase-merge"))) {
|
|
65
|
+
try {
|
|
66
|
+
execSync("git rebase --abort", { cwd, stdio: "pipe" });
|
|
67
|
+
cleaned.push("aborted rebase");
|
|
68
|
+
} catch {
|
|
69
|
+
cleaned.push("rebase abort attempted (may have failed)");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Always hard-reset to HEAD
|
|
74
|
+
try {
|
|
75
|
+
execSync("git reset --hard HEAD", { cwd, stdio: "pipe" });
|
|
76
|
+
if (cleaned.length > 0) {
|
|
77
|
+
cleaned.push("reset to HEAD");
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
cleaned.push("reset to HEAD failed");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { cleaned };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Wrap a merge operation with self-healing retry logic.
|
|
88
|
+
*
|
|
89
|
+
* Calls `mergeFn()`. On failure:
|
|
90
|
+
* - If conflicted files exist (via `git diff --diff-filter=U`), re-throws
|
|
91
|
+
* as MergeConflictError immediately — no retry for real code conflicts.
|
|
92
|
+
* - Otherwise, runs `abortAndReset(cwd)`, retries `mergeFn()` once.
|
|
93
|
+
* - On second failure, throws the error.
|
|
94
|
+
*
|
|
95
|
+
* @param cwd - Working directory for git operations
|
|
96
|
+
* @param mergeFn - Synchronous function that performs the merge
|
|
97
|
+
* @returns The return value of `mergeFn()`
|
|
98
|
+
*/
|
|
99
|
+
export function withMergeHeal<T>(cwd: string, mergeFn: () => T): T {
|
|
100
|
+
try {
|
|
101
|
+
return mergeFn();
|
|
102
|
+
} catch (firstError) {
|
|
103
|
+
// Check for real code conflicts — escalate immediately, no retry
|
|
104
|
+
try {
|
|
105
|
+
const conflictOutput = execSync("git diff --name-only --diff-filter=U", {
|
|
106
|
+
cwd,
|
|
107
|
+
encoding: "utf-8",
|
|
108
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
109
|
+
}).trim();
|
|
110
|
+
|
|
111
|
+
if (conflictOutput.length > 0) {
|
|
112
|
+
const conflictedFiles = conflictOutput.split("\n").filter(Boolean);
|
|
113
|
+
// If the original error is already a MergeConflictError, re-throw as-is
|
|
114
|
+
if (firstError instanceof MergeConflictError) {
|
|
115
|
+
throw firstError;
|
|
116
|
+
}
|
|
117
|
+
throw new MergeConflictError(
|
|
118
|
+
conflictedFiles,
|
|
119
|
+
"merge",
|
|
120
|
+
"unknown",
|
|
121
|
+
"unknown",
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
} catch (diffErr) {
|
|
125
|
+
// If diffErr is a MergeConflictError we just created/re-threw, propagate it
|
|
126
|
+
if (diffErr instanceof MergeConflictError) throw diffErr;
|
|
127
|
+
// Otherwise git diff itself failed — proceed with retry
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// No real conflict detected — try abort+reset+retry once
|
|
131
|
+
abortAndReset(cwd);
|
|
132
|
+
|
|
133
|
+
// Retry
|
|
134
|
+
return mergeFn();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Recover a failed checkout by resetting first, then checking out.
|
|
140
|
+
*
|
|
141
|
+
* Performs `git reset --hard HEAD` then `git checkout <targetBranch>`.
|
|
142
|
+
* If checkout still fails after reset, throws with context.
|
|
143
|
+
*/
|
|
144
|
+
export function recoverCheckout(cwd: string, targetBranch: string): void {
|
|
145
|
+
execSync("git reset --hard HEAD", { cwd, stdio: "pipe" });
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
execSync(`git checkout ${targetBranch}`, { cwd, stdio: "pipe" });
|
|
149
|
+
} catch (err) {
|
|
150
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
151
|
+
throw new Error(
|
|
152
|
+
`recoverCheckout failed: could not checkout '${targetBranch}' after reset. ${msg}`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Known git error patterns mapped to user-friendly messages. */
|
|
158
|
+
const ERROR_PATTERNS: Array<{ pattern: RegExp; message: string }> = [
|
|
159
|
+
{
|
|
160
|
+
pattern: /conflict|CONFLICT|merge conflict/i,
|
|
161
|
+
message: "A merge conflict occurred. Code changes on different branches touched the same files. Run `/gsd doctor` to diagnose.",
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
pattern: /cannot checkout|did not match any|pathspec .* did not match/i,
|
|
165
|
+
message: "Git could not switch branches — the target branch may not exist or the working tree is dirty. Run `/gsd doctor` to diagnose.",
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
pattern: /HEAD detached|detached HEAD/i,
|
|
169
|
+
message: "Git is in a detached HEAD state — not on any branch. Run `/gsd doctor` to diagnose and reattach.",
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
pattern: /\.lock|Unable to create .* lock|lock file/i,
|
|
173
|
+
message: "A git lock file is blocking operations. Another git process may be running, or a previous one crashed. Run `/gsd doctor` to diagnose.",
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
pattern: /fatal: not a git repository/i,
|
|
177
|
+
message: "This directory is not a git repository. Run `/gsd doctor` to check your project setup.",
|
|
178
|
+
},
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Translate raw git error strings into user-friendly messages.
|
|
183
|
+
*
|
|
184
|
+
* Pattern-matches against common git error strings and returns
|
|
185
|
+
* a non-technical message suggesting `/gsd doctor`. Returns the
|
|
186
|
+
* original message if no pattern matches.
|
|
187
|
+
*/
|
|
188
|
+
export function formatGitError(error: string | Error): string {
|
|
189
|
+
const errorStr = error instanceof Error ? error.message : error;
|
|
190
|
+
|
|
191
|
+
for (const { pattern, message } of ERROR_PATTERNS) {
|
|
192
|
+
if (pattern.test(errorStr)) {
|
|
193
|
+
return message;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return `A git error occurred: ${errorStr.slice(0, 200)}. Run \`/gsd doctor\` for help.`;
|
|
198
|
+
}
|
|
@@ -37,6 +37,8 @@ export interface GitPreferences {
|
|
|
37
37
|
commit_type?: string;
|
|
38
38
|
main_branch?: string;
|
|
39
39
|
merge_strategy?: "squash" | "merge";
|
|
40
|
+
isolation?: "worktree" | "branch";
|
|
41
|
+
merge_to_main?: "milestone" | "slice";
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
export const VALID_BRANCH_NAME = /^[a-zA-Z0-9_\-\/.]+$/;
|
|
@@ -764,6 +766,15 @@ export class GitServiceImpl {
|
|
|
764
766
|
this.git(mergeArgs);
|
|
765
767
|
} catch (mergeError) {
|
|
766
768
|
// Check if conflicts can be auto-resolved (#189, #218)
|
|
769
|
+
//
|
|
770
|
+
// ─── BRANCH-MODE ONLY (D038) ────────────────────────────────────────
|
|
771
|
+
// The conflict resolution logic below applies ONLY when git.isolation = "branch".
|
|
772
|
+
// In worktree isolation mode, each milestone works in its own worktree directory
|
|
773
|
+
// so merge conflicts between slice branches and main are handled differently
|
|
774
|
+
// (worktree teardown merges via worktree-manager). This block is never reached
|
|
775
|
+
// in worktree mode because mergeSliceToMain is only called from the branch-mode
|
|
776
|
+
// code path. If you're modifying this logic, verify the isolation mode first.
|
|
777
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
767
778
|
const conflicted = this.git(["diff", "--name-only", "--diff-filter=U"], { allowFailure: true });
|
|
768
779
|
if (conflicted) {
|
|
769
780
|
const conflictedFiles = conflicted.split("\n").filter(Boolean);
|