gsd-pi 2.35.0-dev.30eec3f → 2.35.0-dev.640d5c7
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 +2 -7
- package/dist/resource-loader.d.ts +1 -1
- package/dist/resource-loader.js +1 -13
- package/dist/resources/extensions/async-jobs/await-tool.js +2 -0
- package/dist/resources/extensions/async-jobs/job-manager.js +6 -0
- package/dist/resources/extensions/bg-shell/output-formatter.js +19 -1
- package/dist/resources/extensions/bg-shell/process-manager.js +4 -0
- package/dist/resources/extensions/bg-shell/types.js +2 -0
- package/dist/resources/extensions/context7/index.js +0 -5
- package/dist/resources/extensions/get-secrets-from-user.js +30 -2
- package/dist/resources/extensions/google-search/index.js +0 -5
- package/dist/resources/extensions/gsd/auto-dispatch.js +1 -43
- package/dist/resources/extensions/gsd/auto-loop.js +1 -10
- package/dist/resources/extensions/gsd/auto-recovery.js +0 -35
- package/dist/resources/extensions/gsd/auto-start.js +2 -35
- package/dist/resources/extensions/gsd/auto.js +4 -59
- package/dist/resources/extensions/gsd/commands-handlers.js +2 -2
- package/dist/resources/extensions/gsd/doctor-environment.js +17 -26
- package/dist/resources/extensions/gsd/files.js +1 -9
- package/dist/resources/extensions/gsd/gitignore.js +7 -54
- package/dist/resources/extensions/gsd/guided-flow.js +1 -1
- package/dist/resources/extensions/gsd/health-widget.js +46 -97
- package/dist/resources/extensions/gsd/index.js +1 -10
- package/dist/resources/extensions/gsd/migrate-external.js +2 -55
- package/dist/resources/extensions/gsd/paths.js +7 -74
- package/dist/resources/extensions/gsd/post-unit-hooks.js +1 -4
- package/dist/resources/extensions/gsd/preferences-validation.js +1 -16
- package/dist/resources/extensions/gsd/prompts/complete-milestone.md +0 -2
- package/dist/resources/extensions/gsd/prompts/validate-milestone.md +0 -2
- package/dist/resources/extensions/gsd/session-lock.js +2 -26
- package/dist/resources/extensions/gsd/templates/plan.md +0 -8
- package/dist/resources/extensions/gsd/worktree-resolver.js +0 -12
- package/dist/resources/extensions/remote-questions/remote-command.js +22 -2
- package/dist/resources/extensions/shared/mod.js +1 -1
- package/dist/resources/extensions/shared/sanitize.js +0 -30
- package/dist/resources/extensions/shared/wizard-ui.js +478 -0
- package/dist/resources/extensions/subagent/index.js +14 -6
- package/package.json +1 -2
- package/packages/pi-coding-agent/dist/core/resource-loader.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/resource-loader.js +2 -13
- package/packages/pi-coding-agent/dist/core/resource-loader.js.map +1 -1
- package/packages/pi-coding-agent/src/core/resource-loader.ts +2 -13
- package/src/resources/extensions/async-jobs/await-tool.ts +2 -0
- package/src/resources/extensions/async-jobs/job-manager.ts +7 -0
- package/src/resources/extensions/bg-shell/output-formatter.ts +17 -0
- package/src/resources/extensions/bg-shell/process-manager.ts +4 -0
- package/src/resources/extensions/bg-shell/types.ts +12 -0
- package/src/resources/extensions/context7/index.ts +0 -7
- package/src/resources/extensions/get-secrets-from-user.ts +35 -2
- package/src/resources/extensions/google-search/index.ts +0 -7
- package/src/resources/extensions/gsd/auto-dispatch.ts +1 -49
- package/src/resources/extensions/gsd/auto-loop.ts +1 -11
- package/src/resources/extensions/gsd/auto-recovery.ts +0 -39
- package/src/resources/extensions/gsd/auto-start.ts +2 -42
- package/src/resources/extensions/gsd/auto.ts +3 -61
- package/src/resources/extensions/gsd/commands-handlers.ts +2 -2
- package/src/resources/extensions/gsd/doctor-environment.ts +16 -26
- package/src/resources/extensions/gsd/files.ts +1 -10
- package/src/resources/extensions/gsd/gitignore.ts +7 -54
- package/src/resources/extensions/gsd/guided-flow.ts +1 -1
- package/src/resources/extensions/gsd/health-widget.ts +59 -103
- package/src/resources/extensions/gsd/index.ts +1 -10
- package/src/resources/extensions/gsd/migrate-external.ts +2 -47
- package/src/resources/extensions/gsd/paths.ts +7 -73
- package/src/resources/extensions/gsd/post-unit-hooks.ts +1 -5
- package/src/resources/extensions/gsd/preferences-validation.ts +1 -16
- package/src/resources/extensions/gsd/prompts/complete-milestone.md +0 -2
- package/src/resources/extensions/gsd/prompts/validate-milestone.md +0 -2
- package/src/resources/extensions/gsd/session-lock.ts +2 -29
- package/src/resources/extensions/gsd/templates/plan.md +0 -8
- package/src/resources/extensions/gsd/tests/preferences.test.ts +2 -12
- package/src/resources/extensions/gsd/tests/validate-directory.test.ts +0 -15
- package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +0 -2
- package/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +0 -32
- package/src/resources/extensions/gsd/worktree-resolver.ts +0 -11
- package/src/resources/extensions/remote-questions/remote-command.ts +23 -2
- package/src/resources/extensions/shared/mod.ts +1 -1
- package/src/resources/extensions/shared/sanitize.ts +0 -36
- package/src/resources/extensions/shared/wizard-ui.ts +551 -0
- package/src/resources/extensions/subagent/index.ts +12 -6
- package/dist/resources/extensions/gsd/health-widget-core.js +0 -96
- package/dist/resources/extensions/gsd/roadmap-mutations.js +0 -55
- package/src/resources/extensions/gsd/health-widget-core.ts +0 -129
- package/src/resources/extensions/gsd/roadmap-mutations.ts +0 -66
- package/src/resources/extensions/gsd/tests/gitignore-tracked-gsd.test.ts +0 -214
- package/src/resources/extensions/gsd/tests/health-widget.test.ts +0 -158
- package/src/resources/extensions/gsd/tests/paths.test.ts +0 -113
- package/src/resources/extensions/gsd/tests/test-utils.ts +0 -165
|
@@ -9,37 +9,41 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { ExtensionContext } from "@gsd/pi-coding-agent";
|
|
12
|
-
import type { GSDState } from "./types.js";
|
|
13
12
|
import { runProviderChecks, summariseProviderIssues } from "./doctor-providers.js";
|
|
14
13
|
import { runEnvironmentChecks } from "./doctor-environment.js";
|
|
15
14
|
import { loadEffectiveGSDPreferences } from "./preferences.js";
|
|
16
15
|
import { loadLedgerFromDisk, getProjectTotals } from "./metrics.js";
|
|
17
|
-
import { describeNextUnit, estimateTimeRemaining, updateSliceProgressCache } from "./auto-dashboard.js";
|
|
18
16
|
import { projectRoot } from "./commands.js";
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
|
|
18
|
+
// ── Types ──────────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
interface HealthWidgetData {
|
|
21
|
+
hasProject: boolean;
|
|
22
|
+
budgetCeiling: number | undefined;
|
|
23
|
+
budgetSpent: number;
|
|
24
|
+
providerIssue: string | null; // compact summary from summariseProviderIssues()
|
|
25
|
+
environmentErrorCount: number;
|
|
26
|
+
environmentWarningCount: number;
|
|
27
|
+
lastRefreshed: number;
|
|
28
|
+
}
|
|
25
29
|
|
|
26
30
|
// ── Data loader ────────────────────────────────────────────────────────────────
|
|
27
31
|
|
|
28
|
-
function
|
|
32
|
+
function loadHealthWidgetData(basePath: string): HealthWidgetData {
|
|
33
|
+
let hasProject = false;
|
|
29
34
|
let budgetCeiling: number | undefined;
|
|
30
35
|
let budgetSpent = 0;
|
|
31
36
|
let providerIssue: string | null = null;
|
|
32
37
|
let environmentErrorCount = 0;
|
|
33
38
|
let environmentWarningCount = 0;
|
|
34
39
|
|
|
35
|
-
const projectState = detectHealthWidgetProjectState(basePath);
|
|
36
|
-
|
|
37
40
|
try {
|
|
38
41
|
const prefs = loadEffectiveGSDPreferences();
|
|
39
42
|
budgetCeiling = prefs?.preferences?.budget_ceiling;
|
|
40
43
|
|
|
41
44
|
const ledger = loadLedgerFromDisk(basePath);
|
|
42
45
|
if (ledger) {
|
|
46
|
+
hasProject = true;
|
|
43
47
|
const totals = getProjectTotals(ledger.units ?? []);
|
|
44
48
|
budgetSpent = totals.cost;
|
|
45
49
|
}
|
|
@@ -59,7 +63,7 @@ function loadBaseHealthWidgetData(basePath: string): HealthWidgetData {
|
|
|
59
63
|
} catch { /* non-fatal */ }
|
|
60
64
|
|
|
61
65
|
return {
|
|
62
|
-
|
|
66
|
+
hasProject,
|
|
63
67
|
budgetCeiling,
|
|
64
68
|
budgetSpent,
|
|
65
69
|
providerIssue,
|
|
@@ -69,88 +73,54 @@ function loadBaseHealthWidgetData(basePath: string): HealthWidgetData {
|
|
|
69
73
|
};
|
|
70
74
|
}
|
|
71
75
|
|
|
72
|
-
|
|
73
|
-
const trimmed = text.replace(/\s+/g, " ").trim();
|
|
74
|
-
if (trimmed.length <= max) return trimmed;
|
|
75
|
-
return `${trimmed.slice(0, max - 1).trimEnd()}…`;
|
|
76
|
-
}
|
|
76
|
+
// ── Rendering ──────────────────────────────────────────────────────────────────
|
|
77
77
|
|
|
78
|
-
function
|
|
79
|
-
|
|
80
|
-
case "blocked": return "Blocked";
|
|
81
|
-
case "paused": return "Paused";
|
|
82
|
-
case "complete": return "Complete";
|
|
83
|
-
case "executing": return "Executing";
|
|
84
|
-
case "planning": return "Planning";
|
|
85
|
-
case "pre-planning": return "Pre-planning";
|
|
86
|
-
case "summarizing": return "Summarizing";
|
|
87
|
-
case "validating-milestone": return "Validating";
|
|
88
|
-
case "completing-milestone": return "Completing";
|
|
89
|
-
case "needs-discussion": return "Needs discussion";
|
|
90
|
-
case "replanning-slice": return "Replanning";
|
|
91
|
-
default: return "Active";
|
|
92
|
-
}
|
|
78
|
+
function formatCost(n: number): string {
|
|
79
|
+
return n >= 1 ? `$${n.toFixed(2)}` : `${(n * 100).toFixed(1)}¢`;
|
|
93
80
|
}
|
|
94
81
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
return state.activeSlice ? `Plan ${state.activeSlice.id}` : "Plan next slice";
|
|
103
|
-
case "executing":
|
|
104
|
-
return state.activeTask ? `Execute ${state.activeTask.id}` : "Execute next task";
|
|
105
|
-
case "summarizing":
|
|
106
|
-
return state.activeSlice ? `Complete ${state.activeSlice.id}` : "Complete current slice";
|
|
107
|
-
case "validating-milestone":
|
|
108
|
-
return state.activeMilestone ? `Validate ${state.activeMilestone.id}` : "Validate milestone";
|
|
109
|
-
case "completing-milestone":
|
|
110
|
-
return state.activeMilestone ? `Complete ${state.activeMilestone.id}` : "Complete milestone";
|
|
111
|
-
case "replanning-slice":
|
|
112
|
-
return state.activeSlice ? `Replan ${state.activeSlice.id}` : "Replan current slice";
|
|
113
|
-
case "blocked":
|
|
114
|
-
return `waiting on ${compactText(state.blockers[0] ?? state.nextAction, 56)}`;
|
|
115
|
-
case "paused":
|
|
116
|
-
return compactText(state.nextAction || "waiting to resume", 56);
|
|
117
|
-
case "complete":
|
|
118
|
-
return "All milestones complete";
|
|
119
|
-
default:
|
|
120
|
-
return compactText(describeNextUnit(state).label, 56);
|
|
82
|
+
/**
|
|
83
|
+
* Build compact health lines for the widget.
|
|
84
|
+
* Returns a string array suitable for setWidget().
|
|
85
|
+
*/
|
|
86
|
+
export function buildHealthLines(data: HealthWidgetData): string[] {
|
|
87
|
+
if (!data.hasProject) {
|
|
88
|
+
return [" GSD No project loaded — run /gsd to start"];
|
|
121
89
|
}
|
|
122
|
-
}
|
|
123
90
|
|
|
124
|
-
|
|
125
|
-
if (baseData.projectState !== "active") return baseData;
|
|
91
|
+
const parts: string[] = [];
|
|
126
92
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
93
|
+
// System status signal
|
|
94
|
+
const totalIssues = data.environmentErrorCount + data.environmentWarningCount + (data.providerIssue ? 1 : 0);
|
|
95
|
+
if (totalIssues === 0) {
|
|
96
|
+
parts.push("● System OK");
|
|
97
|
+
} else if (data.environmentErrorCount > 0 || data.providerIssue?.includes("✗")) {
|
|
98
|
+
parts.push(`✗ ${totalIssues} issue${totalIssues > 1 ? "s" : ""}`);
|
|
99
|
+
} else {
|
|
100
|
+
parts.push(`⚠ ${totalIssues} warning${totalIssues > 1 ? "s" : ""}`);
|
|
101
|
+
}
|
|
130
102
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
}
|
|
103
|
+
// Budget
|
|
104
|
+
if (data.budgetCeiling !== undefined && data.budgetCeiling > 0) {
|
|
105
|
+
const pct = Math.min(100, (data.budgetSpent / data.budgetCeiling) * 100);
|
|
106
|
+
parts.push(`Budget: ${formatCost(data.budgetSpent)}/${formatCost(data.budgetCeiling)} (${pct.toFixed(0)}%)`);
|
|
107
|
+
} else if (data.budgetSpent > 0) {
|
|
108
|
+
parts.push(`Spent: ${formatCost(data.budgetSpent)}`);
|
|
109
|
+
}
|
|
135
110
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
executionStatus: summarizeExecutionStatus(state),
|
|
140
|
-
executionTarget: summarizeExecutionTarget(state),
|
|
141
|
-
nextAction: state.nextAction,
|
|
142
|
-
blocker: state.blockers[0] ?? null,
|
|
143
|
-
activeMilestoneId: state.activeMilestone?.id,
|
|
144
|
-
activeSliceId: state.activeSlice?.id,
|
|
145
|
-
activeTaskId: state.activeTask?.id,
|
|
146
|
-
progress: state.progress,
|
|
147
|
-
eta: state.phase === "blocked" || state.phase === "paused" || state.phase === "complete"
|
|
148
|
-
? null
|
|
149
|
-
: estimateTimeRemaining(),
|
|
150
|
-
};
|
|
151
|
-
} catch {
|
|
152
|
-
return baseData;
|
|
111
|
+
// Provider issue (if any)
|
|
112
|
+
if (data.providerIssue) {
|
|
113
|
+
parts.push(data.providerIssue);
|
|
153
114
|
}
|
|
115
|
+
|
|
116
|
+
// Environment issues
|
|
117
|
+
if (data.environmentErrorCount > 0) {
|
|
118
|
+
parts.push(`Env: ${data.environmentErrorCount} error${data.environmentErrorCount > 1 ? "s" : ""}`);
|
|
119
|
+
} else if (data.environmentWarningCount > 0) {
|
|
120
|
+
parts.push(`Env: ${data.environmentWarningCount} warning${data.environmentWarningCount > 1 ? "s" : ""}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return [` ${parts.join(" │ ")}`];
|
|
154
124
|
}
|
|
155
125
|
|
|
156
126
|
// ── Widget init ────────────────────────────────────────────────────────────────
|
|
@@ -167,34 +137,20 @@ export function initHealthWidget(ctx: ExtensionContext): void {
|
|
|
167
137
|
const basePath = projectRoot();
|
|
168
138
|
|
|
169
139
|
// String-array fallback — used in RPC mode (factory is a no-op there)
|
|
170
|
-
const initialData =
|
|
140
|
+
const initialData = loadHealthWidgetData(basePath);
|
|
171
141
|
ctx.ui.setWidget("gsd-health", buildHealthLines(initialData), { placement: "belowEditor" });
|
|
172
142
|
|
|
173
143
|
// Factory-based widget for TUI mode — replaces the string-array above
|
|
174
144
|
ctx.ui.setWidget("gsd-health", (_tui, _theme) => {
|
|
175
145
|
let data = initialData;
|
|
176
146
|
let cachedLines: string[] | undefined;
|
|
177
|
-
let refreshInFlight = false;
|
|
178
147
|
|
|
179
|
-
const
|
|
180
|
-
if (refreshInFlight) return;
|
|
181
|
-
refreshInFlight = true;
|
|
148
|
+
const refreshTimer = setInterval(() => {
|
|
182
149
|
try {
|
|
183
|
-
|
|
184
|
-
data = await enrichHealthWidgetData(basePath, baseData);
|
|
150
|
+
data = loadHealthWidgetData(basePath);
|
|
185
151
|
cachedLines = undefined;
|
|
186
152
|
_tui.requestRender();
|
|
187
|
-
} catch { /* non-fatal */ }
|
|
188
|
-
refreshInFlight = false;
|
|
189
|
-
}
|
|
190
|
-
};
|
|
191
|
-
|
|
192
|
-
// Fire first enrichment immediately. requestRender() inside is a no-op
|
|
193
|
-
// if the widget has not yet rendered, so this is safe before factory return.
|
|
194
|
-
void refresh();
|
|
195
|
-
|
|
196
|
-
const refreshTimer = setInterval(() => {
|
|
197
|
-
void refresh();
|
|
153
|
+
} catch { /* non-fatal */ }
|
|
198
154
|
}, REFRESH_INTERVAL_MS);
|
|
199
155
|
|
|
200
156
|
return {
|
|
@@ -175,16 +175,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
175
175
|
// Pipe closed — nothing we can write; just exit cleanly
|
|
176
176
|
process.exit(0);
|
|
177
177
|
}
|
|
178
|
-
|
|
179
|
-
(err as any).syscall?.startsWith("spawn")) {
|
|
180
|
-
// spawn ENOENT — command not found (e.g., npx on Windows).
|
|
181
|
-
// This surfaces as an uncaught exception from child_process but
|
|
182
|
-
// is not a fatal process error. Log and continue instead of
|
|
183
|
-
// crashing auto-mode (#1384).
|
|
184
|
-
process.stderr.write(`[gsd] spawn ENOENT: ${(err as any).path ?? "unknown"} — command not found\n`);
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
// Re-throw anything that isn't EPIPE/ENOENT so real crashes still surface
|
|
178
|
+
// Re-throw anything that isn't EPIPE so real crashes still surface
|
|
188
179
|
throw err;
|
|
189
180
|
};
|
|
190
181
|
process.on("uncaughtException", _gsdEpipeGuard);
|
|
@@ -6,11 +6,10 @@
|
|
|
6
6
|
* symlink replaces the original directory so all paths remain valid.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { existsSync, lstatSync, mkdirSync, readdirSync,
|
|
9
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, renameSync, cpSync, rmSync, symlinkSync } from "node:fs";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { externalGsdRoot } from "./repo-identity.js";
|
|
12
12
|
import { getErrorMessage } from "./error-utils.js";
|
|
13
|
-
import { hasGitTrackedGsdFiles } from "./gitignore.js";
|
|
14
13
|
|
|
15
14
|
export interface MigrationResult {
|
|
16
15
|
migrated: boolean;
|
|
@@ -52,28 +51,6 @@ export function migrateToExternalState(basePath: string): MigrationResult {
|
|
|
52
51
|
return { migrated: false, error: `Cannot stat .gsd: ${getErrorMessage(err)}` };
|
|
53
52
|
}
|
|
54
53
|
|
|
55
|
-
// Skip if .gsd/ contains git-tracked files — the project intentionally
|
|
56
|
-
// keeps .gsd/ in version control and migration would destroy that.
|
|
57
|
-
if (hasGitTrackedGsdFiles(basePath)) {
|
|
58
|
-
return { migrated: false };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Skip if .gsd/worktrees/ has active worktree directories (#1337).
|
|
62
|
-
// On Windows, active git worktrees hold OS-level directory handles that
|
|
63
|
-
// prevent rename/delete. Attempting migration causes EBUSY and data loss.
|
|
64
|
-
const worktreesDir = join(localGsd, "worktrees");
|
|
65
|
-
if (existsSync(worktreesDir)) {
|
|
66
|
-
try {
|
|
67
|
-
const entries = readdirSync(worktreesDir, { withFileTypes: true });
|
|
68
|
-
if (entries.some(e => e.isDirectory())) {
|
|
69
|
-
return { migrated: false };
|
|
70
|
-
}
|
|
71
|
-
} catch {
|
|
72
|
-
// Can't read worktrees dir — skip migration to be safe
|
|
73
|
-
return { migrated: false };
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
54
|
const externalPath = externalGsdRoot(basePath);
|
|
78
55
|
const migratingPath = join(basePath, ".gsd.migrating");
|
|
79
56
|
|
|
@@ -122,29 +99,7 @@ export function migrateToExternalState(basePath: string): MigrationResult {
|
|
|
122
99
|
// Create symlink .gsd -> external path
|
|
123
100
|
symlinkSync(externalPath, localGsd, "junction");
|
|
124
101
|
|
|
125
|
-
//
|
|
126
|
-
// On Windows, junction creation can silently succeed but resolve to the wrong
|
|
127
|
-
// target, or the external dir may not be accessible. If verification fails,
|
|
128
|
-
// restore from the backup.
|
|
129
|
-
try {
|
|
130
|
-
const resolved = realpathSync(localGsd);
|
|
131
|
-
const resolvedExternal = realpathSync(externalPath);
|
|
132
|
-
if (resolved !== resolvedExternal) {
|
|
133
|
-
// Symlink points to wrong target — restore backup
|
|
134
|
-
try { rmSync(localGsd, { force: true }); } catch { /* may not exist */ }
|
|
135
|
-
renameSync(migratingPath, localGsd);
|
|
136
|
-
return { migrated: false, error: `Migration verification failed: symlink resolves to ${resolved}, expected ${resolvedExternal}` };
|
|
137
|
-
}
|
|
138
|
-
// Verify we can read through the symlink
|
|
139
|
-
readdirSync(localGsd);
|
|
140
|
-
} catch (verifyErr) {
|
|
141
|
-
// Symlink broken or unreadable — restore backup
|
|
142
|
-
try { rmSync(localGsd, { force: true }); } catch { /* may not exist */ }
|
|
143
|
-
try { renameSync(migratingPath, localGsd); } catch { /* best-effort restore */ }
|
|
144
|
-
return { migrated: false, error: `Migration verification failed: ${getErrorMessage(verifyErr)}` };
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Remove .gsd.migrating only after symlink is verified
|
|
102
|
+
// Remove .gsd.migrating
|
|
148
103
|
rmSync(migratingPath, { recursive: true, force: true });
|
|
149
104
|
|
|
150
105
|
return { migrated: true };
|
|
@@ -10,8 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { readdirSync, existsSync, realpathSync, Dirent } from "node:fs";
|
|
13
|
-
import { join
|
|
14
|
-
import { spawnSync } from "node:child_process";
|
|
13
|
+
import { join } from "node:path";
|
|
15
14
|
import { nativeScanGsdTree, type GsdTreeEntry } from "./native-parser-bridge.js";
|
|
16
15
|
import { DIR_CACHE_MAX } from "./constants.js";
|
|
17
16
|
|
|
@@ -278,80 +277,15 @@ const LEGACY_GSD_ROOT_FILES: Record<GSDRootFileKey, string> = {
|
|
|
278
277
|
KNOWLEDGE: "knowledge.md",
|
|
279
278
|
};
|
|
280
279
|
|
|
281
|
-
// ─── GSD Root Discovery ───────────────────────────────────────────────────────
|
|
282
|
-
|
|
283
|
-
const gsdRootCache = new Map<string, string>();
|
|
284
|
-
|
|
285
|
-
/** Exported for tests only — do not call in production code. */
|
|
286
|
-
export function _clearGsdRootCache(): void {
|
|
287
|
-
gsdRootCache.clear();
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* Resolve the `.gsd` directory for a given project base path.
|
|
292
|
-
*
|
|
293
|
-
* Probe order:
|
|
294
|
-
* 1. basePath/.gsd — fast path (common case)
|
|
295
|
-
* 2. git rev-parse root — handles cwd-is-a-subdirectory
|
|
296
|
-
* 3. Walk up from basePath — handles moved .gsd in an ancestor (bounded by git root)
|
|
297
|
-
* 4. basePath/.gsd — creation fallback (init scenario)
|
|
298
|
-
*
|
|
299
|
-
* Result is cached per basePath for the process lifetime.
|
|
300
|
-
*/
|
|
301
280
|
export function gsdRoot(basePath: string): string {
|
|
302
|
-
const
|
|
303
|
-
if (cached) return cached;
|
|
304
|
-
|
|
305
|
-
const result = probeGsdRoot(basePath);
|
|
306
|
-
gsdRootCache.set(basePath, result);
|
|
307
|
-
return result;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
function probeGsdRoot(rawBasePath: string): string {
|
|
311
|
-
// 1. Fast path — check the input path directly
|
|
312
|
-
const local = join(rawBasePath, ".gsd");
|
|
313
|
-
if (existsSync(local)) return local;
|
|
314
|
-
|
|
315
|
-
// Resolve symlinks so path comparisons work correctly across platforms
|
|
316
|
-
// (e.g. macOS /var → /private/var). Use rawBasePath as fallback if not resolvable.
|
|
317
|
-
let basePath: string;
|
|
318
|
-
try { basePath = realpathSync.native(rawBasePath); } catch { basePath = rawBasePath; }
|
|
319
|
-
|
|
320
|
-
// 2. Git root anchor — used as both probe target and walk-up boundary
|
|
321
|
-
// Only walk if we're inside a git project — prevents escaping into
|
|
322
|
-
// unrelated filesystem territory when running outside any repo.
|
|
323
|
-
let gitRoot: string | null = null;
|
|
281
|
+
const local = join(basePath, ".gsd");
|
|
324
282
|
try {
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
if (out.status === 0) {
|
|
330
|
-
const r = out.stdout.trim();
|
|
331
|
-
if (r) gitRoot = normalize(r);
|
|
332
|
-
}
|
|
333
|
-
} catch { /* git not available */ }
|
|
334
|
-
|
|
335
|
-
if (gitRoot) {
|
|
336
|
-
const candidate = join(gitRoot, ".gsd");
|
|
337
|
-
if (existsSync(candidate)) return candidate;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// 3. Walk up from basePath to the git root (only if we are in a subdirectory)
|
|
341
|
-
if (gitRoot && basePath !== gitRoot) {
|
|
342
|
-
let cur = dirname(basePath);
|
|
343
|
-
while (cur !== basePath) {
|
|
344
|
-
const candidate = join(cur, ".gsd");
|
|
345
|
-
if (existsSync(candidate)) return candidate;
|
|
346
|
-
if (cur === gitRoot) break;
|
|
347
|
-
basePath = cur;
|
|
348
|
-
cur = dirname(cur);
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
// 4. Fallback for init/creation
|
|
353
|
-
return local;
|
|
283
|
+
const resolved = realpathSync(local);
|
|
284
|
+
if (resolved !== local) return resolved; // symlink resolved
|
|
285
|
+
} catch { /* doesn't exist yet — fall through */ }
|
|
286
|
+
return local; // backwards compat: unmigrated projects
|
|
354
287
|
}
|
|
288
|
+
|
|
355
289
|
export function milestonesDir(basePath: string): string {
|
|
356
290
|
return join(gsdRoot(basePath), "milestones");
|
|
357
291
|
}
|
|
@@ -149,15 +149,11 @@ function dequeueNextHook(basePath: string): HookDispatchResult | null {
|
|
|
149
149
|
|
|
150
150
|
// Build the prompt with variable substitution
|
|
151
151
|
const [mid, sid, tid] = triggerUnitId.split("/");
|
|
152
|
-
|
|
152
|
+
const prompt = config.prompt
|
|
153
153
|
.replace(/\{milestoneId\}/g, mid ?? "")
|
|
154
154
|
.replace(/\{sliceId\}/g, sid ?? "")
|
|
155
155
|
.replace(/\{taskId\}/g, tid ?? "");
|
|
156
156
|
|
|
157
|
-
// Inject browser safety instruction for hooks that may use browser tools (#1345).
|
|
158
|
-
// Vite HMR and other persistent connections prevent networkidle from resolving.
|
|
159
|
-
prompt += "\n\n**Browser tool safety:** Do NOT use `browser_wait_for` with `condition: \"network_idle\"` — it hangs indefinitely when dev servers keep persistent connections (Vite HMR, WebSocket). Use `selector_visible`, `text_visible`, or `delay` instead.";
|
|
160
|
-
|
|
161
157
|
return {
|
|
162
158
|
hookName: config.name,
|
|
163
159
|
prompt,
|
|
@@ -33,24 +33,9 @@ export function validatePreferences(preferences: GSDPreferences): {
|
|
|
33
33
|
const validated: GSDPreferences = {};
|
|
34
34
|
|
|
35
35
|
// ─── Unknown Key Detection ──────────────────────────────────────────
|
|
36
|
-
// Common key migration hints for pi-level settings that don't map to GSD prefs
|
|
37
|
-
const KEY_MIGRATION_HINTS: Record<string, string> = {
|
|
38
|
-
taskIsolation: 'use "git.isolation" instead (values: worktree, branch, none)',
|
|
39
|
-
task_isolation: 'use "git.isolation" instead (values: worktree, branch, none)',
|
|
40
|
-
isolation: 'use "git.isolation" instead (values: worktree, branch, none)',
|
|
41
|
-
manage_gitignore: 'use "git.manage_gitignore" instead',
|
|
42
|
-
auto_push: 'use "git.auto_push" instead',
|
|
43
|
-
main_branch: 'use "git.main_branch" instead',
|
|
44
|
-
};
|
|
45
|
-
|
|
46
36
|
for (const key of Object.keys(preferences)) {
|
|
47
37
|
if (!KNOWN_PREFERENCE_KEYS.has(key)) {
|
|
48
|
-
|
|
49
|
-
if (hint) {
|
|
50
|
-
warnings.push(`unknown preference key "${key}" — ${hint}`);
|
|
51
|
-
} else {
|
|
52
|
-
warnings.push(`unknown preference key "${key}" — ignored`);
|
|
53
|
-
}
|
|
38
|
+
warnings.push(`unknown preference key "${key}" — ignored`);
|
|
54
39
|
}
|
|
55
40
|
}
|
|
56
41
|
|
|
@@ -28,8 +28,6 @@ Then:
|
|
|
28
28
|
|
|
29
29
|
**Important:** Do NOT skip the success criteria and definition of done verification (steps 3-4). The milestone summary must reflect actual verified outcomes, not assumed success. If any criterion was not met, document it clearly in the summary and do not mark the milestone as passing verification.
|
|
30
30
|
|
|
31
|
-
**File system safety:** When scanning milestone directories for evidence, use `ls` or `find` to list directory contents first — never pass a directory path (e.g. `tasks/`, `slices/`) directly to the `read` tool. The `read` tool only accepts file paths, not directories.
|
|
32
|
-
|
|
33
31
|
**You MUST write `{{milestoneSummaryPath}}` AND update PROJECT.md before finishing.**
|
|
34
32
|
|
|
35
33
|
When done, say: "Milestone {{milestoneId}} complete."
|
|
@@ -67,6 +67,4 @@ If verdict is `needs-remediation`:
|
|
|
67
67
|
|
|
68
68
|
**You MUST write `{{validationPath}}` before finishing.**
|
|
69
69
|
|
|
70
|
-
**File system safety:** When scanning milestone directories for evidence, use `ls` or `find` to list directory contents first — never pass a directory path (e.g. `tasks/`, `slices/`) directly to the `read` tool. The `read` tool only accepts file paths, not directories.
|
|
71
|
-
|
|
72
70
|
When done, say: "Milestone {{milestoneId}} validation complete — verdict: <verdict>."
|
|
@@ -57,19 +57,9 @@ let _lockCompromised: boolean = false;
|
|
|
57
57
|
/** Whether we've already registered a process.on('exit') handler. */
|
|
58
58
|
let _exitHandlerRegistered: boolean = false;
|
|
59
59
|
|
|
60
|
-
/** Snapshotted lock file path — captured at acquireSessionLock time to avoid
|
|
61
|
-
* gsdRoot() resolving differently in worktree vs project root contexts (#1363). */
|
|
62
|
-
let _snapshotLockPath: string | null = null;
|
|
63
|
-
|
|
64
|
-
/** Timestamp when the session lock was acquired — used to detect false-positive
|
|
65
|
-
* onCompromised events from event loop stalls within the stale window (#1362). */
|
|
66
|
-
let _lockAcquiredAt: number = 0;
|
|
67
|
-
|
|
68
60
|
const LOCK_FILE = "auto.lock";
|
|
69
61
|
|
|
70
62
|
function lockPath(basePath: string): string {
|
|
71
|
-
// If we have a snapshotted path from acquisition, use it for consistency
|
|
72
|
-
if (_snapshotLockPath) return _snapshotLockPath;
|
|
73
63
|
return join(gsdRoot(basePath), LOCK_FILE);
|
|
74
64
|
}
|
|
75
65
|
|
|
@@ -208,19 +198,8 @@ export function acquireSessionLock(basePath: string): SessionLockResult {
|
|
|
208
198
|
onCompromised: () => {
|
|
209
199
|
// proper-lockfile detected mtime drift (system sleep, event loop stall, etc.).
|
|
210
200
|
// Default handler throws inside setTimeout — an uncaught exception that crashes
|
|
211
|
-
// or corrupts process state.
|
|
212
|
-
//
|
|
213
|
-
// False-positive suppression (#1362): If we're still within the stale window
|
|
214
|
-
// (30 min since acquisition), the mtime mismatch is from an event loop stall
|
|
215
|
-
// during a long LLM call — not a real takeover. Log and continue.
|
|
216
|
-
const elapsed = Date.now() - _lockAcquiredAt;
|
|
217
|
-
if (elapsed < 1_800_000) {
|
|
218
|
-
process.stderr.write(
|
|
219
|
-
`[gsd] Lock heartbeat mismatch after ${Math.round(elapsed / 1000)}s — event loop stall, continuing.\n`,
|
|
220
|
-
);
|
|
221
|
-
return; // Suppress false positive
|
|
222
|
-
}
|
|
223
|
-
// Past the stale window — this is a real compromise
|
|
201
|
+
// or corrupts process state. Instead, set a flag so validateSessionLock() can
|
|
202
|
+
// detect the compromise gracefully on the next dispatch cycle.
|
|
224
203
|
_lockCompromised = true;
|
|
225
204
|
_releaseFunction = null;
|
|
226
205
|
},
|
|
@@ -230,8 +209,6 @@ export function acquireSessionLock(basePath: string): SessionLockResult {
|
|
|
230
209
|
_lockedPath = basePath;
|
|
231
210
|
_lockPid = process.pid;
|
|
232
211
|
_lockCompromised = false;
|
|
233
|
-
_lockAcquiredAt = Date.now();
|
|
234
|
-
_snapshotLockPath = lp; // Snapshot the resolved path for consistent access (#1363)
|
|
235
212
|
|
|
236
213
|
// Safety net: clean up lock dir on process exit if _releaseFunction
|
|
237
214
|
// wasn't called (e.g., normal exit after clean completion) (#1245).
|
|
@@ -268,8 +245,6 @@ export function acquireSessionLock(basePath: string): SessionLockResult {
|
|
|
268
245
|
_lockedPath = basePath;
|
|
269
246
|
_lockPid = process.pid;
|
|
270
247
|
_lockCompromised = false;
|
|
271
|
-
_lockAcquiredAt = Date.now();
|
|
272
|
-
_snapshotLockPath = lp; // Snapshot for retry path too (#1363)
|
|
273
248
|
|
|
274
249
|
// Safety net — uses centralized handler to avoid double-registration
|
|
275
250
|
ensureExitHandler(gsdDir);
|
|
@@ -419,8 +394,6 @@ export function releaseSessionLock(basePath: string): void {
|
|
|
419
394
|
_lockedPath = null;
|
|
420
395
|
_lockPid = 0;
|
|
421
396
|
_lockCompromised = false;
|
|
422
|
-
_lockAcquiredAt = 0;
|
|
423
|
-
_snapshotLockPath = null;
|
|
424
397
|
}
|
|
425
398
|
|
|
426
399
|
/**
|
|
@@ -113,14 +113,6 @@
|
|
|
113
113
|
- Tasks execute sequentially in order (T01, T02, T03, ...)
|
|
114
114
|
- est: is informational (e.g. 30m, 1h, 2h) and optional
|
|
115
115
|
|
|
116
|
-
Verify field rules:
|
|
117
|
-
- MUST be a mechanically executable command: `npm test`, `grep -q "pattern" file`, `test -f path`
|
|
118
|
-
- For content/document tasks: verify file existence, section count, YAML validity, or word count
|
|
119
|
-
NOT exact phrasing, specific formulas, or "zero TBD" aspirational criteria
|
|
120
|
-
- If no command can verify the output, write: "Manual review — file exists and is non-empty"
|
|
121
|
-
- BAD: "Sections 3.1 and 3.2 exist with exact formulas. Zero TBD/TODO."
|
|
122
|
-
- GOOD: `grep -c "^## " doc.md` returns >= 4 (4+ sections), `! grep -q "TBD\|TODO" doc.md`
|
|
123
|
-
|
|
124
116
|
Integration closure rule:
|
|
125
117
|
- At least one slice in any multi-boundary milestone should perform real composition/wiring, not just contract hardening
|
|
126
118
|
- For the final assembly slice, verification must exercise the real entrypoint or runtime path
|
|
@@ -40,18 +40,8 @@ test("git.merge_to_main produces deprecation warning", () => {
|
|
|
40
40
|
});
|
|
41
41
|
|
|
42
42
|
|
|
43
|
-
test("getIsolationMode defaults to worktree when
|
|
44
|
-
|
|
45
|
-
// preferences.git.isolation is undefined, and getIsolationMode returns "worktree".
|
|
46
|
-
// We test the function's logic by verifying its documented default.
|
|
47
|
-
const { preferences } = validatePreferences({});
|
|
48
|
-
assert.equal(preferences.git?.isolation, undefined, "no isolation in empty prefs");
|
|
49
|
-
// The function returns "worktree" when prefs?.git?.isolation is not "none" or "branch"
|
|
50
|
-
// This is a compile-time-verifiable truth from the function body — test it directly
|
|
51
|
-
// by constructing the same conditions getIsolationMode checks.
|
|
52
|
-
const isolation = preferences.git?.isolation;
|
|
53
|
-
const expected = isolation === "none" ? "none" : isolation === "branch" ? "branch" : "worktree";
|
|
54
|
-
assert.equal(expected, "worktree", "default isolation mode is worktree");
|
|
43
|
+
test("getIsolationMode defaults to worktree when no prefs file", { skip: "requires no global ~/.gsd/preferences.md" }, () => {
|
|
44
|
+
assert.equal(getIsolationMode(), "worktree");
|
|
55
45
|
});
|
|
56
46
|
|
|
57
47
|
// ── Mode defaults ────────────────────────────────────────────────────────────
|
|
@@ -101,21 +101,6 @@ test("validateDirectory: subdirectory of home is NOT blocked", () => {
|
|
|
101
101
|
}
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
-
// Regression test for #1317: GSD worktree inside $HOME must not be blocked even
|
|
105
|
-
// when the resolved project root equals $HOME (e.g. home dir is a git repo).
|
|
106
|
-
test("validateDirectory: GSD worktree path nested under home is NOT blocked (#1317)", () => {
|
|
107
|
-
const worktreePath = join(homedir(), ".gsd", "worktrees", "M001");
|
|
108
|
-
mkdirSync(worktreePath, { recursive: true });
|
|
109
|
-
try {
|
|
110
|
-
// The worktree CWD itself is a valid location — it must pass.
|
|
111
|
-
const result = validateDirectory(worktreePath);
|
|
112
|
-
assert.equal(result.safe, true, "GSD worktree path should be safe to run in");
|
|
113
|
-
assert.equal(result.severity, "ok");
|
|
114
|
-
} finally {
|
|
115
|
-
rmSync(join(homedir(), ".gsd", "worktrees", "M001"), { recursive: true, force: true });
|
|
116
|
-
}
|
|
117
|
-
});
|
|
118
|
-
|
|
119
104
|
// ─── Temp directory root ─────────────────────────────────────────────────────────
|
|
120
105
|
|
|
121
106
|
test("validateDirectory: temp directory root is blocked", () => {
|
|
@@ -196,7 +196,6 @@ test("dispatch rule matches validating-milestone phase", async () => {
|
|
|
196
196
|
try {
|
|
197
197
|
// Set up minimal milestone structure for the prompt builder
|
|
198
198
|
writeRoadmap(base, "M001", ALL_DONE_ROADMAP);
|
|
199
|
-
writeSliceSummary(base, "M001", "S01", "# S01 Summary\nDone."); // Guard requires slice summaries (#1368)
|
|
200
199
|
|
|
201
200
|
const ctx: DispatchContext = {
|
|
202
201
|
basePath: base,
|
|
@@ -232,7 +231,6 @@ test("dispatch rule skips when skip_milestone_validation preference is set", asy
|
|
|
232
231
|
const base = makeTmpBase();
|
|
233
232
|
try {
|
|
234
233
|
writeRoadmap(base, "M001", ALL_DONE_ROADMAP);
|
|
235
|
-
writeSliceSummary(base, "M001", "S01", "# S01 Summary\nDone."); // Guard requires slice summaries (#1368)
|
|
236
234
|
|
|
237
235
|
const ctx: DispatchContext = {
|
|
238
236
|
basePath: base,
|