gsd-opencode 1.22.0 → 1.30.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/agents/gsd-advisor-researcher.md +112 -0
- package/agents/gsd-assumptions-analyzer.md +110 -0
- package/agents/gsd-codebase-mapper.md +1 -2
- package/agents/gsd-debugger.md +119 -2
- package/agents/gsd-executor.md +25 -4
- package/agents/gsd-integration-checker.md +1 -2
- package/agents/gsd-nyquist-auditor.md +1 -2
- package/agents/gsd-phase-researcher.md +151 -5
- package/agents/gsd-plan-checker.md +71 -5
- package/agents/gsd-planner.md +50 -4
- package/agents/gsd-project-researcher.md +29 -3
- package/agents/gsd-research-synthesizer.md +1 -2
- package/agents/gsd-roadmapper.md +30 -2
- package/agents/gsd-ui-auditor.md +445 -0
- package/agents/gsd-ui-checker.md +305 -0
- package/agents/gsd-ui-researcher.md +368 -0
- package/agents/gsd-user-profiler.md +173 -0
- package/agents/gsd-verifier.md +124 -4
- package/commands/gsd/gsd-add-backlog.md +76 -0
- package/commands/gsd/gsd-audit-uat.md +24 -0
- package/commands/gsd/gsd-autonomous.md +41 -0
- package/commands/gsd/gsd-debug.md +5 -0
- package/commands/gsd/gsd-discuss-phase.md +10 -36
- package/commands/gsd/gsd-do.md +30 -0
- package/commands/gsd/gsd-execute-phase.md +20 -2
- package/commands/gsd/gsd-fast.md +30 -0
- package/commands/gsd/gsd-forensics.md +56 -0
- package/commands/gsd/gsd-list-workspaces.md +19 -0
- package/commands/gsd/gsd-manager.md +39 -0
- package/commands/gsd/gsd-milestone-summary.md +51 -0
- package/commands/gsd/gsd-new-workspace.md +44 -0
- package/commands/gsd/gsd-next.md +24 -0
- package/commands/gsd/gsd-note.md +34 -0
- package/commands/gsd/gsd-plan-phase.md +3 -1
- package/commands/gsd/gsd-plant-seed.md +28 -0
- package/commands/gsd/gsd-pr-branch.md +25 -0
- package/commands/gsd/gsd-profile-user.md +46 -0
- package/commands/gsd/gsd-quick.md +4 -2
- package/commands/gsd/gsd-reapply-patches.md +10 -6
- package/commands/gsd/gsd-remove-workspace.md +26 -0
- package/commands/gsd/gsd-research-phase.md +5 -0
- package/commands/gsd/gsd-resume-work.md +1 -1
- package/commands/gsd/gsd-review-backlog.md +61 -0
- package/commands/gsd/gsd-review.md +37 -0
- package/commands/gsd/gsd-session-report.md +19 -0
- package/commands/gsd/gsd-set-profile.md +24 -23
- package/commands/gsd/gsd-ship.md +23 -0
- package/commands/gsd/gsd-stats.md +18 -0
- package/commands/gsd/gsd-thread.md +127 -0
- package/commands/gsd/gsd-ui-phase.md +34 -0
- package/commands/gsd/gsd-ui-review.md +32 -0
- package/commands/gsd/gsd-workstreams.md +66 -0
- package/get-shit-done/bin/gsd-tools.cjs +410 -84
- package/get-shit-done/bin/lib/commands.cjs +429 -18
- package/get-shit-done/bin/lib/config.cjs +318 -45
- package/get-shit-done/bin/lib/core.cjs +822 -84
- package/get-shit-done/bin/lib/frontmatter.cjs +78 -41
- package/get-shit-done/bin/lib/init.cjs +836 -104
- package/get-shit-done/bin/lib/milestone.cjs +44 -33
- package/get-shit-done/bin/lib/model-profiles.cjs +68 -0
- package/get-shit-done/bin/lib/phase.cjs +293 -306
- package/get-shit-done/bin/lib/profile-output.cjs +952 -0
- package/get-shit-done/bin/lib/profile-pipeline.cjs +539 -0
- package/get-shit-done/bin/lib/roadmap.cjs +55 -24
- package/get-shit-done/bin/lib/security.cjs +382 -0
- package/get-shit-done/bin/lib/state.cjs +363 -53
- package/get-shit-done/bin/lib/template.cjs +2 -2
- package/get-shit-done/bin/lib/uat.cjs +282 -0
- package/get-shit-done/bin/lib/verify.cjs +104 -36
- package/get-shit-done/bin/lib/workstream.cjs +491 -0
- package/get-shit-done/references/checkpoints.md +12 -10
- package/get-shit-done/references/decimal-phase-calculation.md +2 -3
- package/get-shit-done/references/git-integration.md +47 -0
- package/get-shit-done/references/model-profile-resolution.md +2 -0
- package/get-shit-done/references/model-profiles.md +62 -16
- package/get-shit-done/references/phase-argument-parsing.md +2 -2
- package/get-shit-done/references/planning-config.md +3 -1
- package/get-shit-done/references/user-profiling.md +681 -0
- package/get-shit-done/references/workstream-flag.md +58 -0
- package/get-shit-done/templates/UAT.md +21 -3
- package/get-shit-done/templates/UI-SPEC.md +100 -0
- package/get-shit-done/templates/claude-md.md +122 -0
- package/get-shit-done/templates/config.json +10 -3
- package/get-shit-done/templates/context.md +61 -6
- package/get-shit-done/templates/dev-preferences.md +21 -0
- package/get-shit-done/templates/discussion-log.md +63 -0
- package/get-shit-done/templates/phase-prompt.md +46 -5
- package/get-shit-done/templates/project.md +2 -0
- package/get-shit-done/templates/state.md +2 -2
- package/get-shit-done/templates/user-profile.md +146 -0
- package/get-shit-done/workflows/add-phase.md +2 -2
- package/get-shit-done/workflows/add-tests.md +4 -4
- package/get-shit-done/workflows/add-todo.md +3 -3
- package/get-shit-done/workflows/audit-milestone.md +13 -5
- package/get-shit-done/workflows/audit-uat.md +109 -0
- package/get-shit-done/workflows/autonomous.md +891 -0
- package/get-shit-done/workflows/check-todos.md +2 -2
- package/get-shit-done/workflows/cleanup.md +4 -4
- package/get-shit-done/workflows/complete-milestone.md +9 -6
- package/get-shit-done/workflows/diagnose-issues.md +15 -3
- package/get-shit-done/workflows/discovery-phase.md +2 -2
- package/get-shit-done/workflows/discuss-phase-assumptions.md +653 -0
- package/get-shit-done/workflows/discuss-phase.md +411 -38
- package/get-shit-done/workflows/do.md +104 -0
- package/get-shit-done/workflows/execute-phase.md +405 -18
- package/get-shit-done/workflows/execute-plan.md +77 -12
- package/get-shit-done/workflows/fast.md +105 -0
- package/get-shit-done/workflows/forensics.md +265 -0
- package/get-shit-done/workflows/health.md +28 -6
- package/get-shit-done/workflows/help.md +124 -7
- package/get-shit-done/workflows/insert-phase.md +2 -2
- package/get-shit-done/workflows/list-phase-assumptions.md +2 -2
- package/get-shit-done/workflows/list-workspaces.md +56 -0
- package/get-shit-done/workflows/manager.md +362 -0
- package/get-shit-done/workflows/map-codebase.md +74 -13
- package/get-shit-done/workflows/milestone-summary.md +223 -0
- package/get-shit-done/workflows/new-milestone.md +120 -18
- package/get-shit-done/workflows/new-project.md +178 -39
- package/get-shit-done/workflows/new-workspace.md +237 -0
- package/get-shit-done/workflows/next.md +97 -0
- package/get-shit-done/workflows/node-repair.md +92 -0
- package/get-shit-done/workflows/note.md +156 -0
- package/get-shit-done/workflows/pause-work.md +62 -8
- package/get-shit-done/workflows/plan-milestone-gaps.md +4 -5
- package/get-shit-done/workflows/plan-phase.md +332 -33
- package/get-shit-done/workflows/plant-seed.md +169 -0
- package/get-shit-done/workflows/pr-branch.md +129 -0
- package/get-shit-done/workflows/profile-user.md +450 -0
- package/get-shit-done/workflows/progress.md +145 -20
- package/get-shit-done/workflows/quick.md +205 -49
- package/get-shit-done/workflows/remove-phase.md +2 -2
- package/get-shit-done/workflows/remove-workspace.md +90 -0
- package/get-shit-done/workflows/research-phase.md +11 -3
- package/get-shit-done/workflows/resume-project.md +35 -16
- package/get-shit-done/workflows/review.md +228 -0
- package/get-shit-done/workflows/session-report.md +146 -0
- package/get-shit-done/workflows/set-profile.md +2 -2
- package/get-shit-done/workflows/settings.md +80 -11
- package/get-shit-done/workflows/ship.md +228 -0
- package/get-shit-done/workflows/stats.md +60 -0
- package/get-shit-done/workflows/transition.md +147 -20
- package/get-shit-done/workflows/ui-phase.md +302 -0
- package/get-shit-done/workflows/ui-review.md +165 -0
- package/get-shit-done/workflows/update.md +108 -25
- package/get-shit-done/workflows/validate-phase.md +15 -8
- package/get-shit-done/workflows/verify-phase.md +16 -5
- package/get-shit-done/workflows/verify-work.md +72 -18
- package/package.json +1 -1
- package/skills/gsd-audit-milestone/SKILL.md +29 -0
- package/skills/gsd-cleanup/SKILL.md +19 -0
- package/skills/gsd-complete-milestone/SKILL.md +131 -0
- package/skills/gsd-discuss-phase/SKILL.md +54 -0
- package/skills/gsd-execute-phase/SKILL.md +49 -0
- package/skills/gsd-plan-phase/SKILL.md +37 -0
- package/skills/gsd-ui-phase/SKILL.md +24 -0
- package/skills/gsd-ui-review/SKILL.md +24 -0
- package/skills/gsd-verify-work/SKILL.md +30 -0
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
|
-
const { execSync } = require('child_process');
|
|
7
|
+
const { execSync, execFileSync, spawnSync } = require('child_process');
|
|
8
|
+
const { MODEL_PROFILES } = require('./model-profiles.cjs');
|
|
8
9
|
|
|
9
10
|
// ─── Path helpers ────────────────────────────────────────────────────────────
|
|
10
11
|
|
|
@@ -13,45 +14,173 @@ function toPosixPath(p) {
|
|
|
13
14
|
return p.split(path.sep).join('/');
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Scan immediate child directories for separate git repos.
|
|
19
|
+
* Returns a sorted array of directory names that have their own `.git`.
|
|
20
|
+
* Excludes hidden directories and node_modules.
|
|
21
|
+
*/
|
|
22
|
+
function detectSubRepos(cwd) {
|
|
23
|
+
const results = [];
|
|
24
|
+
try {
|
|
25
|
+
const entries = fs.readdirSync(cwd, { withFileTypes: true });
|
|
26
|
+
for (const entry of entries) {
|
|
27
|
+
if (!entry.isDirectory()) continue;
|
|
28
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
29
|
+
const gitPath = path.join(cwd, entry.name, '.git');
|
|
30
|
+
try {
|
|
31
|
+
if (fs.existsSync(gitPath)) {
|
|
32
|
+
results.push(entry.name);
|
|
33
|
+
}
|
|
34
|
+
} catch {}
|
|
35
|
+
}
|
|
36
|
+
} catch {}
|
|
37
|
+
return results.sort();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Walk up from `startDir` to find the project root that owns `.planning/`.
|
|
42
|
+
*
|
|
43
|
+
* In multi-repo workspaces, OpenCode may open inside a sub-repo (e.g. `backend/`)
|
|
44
|
+
* instead of the project root. This function prevents `.planning/` from being
|
|
45
|
+
* created inside the sub-repo by locating the nearest ancestor that already has
|
|
46
|
+
* a `.planning/` directory.
|
|
47
|
+
*
|
|
48
|
+
* Detection strategy (checked in order for each ancestor):
|
|
49
|
+
* 1. Parent has `.planning/config.json` with `sub_repos` listing this directory
|
|
50
|
+
* 2. Parent has `.planning/config.json` with `multiRepo: true` (legacy format)
|
|
51
|
+
* 3. Parent has `.planning/` and current dir has its own `.git` (heuristic)
|
|
52
|
+
*
|
|
53
|
+
* Returns `startDir` unchanged when no ancestor `.planning/` is found (first-run
|
|
54
|
+
* or single-repo projects).
|
|
55
|
+
*/
|
|
56
|
+
function findProjectRoot(startDir) {
|
|
57
|
+
const resolved = path.resolve(startDir);
|
|
58
|
+
const root = path.parse(resolved).root;
|
|
59
|
+
const homedir = require('os').homedir();
|
|
60
|
+
|
|
61
|
+
// If startDir already contains .planning/, it IS the project root.
|
|
62
|
+
// Do not walk up to a parent workspace that also has .planning/ (#1362).
|
|
63
|
+
const ownPlanning = path.join(resolved, '.planning');
|
|
64
|
+
if (fs.existsSync(ownPlanning) && fs.statSync(ownPlanning).isDirectory()) {
|
|
65
|
+
return startDir;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Check if startDir or any of its ancestors (up to AND including the
|
|
69
|
+
// candidate project root) contains a .git directory. This handles both
|
|
70
|
+
// `backend/` (direct sub-repo) and `backend/src/modules/` (nested inside),
|
|
71
|
+
// as well as the common case where .git lives at the same level as .planning/.
|
|
72
|
+
function isInsideGitRepo(candidateParent) {
|
|
73
|
+
let d = resolved;
|
|
74
|
+
while (d !== root) {
|
|
75
|
+
if (fs.existsSync(path.join(d, '.git'))) return true;
|
|
76
|
+
if (d === candidateParent) break;
|
|
77
|
+
d = path.dirname(d);
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let dir = resolved;
|
|
83
|
+
while (dir !== root) {
|
|
84
|
+
const parent = path.dirname(dir);
|
|
85
|
+
if (parent === dir) break; // filesystem root
|
|
86
|
+
if (parent === homedir) break; // never go above home
|
|
87
|
+
|
|
88
|
+
const parentPlanning = path.join(parent, '.planning');
|
|
89
|
+
if (fs.existsSync(parentPlanning) && fs.statSync(parentPlanning).isDirectory()) {
|
|
90
|
+
const configPath = path.join(parentPlanning, 'config.json');
|
|
91
|
+
try {
|
|
92
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
93
|
+
const subRepos = config.sub_repos || config.planning?.sub_repos || [];
|
|
94
|
+
|
|
95
|
+
// Check explicit sub_repos list
|
|
96
|
+
if (Array.isArray(subRepos) && subRepos.length > 0) {
|
|
97
|
+
const relPath = path.relative(parent, resolved);
|
|
98
|
+
const topSegment = relPath.split(path.sep)[0];
|
|
99
|
+
if (subRepos.includes(topSegment)) {
|
|
100
|
+
return parent;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Check legacy multiRepo flag
|
|
105
|
+
if (config.multiRepo === true && isInsideGitRepo(parent)) {
|
|
106
|
+
return parent;
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// config.json missing or malformed — fall back to .git heuristic
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Heuristic: parent has .planning/ and we're inside a git repo
|
|
113
|
+
if (isInsideGitRepo(parent)) {
|
|
114
|
+
return parent;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
dir = parent;
|
|
118
|
+
}
|
|
119
|
+
return startDir;
|
|
120
|
+
}
|
|
32
121
|
|
|
33
122
|
// ─── Output helpers ───────────────────────────────────────────────────────────
|
|
34
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Remove stale gsd-* temp files/dirs older than maxAgeMs (default: 5 minutes).
|
|
126
|
+
* Runs opportunistically before each new temp file write to prevent unbounded accumulation.
|
|
127
|
+
* @param {string} prefix - filename prefix to match (e.g., 'gsd-')
|
|
128
|
+
* @param {object} opts
|
|
129
|
+
* @param {number} opts.maxAgeMs - max age in ms before removal (default: 5 min)
|
|
130
|
+
* @param {boolean} opts.dirsOnly - if true, only remove directories (default: false)
|
|
131
|
+
*/
|
|
132
|
+
function reapStaleTempFiles(prefix = 'gsd-', { maxAgeMs = 5 * 60 * 1000, dirsOnly = false } = {}) {
|
|
133
|
+
try {
|
|
134
|
+
const tmpDir = require('os').tmpdir();
|
|
135
|
+
const now = Date.now();
|
|
136
|
+
const entries = fs.readdirSync(tmpDir);
|
|
137
|
+
for (const entry of entries) {
|
|
138
|
+
if (!entry.startsWith(prefix)) continue;
|
|
139
|
+
const fullPath = path.join(tmpDir, entry);
|
|
140
|
+
try {
|
|
141
|
+
const stat = fs.statSync(fullPath);
|
|
142
|
+
if (now - stat.mtimeMs > maxAgeMs) {
|
|
143
|
+
if (stat.isDirectory()) {
|
|
144
|
+
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
145
|
+
} else if (!dirsOnly) {
|
|
146
|
+
fs.unlinkSync(fullPath);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
// File may have been removed between readdir and stat — ignore
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} catch {
|
|
154
|
+
// Non-critical — don't let cleanup failures break output
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
35
158
|
function output(result, raw, rawValue) {
|
|
159
|
+
let data;
|
|
36
160
|
if (raw && rawValue !== undefined) {
|
|
37
|
-
|
|
161
|
+
data = String(rawValue);
|
|
38
162
|
} else {
|
|
39
163
|
const json = JSON.stringify(result, null, 2);
|
|
40
164
|
// Large payloads exceed OpenCode's bash tool buffer (~50KB).
|
|
41
165
|
// write to tmpfile and output the path prefixed with @file: so callers can detect it.
|
|
42
166
|
if (json.length > 50000) {
|
|
167
|
+
reapStaleTempFiles();
|
|
43
168
|
const tmpPath = path.join(require('os').tmpdir(), `gsd-${Date.now()}.json`);
|
|
44
169
|
fs.writeFileSync(tmpPath, json, 'utf-8');
|
|
45
|
-
|
|
170
|
+
data = '@file:' + tmpPath;
|
|
46
171
|
} else {
|
|
47
|
-
|
|
172
|
+
data = json;
|
|
48
173
|
}
|
|
49
174
|
}
|
|
50
|
-
process.exit(
|
|
175
|
+
// process.stdout.write() is async when stdout is a pipe — process.exit()
|
|
176
|
+
// can tear down the process before the reader consumes the buffer.
|
|
177
|
+
// fs.writeSync(1, ...) blocks until the kernel accepts the bytes, and
|
|
178
|
+
// skipping process.exit() lets the event loop drain naturally.
|
|
179
|
+
fs.writeSync(1, data);
|
|
51
180
|
}
|
|
52
181
|
|
|
53
182
|
function error(message) {
|
|
54
|
-
|
|
183
|
+
fs.writeSync(2, 'Error: ' + message + '\n');
|
|
55
184
|
process.exit(1);
|
|
56
185
|
}
|
|
57
186
|
|
|
@@ -74,12 +203,20 @@ function loadConfig(cwd) {
|
|
|
74
203
|
branching_strategy: 'none',
|
|
75
204
|
phase_branch_template: 'gsd/phase-{phase}-{slug}',
|
|
76
205
|
milestone_branch_template: 'gsd/{milestone}-{slug}',
|
|
206
|
+
quick_branch_template: null,
|
|
77
207
|
research: true,
|
|
78
208
|
plan_checker: true,
|
|
79
209
|
verifier: true,
|
|
80
210
|
nyquist_validation: true,
|
|
81
211
|
parallelization: true,
|
|
82
212
|
brave_search: false,
|
|
213
|
+
firecrawl: false,
|
|
214
|
+
exa_search: false,
|
|
215
|
+
text_mode: false, // when true, use plain-text numbered lists instead of question menus
|
|
216
|
+
sub_repos: [],
|
|
217
|
+
resolve_model_ids: false, // false: return alias as-is | true: map to full OpenCode model ID | "omit": return '' (runtime uses its default)
|
|
218
|
+
context_window: 200000, // default 200k; set to 1000000 for Opus/Sonnet 4.6 1M models
|
|
219
|
+
phase_naming: 'sequential', // 'sequential' (default, auto-increment) or 'custom' (arbitrary string IDs)
|
|
83
220
|
};
|
|
84
221
|
|
|
85
222
|
try {
|
|
@@ -91,6 +228,39 @@ function loadConfig(cwd) {
|
|
|
91
228
|
const depthToGranularity = { quick: 'coarse', standard: 'standard', comprehensive: 'fine' };
|
|
92
229
|
parsed.granularity = depthToGranularity[parsed.depth] || parsed.depth;
|
|
93
230
|
delete parsed.depth;
|
|
231
|
+
try { fs.writeFileSync(configPath, JSON.stringify(parsed, null, 2), 'utf-8'); } catch { /* intentionally empty */ }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Auto-detect and sync sub_repos: scan for child directories with .git
|
|
235
|
+
let configDirty = false;
|
|
236
|
+
|
|
237
|
+
// Migrate legacy "multiRepo: true" boolean → sub_repos array
|
|
238
|
+
if (parsed.multiRepo === true && !parsed.sub_repos && !parsed.planning?.sub_repos) {
|
|
239
|
+
const detected = detectSubRepos(cwd);
|
|
240
|
+
if (detected.length > 0) {
|
|
241
|
+
parsed.sub_repos = detected;
|
|
242
|
+
if (!parsed.planning) parsed.planning = {};
|
|
243
|
+
parsed.planning.commit_docs = false;
|
|
244
|
+
delete parsed.multiRepo;
|
|
245
|
+
configDirty = true;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Keep sub_repos in sync with actual filesystem
|
|
250
|
+
const currentSubRepos = parsed.sub_repos || parsed.planning?.sub_repos || [];
|
|
251
|
+
if (Array.isArray(currentSubRepos) && currentSubRepos.length > 0) {
|
|
252
|
+
const detected = detectSubRepos(cwd);
|
|
253
|
+
if (detected.length > 0) {
|
|
254
|
+
const sorted = [...currentSubRepos].sort();
|
|
255
|
+
if (JSON.stringify(sorted) !== JSON.stringify(detected)) {
|
|
256
|
+
parsed.sub_repos = detected;
|
|
257
|
+
configDirty = true;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Persist sub_repos changes (migration or sync)
|
|
263
|
+
if (configDirty) {
|
|
94
264
|
try { fs.writeFileSync(configPath, JSON.stringify(parsed, null, 2), 'utf-8'); } catch {}
|
|
95
265
|
}
|
|
96
266
|
|
|
@@ -111,18 +281,35 @@ function loadConfig(cwd) {
|
|
|
111
281
|
|
|
112
282
|
return {
|
|
113
283
|
model_profile: get('model_profile') ?? defaults.model_profile,
|
|
114
|
-
commit_docs:
|
|
284
|
+
commit_docs: (() => {
|
|
285
|
+
const explicit = get('commit_docs', { section: 'planning', field: 'commit_docs' });
|
|
286
|
+
// If explicitly set in config, respect the user's choice
|
|
287
|
+
if (explicit !== undefined) return explicit;
|
|
288
|
+
// Auto-detection: when no explicit value and .planning/ is gitignored,
|
|
289
|
+
// default to false instead of true
|
|
290
|
+
if (isGitIgnored(cwd, '.planning/')) return false;
|
|
291
|
+
return defaults.commit_docs;
|
|
292
|
+
})(),
|
|
115
293
|
search_gitignored: get('search_gitignored', { section: 'planning', field: 'search_gitignored' }) ?? defaults.search_gitignored,
|
|
116
294
|
branching_strategy: get('branching_strategy', { section: 'git', field: 'branching_strategy' }) ?? defaults.branching_strategy,
|
|
117
295
|
phase_branch_template: get('phase_branch_template', { section: 'git', field: 'phase_branch_template' }) ?? defaults.phase_branch_template,
|
|
118
296
|
milestone_branch_template: get('milestone_branch_template', { section: 'git', field: 'milestone_branch_template' }) ?? defaults.milestone_branch_template,
|
|
297
|
+
quick_branch_template: get('quick_branch_template', { section: 'git', field: 'quick_branch_template' }) ?? defaults.quick_branch_template,
|
|
119
298
|
research: get('research', { section: 'workflow', field: 'research' }) ?? defaults.research,
|
|
120
299
|
plan_checker: get('plan_checker', { section: 'workflow', field: 'plan_check' }) ?? defaults.plan_checker,
|
|
121
300
|
verifier: get('verifier', { section: 'workflow', field: 'verifier' }) ?? defaults.verifier,
|
|
122
301
|
nyquist_validation: get('nyquist_validation', { section: 'workflow', field: 'nyquist_validation' }) ?? defaults.nyquist_validation,
|
|
123
302
|
parallelization,
|
|
124
303
|
brave_search: get('brave_search') ?? defaults.brave_search,
|
|
304
|
+
firecrawl: get('firecrawl') ?? defaults.firecrawl,
|
|
305
|
+
exa_search: get('exa_search') ?? defaults.exa_search,
|
|
306
|
+
text_mode: get('text_mode', { section: 'workflow', field: 'text_mode' }) ?? defaults.text_mode,
|
|
307
|
+
sub_repos: get('sub_repos', { section: 'planning', field: 'sub_repos' }) ?? defaults.sub_repos,
|
|
308
|
+
resolve_model_ids: get('resolve_model_ids') ?? defaults.resolve_model_ids,
|
|
309
|
+
context_window: get('context_window') ?? defaults.context_window,
|
|
310
|
+
phase_naming: get('phase_naming') ?? defaults.phase_naming,
|
|
125
311
|
model_overrides: parsed.model_overrides || null,
|
|
312
|
+
agent_skills: parsed.agent_skills || {},
|
|
126
313
|
};
|
|
127
314
|
} catch {
|
|
128
315
|
return defaults;
|
|
@@ -137,7 +324,9 @@ function isGitIgnored(cwd, targetPath) {
|
|
|
137
324
|
// Without it, git check-ignore returns "not ignored" for tracked files even when
|
|
138
325
|
// .gitignore explicitly lists them — a common source of confusion when .planning/
|
|
139
326
|
// was committed before being added to .gitignore.
|
|
140
|
-
|
|
327
|
+
// Use execFileSync (array args) to prevent shell interpretation of special characters
|
|
328
|
+
// in file paths — avoids command injection via crafted path names.
|
|
329
|
+
execFileSync('git', ['check-ignore', '-q', '--no-index', '--', targetPath], {
|
|
141
330
|
cwd,
|
|
142
331
|
stdio: 'pipe',
|
|
143
332
|
});
|
|
@@ -147,27 +336,281 @@ function isGitIgnored(cwd, targetPath) {
|
|
|
147
336
|
}
|
|
148
337
|
}
|
|
149
338
|
|
|
339
|
+
// ─── Markdown normalization ─────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Normalize markdown to fix common markdownlint violations.
|
|
343
|
+
* Applied at write points so GSD-generated .planning/ files are IDE-friendly.
|
|
344
|
+
*
|
|
345
|
+
* Rules enforced:
|
|
346
|
+
* MD022 — Blank lines around headings
|
|
347
|
+
* MD031 — Blank lines around fenced code blocks
|
|
348
|
+
* MD032 — Blank lines around lists
|
|
349
|
+
* MD012 — No multiple consecutive blank lines (collapsed to 2 max)
|
|
350
|
+
* MD047 — Files end with a single newline
|
|
351
|
+
*/
|
|
352
|
+
function normalizeMd(content) {
|
|
353
|
+
if (!content || typeof content !== 'string') return content;
|
|
354
|
+
|
|
355
|
+
// Normalize line endings to LF for consistent processing
|
|
356
|
+
let text = content.replace(/\r\n/g, '\n');
|
|
357
|
+
|
|
358
|
+
const lines = text.split('\n');
|
|
359
|
+
const result = [];
|
|
360
|
+
|
|
361
|
+
for (let i = 0; i < lines.length; i++) {
|
|
362
|
+
const line = lines[i];
|
|
363
|
+
const prev = i > 0 ? lines[i - 1] : '';
|
|
364
|
+
const prevTrimmed = prev.trimEnd();
|
|
365
|
+
const trimmed = line.trimEnd();
|
|
366
|
+
|
|
367
|
+
// MD022: Blank line before headings (skip first line and frontmatter delimiters)
|
|
368
|
+
if (/^#{1,6}\s/.test(trimmed) && i > 0 && prevTrimmed !== '' && prevTrimmed !== '---') {
|
|
369
|
+
result.push('');
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// MD031: Blank line before fenced code blocks
|
|
373
|
+
if (/^```/.test(trimmed) && i > 0 && prevTrimmed !== '' && !isInsideFencedBlock(lines, i)) {
|
|
374
|
+
result.push('');
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// MD032: Blank line before lists (- item, * item, N. item, - [ ] item)
|
|
378
|
+
if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) && i > 0 &&
|
|
379
|
+
prevTrimmed !== '' && !/^(\s*[-*+]\s|\s*\d+\.\s)/.test(prev) &&
|
|
380
|
+
prevTrimmed !== '---') {
|
|
381
|
+
result.push('');
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
result.push(line);
|
|
385
|
+
|
|
386
|
+
// MD022: Blank line after headings
|
|
387
|
+
if (/^#{1,6}\s/.test(trimmed) && i < lines.length - 1) {
|
|
388
|
+
const next = lines[i + 1];
|
|
389
|
+
if (next !== undefined && next.trimEnd() !== '') {
|
|
390
|
+
result.push('');
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// MD031: Blank line after closing fenced code blocks
|
|
395
|
+
if (/^```\s*$/.test(trimmed) && isClosingFence(lines, i) && i < lines.length - 1) {
|
|
396
|
+
const next = lines[i + 1];
|
|
397
|
+
if (next !== undefined && next.trimEnd() !== '') {
|
|
398
|
+
result.push('');
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// MD032: Blank line after last list item in a block
|
|
403
|
+
if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) && i < lines.length - 1) {
|
|
404
|
+
const next = lines[i + 1];
|
|
405
|
+
if (next !== undefined && next.trimEnd() !== '' &&
|
|
406
|
+
!/^(\s*[-*+]\s|\s*\d+\.\s)/.test(next) &&
|
|
407
|
+
!/^\s/.test(next)) {
|
|
408
|
+
// Only add blank line if next line is not a continuation/indented line
|
|
409
|
+
result.push('');
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
text = result.join('\n');
|
|
415
|
+
|
|
416
|
+
// MD012: Collapse 3+ consecutive blank lines to 2
|
|
417
|
+
text = text.replace(/\n{3,}/g, '\n\n');
|
|
418
|
+
|
|
419
|
+
// MD047: Ensure file ends with exactly one newline
|
|
420
|
+
text = text.replace(/\n*$/, '\n');
|
|
421
|
+
|
|
422
|
+
return text;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Check if line index i is inside an already-open fenced code block */
|
|
426
|
+
function isInsideFencedBlock(lines, i) {
|
|
427
|
+
let fenceCount = 0;
|
|
428
|
+
for (let j = 0; j < i; j++) {
|
|
429
|
+
if (/^```/.test(lines[j].trimEnd())) fenceCount++;
|
|
430
|
+
}
|
|
431
|
+
return fenceCount % 2 === 1;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Check if a ``` line is a closing fence (odd number of fences up to and including this one) */
|
|
435
|
+
function isClosingFence(lines, i) {
|
|
436
|
+
let fenceCount = 0;
|
|
437
|
+
for (let j = 0; j <= i; j++) {
|
|
438
|
+
if (/^```/.test(lines[j].trimEnd())) fenceCount++;
|
|
439
|
+
}
|
|
440
|
+
return fenceCount % 2 === 0;
|
|
441
|
+
}
|
|
442
|
+
|
|
150
443
|
function execGit(cwd, args) {
|
|
444
|
+
const result = spawnSync('git', args, {
|
|
445
|
+
cwd,
|
|
446
|
+
stdio: 'pipe',
|
|
447
|
+
encoding: 'utf-8',
|
|
448
|
+
});
|
|
449
|
+
return {
|
|
450
|
+
exitCode: result.status ?? 1,
|
|
451
|
+
stdout: (result.stdout ?? '').toString().trim(),
|
|
452
|
+
stderr: (result.stderr ?? '').toString().trim(),
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ─── Common path helpers ──────────────────────────────────────────────────────
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Resolve the main worktree root when running inside a git worktree.
|
|
460
|
+
* In a linked worktree, .planning/ lives in the main worktree, not in the linked one.
|
|
461
|
+
* Returns the main worktree path, or cwd if not in a worktree.
|
|
462
|
+
*/
|
|
463
|
+
function resolveWorktreeRoot(cwd) {
|
|
464
|
+
// If the current directory already has its own .planning/, respect it.
|
|
465
|
+
// This handles linked worktrees with independent planning state (e.g., Conductor workspaces).
|
|
466
|
+
if (fs.existsSync(path.join(cwd, '.planning'))) {
|
|
467
|
+
return cwd;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Check if we're in a linked worktree
|
|
471
|
+
const gitDir = execGit(cwd, ['rev-parse', '--git-dir']);
|
|
472
|
+
const commonDir = execGit(cwd, ['rev-parse', '--git-common-dir']);
|
|
473
|
+
|
|
474
|
+
if (gitDir.exitCode !== 0 || commonDir.exitCode !== 0) return cwd;
|
|
475
|
+
|
|
476
|
+
// In a linked worktree, .git is a file pointing to .git/worktrees/<name>
|
|
477
|
+
// and git-common-dir points to the main repo's .git directory
|
|
478
|
+
const gitDirResolved = path.resolve(cwd, gitDir.stdout);
|
|
479
|
+
const commonDirResolved = path.resolve(cwd, commonDir.stdout);
|
|
480
|
+
|
|
481
|
+
if (gitDirResolved !== commonDirResolved) {
|
|
482
|
+
// We're in a linked worktree — resolve main worktree root
|
|
483
|
+
// The common dir is the main repo's .git, so its parent is the main worktree root
|
|
484
|
+
return path.dirname(commonDirResolved);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return cwd;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Acquire a file-based lock for .planning/ writes.
|
|
492
|
+
* Prevents concurrent worktrees from corrupting shared planning files.
|
|
493
|
+
* Lock is auto-released after the callback completes.
|
|
494
|
+
*/
|
|
495
|
+
function withPlanningLock(cwd, fn) {
|
|
496
|
+
const lockPath = path.join(planningDir(cwd), '.lock');
|
|
497
|
+
const lockTimeout = 10000; // 10 seconds
|
|
498
|
+
const retryDelay = 100;
|
|
499
|
+
const start = Date.now();
|
|
500
|
+
|
|
501
|
+
// Ensure .planning/ exists
|
|
502
|
+
try { fs.mkdirSync(planningDir(cwd), { recursive: true }); } catch { /* ok */ }
|
|
503
|
+
|
|
504
|
+
while (Date.now() - start < lockTimeout) {
|
|
505
|
+
try {
|
|
506
|
+
// Atomic create — fails if file exists
|
|
507
|
+
fs.writeFileSync(lockPath, JSON.stringify({
|
|
508
|
+
pid: process.pid,
|
|
509
|
+
cwd,
|
|
510
|
+
acquired: new Date().toISOString(),
|
|
511
|
+
}), { flag: 'wx' });
|
|
512
|
+
|
|
513
|
+
// Lock acquired — run the function
|
|
514
|
+
try {
|
|
515
|
+
return fn();
|
|
516
|
+
} finally {
|
|
517
|
+
try { fs.unlinkSync(lockPath); } catch { /* already released */ }
|
|
518
|
+
}
|
|
519
|
+
} catch (err) {
|
|
520
|
+
if (err.code === 'EEXIST') {
|
|
521
|
+
// Lock exists — check if stale (>30s old)
|
|
522
|
+
try {
|
|
523
|
+
const stat = fs.statSync(lockPath);
|
|
524
|
+
if (Date.now() - stat.mtimeMs > 30000) {
|
|
525
|
+
fs.unlinkSync(lockPath);
|
|
526
|
+
continue; // retry
|
|
527
|
+
}
|
|
528
|
+
} catch { continue; }
|
|
529
|
+
|
|
530
|
+
// Wait and retry
|
|
531
|
+
spawnSync('sleep', ['0.1'], { stdio: 'ignore' });
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
throw err;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
// Timeout — force acquire (stale lock recovery)
|
|
538
|
+
try { fs.unlinkSync(lockPath); } catch { /* ok */ }
|
|
539
|
+
return fn();
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Get the .planning directory path, workstream-aware.
|
|
544
|
+
* When a workstream is active (via explicit ws arg or GSD_WORKSTREAM env var),
|
|
545
|
+
* returns `.planning/workstreams/{ws}/`. Otherwise returns `.planning/`.
|
|
546
|
+
*
|
|
547
|
+
* @param {string} cwd - project root
|
|
548
|
+
* @param {string} [ws] - explicit workstream name; if omitted, checks GSD_WORKSTREAM env var
|
|
549
|
+
*/
|
|
550
|
+
function planningDir(cwd, ws) {
|
|
551
|
+
if (ws === undefined) ws = process.env.GSD_WORKSTREAM || null;
|
|
552
|
+
if (!ws) return path.join(cwd, '.planning');
|
|
553
|
+
return path.join(cwd, '.planning', 'workstreams', ws);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Always returns the root .planning/ path, ignoring workstreams. For shared resources. */
|
|
557
|
+
function planningRoot(cwd) {
|
|
558
|
+
return path.join(cwd, '.planning');
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Get common .planning file paths, workstream-aware.
|
|
563
|
+
* Scoped paths (state, roadmap, phases, requirements) resolve to the active workstream.
|
|
564
|
+
* Shared paths (project, config) always resolve to the root .planning/.
|
|
565
|
+
*/
|
|
566
|
+
function planningPaths(cwd, ws) {
|
|
567
|
+
const base = planningDir(cwd, ws);
|
|
568
|
+
const root = path.join(cwd, '.planning');
|
|
569
|
+
return {
|
|
570
|
+
planning: base,
|
|
571
|
+
state: path.join(base, 'STATE.md'),
|
|
572
|
+
roadmap: path.join(base, 'ROADMAP.md'),
|
|
573
|
+
project: path.join(root, 'PROJECT.md'),
|
|
574
|
+
config: path.join(root, 'config.json'),
|
|
575
|
+
phases: path.join(base, 'phases'),
|
|
576
|
+
requirements: path.join(base, 'REQUIREMENTS.md'),
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ─── Active Workstream Detection ─────────────────────────────────────────────
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Get the active workstream name from .planning/active-workstream file.
|
|
584
|
+
* Returns null if no active workstream or file doesn't exist.
|
|
585
|
+
*/
|
|
586
|
+
function getActiveWorkstream(cwd) {
|
|
587
|
+
const filePath = path.join(planningRoot(cwd), 'active-workstream');
|
|
151
588
|
try {
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
encoding: 'utf-8',
|
|
160
|
-
});
|
|
161
|
-
return { exitCode: 0, stdout: stdout.trim(), stderr: '' };
|
|
162
|
-
} catch (err) {
|
|
163
|
-
return {
|
|
164
|
-
exitCode: err.status ?? 1,
|
|
165
|
-
stdout: (err.stdout ?? '').toString().trim(),
|
|
166
|
-
stderr: (err.stderr ?? '').toString().trim(),
|
|
167
|
-
};
|
|
589
|
+
const name = fs.readFileSync(filePath, 'utf-8').trim();
|
|
590
|
+
if (!name || !/^[a-zA-Z0-9_-]+$/.test(name)) return null;
|
|
591
|
+
const wsDir = path.join(planningRoot(cwd), 'workstreams', name);
|
|
592
|
+
if (!fs.existsSync(wsDir)) return null;
|
|
593
|
+
return name;
|
|
594
|
+
} catch {
|
|
595
|
+
return null;
|
|
168
596
|
}
|
|
169
597
|
}
|
|
170
598
|
|
|
599
|
+
/**
|
|
600
|
+
* Set the active workstream. Pass null to clear.
|
|
601
|
+
*/
|
|
602
|
+
function setActiveWorkstream(cwd, name) {
|
|
603
|
+
const filePath = path.join(planningRoot(cwd), 'active-workstream');
|
|
604
|
+
if (!name) {
|
|
605
|
+
try { fs.unlinkSync(filePath); } catch {}
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
609
|
+
throw new Error('Invalid workstream name: must be alphanumeric, hyphens, and underscores only');
|
|
610
|
+
}
|
|
611
|
+
fs.writeFileSync(filePath, name + '\n', 'utf-8');
|
|
612
|
+
}
|
|
613
|
+
|
|
171
614
|
// ─── Phase utilities ──────────────────────────────────────────────────────────
|
|
172
615
|
|
|
173
616
|
function escapeRegex(value) {
|
|
@@ -175,17 +618,23 @@ function escapeRegex(value) {
|
|
|
175
618
|
}
|
|
176
619
|
|
|
177
620
|
function normalizePhaseName(phase) {
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
621
|
+
const str = String(phase);
|
|
622
|
+
// Standard numeric phases: 1, 01, 12A, 12.1
|
|
623
|
+
const match = str.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
|
|
624
|
+
if (match) {
|
|
625
|
+
const padded = match[1].padStart(2, '0');
|
|
626
|
+
const letter = match[2] ? match[2].toUpperCase() : '';
|
|
627
|
+
const decimal = match[3] || '';
|
|
628
|
+
return padded + letter + decimal;
|
|
629
|
+
}
|
|
630
|
+
// Custom phase IDs (e.g. PROJ-42, AUTH-101): return as-is
|
|
631
|
+
return str;
|
|
184
632
|
}
|
|
185
633
|
|
|
186
634
|
function comparePhaseNum(a, b) {
|
|
187
635
|
const pa = String(a).match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
|
|
188
636
|
const pb = String(b).match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
|
|
637
|
+
// If either is non-numeric (custom ID), fall back to string comparison
|
|
189
638
|
if (!pa || !pb) return String(a).localeCompare(String(b));
|
|
190
639
|
const intDiff = parseInt(pa[1], 10) - parseInt(pb[1], 10);
|
|
191
640
|
if (intDiff !== 0) return intDiff;
|
|
@@ -213,22 +662,26 @@ function comparePhaseNum(a, b) {
|
|
|
213
662
|
|
|
214
663
|
function searchPhaseInDir(baseDir, relBase, normalized) {
|
|
215
664
|
try {
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
const match = dirs.find(d =>
|
|
665
|
+
const dirs = readSubdirectories(baseDir, true);
|
|
666
|
+
// Match: starts with normalized (numeric) OR contains normalized as prefix segment (custom ID)
|
|
667
|
+
const match = dirs.find(d => {
|
|
668
|
+
if (d.startsWith(normalized)) return true;
|
|
669
|
+
// For custom IDs like PROJ-42, match case-insensitively
|
|
670
|
+
if (d.toUpperCase().startsWith(normalized.toUpperCase())) return true;
|
|
671
|
+
return false;
|
|
672
|
+
});
|
|
219
673
|
if (!match) return null;
|
|
220
674
|
|
|
221
|
-
|
|
675
|
+
// Extract phase number and name — supports both numeric (01-name) and custom (PROJ-42-name)
|
|
676
|
+
const dirMatch = match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i)
|
|
677
|
+
|| match.match(/^([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*)-(.+)/i)
|
|
678
|
+
|| [null, match, null];
|
|
222
679
|
const phaseNumber = dirMatch ? dirMatch[1] : normalized;
|
|
223
680
|
const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
|
|
224
681
|
const phaseDir = path.join(baseDir, match);
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
const
|
|
228
|
-
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').sort();
|
|
229
|
-
const hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
|
|
230
|
-
const hasContext = phaseFiles.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md');
|
|
231
|
-
const hasVerification = phaseFiles.some(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md');
|
|
682
|
+
const { plans: unsortedPlans, summaries: unsortedSummaries, hasResearch, hasContext, hasVerification, hasReviews } = getPhaseFileStats(phaseDir);
|
|
683
|
+
const plans = unsortedPlans.sort();
|
|
684
|
+
const summaries = unsortedSummaries.sort();
|
|
232
685
|
|
|
233
686
|
const completedPlanIds = new Set(
|
|
234
687
|
summaries.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', ''))
|
|
@@ -250,6 +703,7 @@ function searchPhaseInDir(baseDir, relBase, normalized) {
|
|
|
250
703
|
has_research: hasResearch,
|
|
251
704
|
has_context: hasContext,
|
|
252
705
|
has_verification: hasVerification,
|
|
706
|
+
has_reviews: hasReviews,
|
|
253
707
|
};
|
|
254
708
|
} catch {
|
|
255
709
|
return null;
|
|
@@ -259,11 +713,12 @@ function searchPhaseInDir(baseDir, relBase, normalized) {
|
|
|
259
713
|
function findPhaseInternal(cwd, phase) {
|
|
260
714
|
if (!phase) return null;
|
|
261
715
|
|
|
262
|
-
const phasesDir = path.join(cwd, '
|
|
716
|
+
const phasesDir = path.join(planningDir(cwd), 'phases');
|
|
263
717
|
const normalized = normalizePhaseName(phase);
|
|
264
718
|
|
|
265
719
|
// Search current phases first
|
|
266
|
-
const
|
|
720
|
+
const relPhasesDir = toPosixPath(path.relative(cwd, phasesDir));
|
|
721
|
+
const current = searchPhaseInDir(phasesDir, relPhasesDir, normalized);
|
|
267
722
|
if (current) return current;
|
|
268
723
|
|
|
269
724
|
// Search archived milestone phases (newest first)
|
|
@@ -288,7 +743,7 @@ function findPhaseInternal(cwd, phase) {
|
|
|
288
743
|
return result;
|
|
289
744
|
}
|
|
290
745
|
}
|
|
291
|
-
} catch {}
|
|
746
|
+
} catch { /* intentionally empty */ }
|
|
292
747
|
|
|
293
748
|
return null;
|
|
294
749
|
}
|
|
@@ -311,8 +766,7 @@ function getArchivedPhaseDirs(cwd) {
|
|
|
311
766
|
for (const archiveName of phaseDirs) {
|
|
312
767
|
const version = archiveName.match(/^(v[\d.]+)-phases$/)[1];
|
|
313
768
|
const archivePath = path.join(milestonesDir, archiveName);
|
|
314
|
-
const
|
|
315
|
-
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
|
|
769
|
+
const dirs = readSubdirectories(archivePath, true);
|
|
316
770
|
|
|
317
771
|
for (const dir of dirs) {
|
|
318
772
|
results.push({
|
|
@@ -323,21 +777,135 @@ function getArchivedPhaseDirs(cwd) {
|
|
|
323
777
|
});
|
|
324
778
|
}
|
|
325
779
|
}
|
|
326
|
-
} catch {}
|
|
780
|
+
} catch { /* intentionally empty */ }
|
|
327
781
|
|
|
328
782
|
return results;
|
|
329
783
|
}
|
|
330
784
|
|
|
785
|
+
// ─── Roadmap milestone scoping ───────────────────────────────────────────────
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Strip shipped milestone content wrapped in <details> blocks.
|
|
789
|
+
* Used to isolate current milestone phases when searching ROADMAP.md
|
|
790
|
+
* for phase headings or checkboxes — prevents matching archived milestone
|
|
791
|
+
* phases that share the same numbers as current milestone phases.
|
|
792
|
+
*/
|
|
793
|
+
function stripShippedMilestones(content) {
|
|
794
|
+
return content.replace(/<details>[\s\S]*?<\/details>/gi, '');
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Extract the current milestone section from ROADMAP.md by positive lookup.
|
|
799
|
+
*
|
|
800
|
+
* Instead of stripping <details> blocks (negative heuristic that breaks if
|
|
801
|
+
* agents wrap the current milestone in <details>), this finds the section
|
|
802
|
+
* matching the current milestone version and returns only that content.
|
|
803
|
+
*
|
|
804
|
+
* Falls back to stripShippedMilestones() if:
|
|
805
|
+
* - cwd is not provided
|
|
806
|
+
* - STATE.md doesn't exist or has no milestone field
|
|
807
|
+
* - Version can't be found in ROADMAP.md
|
|
808
|
+
*
|
|
809
|
+
* @param {string} content - Full ROADMAP.md content
|
|
810
|
+
* @param {string} [cwd] - Working directory for reading STATE.md
|
|
811
|
+
* @returns {string} Content scoped to current milestone
|
|
812
|
+
*/
|
|
813
|
+
function extractCurrentMilestone(content, cwd) {
|
|
814
|
+
if (!cwd) return stripShippedMilestones(content);
|
|
815
|
+
|
|
816
|
+
// 1. Get current milestone version from STATE.md frontmatter
|
|
817
|
+
let version = null;
|
|
818
|
+
try {
|
|
819
|
+
const statePath = path.join(planningDir(cwd), 'STATE.md');
|
|
820
|
+
if (fs.existsSync(statePath)) {
|
|
821
|
+
const stateRaw = fs.readFileSync(statePath, 'utf-8');
|
|
822
|
+
const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m);
|
|
823
|
+
if (milestoneMatch) {
|
|
824
|
+
version = milestoneMatch[1].trim();
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
} catch {}
|
|
828
|
+
|
|
829
|
+
// 2. Fallback: derive version from getMilestoneInfo pattern in ROADMAP.md itself
|
|
830
|
+
if (!version) {
|
|
831
|
+
// Check for 🚧 in-progress marker
|
|
832
|
+
const inProgressMatch = content.match(/🚧\s*\*\*v(\d+\.\d+)\s/);
|
|
833
|
+
if (inProgressMatch) {
|
|
834
|
+
version = 'v' + inProgressMatch[1];
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
if (!version) return stripShippedMilestones(content);
|
|
839
|
+
|
|
840
|
+
// 3. Find the section matching this version
|
|
841
|
+
// Match headings like: ## Roadmap v3.0: Name, ## v3.0 Name, etc.
|
|
842
|
+
const escapedVersion = escapeRegex(version);
|
|
843
|
+
const sectionPattern = new RegExp(
|
|
844
|
+
`(^#{1,3}\\s+.*${escapedVersion}[^\\n]*)`,
|
|
845
|
+
'mi'
|
|
846
|
+
);
|
|
847
|
+
const sectionMatch = content.match(sectionPattern);
|
|
848
|
+
|
|
849
|
+
if (!sectionMatch) return stripShippedMilestones(content);
|
|
850
|
+
|
|
851
|
+
const sectionStart = sectionMatch.index;
|
|
852
|
+
|
|
853
|
+
// Find the end: next milestone heading at same or higher level, or EOF
|
|
854
|
+
// Milestone headings look like: ## v2.0, ## Roadmap v2.0, ## ✅ v1.0, etc.
|
|
855
|
+
const headingLevel = sectionMatch[1].match(/^(#{1,3})\s/)[1].length;
|
|
856
|
+
const restContent = content.slice(sectionStart + sectionMatch[0].length);
|
|
857
|
+
const nextMilestonePattern = new RegExp(
|
|
858
|
+
`^#{1,${headingLevel}}\\s+(?:.*v\\d+\\.\\d+|✅|📋|🚧)`,
|
|
859
|
+
'mi'
|
|
860
|
+
);
|
|
861
|
+
const nextMatch = restContent.match(nextMilestonePattern);
|
|
862
|
+
|
|
863
|
+
let sectionEnd;
|
|
864
|
+
if (nextMatch) {
|
|
865
|
+
sectionEnd = sectionStart + sectionMatch[0].length + nextMatch.index;
|
|
866
|
+
} else {
|
|
867
|
+
sectionEnd = content.length;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Return everything before the current milestone section (non-milestone content
|
|
871
|
+
// like title, overview) plus the current milestone section
|
|
872
|
+
const beforeMilestones = content.slice(0, sectionStart);
|
|
873
|
+
const currentSection = content.slice(sectionStart, sectionEnd);
|
|
874
|
+
|
|
875
|
+
// Also include any content before the first milestone heading (title, overview, etc.)
|
|
876
|
+
// but strip any <details> blocks in it (these are definitely shipped)
|
|
877
|
+
const preamble = beforeMilestones.replace(/<details>[\s\S]*?<\/details>/gi, '');
|
|
878
|
+
|
|
879
|
+
return preamble + currentSection;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Replace a pattern only in the current milestone section of ROADMAP.md
|
|
884
|
+
* (everything after the last </details> close tag). Used for write operations
|
|
885
|
+
* that must not accidentally modify archived milestone checkboxes/tables.
|
|
886
|
+
*/
|
|
887
|
+
function replaceInCurrentMilestone(content, pattern, replacement) {
|
|
888
|
+
const lastDetailsClose = content.lastIndexOf('</details>');
|
|
889
|
+
if (lastDetailsClose === -1) {
|
|
890
|
+
return content.replace(pattern, replacement);
|
|
891
|
+
}
|
|
892
|
+
const offset = lastDetailsClose + '</details>'.length;
|
|
893
|
+
const before = content.slice(0, offset);
|
|
894
|
+
const after = content.slice(offset);
|
|
895
|
+
return before + after.replace(pattern, replacement);
|
|
896
|
+
}
|
|
897
|
+
|
|
331
898
|
// ─── Roadmap & model utilities ────────────────────────────────────────────────
|
|
332
899
|
|
|
333
900
|
function getRoadmapPhaseInternal(cwd, phaseNum) {
|
|
334
901
|
if (!phaseNum) return null;
|
|
335
|
-
const roadmapPath = path.join(cwd, '
|
|
902
|
+
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
|
|
336
903
|
if (!fs.existsSync(roadmapPath)) return null;
|
|
337
904
|
|
|
338
905
|
try {
|
|
339
|
-
const content = fs.readFileSync(roadmapPath, 'utf-8');
|
|
906
|
+
const content = extractCurrentMilestone(fs.readFileSync(roadmapPath, 'utf-8'), cwd);
|
|
340
907
|
const escapedPhase = escapeRegex(phaseNum.toString());
|
|
908
|
+
// Match both numeric (Phase 1:) and custom (Phase PROJ-42:) headers
|
|
341
909
|
const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`, 'i');
|
|
342
910
|
const headerMatch = content.match(phasePattern);
|
|
343
911
|
if (!headerMatch) return null;
|
|
@@ -345,11 +913,11 @@ function getRoadmapPhaseInternal(cwd, phaseNum) {
|
|
|
345
913
|
const phaseName = headerMatch[1].trim();
|
|
346
914
|
const headerIndex = headerMatch.index;
|
|
347
915
|
const restOfContent = content.slice(headerIndex);
|
|
348
|
-
const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+Phase\s
|
|
916
|
+
const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+Phase\s+[\w]/i);
|
|
349
917
|
const sectionEnd = nextHeaderMatch ? headerIndex + nextHeaderMatch.index : content.length;
|
|
350
918
|
const section = content.slice(headerIndex, sectionEnd).trim();
|
|
351
919
|
|
|
352
|
-
const goalMatch = section.match(/\*\*Goal
|
|
920
|
+
const goalMatch = section.match(/\*\*Goal(?:\*\*:|\*?\*?:\*\*)\s*([^\n]+)/i);
|
|
353
921
|
const goal = goalMatch ? goalMatch[1].trim() : null;
|
|
354
922
|
|
|
355
923
|
return {
|
|
@@ -364,21 +932,119 @@ function getRoadmapPhaseInternal(cwd, phaseNum) {
|
|
|
364
932
|
}
|
|
365
933
|
}
|
|
366
934
|
|
|
935
|
+
// ─── Agent installation validation (#1371) ───────────────────────────────────
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Resolve the agents directory from the GSD install location.
|
|
939
|
+
* gsd-tools.cjs lives at <configDir>/get-shit-done/bin/gsd-tools.cjs,
|
|
940
|
+
* so agents/ is at <configDir>/agents/.
|
|
941
|
+
*
|
|
942
|
+
* @returns {string} Absolute path to the agents directory
|
|
943
|
+
*/
|
|
944
|
+
function getAgentsDir() {
|
|
945
|
+
// __dirname is get-shit-done/bin/lib/ → go up 3 levels to configDir
|
|
946
|
+
return path.join(__dirname, '..', '..', '..', 'agents');
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Check which GSD agents are installed on disk.
|
|
951
|
+
* Returns an object with installation status and details.
|
|
952
|
+
*
|
|
953
|
+
* @returns {{ agents_installed: boolean, missing_agents: string[], installed_agents: string[], agents_dir: string }}
|
|
954
|
+
*/
|
|
955
|
+
function checkAgentsInstalled() {
|
|
956
|
+
const agentsDir = getAgentsDir();
|
|
957
|
+
const expectedAgents = Object.keys(MODEL_PROFILES);
|
|
958
|
+
const installed = [];
|
|
959
|
+
const missing = [];
|
|
960
|
+
|
|
961
|
+
if (!fs.existsSync(agentsDir)) {
|
|
962
|
+
return {
|
|
963
|
+
agents_installed: false,
|
|
964
|
+
missing_agents: expectedAgents,
|
|
965
|
+
installed_agents: [],
|
|
966
|
+
agents_dir: agentsDir,
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
for (const agent of expectedAgents) {
|
|
971
|
+
const agentFile = path.join(agentsDir, `${agent}.md`);
|
|
972
|
+
if (fs.existsSync(agentFile)) {
|
|
973
|
+
installed.push(agent);
|
|
974
|
+
} else {
|
|
975
|
+
missing.push(agent);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
return {
|
|
980
|
+
agents_installed: installed.length > 0 && missing.length === 0,
|
|
981
|
+
missing_agents: missing,
|
|
982
|
+
installed_agents: installed,
|
|
983
|
+
agents_dir: agentsDir,
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// ─── Model alias resolution ───────────────────────────────────────────────────
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* Map short model aliases to full model IDs.
|
|
991
|
+
* Updated each release to match current model versions.
|
|
992
|
+
* Users can override with model_overrides in config.json for custom/latest models.
|
|
993
|
+
*/
|
|
994
|
+
const MODEL_ALIAS_MAP = {
|
|
995
|
+
'opus': 'OpenCode-opus-4-0',
|
|
996
|
+
'sonnet': 'OpenCode-sonnet-4-5',
|
|
997
|
+
'haiku': 'OpenCode-haiku-3-5',
|
|
998
|
+
};
|
|
999
|
+
|
|
367
1000
|
function resolveModelInternal(cwd, agentType) {
|
|
368
1001
|
const config = loadConfig(cwd);
|
|
369
1002
|
|
|
370
|
-
// Check per-agent override first
|
|
1003
|
+
// Check per-agent override first — always respected regardless of resolve_model_ids.
|
|
1004
|
+
// Users who set fully-qualified model IDs (e.g., "openai/gpt-5.4") get exactly that.
|
|
371
1005
|
const override = config.model_overrides?.[agentType];
|
|
372
1006
|
if (override) {
|
|
373
|
-
return override
|
|
1007
|
+
return override;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// resolve_model_ids: "omit" — return empty string so the runtime uses its configured
|
|
1011
|
+
// default model. For non-OpenCode runtimes (OpenCode, Codex, etc.) that don't recognize
|
|
1012
|
+
// OpenCode aliases (opus/sonnet/haiku/inherit). Set automatically during install. See #1156.
|
|
1013
|
+
if (config.resolve_model_ids === 'omit') {
|
|
1014
|
+
return '';
|
|
374
1015
|
}
|
|
375
1016
|
|
|
376
1017
|
// Fall back to profile lookup
|
|
377
|
-
const profile = config.model_profile || 'balanced';
|
|
1018
|
+
const profile = String(config.model_profile || 'balanced').toLowerCase();
|
|
378
1019
|
const agentModels = MODEL_PROFILES[agentType];
|
|
379
1020
|
if (!agentModels) return 'sonnet';
|
|
380
|
-
|
|
381
|
-
|
|
1021
|
+
if (profile === 'inherit') return 'inherit';
|
|
1022
|
+
const alias = agentModels[profile] || agentModels['balanced'] || 'sonnet';
|
|
1023
|
+
|
|
1024
|
+
// resolve_model_ids: true — map alias to full OpenCode model ID
|
|
1025
|
+
// Prevents 404s when the task tool passes aliases directly to the API
|
|
1026
|
+
if (config.resolve_model_ids) {
|
|
1027
|
+
return MODEL_ALIAS_MAP[alias] || alias;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
return alias;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// ─── Summary body helpers ─────────────────────────────────────────────────
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Extract a one-liner from the summary body when it's not in frontmatter.
|
|
1037
|
+
* The summary template defines one-liner as a bold markdown line after the heading:
|
|
1038
|
+
* # Phase X: Name Summary
|
|
1039
|
+
* **[substantive one-liner text]**
|
|
1040
|
+
*/
|
|
1041
|
+
function extractOneLinerFromBody(content) {
|
|
1042
|
+
if (!content) return null;
|
|
1043
|
+
// Strip frontmatter first
|
|
1044
|
+
const body = content.replace(/^---\n[\s\S]*?\n---\n*/, '');
|
|
1045
|
+
// Find the first **...** line after a # heading
|
|
1046
|
+
const match = body.match(/^#[^\n]*\n+\*\*([^*]+)\*\*/m);
|
|
1047
|
+
return match ? match[1].trim() : null;
|
|
382
1048
|
}
|
|
383
1049
|
|
|
384
1050
|
// ─── Misc utilities ───────────────────────────────────────────────────────────
|
|
@@ -400,11 +1066,12 @@ function generateSlugInternal(text) {
|
|
|
400
1066
|
|
|
401
1067
|
function getMilestoneInfo(cwd) {
|
|
402
1068
|
try {
|
|
403
|
-
const roadmap = fs.readFileSync(path.join(cwd, '
|
|
1069
|
+
const roadmap = fs.readFileSync(path.join(planningDir(cwd), 'ROADMAP.md'), 'utf-8');
|
|
404
1070
|
|
|
405
1071
|
// First: check for list-format roadmaps using 🚧 (in-progress) marker
|
|
406
1072
|
// e.g. "- 🚧 **v2.1 Belgium** — Phases 24-28 (in progress)"
|
|
407
|
-
|
|
1073
|
+
// e.g. "- 🚧 **v1.2.1 Tech Debt** — Phases 1-8 (in progress)"
|
|
1074
|
+
const inProgressMatch = roadmap.match(/🚧\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/);
|
|
408
1075
|
if (inProgressMatch) {
|
|
409
1076
|
return {
|
|
410
1077
|
version: 'v' + inProgressMatch[1],
|
|
@@ -413,17 +1080,18 @@ function getMilestoneInfo(cwd) {
|
|
|
413
1080
|
}
|
|
414
1081
|
|
|
415
1082
|
// Second: heading-format roadmaps — strip shipped milestones in <details> blocks
|
|
416
|
-
const cleaned = roadmap
|
|
1083
|
+
const cleaned = stripShippedMilestones(roadmap);
|
|
417
1084
|
// Extract version and name from the same ## heading for consistency
|
|
418
|
-
|
|
1085
|
+
// Supports 2+ segment versions: v1.2, v1.2.1, v2.0.1, etc.
|
|
1086
|
+
const headingMatch = cleaned.match(/## .*v(\d+(?:\.\d+)+)[:\s]+([^\n(]+)/);
|
|
419
1087
|
if (headingMatch) {
|
|
420
1088
|
return {
|
|
421
1089
|
version: 'v' + headingMatch[1],
|
|
422
1090
|
name: headingMatch[2].trim(),
|
|
423
1091
|
};
|
|
424
1092
|
}
|
|
425
|
-
// Fallback: try bare version match
|
|
426
|
-
const versionMatch = cleaned.match(/v(\d
|
|
1093
|
+
// Fallback: try bare version match (greedy — capture longest version string)
|
|
1094
|
+
const versionMatch = cleaned.match(/v(\d+(?:\.\d+)+)/);
|
|
427
1095
|
return {
|
|
428
1096
|
version: versionMatch ? versionMatch[0] : 'v1.0',
|
|
429
1097
|
name: 'milestone',
|
|
@@ -441,13 +1109,14 @@ function getMilestoneInfo(cwd) {
|
|
|
441
1109
|
function getMilestonePhaseFilter(cwd) {
|
|
442
1110
|
const milestonePhaseNums = new Set();
|
|
443
1111
|
try {
|
|
444
|
-
const roadmap = fs.readFileSync(path.join(cwd, '
|
|
445
|
-
|
|
1112
|
+
const roadmap = extractCurrentMilestone(fs.readFileSync(path.join(planningDir(cwd), 'ROADMAP.md'), 'utf-8'), cwd);
|
|
1113
|
+
// Match both numeric phases (Phase 1:) and custom IDs (Phase PROJ-42:)
|
|
1114
|
+
const phasePattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
|
|
446
1115
|
let m;
|
|
447
1116
|
while ((m = phasePattern.exec(roadmap)) !== null) {
|
|
448
1117
|
milestonePhaseNums.add(m[1]);
|
|
449
1118
|
}
|
|
450
|
-
} catch {}
|
|
1119
|
+
} catch { /* intentionally empty */ }
|
|
451
1120
|
|
|
452
1121
|
if (milestonePhaseNums.size === 0) {
|
|
453
1122
|
const passAll = () => true;
|
|
@@ -460,22 +1129,70 @@ function getMilestonePhaseFilter(cwd) {
|
|
|
460
1129
|
);
|
|
461
1130
|
|
|
462
1131
|
function isDirInMilestone(dirName) {
|
|
1132
|
+
// Try numeric match first
|
|
463
1133
|
const m = dirName.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/);
|
|
464
|
-
if (
|
|
465
|
-
|
|
1134
|
+
if (m && normalized.has(m[1].toLowerCase())) return true;
|
|
1135
|
+
// Try custom ID match (e.g. PROJ-42-description → PROJ-42)
|
|
1136
|
+
const customMatch = dirName.match(/^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*)/);
|
|
1137
|
+
if (customMatch && normalized.has(customMatch[1].toLowerCase())) return true;
|
|
1138
|
+
return false;
|
|
466
1139
|
}
|
|
467
1140
|
isDirInMilestone.phaseCount = milestonePhaseNums.size;
|
|
468
1141
|
return isDirInMilestone;
|
|
469
1142
|
}
|
|
470
1143
|
|
|
1144
|
+
// ─── Phase file helpers ──────────────────────────────────────────────────────
|
|
1145
|
+
|
|
1146
|
+
/** Filter a file list to just PLAN.md / *-PLAN.md entries. */
|
|
1147
|
+
function filterPlanFiles(files) {
|
|
1148
|
+
return files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/** Filter a file list to just SUMMARY.md / *-SUMMARY.md entries. */
|
|
1152
|
+
function filterSummaryFiles(files) {
|
|
1153
|
+
return files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/**
|
|
1157
|
+
* read a phase directory and return counts/flags for common file types.
|
|
1158
|
+
* Returns an object with plans[], summaries[], and boolean flags for
|
|
1159
|
+
* research/context/verification files.
|
|
1160
|
+
*/
|
|
1161
|
+
function getPhaseFileStats(phaseDir) {
|
|
1162
|
+
const files = fs.readdirSync(phaseDir);
|
|
1163
|
+
return {
|
|
1164
|
+
plans: filterPlanFiles(files),
|
|
1165
|
+
summaries: filterSummaryFiles(files),
|
|
1166
|
+
hasResearch: files.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'),
|
|
1167
|
+
hasContext: files.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'),
|
|
1168
|
+
hasVerification: files.some(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'),
|
|
1169
|
+
hasReviews: files.some(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'),
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* read immediate child directories from a path.
|
|
1175
|
+
* Returns [] if the path doesn't exist or can't be read.
|
|
1176
|
+
* Pass sort=true to apply comparePhaseNum ordering.
|
|
1177
|
+
*/
|
|
1178
|
+
function readSubdirectories(dirPath, sort = false) {
|
|
1179
|
+
try {
|
|
1180
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
1181
|
+
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
|
1182
|
+
return sort ? dirs.sort((a, b) => comparePhaseNum(a, b)) : dirs;
|
|
1183
|
+
} catch {
|
|
1184
|
+
return [];
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
471
1188
|
module.exports = {
|
|
472
|
-
MODEL_PROFILES,
|
|
473
1189
|
output,
|
|
474
1190
|
error,
|
|
475
1191
|
safeReadFile,
|
|
476
1192
|
loadConfig,
|
|
477
1193
|
isGitIgnored,
|
|
478
1194
|
execGit,
|
|
1195
|
+
normalizeMd,
|
|
479
1196
|
escapeRegex,
|
|
480
1197
|
normalizePhaseName,
|
|
481
1198
|
comparePhaseNum,
|
|
@@ -488,5 +1205,26 @@ module.exports = {
|
|
|
488
1205
|
generateSlugInternal,
|
|
489
1206
|
getMilestoneInfo,
|
|
490
1207
|
getMilestonePhaseFilter,
|
|
1208
|
+
stripShippedMilestones,
|
|
1209
|
+
extractCurrentMilestone,
|
|
1210
|
+
replaceInCurrentMilestone,
|
|
491
1211
|
toPosixPath,
|
|
1212
|
+
extractOneLinerFromBody,
|
|
1213
|
+
resolveWorktreeRoot,
|
|
1214
|
+
withPlanningLock,
|
|
1215
|
+
findProjectRoot,
|
|
1216
|
+
detectSubRepos,
|
|
1217
|
+
reapStaleTempFiles,
|
|
1218
|
+
MODEL_ALIAS_MAP,
|
|
1219
|
+
planningDir,
|
|
1220
|
+
planningRoot,
|
|
1221
|
+
planningPaths,
|
|
1222
|
+
getActiveWorkstream,
|
|
1223
|
+
setActiveWorkstream,
|
|
1224
|
+
filterPlanFiles,
|
|
1225
|
+
filterSummaryFiles,
|
|
1226
|
+
getPhaseFileStats,
|
|
1227
|
+
readSubdirectories,
|
|
1228
|
+
getAgentsDir,
|
|
1229
|
+
checkAgentsInstalled,
|
|
492
1230
|
};
|