gsd-pi 2.35.0-dev.640d5c7 → 2.35.0-dev.67d0e02
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/README.md +3 -1
- package/dist/cli.js +7 -2
- package/dist/resource-loader.d.ts +1 -1
- package/dist/resource-loader.js +13 -1
- package/dist/resources/extensions/async-jobs/await-tool.js +0 -2
- package/dist/resources/extensions/async-jobs/job-manager.js +0 -6
- package/dist/resources/extensions/bg-shell/output-formatter.js +1 -19
- package/dist/resources/extensions/bg-shell/process-manager.js +0 -4
- package/dist/resources/extensions/bg-shell/types.js +0 -2
- package/dist/resources/extensions/context7/index.js +5 -0
- package/dist/resources/extensions/get-secrets-from-user.js +2 -30
- package/dist/resources/extensions/google-search/index.js +5 -0
- package/dist/resources/extensions/gsd/auto-dispatch.js +43 -1
- package/dist/resources/extensions/gsd/auto-loop.js +10 -1
- package/dist/resources/extensions/gsd/auto-recovery.js +35 -0
- package/dist/resources/extensions/gsd/auto-start.js +35 -2
- package/dist/resources/extensions/gsd/auto.js +59 -4
- package/dist/resources/extensions/gsd/commands-handlers.js +2 -2
- package/dist/resources/extensions/gsd/doctor-environment.js +26 -17
- package/dist/resources/extensions/gsd/files.js +9 -1
- package/dist/resources/extensions/gsd/gitignore.js +54 -7
- package/dist/resources/extensions/gsd/guided-flow.js +1 -1
- package/dist/resources/extensions/gsd/health-widget-core.js +96 -0
- package/dist/resources/extensions/gsd/health-widget.js +97 -46
- package/dist/resources/extensions/gsd/index.js +26 -33
- package/dist/resources/extensions/gsd/migrate-external.js +55 -2
- package/dist/resources/extensions/gsd/milestone-ids.js +3 -2
- package/dist/resources/extensions/gsd/paths.js +74 -7
- package/dist/resources/extensions/gsd/post-unit-hooks.js +4 -1
- package/dist/resources/extensions/gsd/preferences-validation.js +16 -1
- package/dist/resources/extensions/gsd/prompts/complete-milestone.md +2 -0
- package/dist/resources/extensions/gsd/prompts/validate-milestone.md +2 -0
- package/dist/resources/extensions/gsd/roadmap-mutations.js +55 -0
- package/dist/resources/extensions/gsd/session-lock.js +53 -2
- package/dist/resources/extensions/gsd/state.js +2 -1
- package/dist/resources/extensions/gsd/templates/plan.md +8 -0
- package/dist/resources/extensions/gsd/worktree-resolver.js +12 -0
- package/dist/resources/extensions/remote-questions/remote-command.js +2 -22
- package/dist/resources/extensions/shared/mod.js +1 -1
- package/dist/resources/extensions/shared/sanitize.js +30 -0
- package/dist/resources/extensions/subagent/index.js +6 -14
- package/package.json +2 -1
- package/packages/pi-coding-agent/dist/core/resource-loader.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/resource-loader.js +13 -2
- package/packages/pi-coding-agent/dist/core/resource-loader.js.map +1 -1
- package/packages/pi-coding-agent/src/core/resource-loader.ts +13 -2
- package/src/resources/extensions/async-jobs/await-tool.ts +0 -2
- package/src/resources/extensions/async-jobs/job-manager.ts +0 -7
- package/src/resources/extensions/bg-shell/output-formatter.ts +0 -17
- package/src/resources/extensions/bg-shell/process-manager.ts +0 -4
- package/src/resources/extensions/bg-shell/types.ts +0 -12
- package/src/resources/extensions/context7/index.ts +7 -0
- package/src/resources/extensions/get-secrets-from-user.ts +2 -35
- package/src/resources/extensions/google-search/index.ts +7 -0
- package/src/resources/extensions/gsd/auto-dispatch.ts +49 -1
- package/src/resources/extensions/gsd/auto-loop.ts +11 -1
- package/src/resources/extensions/gsd/auto-recovery.ts +39 -0
- package/src/resources/extensions/gsd/auto-start.ts +42 -2
- package/src/resources/extensions/gsd/auto.ts +61 -3
- package/src/resources/extensions/gsd/commands-handlers.ts +2 -2
- package/src/resources/extensions/gsd/doctor-environment.ts +26 -16
- package/src/resources/extensions/gsd/files.ts +10 -1
- package/src/resources/extensions/gsd/gitignore.ts +54 -7
- package/src/resources/extensions/gsd/guided-flow.ts +1 -1
- package/src/resources/extensions/gsd/health-widget-core.ts +129 -0
- package/src/resources/extensions/gsd/health-widget.ts +103 -59
- package/src/resources/extensions/gsd/index.ts +30 -33
- package/src/resources/extensions/gsd/migrate-external.ts +47 -2
- package/src/resources/extensions/gsd/milestone-ids.ts +3 -2
- package/src/resources/extensions/gsd/paths.ts +73 -7
- package/src/resources/extensions/gsd/post-unit-hooks.ts +5 -1
- package/src/resources/extensions/gsd/preferences-validation.ts +16 -1
- package/src/resources/extensions/gsd/prompts/complete-milestone.md +2 -0
- package/src/resources/extensions/gsd/prompts/validate-milestone.md +2 -0
- package/src/resources/extensions/gsd/roadmap-mutations.ts +66 -0
- package/src/resources/extensions/gsd/session-lock.ts +59 -2
- package/src/resources/extensions/gsd/state.ts +2 -1
- package/src/resources/extensions/gsd/templates/plan.md +8 -0
- package/src/resources/extensions/gsd/tests/gitignore-tracked-gsd.test.ts +214 -0
- package/src/resources/extensions/gsd/tests/health-widget.test.ts +158 -0
- package/src/resources/extensions/gsd/tests/paths.test.ts +113 -0
- package/src/resources/extensions/gsd/tests/preferences.test.ts +12 -2
- package/src/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +26 -0
- package/src/resources/extensions/gsd/tests/test-utils.ts +165 -0
- package/src/resources/extensions/gsd/tests/validate-directory.test.ts +15 -0
- package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +7 -0
- package/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +32 -0
- package/src/resources/extensions/gsd/worktree-resolver.ts +11 -0
- package/src/resources/extensions/remote-questions/remote-command.ts +2 -23
- package/src/resources/extensions/shared/mod.ts +1 -1
- package/src/resources/extensions/shared/sanitize.ts +36 -0
- package/src/resources/extensions/subagent/index.ts +6 -12
- package/dist/resources/extensions/shared/wizard-ui.js +0 -478
- package/src/resources/extensions/shared/wizard-ui.ts +0 -551
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { ExtensionContext } from "@gsd/pi-coding-agent";
|
|
11
|
+
import { parseUnitId } from "./unit-id.js";
|
|
12
|
+
import { atomicWriteSync } from "./atomic-write.js";
|
|
11
13
|
import { clearUnitRuntimeRecord } from "./unit-runtime.js";
|
|
12
14
|
import { clearParseCache, parseRoadmap, parsePlan } from "./files.js";
|
|
13
15
|
import { isValidationTerminal } from "./state.js";
|
|
@@ -35,6 +37,7 @@ import {
|
|
|
35
37
|
clearPathCache,
|
|
36
38
|
resolveGsdRootFile,
|
|
37
39
|
} from "./paths.js";
|
|
40
|
+
import { markSliceDoneInRoadmap } from "./roadmap-mutations.js";
|
|
38
41
|
import {
|
|
39
42
|
existsSync,
|
|
40
43
|
mkdirSync,
|
|
@@ -499,6 +502,42 @@ export async function selfHealRuntimeRecords(
|
|
|
499
502
|
for (const record of records) {
|
|
500
503
|
const { unitType, unitId } = record;
|
|
501
504
|
|
|
505
|
+
// Case 0: complete-slice with SUMMARY + UAT but unchecked roadmap (#1350).
|
|
506
|
+
// If a complete-slice was interrupted after writing artifacts but before
|
|
507
|
+
// flipping the roadmap checkbox, the verification fails and the dispatch
|
|
508
|
+
// loop relaunches the same unit forever. Auto-fix the checkbox.
|
|
509
|
+
if (unitType === "complete-slice") {
|
|
510
|
+
const { milestone: mid, slice: sid } = parseUnitId(unitId);
|
|
511
|
+
if (mid && sid) {
|
|
512
|
+
const dir = resolveSlicePath(base, mid, sid);
|
|
513
|
+
if (dir) {
|
|
514
|
+
const summaryPath = join(dir, buildSliceFileName(sid, "SUMMARY"));
|
|
515
|
+
const uatPath = join(dir, buildSliceFileName(sid, "UAT"));
|
|
516
|
+
if (existsSync(summaryPath) && existsSync(uatPath)) {
|
|
517
|
+
const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP");
|
|
518
|
+
if (roadmapFile && existsSync(roadmapFile)) {
|
|
519
|
+
try {
|
|
520
|
+
const roadmapContent = readFileSync(roadmapFile, "utf-8");
|
|
521
|
+
const roadmap = parseRoadmap(roadmapContent);
|
|
522
|
+
const slice = (roadmap.slices ?? []).find(s => s.id === sid);
|
|
523
|
+
if (slice && !slice.done) {
|
|
524
|
+
// Auto-fix: flip the checkbox using shared utility
|
|
525
|
+
if (markSliceDoneInRoadmap(base, mid, sid)) {
|
|
526
|
+
ctx.ui.notify(
|
|
527
|
+
`Self-heal: marked ${sid} done in roadmap (SUMMARY + UAT exist but checkbox was stale).`,
|
|
528
|
+
"info",
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
} catch {
|
|
533
|
+
// Roadmap parse failure — don't block self-heal
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
502
541
|
// Clear stale dispatched records (dispatched > 1h ago, process crashed)
|
|
503
542
|
const age = now - (record.startedAt ?? 0);
|
|
504
543
|
if (record.phase === "dispatched" && age > STALE_THRESHOLD_MS) {
|
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
resolveSkillDiscoveryMode,
|
|
21
21
|
getIsolationMode,
|
|
22
22
|
} from "./preferences.js";
|
|
23
|
+
import { ensureGsdSymlink } from "./repo-identity.js";
|
|
24
|
+
import { migrateToExternalState, recoverFailedMigration } from "./migrate-external.js";
|
|
23
25
|
import { collectSecretsFromManifest } from "../get-secrets-from-user.js";
|
|
24
26
|
import { gsdRoot, resolveMilestoneFile, milestonesDir } from "./paths.js";
|
|
25
27
|
import { invalidateAllCaches } from "./cache.js";
|
|
@@ -92,6 +94,13 @@ export interface BootstrapDeps {
|
|
|
92
94
|
* Returns false if the bootstrap aborted (e.g., guided flow returned,
|
|
93
95
|
* concurrent session detected). Returns true when ready to dispatch.
|
|
94
96
|
*/
|
|
97
|
+
|
|
98
|
+
/** Guard: tracks consecutive bootstrap attempts that found phase === "complete".
|
|
99
|
+
* Prevents the recursive dialog loop described in #1348 where
|
|
100
|
+
* bootstrapAutoSession → showSmartEntry → checkAutoStartAfterDiscuss → startAuto
|
|
101
|
+
* cycles indefinitely when the discuss workflow doesn't produce a milestone. */
|
|
102
|
+
let _consecutiveCompleteBootstraps = 0;
|
|
103
|
+
const MAX_CONSECUTIVE_COMPLETE_BOOTSTRAPS = 2;
|
|
95
104
|
export async function bootstrapAutoSession(
|
|
96
105
|
s: AutoSession,
|
|
97
106
|
ctx: ExtensionCommandContext,
|
|
@@ -128,7 +137,20 @@ export async function bootstrapAutoSession(
|
|
|
128
137
|
nativeInit(base, mainBranch);
|
|
129
138
|
}
|
|
130
139
|
|
|
131
|
-
//
|
|
140
|
+
// Migrate legacy in-project .gsd/ to external state directory.
|
|
141
|
+
// Migration MUST run before ensureGitignore to avoid adding ".gsd" to
|
|
142
|
+
// .gitignore when .gsd/ is git-tracked (data-loss bug #1364).
|
|
143
|
+
recoverFailedMigration(base);
|
|
144
|
+
const migration = migrateToExternalState(base);
|
|
145
|
+
if (migration.error) {
|
|
146
|
+
ctx.ui.notify(`External state migration warning: ${migration.error}`, "warning");
|
|
147
|
+
}
|
|
148
|
+
// Ensure symlink exists (handles fresh projects and post-migration)
|
|
149
|
+
ensureGsdSymlink(base);
|
|
150
|
+
|
|
151
|
+
// Ensure .gitignore has baseline patterns.
|
|
152
|
+
// ensureGitignore checks for git-tracked .gsd/ files and skips the
|
|
153
|
+
// ".gsd" pattern if the project intentionally tracks .gsd/ in git.
|
|
132
154
|
const gitPrefs = loadEffectiveGSDPreferences()?.preferences?.git;
|
|
133
155
|
const commitDocs = gitPrefs?.commit_docs;
|
|
134
156
|
const manageGitignore = gitPrefs?.manage_gitignore;
|
|
@@ -286,6 +308,20 @@ export async function bootstrapAutoSession(
|
|
|
286
308
|
if (!hasSurvivorBranch) {
|
|
287
309
|
// No active work — start a new milestone via discuss flow
|
|
288
310
|
if (!state.activeMilestone || state.phase === "complete") {
|
|
311
|
+
// Guard against recursive dialog loop (#1348):
|
|
312
|
+
// If we've entered this branch multiple times in quick succession,
|
|
313
|
+
// the discuss workflow isn't producing a milestone. Break the cycle.
|
|
314
|
+
_consecutiveCompleteBootstraps++;
|
|
315
|
+
if (_consecutiveCompleteBootstraps > MAX_CONSECUTIVE_COMPLETE_BOOTSTRAPS) {
|
|
316
|
+
_consecutiveCompleteBootstraps = 0;
|
|
317
|
+
ctx.ui.notify(
|
|
318
|
+
"All milestones are complete and the discussion didn't produce a new one. " +
|
|
319
|
+
"Run /gsd to start a new milestone manually.",
|
|
320
|
+
"warning",
|
|
321
|
+
);
|
|
322
|
+
return releaseLockAndReturn();
|
|
323
|
+
}
|
|
324
|
+
|
|
289
325
|
const { showSmartEntry } = await import("./guided-flow.js");
|
|
290
326
|
await showSmartEntry(ctx, pi, base, { step: requestedStepMode });
|
|
291
327
|
|
|
@@ -296,6 +332,7 @@ export async function bootstrapAutoSession(
|
|
|
296
332
|
postState.phase !== "complete" &&
|
|
297
333
|
postState.phase !== "pre-planning"
|
|
298
334
|
) {
|
|
335
|
+
_consecutiveCompleteBootstraps = 0; // Successfully advanced past "complete"
|
|
299
336
|
state = postState;
|
|
300
337
|
} else if (
|
|
301
338
|
postState.activeMilestone &&
|
|
@@ -352,6 +389,9 @@ export async function bootstrapAutoSession(
|
|
|
352
389
|
return releaseLockAndReturn();
|
|
353
390
|
}
|
|
354
391
|
|
|
392
|
+
// Successfully resolved an active milestone — reset the re-entry guard
|
|
393
|
+
_consecutiveCompleteBootstraps = 0;
|
|
394
|
+
|
|
355
395
|
// ── Initialize session state ──
|
|
356
396
|
s.active = true;
|
|
357
397
|
s.stepMode = requestedStepMode;
|
|
@@ -484,7 +524,7 @@ export async function bootstrapAutoSession(
|
|
|
484
524
|
// Secrets collection gate
|
|
485
525
|
const mid = state.activeMilestone!.id;
|
|
486
526
|
try {
|
|
487
|
-
const manifestStatus = await getManifestStatus(base, mid);
|
|
527
|
+
const manifestStatus = await getManifestStatus(base, mid, s.originalBasePath || base);
|
|
488
528
|
if (manifestStatus && manifestStatus.pending.length > 0) {
|
|
489
529
|
const result = await collectSecretsFromManifest(base, mid, ctx);
|
|
490
530
|
if (
|
|
@@ -127,7 +127,7 @@ import {
|
|
|
127
127
|
formatTokenCount,
|
|
128
128
|
} from "./metrics.js";
|
|
129
129
|
import { join } from "node:path";
|
|
130
|
-
import { readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
130
|
+
import { readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs";
|
|
131
131
|
import { atomicWriteSync } from "./atomic-write.js";
|
|
132
132
|
import {
|
|
133
133
|
autoCommitCurrentBranch,
|
|
@@ -554,6 +554,13 @@ export async function stopAuto(
|
|
|
554
554
|
resetRoutingHistory();
|
|
555
555
|
resetHookState();
|
|
556
556
|
if (s.basePath) clearPersistedHookState(s.basePath);
|
|
557
|
+
|
|
558
|
+
// Remove paused-session metadata if present (#1383)
|
|
559
|
+
try {
|
|
560
|
+
const pausedPath = join(gsdRoot(s.originalBasePath || s.basePath), "runtime", "paused-session.json");
|
|
561
|
+
if (existsSync(pausedPath)) unlinkSync(pausedPath);
|
|
562
|
+
} catch { /* non-fatal */ }
|
|
563
|
+
|
|
557
564
|
s.active = false;
|
|
558
565
|
s.paused = false;
|
|
559
566
|
s.stepMode = false;
|
|
@@ -607,8 +614,32 @@ export async function pauseAuto(
|
|
|
607
614
|
|
|
608
615
|
s.pausedSessionFile = ctx?.sessionManager?.getSessionFile() ?? null;
|
|
609
616
|
|
|
610
|
-
|
|
611
|
-
|
|
617
|
+
// Persist paused-session metadata so resume survives /exit (#1383).
|
|
618
|
+
// The fresh-start bootstrap checks for this file and restores worktree context.
|
|
619
|
+
try {
|
|
620
|
+
const pausedMeta = {
|
|
621
|
+
milestoneId: s.currentMilestoneId,
|
|
622
|
+
worktreePath: isInAutoWorktree(s.basePath) ? s.basePath : null,
|
|
623
|
+
originalBasePath: s.originalBasePath,
|
|
624
|
+
stepMode: s.stepMode,
|
|
625
|
+
pausedAt: new Date().toISOString(),
|
|
626
|
+
sessionFile: s.pausedSessionFile,
|
|
627
|
+
};
|
|
628
|
+
const runtimeDir = join(gsdRoot(s.originalBasePath || s.basePath), "runtime");
|
|
629
|
+
mkdirSync(runtimeDir, { recursive: true });
|
|
630
|
+
writeFileSync(
|
|
631
|
+
join(runtimeDir, "paused-session.json"),
|
|
632
|
+
JSON.stringify(pausedMeta, null, 2),
|
|
633
|
+
"utf-8",
|
|
634
|
+
);
|
|
635
|
+
} catch {
|
|
636
|
+
// Non-fatal — resume will still work via full bootstrap, just without worktree context
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (lockBase()) {
|
|
640
|
+
releaseSessionLock(lockBase());
|
|
641
|
+
clearLock(lockBase());
|
|
642
|
+
}
|
|
612
643
|
|
|
613
644
|
deregisterSigtermHandler();
|
|
614
645
|
|
|
@@ -792,6 +823,30 @@ export async function startAuto(
|
|
|
792
823
|
base = escapeStaleWorktree(base);
|
|
793
824
|
|
|
794
825
|
// If resuming from paused state, just re-activate and dispatch next unit.
|
|
826
|
+
// Check persisted paused-session first (#1383) — survives /exit.
|
|
827
|
+
if (!s.paused) {
|
|
828
|
+
try {
|
|
829
|
+
const pausedPath = join(gsdRoot(base), "runtime", "paused-session.json");
|
|
830
|
+
if (existsSync(pausedPath)) {
|
|
831
|
+
const meta = JSON.parse(readFileSync(pausedPath, "utf-8"));
|
|
832
|
+
if (meta.milestoneId) {
|
|
833
|
+
s.currentMilestoneId = meta.milestoneId;
|
|
834
|
+
s.originalBasePath = meta.originalBasePath || base;
|
|
835
|
+
s.stepMode = meta.stepMode ?? requestedStepMode;
|
|
836
|
+
s.paused = true;
|
|
837
|
+
// Clean up the persisted file — we're consuming it
|
|
838
|
+
try { unlinkSync(pausedPath); } catch { /* non-fatal */ }
|
|
839
|
+
ctx.ui.notify(
|
|
840
|
+
`Resuming paused session for ${meta.milestoneId}${meta.worktreePath ? ` (worktree)` : ""}.`,
|
|
841
|
+
"info",
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
} catch {
|
|
846
|
+
// Malformed or missing — proceed with fresh bootstrap
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
795
850
|
if (s.paused) {
|
|
796
851
|
const resumeLock = acquireSessionLock(base);
|
|
797
852
|
if (!resumeLock.acquired) {
|
|
@@ -1145,6 +1200,9 @@ export async function dispatchHookUnit(
|
|
|
1145
1200
|
ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto");
|
|
1146
1201
|
ctx.ui.notify(`Running post-unit hook: ${hookName}`, "info");
|
|
1147
1202
|
|
|
1203
|
+
// Ensure cwd matches basePath before hook dispatch (#1389)
|
|
1204
|
+
try { if (process.cwd() !== s.basePath) process.chdir(s.basePath); } catch {}
|
|
1205
|
+
|
|
1148
1206
|
debugLog("dispatchHookUnit", {
|
|
1149
1207
|
phase: "send-message",
|
|
1150
1208
|
promptLength: hookPrompt.length,
|
|
@@ -24,7 +24,7 @@ import { projectRoot } from "./commands.js";
|
|
|
24
24
|
import { loadPrompt } from "./prompt-loader.js";
|
|
25
25
|
|
|
26
26
|
export function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportText: string, structuredIssues: string): void {
|
|
27
|
-
const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".
|
|
27
|
+
const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".gsd", "agent", "GSD-WORKFLOW.md");
|
|
28
28
|
const workflow = readFileSync(workflowPath, "utf-8");
|
|
29
29
|
const prompt = loadPrompt("doctor-heal", {
|
|
30
30
|
doctorSummary: reportText,
|
|
@@ -187,7 +187,7 @@ export async function handleTriage(ctx: ExtensionCommandContext, pi: ExtensionAP
|
|
|
187
187
|
roadmapContext: roadmapContext || "(no active roadmap)",
|
|
188
188
|
});
|
|
189
189
|
|
|
190
|
-
const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".
|
|
190
|
+
const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".gsd", "agent", "GSD-WORKFLOW.md");
|
|
191
191
|
const workflow = readFileSync(workflowPath, "utf-8");
|
|
192
192
|
|
|
193
193
|
pi.sendMessage(
|
|
@@ -180,26 +180,36 @@ function checkPortConflicts(basePath: string): EnvironmentCheckResult[] {
|
|
|
180
180
|
const portsToCheck = new Set<number>();
|
|
181
181
|
const pkgPath = join(basePath, "package.json");
|
|
182
182
|
|
|
183
|
-
if (existsSync(pkgPath)) {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
183
|
+
if (!existsSync(pkgPath)) {
|
|
184
|
+
// No package.json — this isn't a Node.js project. Skip port checks
|
|
185
|
+
// entirely to avoid false positives from system services (e.g., macOS
|
|
186
|
+
// AirPlay Receiver on port 5000). (#1381)
|
|
187
|
+
return [];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
192
|
+
const scripts = pkg.scripts ?? {};
|
|
193
|
+
const scriptText = Object.values(scripts).join(" ");
|
|
194
|
+
|
|
195
|
+
// Look for --port NNNN, -p NNNN, PORT=NNNN, :NNNN patterns
|
|
196
|
+
const portMatches = scriptText.matchAll(/(?:--port\s+|(?:^|[^a-z])PORT[=:]\s*|-p\s+|:)(\d{4,5})\b/gi);
|
|
197
|
+
for (const m of portMatches) {
|
|
198
|
+
const port = parseInt(m[1], 10);
|
|
199
|
+
if (port >= 1024 && port <= 65535) portsToCheck.add(port);
|
|
197
200
|
}
|
|
201
|
+
} catch {
|
|
202
|
+
// parse failed — skip port checks rather than using defaults
|
|
203
|
+
return [];
|
|
198
204
|
}
|
|
199
205
|
|
|
200
|
-
// If no ports found in scripts, check common defaults
|
|
206
|
+
// If no ports found in scripts, check common defaults.
|
|
207
|
+
// Filter out port 5000 on macOS — AirPlay Receiver uses it by default (#1381).
|
|
201
208
|
if (portsToCheck.size === 0) {
|
|
202
|
-
for (const p of DEFAULT_DEV_PORTS)
|
|
209
|
+
for (const p of DEFAULT_DEV_PORTS) {
|
|
210
|
+
if (p === 5000 && process.platform === "darwin") continue;
|
|
211
|
+
portsToCheck.add(p);
|
|
212
|
+
}
|
|
203
213
|
}
|
|
204
214
|
|
|
205
215
|
for (const port of portsToCheck) {
|
|
@@ -805,7 +805,7 @@ export async function inlinePriorMilestoneSummary(mid: string, base: string): Pr
|
|
|
805
805
|
* file not on disk) - callers can distinguish "no manifest" from "empty manifest".
|
|
806
806
|
*/
|
|
807
807
|
export async function getManifestStatus(
|
|
808
|
-
base: string, milestoneId: string,
|
|
808
|
+
base: string, milestoneId: string, projectRoot?: string,
|
|
809
809
|
): Promise<ManifestStatus | null> {
|
|
810
810
|
const resolvedPath = resolveMilestoneFile(base, milestoneId, 'SECRETS');
|
|
811
811
|
if (!resolvedPath) return null;
|
|
@@ -815,9 +815,18 @@ export async function getManifestStatus(
|
|
|
815
815
|
|
|
816
816
|
const manifest = parseSecretsManifest(content);
|
|
817
817
|
const keys = manifest.entries.map(e => e.key);
|
|
818
|
+
|
|
819
|
+
// Check both the base path .env AND the project root .env (#1387).
|
|
820
|
+
// In worktree mode, base is the worktree path which may not have .env.
|
|
821
|
+
// The project root's .env is where the user actually defined their keys.
|
|
818
822
|
const existingKeys = await checkExistingEnvKeys(keys, resolve(base, '.env'));
|
|
819
823
|
const existingSet = new Set(existingKeys);
|
|
820
824
|
|
|
825
|
+
if (projectRoot && projectRoot !== base) {
|
|
826
|
+
const rootKeys = await checkExistingEnvKeys(keys, resolve(projectRoot, '.env'));
|
|
827
|
+
for (const k of rootKeys) existingSet.add(k);
|
|
828
|
+
}
|
|
829
|
+
|
|
821
830
|
const result: ManifestStatus = {
|
|
822
831
|
pending: [],
|
|
823
832
|
collected: [],
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { join } from "node:path";
|
|
10
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
-
import { nativeRmCached } from "./native-git-bridge.js";
|
|
10
|
+
import { existsSync, lstatSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { nativeRmCached, nativeLsFiles } from "./native-git-bridge.js";
|
|
12
12
|
import { gsdRoot } from "./paths.js";
|
|
13
13
|
|
|
14
14
|
/**
|
|
@@ -79,12 +79,47 @@ const BASELINE_PATTERNS = [
|
|
|
79
79
|
];
|
|
80
80
|
|
|
81
81
|
/**
|
|
82
|
-
*
|
|
83
|
-
*
|
|
82
|
+
* Check whether `.gsd/` contains files tracked by git.
|
|
83
|
+
* If so, the project intentionally keeps `.gsd/` in version control
|
|
84
|
+
* and we must NOT add `.gsd` to `.gitignore` or attempt migration.
|
|
85
|
+
*
|
|
86
|
+
* Returns true if git tracks at least one file under `.gsd/`.
|
|
87
|
+
* Returns false (safe to ignore) if:
|
|
88
|
+
* - Not a git repo
|
|
89
|
+
* - `.gsd/` is a symlink (external state, should be ignored)
|
|
90
|
+
* - `.gsd/` doesn't exist
|
|
91
|
+
* - No tracked files found under `.gsd/`
|
|
92
|
+
*/
|
|
93
|
+
export function hasGitTrackedGsdFiles(basePath: string): boolean {
|
|
94
|
+
const localGsd = join(basePath, ".gsd");
|
|
95
|
+
|
|
96
|
+
// If .gsd doesn't exist or is already a symlink, no tracked files concern
|
|
97
|
+
if (!existsSync(localGsd)) return false;
|
|
98
|
+
try {
|
|
99
|
+
if (lstatSync(localGsd).isSymbolicLink()) return false;
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Check if git tracks any files under .gsd/
|
|
105
|
+
try {
|
|
106
|
+
const tracked = nativeLsFiles(basePath, ".gsd");
|
|
107
|
+
return tracked.length > 0;
|
|
108
|
+
} catch {
|
|
109
|
+
// Not a git repo or git not available — safe to proceed
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Ensure basePath/.gitignore contains baseline ignore patterns.
|
|
116
|
+
* Creates the file if missing; appends missing patterns.
|
|
84
117
|
* Returns true if the file was created or modified, false if already complete.
|
|
85
118
|
*
|
|
86
|
-
* `.gsd/`
|
|
87
|
-
*
|
|
119
|
+
* **Safety check:** If `.gsd/` contains git-tracked files (i.e., the project
|
|
120
|
+
* intentionally keeps `.gsd/` in version control), the `.gsd` ignore pattern
|
|
121
|
+
* is excluded to prevent data loss. Only the `.gsd` pattern is affected —
|
|
122
|
+
* all other baseline patterns are still applied normally.
|
|
88
123
|
*/
|
|
89
124
|
export function ensureGitignore(
|
|
90
125
|
basePath: string,
|
|
@@ -108,8 +143,15 @@ export function ensureGitignore(
|
|
|
108
143
|
.filter((l) => l && !l.startsWith("#")),
|
|
109
144
|
);
|
|
110
145
|
|
|
146
|
+
// Determine which patterns to apply. If .gsd/ has tracked files,
|
|
147
|
+
// exclude the ".gsd" pattern to prevent deleting tracked state.
|
|
148
|
+
const gsdIsTracked = hasGitTrackedGsdFiles(basePath);
|
|
149
|
+
const patternsToApply = gsdIsTracked
|
|
150
|
+
? BASELINE_PATTERNS.filter((p) => p !== ".gsd")
|
|
151
|
+
: BASELINE_PATTERNS;
|
|
152
|
+
|
|
111
153
|
// Find patterns not yet present
|
|
112
|
-
const missing =
|
|
154
|
+
const missing = patternsToApply.filter((p) => !existingLines.has(p));
|
|
113
155
|
|
|
114
156
|
if (missing.length === 0) return false;
|
|
115
157
|
|
|
@@ -135,6 +177,11 @@ export function ensureGitignore(
|
|
|
135
177
|
* already in the index even after .gitignore is updated.
|
|
136
178
|
*
|
|
137
179
|
* Only removes from the index (`--cached`), never from disk. Idempotent.
|
|
180
|
+
*
|
|
181
|
+
* Note: These are strictly runtime/ephemeral paths (activity logs, lock files,
|
|
182
|
+
* metrics, STATE.md). They are always safe to untrack, even when the project
|
|
183
|
+
* intentionally keeps other `.gsd/` files (like PROJECT.md, milestones/) in
|
|
184
|
+
* version control.
|
|
138
185
|
*/
|
|
139
186
|
export function untrackRuntimeFiles(basePath: string): void {
|
|
140
187
|
const runtimePaths = GSD_RUNTIME_PATTERNS;
|
|
@@ -191,7 +191,7 @@ type UIContext = ExtensionContext;
|
|
|
191
191
|
* This is the only way the wizard triggers work — everything else is the LLM's job.
|
|
192
192
|
*/
|
|
193
193
|
function dispatchWorkflow(pi: ExtensionAPI, note: string, customType = "gsd-run"): void {
|
|
194
|
-
const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".
|
|
194
|
+
const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".gsd", "agent", "GSD-WORKFLOW.md");
|
|
195
195
|
const workflow = readFileSync(workflowPath, "utf-8");
|
|
196
196
|
|
|
197
197
|
pi.sendMessage(
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure GSD health widget logic.
|
|
3
|
+
*
|
|
4
|
+
* Separates project-state detection and line rendering from the widget's
|
|
5
|
+
* runtime integrations so the regressions can be tested directly.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
9
|
+
import { gsdRoot } from "./paths.js";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import type { GSDState, Phase } from "./types.js";
|
|
12
|
+
|
|
13
|
+
export type HealthWidgetProjectState = "none" | "initialized" | "active";
|
|
14
|
+
|
|
15
|
+
export interface HealthWidgetData {
|
|
16
|
+
projectState: HealthWidgetProjectState;
|
|
17
|
+
budgetCeiling: number | undefined;
|
|
18
|
+
budgetSpent: number;
|
|
19
|
+
providerIssue: string | null;
|
|
20
|
+
environmentErrorCount: number;
|
|
21
|
+
environmentWarningCount: number;
|
|
22
|
+
lastRefreshed: number;
|
|
23
|
+
executionPhase?: Phase;
|
|
24
|
+
executionStatus?: string;
|
|
25
|
+
executionTarget?: string;
|
|
26
|
+
nextAction?: string;
|
|
27
|
+
blocker?: string | null;
|
|
28
|
+
activeMilestoneId?: string;
|
|
29
|
+
activeSliceId?: string;
|
|
30
|
+
activeTaskId?: string;
|
|
31
|
+
progress?: GSDState["progress"];
|
|
32
|
+
eta?: string | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function detectHealthWidgetProjectState(basePath: string): HealthWidgetProjectState {
|
|
36
|
+
const root = gsdRoot(basePath);
|
|
37
|
+
if (!existsSync(root)) return "none";
|
|
38
|
+
|
|
39
|
+
// Lightweight milestone count — avoids the full detectProjectState() scan
|
|
40
|
+
// (CI markers, Makefile targets, etc.) that is unnecessary on the 60s refresh.
|
|
41
|
+
try {
|
|
42
|
+
const milestonesDir = join(root, "milestones");
|
|
43
|
+
if (existsSync(milestonesDir)) {
|
|
44
|
+
const entries = readdirSync(milestonesDir, { withFileTypes: true });
|
|
45
|
+
if (entries.some(e => e.isDirectory())) return "active";
|
|
46
|
+
}
|
|
47
|
+
} catch { /* non-fatal */ }
|
|
48
|
+
|
|
49
|
+
return "initialized";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function formatCost(n: number): string {
|
|
53
|
+
return n >= 1 ? `$${n.toFixed(2)}` : `${(n * 100).toFixed(1)}¢`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function formatProgress(progress?: GSDState["progress"]): string | null {
|
|
57
|
+
if (!progress) return null;
|
|
58
|
+
|
|
59
|
+
const parts: string[] = [];
|
|
60
|
+
parts.push(`M ${progress.milestones.done}/${progress.milestones.total}`);
|
|
61
|
+
if (progress.slices) parts.push(`S ${progress.slices.done}/${progress.slices.total}`);
|
|
62
|
+
if (progress.tasks) parts.push(`T ${progress.tasks.done}/${progress.tasks.total}`);
|
|
63
|
+
return parts.length > 0 ? `Progress: ${parts.join(" · ")}` : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function formatEnvironmentSummary(errorCount: number, warningCount: number): string | null {
|
|
67
|
+
if (errorCount <= 0 && warningCount <= 0) return null;
|
|
68
|
+
|
|
69
|
+
const parts: string[] = [];
|
|
70
|
+
if (errorCount > 0) parts.push(`${errorCount} error${errorCount > 1 ? "s" : ""}`);
|
|
71
|
+
if (warningCount > 0) parts.push(`${warningCount} warning${warningCount > 1 ? "s" : ""}`);
|
|
72
|
+
return `Env: ${parts.join(", ")}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function formatBudgetSummary(data: HealthWidgetData): string | null {
|
|
76
|
+
if (data.budgetCeiling !== undefined && data.budgetCeiling > 0) {
|
|
77
|
+
const pct = Math.min(100, (data.budgetSpent / data.budgetCeiling) * 100);
|
|
78
|
+
return `Budget: ${formatCost(data.budgetSpent)}/${formatCost(data.budgetCeiling)} (${pct.toFixed(0)}%)`;
|
|
79
|
+
}
|
|
80
|
+
if (data.budgetSpent > 0) {
|
|
81
|
+
return `Spent: ${formatCost(data.budgetSpent)}`;
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function buildExecutionHeadline(data: HealthWidgetData): string {
|
|
87
|
+
const status = data.executionStatus ?? "Active project";
|
|
88
|
+
const target = data.executionTarget ?? data.blocker ?? "loading status…";
|
|
89
|
+
return ` GSD ${status}${target ? ` - ${target}` : ""}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Build compact health lines for the widget.
|
|
94
|
+
* Returns a string array suitable for setWidget().
|
|
95
|
+
*/
|
|
96
|
+
export function buildHealthLines(data: HealthWidgetData): string[] {
|
|
97
|
+
if (data.projectState === "none") {
|
|
98
|
+
return [" GSD No project loaded — run /gsd to start"];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (data.projectState === "initialized") {
|
|
102
|
+
return [" GSD Project initialized — run /gsd to continue setup"];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const lines = [buildExecutionHeadline(data)];
|
|
106
|
+
const details: string[] = [];
|
|
107
|
+
|
|
108
|
+
const progress = formatProgress(data.progress);
|
|
109
|
+
if (progress) details.push(progress);
|
|
110
|
+
|
|
111
|
+
if (data.providerIssue) details.push(data.providerIssue);
|
|
112
|
+
|
|
113
|
+
const environment = formatEnvironmentSummary(
|
|
114
|
+
data.environmentErrorCount,
|
|
115
|
+
data.environmentWarningCount,
|
|
116
|
+
);
|
|
117
|
+
if (environment) details.push(environment);
|
|
118
|
+
|
|
119
|
+
const budget = formatBudgetSummary(data);
|
|
120
|
+
if (budget) details.push(budget);
|
|
121
|
+
|
|
122
|
+
if (data.eta) details.push(data.eta);
|
|
123
|
+
|
|
124
|
+
if (details.length > 0) {
|
|
125
|
+
lines.push(` ${details.join(" │ ")}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return lines;
|
|
129
|
+
}
|