gsd-pi 0.2.8 → 0.3.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/README.md +23 -0
- package/dist/cli.js +47 -5
- package/dist/wizard.js +2 -1
- package/package.json +3 -2
- package/pkg/package.json +1 -1
- package/src/resources/extensions/gsd/auto.ts +216 -13
- package/src/resources/extensions/gsd/commands.ts +9 -3
- package/src/resources/extensions/gsd/dashboard-overlay.ts +6 -1
- package/src/resources/extensions/gsd/files.ts +7 -7
- package/src/resources/extensions/gsd/gitignore.ts +1 -0
- package/src/resources/extensions/gsd/index.ts +36 -1
- package/src/resources/extensions/gsd/migrate/command.ts +215 -0
- package/src/resources/extensions/gsd/migrate/index.ts +42 -0
- package/src/resources/extensions/gsd/migrate/parser.ts +323 -0
- package/src/resources/extensions/gsd/migrate/parsers.ts +624 -0
- package/src/resources/extensions/gsd/migrate/preview.ts +48 -0
- package/src/resources/extensions/gsd/migrate/transformer.ts +346 -0
- package/src/resources/extensions/gsd/migrate/types.ts +370 -0
- package/src/resources/extensions/gsd/migrate/validator.ts +53 -0
- package/src/resources/extensions/gsd/migrate/writer.ts +539 -0
- package/src/resources/extensions/gsd/prompts/review-migration.md +66 -0
- package/src/resources/extensions/gsd/prompts/worktree-merge.md +89 -0
- package/src/resources/extensions/gsd/state.ts +24 -3
- package/src/resources/extensions/gsd/tests/derive-state.test.ts +52 -0
- package/src/resources/extensions/gsd/tests/idle-recovery.test.ts +308 -0
- package/src/resources/extensions/gsd/tests/migrate-command.test.ts +390 -0
- package/src/resources/extensions/gsd/tests/migrate-parser.test.ts +786 -0
- package/src/resources/extensions/gsd/tests/migrate-transformer.test.ts +657 -0
- package/src/resources/extensions/gsd/tests/migrate-validator-parsers.test.ts +443 -0
- package/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +318 -0
- package/src/resources/extensions/gsd/tests/migrate-writer.test.ts +420 -0
- package/src/resources/extensions/gsd/tests/worktree-manager.test.ts +160 -0
- package/src/resources/extensions/gsd/unit-runtime.ts +2 -1
- package/src/resources/extensions/gsd/worktree-command.ts +527 -0
- package/src/resources/extensions/gsd/worktree-manager.ts +302 -0
package/README.md
CHANGED
|
@@ -46,6 +46,28 @@ GSD v2 solves all of these because it's not a prompt framework anymore — it's
|
|
|
46
46
|
| Roadmap reassessment | Manual | Automatic after each slice completes |
|
|
47
47
|
| Skill discovery | None | Auto-detect and install relevant skills during research |
|
|
48
48
|
|
|
49
|
+
### Migrating from v1
|
|
50
|
+
|
|
51
|
+
If you have projects with `.planning` directories from the original Get Shit Done, you can migrate them to GSD-2's `.gsd` format:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# From within the project directory
|
|
55
|
+
/gsd migrate
|
|
56
|
+
|
|
57
|
+
# Or specify a path
|
|
58
|
+
/gsd migrate ~/projects/my-old-project
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The migration tool:
|
|
62
|
+
- Parses your old `PROJECT.md`, `ROADMAP.md`, `REQUIREMENTS.md`, phase directories, plans, summaries, and research
|
|
63
|
+
- Maps phases → slices, plans → tasks, milestones → milestones
|
|
64
|
+
- Preserves completion state (`[x]` phases stay done, summaries carry over)
|
|
65
|
+
- Consolidates research files into the new structure
|
|
66
|
+
- Shows a preview before writing anything
|
|
67
|
+
- Optionally runs an agent-driven review of the output for quality assurance
|
|
68
|
+
|
|
69
|
+
Supports format variations including milestone-sectioned roadmaps with `<details>` blocks, bold phase entries, bullet-format requirements, decimal phase numbering, and duplicate phase numbers across milestones.
|
|
70
|
+
|
|
49
71
|
---
|
|
50
72
|
|
|
51
73
|
## How It Works
|
|
@@ -187,6 +209,7 @@ On first run, GSD prompts for optional API keys (Brave Search, Context7, Jina) f
|
|
|
187
209
|
| `/gsd status` | Progress dashboard |
|
|
188
210
|
| `/gsd queue` | Queue future milestones (safe during auto mode) |
|
|
189
211
|
| `/gsd prefs` | Model selection, timeouts, budget ceiling |
|
|
212
|
+
| `/gsd migrate` | Migrate a v1 `.planning` directory to `.gsd` format |
|
|
190
213
|
| `/gsd doctor` | Validate `.gsd/` integrity, find and fix issues |
|
|
191
214
|
| `Ctrl+Alt+G` | Toggle dashboard overlay |
|
|
192
215
|
|
package/dist/cli.js
CHANGED
|
@@ -7,18 +7,19 @@ loadStoredEnvKeys(authStorage);
|
|
|
7
7
|
await runWizardIfNeeded(authStorage);
|
|
8
8
|
const modelRegistry = new ModelRegistry(authStorage);
|
|
9
9
|
const settingsManager = SettingsManager.create(agentDir);
|
|
10
|
-
//
|
|
11
|
-
// Validates on every startup — catches stale settings from prior installs
|
|
10
|
+
// Validate configured model on startup — catches stale settings from prior installs
|
|
12
11
|
// (e.g. grok-2 which no longer exists) and fresh installs with no settings.
|
|
12
|
+
// Only resets the default when the configured model no longer exists in the registry;
|
|
13
|
+
// never overwrites a valid user choice.
|
|
13
14
|
const configuredProvider = settingsManager.getDefaultProvider();
|
|
14
15
|
const configuredModel = settingsManager.getDefaultModel();
|
|
15
16
|
const allModels = modelRegistry.getAll();
|
|
16
17
|
const configuredExists = configuredProvider && configuredModel &&
|
|
17
18
|
allModels.some((m) => m.provider === configuredProvider && m.id === configuredModel);
|
|
18
19
|
if (!configuredModel || !configuredExists) {
|
|
19
|
-
//
|
|
20
|
-
const preferred = allModels.find((m) => m.provider === 'anthropic' && m.id === 'claude-
|
|
21
|
-
allModels.find((m) => m.provider === 'anthropic' && m.id.includes('
|
|
20
|
+
// Fallback: pick the best available Anthropic model
|
|
21
|
+
const preferred = allModels.find((m) => m.provider === 'anthropic' && m.id === 'claude-opus-4-6') ||
|
|
22
|
+
allModels.find((m) => m.provider === 'anthropic' && m.id.includes('opus')) ||
|
|
22
23
|
allModels.find((m) => m.provider === 'anthropic');
|
|
23
24
|
if (preferred) {
|
|
24
25
|
settingsManager.setDefaultModelAndProvider(preferred.provider, preferred.id);
|
|
@@ -52,5 +53,46 @@ if (extensionsResult.errors.length > 0) {
|
|
|
52
53
|
process.stderr.write(`[gsd] Extension load error: ${err.error}\n`);
|
|
53
54
|
}
|
|
54
55
|
}
|
|
56
|
+
// Restore scoped models from settings on startup.
|
|
57
|
+
// The upstream InteractiveMode reads enabledModels from settings when /scoped-models is opened,
|
|
58
|
+
// but doesn't apply them to the session at startup — so Ctrl+P cycles all models instead of
|
|
59
|
+
// just the saved selection until the user re-runs /scoped-models.
|
|
60
|
+
const enabledModelPatterns = settingsManager.getEnabledModels();
|
|
61
|
+
if (enabledModelPatterns && enabledModelPatterns.length > 0) {
|
|
62
|
+
const availableModels = modelRegistry.getAvailable();
|
|
63
|
+
const scopedModels = [];
|
|
64
|
+
const seen = new Set();
|
|
65
|
+
for (const pattern of enabledModelPatterns) {
|
|
66
|
+
// Patterns are "provider/modelId" exact strings saved by /scoped-models
|
|
67
|
+
const slashIdx = pattern.indexOf('/');
|
|
68
|
+
if (slashIdx !== -1) {
|
|
69
|
+
const provider = pattern.substring(0, slashIdx);
|
|
70
|
+
const modelId = pattern.substring(slashIdx + 1);
|
|
71
|
+
const model = availableModels.find((m) => m.provider === provider && m.id === modelId);
|
|
72
|
+
if (model) {
|
|
73
|
+
const key = `${model.provider}/${model.id}`;
|
|
74
|
+
if (!seen.has(key)) {
|
|
75
|
+
seen.add(key);
|
|
76
|
+
scopedModels.push({ model });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
// Fallback: match by model id alone
|
|
82
|
+
const model = availableModels.find((m) => m.id === pattern);
|
|
83
|
+
if (model) {
|
|
84
|
+
const key = `${model.provider}/${model.id}`;
|
|
85
|
+
if (!seen.has(key)) {
|
|
86
|
+
seen.add(key);
|
|
87
|
+
scopedModels.push({ model });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Only apply if we resolved some models and it's a genuine subset
|
|
93
|
+
if (scopedModels.length > 0 && scopedModels.length < availableModels.length) {
|
|
94
|
+
session.setScopedModels(scopedModels);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
55
97
|
const interactiveMode = new InteractiveMode(session);
|
|
56
98
|
await interactiveMode.run();
|
package/dist/wizard.js
CHANGED
|
@@ -75,7 +75,7 @@ export function loadStoredEnvKeys(authStorage) {
|
|
|
75
75
|
for (const [provider, envVar] of providers) {
|
|
76
76
|
if (!process.env[envVar]) {
|
|
77
77
|
const cred = authStorage.get(provider);
|
|
78
|
-
if (cred?.type === 'api_key') {
|
|
78
|
+
if (cred?.type === 'api_key' && cred.key) {
|
|
79
79
|
process.env[envVar] = cred.key;
|
|
80
80
|
}
|
|
81
81
|
}
|
|
@@ -143,6 +143,7 @@ export async function runWizardIfNeeded(authStorage) {
|
|
|
143
143
|
savedCount++;
|
|
144
144
|
}
|
|
145
145
|
else {
|
|
146
|
+
authStorage.set(key.provider, { type: 'api_key', key: '' });
|
|
146
147
|
process.stdout.write(` ${dim}↷ ${key.label} skipped${reset}\n\n`);
|
|
147
148
|
}
|
|
148
149
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gsd-pi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "GSD — Get Stuff Done coding agent",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
"test": "node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test 'src/resources/extensions/gsd/tests/*.test.ts' 'src/resources/extensions/gsd/tests/*.test.mjs' 'src/tests/*.test.ts'",
|
|
38
38
|
"dev": "tsc --watch",
|
|
39
39
|
"postinstall": "node scripts/postinstall.js",
|
|
40
|
-
"
|
|
40
|
+
"sync-pkg-version": "node scripts/sync-pkg-version.cjs",
|
|
41
|
+
"prepublishOnly": "npm run sync-pkg-version && npm run build"
|
|
41
42
|
},
|
|
42
43
|
"dependencies": {
|
|
43
44
|
"@mariozechner/pi-coding-agent": "^0.57.1",
|
package/pkg/package.json
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
resolveMilestonePath, resolveDir, resolveTasksDir, resolveTaskFiles, resolveTaskFile,
|
|
28
28
|
relMilestoneFile, relSliceFile, relTaskFile, relSlicePath, relMilestonePath,
|
|
29
29
|
milestonesDir, resolveGsdRootFile, relGsdRootFile,
|
|
30
|
+
buildMilestoneFileName, buildSliceFileName, buildTaskFileName,
|
|
30
31
|
} from "./paths.js";
|
|
31
32
|
import { saveActivityLog } from "./activity-log.js";
|
|
32
33
|
import { synthesizeCrashRecovery, getDeepDiagnostic } from "./session-forensics.js";
|
|
@@ -54,7 +55,7 @@ import {
|
|
|
54
55
|
getProjectTotals, formatCost, formatTokenCount,
|
|
55
56
|
} from "./metrics.js";
|
|
56
57
|
import { join } from "node:path";
|
|
57
|
-
import { readdirSync, readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
58
|
+
import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
58
59
|
import { execSync } from "node:child_process";
|
|
59
60
|
import {
|
|
60
61
|
autoCommitCurrentBranch,
|
|
@@ -1912,6 +1913,7 @@ async function recoverTimedOutUnit(
|
|
|
1912
1913
|
}
|
|
1913
1914
|
|
|
1914
1915
|
if (recoveryAttempts < maxRecoveryAttempts) {
|
|
1916
|
+
const isEscalation = recoveryAttempts > 0;
|
|
1915
1917
|
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
1916
1918
|
phase: "recovered",
|
|
1917
1919
|
recovery: status,
|
|
@@ -1921,11 +1923,19 @@ async function recoverTimedOutUnit(
|
|
|
1921
1923
|
progressCount: (runtime?.progressCount ?? 0) + 1,
|
|
1922
1924
|
lastProgressKind: reason === "idle" ? "idle-recovery-retry" : "hard-recovery-retry",
|
|
1923
1925
|
});
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1926
|
+
|
|
1927
|
+
const steeringLines = isEscalation
|
|
1928
|
+
? [
|
|
1929
|
+
`**FINAL ${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — last chance before this task is skipped.**`,
|
|
1930
|
+
`You are still executing ${unitType} ${unitId}.`,
|
|
1931
|
+
`Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts}.`,
|
|
1932
|
+
`Current durability status: ${formatExecuteTaskRecoveryStatus(status)}.`,
|
|
1933
|
+
"You MUST finish the durable output NOW, even if incomplete.",
|
|
1934
|
+
"Write the task summary with whatever you have accomplished so far.",
|
|
1935
|
+
"Mark the task [x] in the plan. Commit your work.",
|
|
1936
|
+
"A partial summary is infinitely better than no summary.",
|
|
1937
|
+
]
|
|
1938
|
+
: [
|
|
1929
1939
|
`**${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — do not stop.**`,
|
|
1930
1940
|
`You are still executing ${unitType} ${unitId}.`,
|
|
1931
1941
|
`Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts}.`,
|
|
@@ -1933,7 +1943,13 @@ async function recoverTimedOutUnit(
|
|
|
1933
1943
|
"Do not keep exploring.",
|
|
1934
1944
|
"Immediately finish the required durable output for this unit.",
|
|
1935
1945
|
"If full completion is impossible, write the partial artifact/state needed for recovery and make the blocker explicit.",
|
|
1936
|
-
]
|
|
1946
|
+
];
|
|
1947
|
+
|
|
1948
|
+
pi.sendMessage(
|
|
1949
|
+
{
|
|
1950
|
+
customType: "gsd-auto-timeout-recovery",
|
|
1951
|
+
display: verbose,
|
|
1952
|
+
content: steeringLines.join("\n"),
|
|
1937
1953
|
},
|
|
1938
1954
|
{ triggerTurn: true, deliverAs: "steer" },
|
|
1939
1955
|
);
|
|
@@ -1944,7 +1960,29 @@ async function recoverTimedOutUnit(
|
|
|
1944
1960
|
return "recovered";
|
|
1945
1961
|
}
|
|
1946
1962
|
|
|
1963
|
+
// Retries exhausted — write missing durable artifacts and advance.
|
|
1947
1964
|
const diagnostic = formatExecuteTaskRecoveryStatus(status);
|
|
1965
|
+
const [mid, sid, tid] = unitId.split("/");
|
|
1966
|
+
const skipped = mid && sid && tid
|
|
1967
|
+
? skipExecuteTask(basePath, mid, sid, tid, status, reason, maxRecoveryAttempts)
|
|
1968
|
+
: false;
|
|
1969
|
+
|
|
1970
|
+
if (skipped) {
|
|
1971
|
+
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
1972
|
+
phase: "skipped",
|
|
1973
|
+
recovery: status,
|
|
1974
|
+
recoveryAttempts: recoveryAttempts + 1,
|
|
1975
|
+
lastRecoveryReason: reason,
|
|
1976
|
+
});
|
|
1977
|
+
ctx.ui.notify(
|
|
1978
|
+
`${unitType} ${unitId} skipped after ${maxRecoveryAttempts} recovery attempts (${diagnostic}). Blocker artifacts written. Advancing pipeline.`,
|
|
1979
|
+
"warning",
|
|
1980
|
+
);
|
|
1981
|
+
await dispatchNextUnit(ctx, pi);
|
|
1982
|
+
return "recovered";
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
// Fallback: couldn't write skip artifacts — pause as before.
|
|
1948
1986
|
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
1949
1987
|
phase: "paused",
|
|
1950
1988
|
recovery: status,
|
|
@@ -1959,7 +1997,26 @@ async function recoverTimedOutUnit(
|
|
|
1959
1997
|
}
|
|
1960
1998
|
|
|
1961
1999
|
const expected = diagnoseExpectedArtifact(unitType, unitId, basePath) ?? "required durable artifact";
|
|
2000
|
+
|
|
2001
|
+
// Check if the artifact already exists on disk — agent may have written it
|
|
2002
|
+
// without signaling completion.
|
|
2003
|
+
const artifactPath = resolveExpectedArtifactPath(unitType, unitId, basePath);
|
|
2004
|
+
if (artifactPath && existsSync(artifactPath)) {
|
|
2005
|
+
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
2006
|
+
phase: "finalized",
|
|
2007
|
+
recoveryAttempts: recoveryAttempts + 1,
|
|
2008
|
+
lastRecoveryReason: reason,
|
|
2009
|
+
});
|
|
2010
|
+
ctx.ui.notify(
|
|
2011
|
+
`${reason === "idle" ? "Idle" : "Timeout"} recovery: ${unitType} ${unitId} artifact already exists on disk. Advancing.`,
|
|
2012
|
+
"info",
|
|
2013
|
+
);
|
|
2014
|
+
await dispatchNextUnit(ctx, pi);
|
|
2015
|
+
return "recovered";
|
|
2016
|
+
}
|
|
2017
|
+
|
|
1962
2018
|
if (recoveryAttempts < maxRecoveryAttempts) {
|
|
2019
|
+
const isEscalation = recoveryAttempts > 0;
|
|
1963
2020
|
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
1964
2021
|
phase: "recovered",
|
|
1965
2022
|
recoveryAttempts: recoveryAttempts + 1,
|
|
@@ -1968,11 +2025,19 @@ async function recoverTimedOutUnit(
|
|
|
1968
2025
|
progressCount: (runtime?.progressCount ?? 0) + 1,
|
|
1969
2026
|
lastProgressKind: reason === "idle" ? "idle-recovery-retry" : "hard-recovery-retry",
|
|
1970
2027
|
});
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
2028
|
+
|
|
2029
|
+
const steeringLines = isEscalation
|
|
2030
|
+
? [
|
|
2031
|
+
`**FINAL ${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — last chance before skip.**`,
|
|
2032
|
+
`You are still executing ${unitType} ${unitId}.`,
|
|
2033
|
+
`Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts} — next failure skips this unit.`,
|
|
2034
|
+
`Expected durable output: ${expected}.`,
|
|
2035
|
+
"You MUST write the artifact file NOW, even if incomplete.",
|
|
2036
|
+
"Write whatever you have — partial research, preliminary findings, best-effort analysis.",
|
|
2037
|
+
"A partial artifact is infinitely better than no artifact.",
|
|
2038
|
+
"If you are truly blocked, write the file with a BLOCKER section explaining why.",
|
|
2039
|
+
]
|
|
2040
|
+
: [
|
|
1976
2041
|
`**${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — stay in auto-mode.**`,
|
|
1977
2042
|
`You are still executing ${unitType} ${unitId}.`,
|
|
1978
2043
|
`Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts}.`,
|
|
@@ -1980,7 +2045,13 @@ async function recoverTimedOutUnit(
|
|
|
1980
2045
|
"Stop broad exploration.",
|
|
1981
2046
|
"Write the required artifact now.",
|
|
1982
2047
|
"If blocked, write the partial artifact and explicitly record the blocker instead of going silent.",
|
|
1983
|
-
]
|
|
2048
|
+
];
|
|
2049
|
+
|
|
2050
|
+
pi.sendMessage(
|
|
2051
|
+
{
|
|
2052
|
+
customType: "gsd-auto-timeout-recovery",
|
|
2053
|
+
display: verbose,
|
|
2054
|
+
content: steeringLines.join("\n"),
|
|
1984
2055
|
},
|
|
1985
2056
|
{ triggerTurn: true, deliverAs: "steer" },
|
|
1986
2057
|
);
|
|
@@ -1991,6 +2062,28 @@ async function recoverTimedOutUnit(
|
|
|
1991
2062
|
return "recovered";
|
|
1992
2063
|
}
|
|
1993
2064
|
|
|
2065
|
+
// Retries exhausted — write a blocker placeholder and advance the pipeline
|
|
2066
|
+
// instead of silently stalling.
|
|
2067
|
+
const placeholder = writeBlockerPlaceholder(
|
|
2068
|
+
unitType, unitId, basePath,
|
|
2069
|
+
`${reason} recovery exhausted ${maxRecoveryAttempts} attempts without producing the artifact.`,
|
|
2070
|
+
);
|
|
2071
|
+
|
|
2072
|
+
if (placeholder) {
|
|
2073
|
+
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
2074
|
+
phase: "skipped",
|
|
2075
|
+
recoveryAttempts: recoveryAttempts + 1,
|
|
2076
|
+
lastRecoveryReason: reason,
|
|
2077
|
+
});
|
|
2078
|
+
ctx.ui.notify(
|
|
2079
|
+
`${unitType} ${unitId} skipped after ${maxRecoveryAttempts} recovery attempts. Blocker placeholder written to ${placeholder}. Advancing pipeline.`,
|
|
2080
|
+
"warning",
|
|
2081
|
+
);
|
|
2082
|
+
await dispatchNextUnit(ctx, pi);
|
|
2083
|
+
return "recovered";
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
// Fallback: couldn't resolve artifact path — pause as before.
|
|
1994
2087
|
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
1995
2088
|
phase: "paused",
|
|
1996
2089
|
recoveryAttempts: recoveryAttempts + 1,
|
|
@@ -1999,6 +2092,116 @@ async function recoverTimedOutUnit(
|
|
|
1999
2092
|
return "paused";
|
|
2000
2093
|
}
|
|
2001
2094
|
|
|
2095
|
+
/**
|
|
2096
|
+
* Write skip artifacts for a stuck execute-task: a blocker task summary and
|
|
2097
|
+
* the [x] checkbox in the slice plan. Returns true if artifacts were written.
|
|
2098
|
+
*/
|
|
2099
|
+
export function skipExecuteTask(
|
|
2100
|
+
base: string, mid: string, sid: string, tid: string,
|
|
2101
|
+
status: { summaryExists: boolean; taskChecked: boolean },
|
|
2102
|
+
reason: string, maxAttempts: number,
|
|
2103
|
+
): boolean {
|
|
2104
|
+
// Write a blocker task summary if missing.
|
|
2105
|
+
if (!status.summaryExists) {
|
|
2106
|
+
const tasksDir = resolveTasksDir(base, mid, sid);
|
|
2107
|
+
const sDir = resolveSlicePath(base, mid, sid);
|
|
2108
|
+
const targetDir = tasksDir ?? (sDir ? join(sDir, "tasks") : null);
|
|
2109
|
+
if (!targetDir) return false;
|
|
2110
|
+
if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true });
|
|
2111
|
+
const summaryPath = join(targetDir, buildTaskFileName(tid, "SUMMARY"));
|
|
2112
|
+
const content = [
|
|
2113
|
+
`# BLOCKER — task skipped by auto-mode recovery`,
|
|
2114
|
+
``,
|
|
2115
|
+
`Task \`${tid}\` in slice \`${sid}\` (milestone \`${mid}\`) failed to complete after ${reason} recovery exhausted ${maxAttempts} attempts.`,
|
|
2116
|
+
``,
|
|
2117
|
+
`This placeholder was written by auto-mode so the pipeline can advance.`,
|
|
2118
|
+
`Review this task manually and replace this file with a real summary.`,
|
|
2119
|
+
].join("\n");
|
|
2120
|
+
writeFileSync(summaryPath, content, "utf-8");
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
// Mark [x] in the slice plan if not already checked.
|
|
2124
|
+
if (!status.taskChecked) {
|
|
2125
|
+
const planAbs = resolveSliceFile(base, mid, sid, "PLAN");
|
|
2126
|
+
if (planAbs && existsSync(planAbs)) {
|
|
2127
|
+
const planContent = readFileSync(planAbs, "utf-8");
|
|
2128
|
+
const escapedTid = tid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2129
|
+
const re = new RegExp(`^(- \\[) \\] (\\*\\*${escapedTid}:)`, "m");
|
|
2130
|
+
if (re.test(planContent)) {
|
|
2131
|
+
writeFileSync(planAbs, planContent.replace(re, "$1x] $2"), "utf-8");
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
return true;
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
/**
|
|
2140
|
+
* Resolve the expected artifact for a non-execute-task unit to an absolute path.
|
|
2141
|
+
* Returns null for unit types that don't produce a single file (execute-task,
|
|
2142
|
+
* complete-slice, replan-slice).
|
|
2143
|
+
*/
|
|
2144
|
+
export function resolveExpectedArtifactPath(unitType: string, unitId: string, base: string): string | null {
|
|
2145
|
+
const parts = unitId.split("/");
|
|
2146
|
+
const mid = parts[0]!;
|
|
2147
|
+
const sid = parts[1];
|
|
2148
|
+
switch (unitType) {
|
|
2149
|
+
case "research-milestone": {
|
|
2150
|
+
const dir = resolveMilestonePath(base, mid);
|
|
2151
|
+
return dir ? join(dir, buildMilestoneFileName(mid, "RESEARCH")) : null;
|
|
2152
|
+
}
|
|
2153
|
+
case "plan-milestone": {
|
|
2154
|
+
const dir = resolveMilestonePath(base, mid);
|
|
2155
|
+
return dir ? join(dir, buildMilestoneFileName(mid, "ROADMAP")) : null;
|
|
2156
|
+
}
|
|
2157
|
+
case "research-slice": {
|
|
2158
|
+
const dir = resolveSlicePath(base, mid, sid!);
|
|
2159
|
+
return dir ? join(dir, buildSliceFileName(sid!, "RESEARCH")) : null;
|
|
2160
|
+
}
|
|
2161
|
+
case "plan-slice": {
|
|
2162
|
+
const dir = resolveSlicePath(base, mid, sid!);
|
|
2163
|
+
return dir ? join(dir, buildSliceFileName(sid!, "PLAN")) : null;
|
|
2164
|
+
}
|
|
2165
|
+
case "reassess-roadmap": {
|
|
2166
|
+
const dir = resolveSlicePath(base, mid, sid!);
|
|
2167
|
+
return dir ? join(dir, buildSliceFileName(sid!, "ASSESSMENT")) : null;
|
|
2168
|
+
}
|
|
2169
|
+
case "run-uat": {
|
|
2170
|
+
const dir = resolveSlicePath(base, mid, sid!);
|
|
2171
|
+
return dir ? join(dir, buildSliceFileName(sid!, "UAT-RESULT")) : null;
|
|
2172
|
+
}
|
|
2173
|
+
case "complete-milestone": {
|
|
2174
|
+
const dir = resolveMilestonePath(base, mid);
|
|
2175
|
+
return dir ? join(dir, buildMilestoneFileName(mid, "SUMMARY")) : null;
|
|
2176
|
+
}
|
|
2177
|
+
default:
|
|
2178
|
+
return null;
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
/**
|
|
2183
|
+
* Write a placeholder artifact so the pipeline can advance past a stuck unit.
|
|
2184
|
+
* Returns the relative path written, or null if the path couldn't be resolved.
|
|
2185
|
+
*/
|
|
2186
|
+
export function writeBlockerPlaceholder(unitType: string, unitId: string, base: string, reason: string): string | null {
|
|
2187
|
+
const absPath = resolveExpectedArtifactPath(unitType, unitId, base);
|
|
2188
|
+
if (!absPath) return null;
|
|
2189
|
+
const dir = absPath.substring(0, absPath.lastIndexOf("/"));
|
|
2190
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
2191
|
+
const content = [
|
|
2192
|
+
`# BLOCKER — auto-mode recovery failed`,
|
|
2193
|
+
``,
|
|
2194
|
+
`Unit \`${unitType}\` for \`${unitId}\` failed to produce this artifact after idle recovery exhausted all retries.`,
|
|
2195
|
+
``,
|
|
2196
|
+
`**Reason**: ${reason}`,
|
|
2197
|
+
``,
|
|
2198
|
+
`This placeholder was written by auto-mode so the pipeline can advance.`,
|
|
2199
|
+
`Review and replace this file before relying on downstream artifacts.`,
|
|
2200
|
+
].join("\n");
|
|
2201
|
+
writeFileSync(absPath, content, "utf-8");
|
|
2202
|
+
return diagnoseExpectedArtifact(unitType, unitId, base);
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2002
2205
|
function diagnoseExpectedArtifact(unitType: string, unitId: string, base: string): string | null {
|
|
2003
2206
|
const parts = unitId.split("/");
|
|
2004
2207
|
const mid = parts[0];
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
filterDoctorIssues,
|
|
31
31
|
} from "./doctor.js";
|
|
32
32
|
import { loadPrompt } from "./prompt-loader.js";
|
|
33
|
+
import { handleMigrate } from "./migrate/command.js";
|
|
33
34
|
|
|
34
35
|
function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportText: string, structuredIssues: string): void {
|
|
35
36
|
const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".pi", "GSD-WORKFLOW.md");
|
|
@@ -51,10 +52,10 @@ function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportT
|
|
|
51
52
|
|
|
52
53
|
export function registerGSDCommand(pi: ExtensionAPI): void {
|
|
53
54
|
pi.registerCommand("gsd", {
|
|
54
|
-
description: "GSD — Get Stuff Done: /gsd auto|stop|status|queue|prefs|doctor",
|
|
55
|
+
description: "GSD — Get Stuff Done: /gsd auto|stop|status|queue|prefs|doctor|migrate",
|
|
55
56
|
|
|
56
57
|
getArgumentCompletions: (prefix: string) => {
|
|
57
|
-
const subcommands = ["auto", "stop", "status", "queue", "discuss", "prefs", "doctor"];
|
|
58
|
+
const subcommands = ["auto", "stop", "status", "queue", "discuss", "prefs", "doctor", "migrate"];
|
|
58
59
|
const parts = prefix.trim().split(/\s+/);
|
|
59
60
|
|
|
60
61
|
if (parts.length <= 1) {
|
|
@@ -136,13 +137,18 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
|
|
|
136
137
|
return;
|
|
137
138
|
}
|
|
138
139
|
|
|
140
|
+
if (trimmed === "migrate" || trimmed.startsWith("migrate ")) {
|
|
141
|
+
await handleMigrate(trimmed.replace(/^migrate\s*/, "").trim(), ctx, pi);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
139
145
|
if (trimmed === "") {
|
|
140
146
|
await showSmartEntry(ctx, pi, process.cwd());
|
|
141
147
|
return;
|
|
142
148
|
}
|
|
143
149
|
|
|
144
150
|
ctx.ui.notify(
|
|
145
|
-
`Unknown: /gsd ${trimmed}. Use /gsd, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs [global|project|status],
|
|
151
|
+
`Unknown: /gsd ${trimmed}. Use /gsd, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs [global|project|status], /gsd doctor [audit|fix|heal] [M###/S##], or /gsd migrate <path>.`,
|
|
146
152
|
"warning",
|
|
147
153
|
);
|
|
148
154
|
},
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
aggregateByModel, formatCost, formatTokenCount, formatCostProjection,
|
|
18
18
|
} from "./metrics.js";
|
|
19
19
|
import { loadEffectiveGSDPreferences } from "./preferences.js";
|
|
20
|
+
import { getActiveWorktreeName } from "./worktree-command.js";
|
|
20
21
|
|
|
21
22
|
function formatDuration(ms: number): string {
|
|
22
23
|
const s = Math.floor(ms / 1000);
|
|
@@ -273,8 +274,12 @@ export class GSDDashboardOverlay {
|
|
|
273
274
|
: this.dashData.paused
|
|
274
275
|
? th.fg("warning", "⏸ PAUSED")
|
|
275
276
|
: th.fg("dim", "idle");
|
|
277
|
+
const worktreeName = getActiveWorktreeName();
|
|
278
|
+
const worktreeTag = worktreeName
|
|
279
|
+
? ` ${th.fg("warning", `⎇ ${worktreeName}`)}`
|
|
280
|
+
: "";
|
|
276
281
|
const elapsed = th.fg("dim", formatDuration(this.dashData.elapsed));
|
|
277
|
-
lines.push(row(joinColumns(`${title} ${status}`, elapsed, contentWidth)));
|
|
282
|
+
lines.push(row(joinColumns(`${title} ${status}${worktreeTag}`, elapsed, contentWidth)));
|
|
278
283
|
lines.push(blank());
|
|
279
284
|
|
|
280
285
|
if (this.dashData.currentUnit) {
|
|
@@ -21,7 +21,7 @@ import type {
|
|
|
21
21
|
* Split markdown content into frontmatter (YAML-like) and body.
|
|
22
22
|
* Returns [frontmatterLines, body] where frontmatterLines is null if no frontmatter.
|
|
23
23
|
*/
|
|
24
|
-
function splitFrontmatter(content: string): [string[] | null, string] {
|
|
24
|
+
export function splitFrontmatter(content: string): [string[] | null, string] {
|
|
25
25
|
const trimmed = content.trimStart();
|
|
26
26
|
if (!trimmed.startsWith('---')) return [null, content];
|
|
27
27
|
|
|
@@ -42,7 +42,7 @@ function splitFrontmatter(content: string): [string[] | null, string] {
|
|
|
42
42
|
* Handles simple scalars and arrays (lines starting with " - ").
|
|
43
43
|
* Handles nested objects like requires (lines with " key: value").
|
|
44
44
|
*/
|
|
45
|
-
function parseFrontmatterMap(lines: string[]): Record<string, unknown> {
|
|
45
|
+
export function parseFrontmatterMap(lines: string[]): Record<string, unknown> {
|
|
46
46
|
const result: Record<string, unknown> = {};
|
|
47
47
|
let currentKey: string | null = null;
|
|
48
48
|
let currentArray: unknown[] | null = null;
|
|
@@ -124,7 +124,7 @@ function parseFrontmatterMap(lines: string[]): Record<string, unknown> {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
/** Extract the text after a heading at a given level, up to the next heading of same or higher level. */
|
|
127
|
-
function extractSection(body: string, heading: string, level: number = 2): string | null {
|
|
127
|
+
export function extractSection(body: string, heading: string, level: number = 2): string | null {
|
|
128
128
|
const prefix = '#'.repeat(level) + ' ';
|
|
129
129
|
const regex = new RegExp(`^${prefix}${escapeRegex(heading)}\\s*$`, 'm');
|
|
130
130
|
const match = regex.exec(body);
|
|
@@ -140,7 +140,7 @@ function extractSection(body: string, heading: string, level: number = 2): strin
|
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
/** Extract all sections at a given level, returning heading → content map. */
|
|
143
|
-
function extractAllSections(body: string, level: number = 2): Map<string, string> {
|
|
143
|
+
export function extractAllSections(body: string, level: number = 2): Map<string, string> {
|
|
144
144
|
const prefix = '#'.repeat(level) + ' ';
|
|
145
145
|
const regex = new RegExp(`^${prefix}(.+)$`, 'gm');
|
|
146
146
|
const sections = new Map<string, string>();
|
|
@@ -161,14 +161,14 @@ function escapeRegex(s: string): string {
|
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
/** Parse bullet list items from a text block. */
|
|
164
|
-
function parseBullets(text: string): string[] {
|
|
164
|
+
export function parseBullets(text: string): string[] {
|
|
165
165
|
return text.split('\n')
|
|
166
166
|
.map(l => l.replace(/^\s*[-*]\s+/, '').trim())
|
|
167
167
|
.filter(l => l.length > 0 && !l.startsWith('#'));
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
/** Extract key: value from bold-prefixed lines like "**Key:** Value" */
|
|
171
|
-
function extractBoldField(text: string, key: string): string | null {
|
|
171
|
+
export function extractBoldField(text: string, key: string): string | null {
|
|
172
172
|
const regex = new RegExp(`^\\*\\*${escapeRegex(key)}:\\*\\*\\s*(.+)$`, 'm');
|
|
173
173
|
const match = regex.exec(text);
|
|
174
174
|
return match ? match[1].trim() : null;
|
|
@@ -548,7 +548,7 @@ export function parseRequirementCounts(content: string | null): RequirementCount
|
|
|
548
548
|
for (const section of sections) {
|
|
549
549
|
const text = extractSection(content, section.heading, 2);
|
|
550
550
|
if (!text) continue;
|
|
551
|
-
const matches = text.match(/^###\s+
|
|
551
|
+
const matches = text.match(/^###\s+[A-Z][\w-]*\d+\s+—/gm);
|
|
552
552
|
counts[section.key] = matches ? matches.length : 0;
|
|
553
553
|
}
|
|
554
554
|
|
|
@@ -22,8 +22,10 @@ import type {
|
|
|
22
22
|
ExtensionAPI,
|
|
23
23
|
ExtensionContext,
|
|
24
24
|
} from "@mariozechner/pi-coding-agent";
|
|
25
|
+
import { createBashTool } from "@mariozechner/pi-coding-agent";
|
|
25
26
|
|
|
26
27
|
import { registerGSDCommand } from "./commands.js";
|
|
28
|
+
import { registerWorktreeCommand, getWorktreeOriginalCwd, getActiveWorktreeName } from "./worktree-command.js";
|
|
27
29
|
import { saveFile, formatContinue, loadFile, parseContinue, parseSummary } from "./files.js";
|
|
28
30
|
import { loadPrompt } from "./prompt-loader.js";
|
|
29
31
|
import { deriveState } from "./state.js";
|
|
@@ -59,6 +61,16 @@ const GSD_LOGO_LINES = [
|
|
|
59
61
|
|
|
60
62
|
export default function (pi: ExtensionAPI) {
|
|
61
63
|
registerGSDCommand(pi);
|
|
64
|
+
registerWorktreeCommand(pi);
|
|
65
|
+
|
|
66
|
+
// ── Dynamic-cwd bash tool ──────────────────────────────────────────────
|
|
67
|
+
// The built-in bash tool captures cwd at startup. This replacement uses
|
|
68
|
+
// a spawnHook to read process.cwd() dynamically so that process.chdir()
|
|
69
|
+
// (used by /worktree switch) propagates to shell commands.
|
|
70
|
+
const dynamicBash = createBashTool(process.cwd(), {
|
|
71
|
+
spawnHook: (ctx) => ({ ...ctx, cwd: process.cwd() }),
|
|
72
|
+
});
|
|
73
|
+
pi.registerTool(dynamicBash as any);
|
|
62
74
|
|
|
63
75
|
// ── session_start: render branded GSD header ───────────────────────────
|
|
64
76
|
pi.on("session_start", async (_event, ctx) => {
|
|
@@ -131,8 +143,31 @@ export default function (pi: ExtensionAPI) {
|
|
|
131
143
|
|
|
132
144
|
const injection = await buildGuidedExecuteContextInjection(event.prompt, process.cwd());
|
|
133
145
|
|
|
146
|
+
// Worktree context — override the static CWD in the system prompt
|
|
147
|
+
let worktreeBlock = "";
|
|
148
|
+
const worktreeName = getActiveWorktreeName();
|
|
149
|
+
const worktreeMainCwd = getWorktreeOriginalCwd();
|
|
150
|
+
if (worktreeName && worktreeMainCwd) {
|
|
151
|
+
worktreeBlock = [
|
|
152
|
+
"",
|
|
153
|
+
"",
|
|
154
|
+
"[WORKTREE CONTEXT — OVERRIDES CURRENT WORKING DIRECTORY ABOVE]",
|
|
155
|
+
`IMPORTANT: Ignore the "Current working directory" shown earlier in this prompt.`,
|
|
156
|
+
`The actual current working directory is: ${process.cwd()}`,
|
|
157
|
+
"",
|
|
158
|
+
`You are working inside a GSD worktree.`,
|
|
159
|
+
`- Worktree name: ${worktreeName}`,
|
|
160
|
+
`- Worktree path (this is the real cwd): ${process.cwd()}`,
|
|
161
|
+
`- Main project: ${worktreeMainCwd}`,
|
|
162
|
+
`- Branch: worktree/${worktreeName}`,
|
|
163
|
+
"",
|
|
164
|
+
"All file operations, bash commands, and GSD state resolve against the worktree path above.",
|
|
165
|
+
"Use /worktree merge to merge changes back. Use /worktree return to switch back to the main tree.",
|
|
166
|
+
].join("\n");
|
|
167
|
+
}
|
|
168
|
+
|
|
134
169
|
return {
|
|
135
|
-
systemPrompt: `${event.systemPrompt}\n\n[SYSTEM CONTEXT — GSD]\n\n${systemContent}${preferenceBlock}${newSkillsBlock}`,
|
|
170
|
+
systemPrompt: `${event.systemPrompt}\n\n[SYSTEM CONTEXT — GSD]\n\n${systemContent}${preferenceBlock}${newSkillsBlock}${worktreeBlock}`,
|
|
136
171
|
...(injection
|
|
137
172
|
? {
|
|
138
173
|
message: {
|