gsd-opencode 1.30.0 → 1.33.1
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-debugger.md +0 -1
- package/agents/gsd-doc-verifier.md +207 -0
- package/agents/gsd-doc-writer.md +608 -0
- package/agents/gsd-executor.md +22 -1
- package/agents/gsd-phase-researcher.md +41 -0
- package/agents/gsd-plan-checker.md +82 -0
- package/agents/gsd-planner.md +123 -194
- package/agents/gsd-security-auditor.md +129 -0
- package/agents/gsd-ui-auditor.md +40 -0
- package/agents/gsd-user-profiler.md +2 -2
- package/agents/gsd-verifier.md +84 -18
- package/commands/gsd/gsd-add-backlog.md +1 -1
- package/commands/gsd/gsd-analyze-dependencies.md +34 -0
- package/commands/gsd/gsd-autonomous.md +6 -2
- package/commands/gsd/gsd-cleanup.md +5 -0
- package/commands/gsd/gsd-debug.md +24 -21
- package/commands/gsd/gsd-discuss-phase.md +7 -2
- package/commands/gsd/gsd-docs-update.md +48 -0
- package/commands/gsd/gsd-execute-phase.md +4 -0
- package/commands/gsd/gsd-help.md +2 -0
- package/commands/gsd/gsd-join-discord.md +2 -1
- package/commands/gsd/gsd-manager.md +1 -0
- package/commands/gsd/gsd-new-project.md +4 -0
- package/commands/gsd/gsd-plan-phase.md +5 -0
- package/commands/gsd/gsd-quick.md +5 -3
- package/commands/gsd/gsd-reapply-patches.md +171 -39
- package/commands/gsd/gsd-research-phase.md +2 -12
- package/commands/gsd/gsd-review-backlog.md +1 -0
- package/commands/gsd/gsd-review.md +3 -2
- package/commands/gsd/gsd-secure-phase.md +35 -0
- package/commands/gsd/gsd-set-profile.md +0 -1
- package/commands/gsd/gsd-thread.md +1 -1
- package/commands/gsd/gsd-workstreams.md +7 -2
- package/get-shit-done/bin/gsd-tools.cjs +42 -8
- package/get-shit-done/bin/lib/commands.cjs +68 -14
- package/get-shit-done/bin/lib/config.cjs +18 -10
- package/get-shit-done/bin/lib/core.cjs +383 -80
- package/get-shit-done/bin/lib/docs.cjs +267 -0
- package/get-shit-done/bin/lib/frontmatter.cjs +47 -2
- package/get-shit-done/bin/lib/init.cjs +85 -5
- package/get-shit-done/bin/lib/milestone.cjs +21 -0
- package/get-shit-done/bin/lib/model-profiles.cjs +2 -0
- package/get-shit-done/bin/lib/phase.cjs +232 -189
- package/get-shit-done/bin/lib/profile-output.cjs +97 -1
- package/get-shit-done/bin/lib/roadmap.cjs +137 -113
- package/get-shit-done/bin/lib/schema-detect.cjs +238 -0
- package/get-shit-done/bin/lib/security.cjs +5 -3
- package/get-shit-done/bin/lib/state.cjs +366 -44
- package/get-shit-done/bin/lib/verify.cjs +158 -14
- package/get-shit-done/bin/lib/workstream.cjs +6 -2
- package/get-shit-done/references/agent-contracts.md +79 -0
- package/get-shit-done/references/artifact-types.md +113 -0
- package/get-shit-done/references/context-budget.md +49 -0
- package/get-shit-done/references/continuation-format.md +15 -15
- package/get-shit-done/references/domain-probes.md +125 -0
- package/get-shit-done/references/gate-prompts.md +100 -0
- package/get-shit-done/references/model-profiles.md +2 -2
- package/get-shit-done/references/planner-gap-closure.md +62 -0
- package/get-shit-done/references/planner-reviews.md +39 -0
- package/get-shit-done/references/planner-revision.md +87 -0
- package/get-shit-done/references/planning-config.md +15 -0
- package/get-shit-done/references/revision-loop.md +97 -0
- package/get-shit-done/references/ui-brand.md +2 -2
- package/get-shit-done/references/universal-anti-patterns.md +58 -0
- package/get-shit-done/references/workstream-flag.md +56 -3
- package/get-shit-done/templates/SECURITY.md +61 -0
- package/get-shit-done/templates/VALIDATION.md +3 -3
- package/get-shit-done/templates/claude-md.md +27 -4
- package/get-shit-done/templates/config.json +4 -0
- package/get-shit-done/templates/debug-subagent-prompt.md +2 -6
- package/get-shit-done/templates/planner-subagent-prompt.md +2 -10
- package/get-shit-done/workflows/add-phase.md +2 -2
- package/get-shit-done/workflows/add-todo.md +1 -1
- package/get-shit-done/workflows/analyze-dependencies.md +96 -0
- package/get-shit-done/workflows/audit-milestone.md +8 -12
- package/get-shit-done/workflows/autonomous.md +158 -13
- package/get-shit-done/workflows/check-todos.md +2 -2
- package/get-shit-done/workflows/complete-milestone.md +13 -4
- package/get-shit-done/workflows/diagnose-issues.md +8 -6
- package/get-shit-done/workflows/discovery-phase.md +1 -1
- package/get-shit-done/workflows/discuss-phase-assumptions.md +24 -6
- package/get-shit-done/workflows/discuss-phase-power.md +291 -0
- package/get-shit-done/workflows/discuss-phase.md +153 -20
- package/get-shit-done/workflows/docs-update.md +1093 -0
- package/get-shit-done/workflows/execute-phase.md +362 -66
- package/get-shit-done/workflows/execute-plan.md +1 -1
- package/get-shit-done/workflows/help.md +9 -6
- package/get-shit-done/workflows/insert-phase.md +2 -2
- package/get-shit-done/workflows/manager.md +27 -26
- package/get-shit-done/workflows/map-codebase.md +10 -32
- package/get-shit-done/workflows/new-milestone.md +14 -8
- package/get-shit-done/workflows/new-project.md +48 -25
- package/get-shit-done/workflows/next.md +1 -1
- package/get-shit-done/workflows/note.md +1 -1
- package/get-shit-done/workflows/pause-work.md +73 -10
- package/get-shit-done/workflows/plan-milestone-gaps.md +2 -2
- package/get-shit-done/workflows/plan-phase.md +184 -32
- package/get-shit-done/workflows/progress.md +20 -20
- package/get-shit-done/workflows/quick.md +102 -84
- package/get-shit-done/workflows/research-phase.md +2 -6
- package/get-shit-done/workflows/resume-project.md +4 -4
- package/get-shit-done/workflows/review.md +56 -3
- package/get-shit-done/workflows/secure-phase.md +154 -0
- package/get-shit-done/workflows/settings.md +13 -2
- package/get-shit-done/workflows/ship.md +13 -4
- package/get-shit-done/workflows/transition.md +6 -6
- package/get-shit-done/workflows/ui-phase.md +4 -14
- package/get-shit-done/workflows/ui-review.md +25 -7
- package/get-shit-done/workflows/update.md +165 -16
- package/get-shit-done/workflows/validate-phase.md +1 -11
- package/get-shit-done/workflows/verify-phase.md +127 -6
- package/get-shit-done/workflows/verify-work.md +69 -21
- package/package.json +1 -1
|
@@ -3,10 +3,30 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
6
7
|
const path = require('path');
|
|
8
|
+
const crypto = require('crypto');
|
|
7
9
|
const { execSync, execFileSync, spawnSync } = require('child_process');
|
|
8
10
|
const { MODEL_PROFILES } = require('./model-profiles.cjs');
|
|
9
11
|
|
|
12
|
+
const WORKSTREAM_SESSION_ENV_KEYS = [
|
|
13
|
+
'GSD_SESSION_KEY',
|
|
14
|
+
'CODEX_THREAD_ID',
|
|
15
|
+
'CLAUDE_SESSION_ID',
|
|
16
|
+
'CLAUDE_CODE_SSE_PORT',
|
|
17
|
+
'OPENCODE_SESSION_ID',
|
|
18
|
+
'GEMINI_SESSION_ID',
|
|
19
|
+
'CURSOR_SESSION_ID',
|
|
20
|
+
'WINDSURF_SESSION_ID',
|
|
21
|
+
'TERM_SESSION_ID',
|
|
22
|
+
'WT_SESSION',
|
|
23
|
+
'TMUX_PANE',
|
|
24
|
+
'ZELLIJ_SESSION_NAME',
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
let cachedControllingTtyToken = null;
|
|
28
|
+
let didProbeControllingTtyToken = false;
|
|
29
|
+
|
|
10
30
|
// ─── Path helpers ────────────────────────────────────────────────────────────
|
|
11
31
|
|
|
12
32
|
/** Normalize a relative path to always use forward slashes (cross-platform). */
|
|
@@ -194,30 +214,37 @@ function safeReadFile(filePath) {
|
|
|
194
214
|
}
|
|
195
215
|
}
|
|
196
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Canonical config defaults. Single source of truth — imported by config.cjs and verify.cjs.
|
|
219
|
+
*/
|
|
220
|
+
const CONFIG_DEFAULTS = {
|
|
221
|
+
model_profile: 'balanced',
|
|
222
|
+
commit_docs: true,
|
|
223
|
+
search_gitignored: false,
|
|
224
|
+
branching_strategy: 'none',
|
|
225
|
+
phase_branch_template: 'gsd/phase-{phase}-{slug}',
|
|
226
|
+
milestone_branch_template: 'gsd/{milestone}-{slug}',
|
|
227
|
+
quick_branch_template: null,
|
|
228
|
+
research: true,
|
|
229
|
+
plan_checker: true,
|
|
230
|
+
verifier: true,
|
|
231
|
+
nyquist_validation: true,
|
|
232
|
+
parallelization: true,
|
|
233
|
+
brave_search: false,
|
|
234
|
+
firecrawl: false,
|
|
235
|
+
exa_search: false,
|
|
236
|
+
text_mode: false, // when true, use plain-text numbered lists instead of question menus
|
|
237
|
+
sub_repos: [],
|
|
238
|
+
resolve_model_ids: false, // false: return alias as-is | true: map to full OpenCode model ID | "omit": return '' (runtime uses its default)
|
|
239
|
+
context_window: 200000, // default 200k; set to 1000000 for Opus/Sonnet 4.6 1M models
|
|
240
|
+
phase_naming: 'sequential', // 'sequential' (default, auto-increment) or 'custom' (arbitrary string IDs)
|
|
241
|
+
project_code: null, // optional short prefix for phase dirs (e.g., 'CK' → 'CK-01-foundation')
|
|
242
|
+
subagent_timeout: 300000, // 5 min default; increase for large codebases or slower models (ms)
|
|
243
|
+
};
|
|
244
|
+
|
|
197
245
|
function loadConfig(cwd) {
|
|
198
|
-
const configPath = path.join(cwd, '
|
|
199
|
-
const defaults =
|
|
200
|
-
model_profile: 'balanced',
|
|
201
|
-
commit_docs: true,
|
|
202
|
-
search_gitignored: false,
|
|
203
|
-
branching_strategy: 'none',
|
|
204
|
-
phase_branch_template: 'gsd/phase-{phase}-{slug}',
|
|
205
|
-
milestone_branch_template: 'gsd/{milestone}-{slug}',
|
|
206
|
-
quick_branch_template: null,
|
|
207
|
-
research: true,
|
|
208
|
-
plan_checker: true,
|
|
209
|
-
verifier: true,
|
|
210
|
-
nyquist_validation: true,
|
|
211
|
-
parallelization: true,
|
|
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)
|
|
220
|
-
};
|
|
246
|
+
const configPath = path.join(planningDir(cwd), 'config.json');
|
|
247
|
+
const defaults = CONFIG_DEFAULTS;
|
|
221
248
|
|
|
222
249
|
try {
|
|
223
250
|
const raw = fs.readFileSync(configPath, 'utf-8');
|
|
@@ -264,6 +291,28 @@ function loadConfig(cwd) {
|
|
|
264
291
|
try { fs.writeFileSync(configPath, JSON.stringify(parsed, null, 2), 'utf-8'); } catch {}
|
|
265
292
|
}
|
|
266
293
|
|
|
294
|
+
// Warn about unrecognized top-level keys so users don't silently lose config.
|
|
295
|
+
// Derived from config-set's VALID_CONFIG_KEYS (canonical source) plus internal-only
|
|
296
|
+
// keys that loadConfig handles but config-set doesn't expose. This avoids maintaining
|
|
297
|
+
// a hardcoded duplicate that drifts when new config keys are added.
|
|
298
|
+
const { VALID_CONFIG_KEYS } = require('./config.cjs');
|
|
299
|
+
const KNOWN_TOP_LEVEL = new Set([
|
|
300
|
+
// Extract top-level key names from dot-notation paths (e.g., 'workflow.research' → 'workflow')
|
|
301
|
+
...[...VALID_CONFIG_KEYS].map(k => k.split('.')[0]),
|
|
302
|
+
// Section containers that hold nested sub-keys
|
|
303
|
+
'git', 'workflow', 'planning', 'hooks',
|
|
304
|
+
// Internal keys loadConfig reads but config-set doesn't expose
|
|
305
|
+
'model_overrides', 'agent_skills', 'context_window', 'resolve_model_ids',
|
|
306
|
+
// Deprecated keys (still accepted for migration, not in config-set)
|
|
307
|
+
'depth', 'multiRepo',
|
|
308
|
+
]);
|
|
309
|
+
const unknownKeys = Object.keys(parsed).filter(k => !KNOWN_TOP_LEVEL.has(k));
|
|
310
|
+
if (unknownKeys.length > 0) {
|
|
311
|
+
process.stderr.write(
|
|
312
|
+
`gsd-tools: warning: unknown config key(s) in .planning/config.json: ${unknownKeys.join(', ')} — these will be ignored\n`
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
267
316
|
const get = (key, nested) => {
|
|
268
317
|
if (parsed[key] !== undefined) return parsed[key];
|
|
269
318
|
if (nested && parsed[nested.section] && parsed[nested.section][nested.field] !== undefined) {
|
|
@@ -308,11 +357,44 @@ function loadConfig(cwd) {
|
|
|
308
357
|
resolve_model_ids: get('resolve_model_ids') ?? defaults.resolve_model_ids,
|
|
309
358
|
context_window: get('context_window') ?? defaults.context_window,
|
|
310
359
|
phase_naming: get('phase_naming') ?? defaults.phase_naming,
|
|
360
|
+
project_code: get('project_code') ?? defaults.project_code,
|
|
361
|
+
subagent_timeout: get('subagent_timeout', { section: 'workflow', field: 'subagent_timeout' }) ?? defaults.subagent_timeout,
|
|
311
362
|
model_overrides: parsed.model_overrides || null,
|
|
312
363
|
agent_skills: parsed.agent_skills || {},
|
|
364
|
+
manager: parsed.manager || {},
|
|
365
|
+
response_language: get('response_language') || null,
|
|
313
366
|
};
|
|
314
367
|
} catch {
|
|
315
|
-
|
|
368
|
+
// Fall back to ~/.gsd/defaults.json only for truly pre-project contexts (#1683)
|
|
369
|
+
// If .planning/ exists, the project is initialized — just missing config.json
|
|
370
|
+
if (fs.existsSync(planningDir(cwd))) {
|
|
371
|
+
return defaults;
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
const home = process.env.GSD_HOME || os.homedir();
|
|
375
|
+
const globalDefaultsPath = path.join(home, '.gsd', 'defaults.json');
|
|
376
|
+
const raw = fs.readFileSync(globalDefaultsPath, 'utf-8');
|
|
377
|
+
const globalDefaults = JSON.parse(raw);
|
|
378
|
+
return {
|
|
379
|
+
...defaults,
|
|
380
|
+
model_profile: globalDefaults.model_profile ?? defaults.model_profile,
|
|
381
|
+
commit_docs: globalDefaults.commit_docs ?? defaults.commit_docs,
|
|
382
|
+
research: globalDefaults.research ?? defaults.research,
|
|
383
|
+
plan_checker: globalDefaults.plan_checker ?? defaults.plan_checker,
|
|
384
|
+
verifier: globalDefaults.verifier ?? defaults.verifier,
|
|
385
|
+
nyquist_validation: globalDefaults.nyquist_validation ?? defaults.nyquist_validation,
|
|
386
|
+
parallelization: globalDefaults.parallelization ?? defaults.parallelization,
|
|
387
|
+
text_mode: globalDefaults.text_mode ?? defaults.text_mode,
|
|
388
|
+
resolve_model_ids: globalDefaults.resolve_model_ids ?? defaults.resolve_model_ids,
|
|
389
|
+
context_window: globalDefaults.context_window ?? defaults.context_window,
|
|
390
|
+
subagent_timeout: globalDefaults.subagent_timeout ?? defaults.subagent_timeout,
|
|
391
|
+
model_overrides: globalDefaults.model_overrides || null,
|
|
392
|
+
agent_skills: globalDefaults.agent_skills || {},
|
|
393
|
+
response_language: globalDefaults.response_language || null,
|
|
394
|
+
};
|
|
395
|
+
} catch {
|
|
396
|
+
return defaults;
|
|
397
|
+
}
|
|
316
398
|
}
|
|
317
399
|
}
|
|
318
400
|
|
|
@@ -358,20 +440,44 @@ function normalizeMd(content) {
|
|
|
358
440
|
const lines = text.split('\n');
|
|
359
441
|
const result = [];
|
|
360
442
|
|
|
443
|
+
// Pre-compute fence state in a single O(n) pass instead of O(n^2) per-line scanning
|
|
444
|
+
const fenceRegex = /^```/;
|
|
445
|
+
const insideFence = new Array(lines.length);
|
|
446
|
+
let fenceOpen = false;
|
|
447
|
+
for (let i = 0; i < lines.length; i++) {
|
|
448
|
+
if (fenceRegex.test(lines[i].trimEnd())) {
|
|
449
|
+
if (fenceOpen) {
|
|
450
|
+
// This is a closing fence — mark as NOT inside (it's the boundary)
|
|
451
|
+
insideFence[i] = false;
|
|
452
|
+
fenceOpen = false;
|
|
453
|
+
} else {
|
|
454
|
+
// This is an opening fence
|
|
455
|
+
insideFence[i] = false;
|
|
456
|
+
fenceOpen = true;
|
|
457
|
+
}
|
|
458
|
+
} else {
|
|
459
|
+
insideFence[i] = fenceOpen;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
361
463
|
for (let i = 0; i < lines.length; i++) {
|
|
362
464
|
const line = lines[i];
|
|
363
465
|
const prev = i > 0 ? lines[i - 1] : '';
|
|
364
466
|
const prevTrimmed = prev.trimEnd();
|
|
365
467
|
const trimmed = line.trimEnd();
|
|
468
|
+
const isFenceLine = fenceRegex.test(trimmed);
|
|
366
469
|
|
|
367
470
|
// MD022: Blank line before headings (skip first line and frontmatter delimiters)
|
|
368
471
|
if (/^#{1,6}\s/.test(trimmed) && i > 0 && prevTrimmed !== '' && prevTrimmed !== '---') {
|
|
369
472
|
result.push('');
|
|
370
473
|
}
|
|
371
474
|
|
|
372
|
-
// MD031: Blank line before fenced code blocks
|
|
373
|
-
if (
|
|
374
|
-
|
|
475
|
+
// MD031: Blank line before fenced code blocks (opening fences only)
|
|
476
|
+
if (isFenceLine && i > 0 && prevTrimmed !== '' && !insideFence[i] && (i === 0 || !insideFence[i - 1] || isFenceLine)) {
|
|
477
|
+
// Only add blank before opening fences (not closing ones)
|
|
478
|
+
if (i === 0 || !insideFence[i - 1]) {
|
|
479
|
+
result.push('');
|
|
480
|
+
}
|
|
375
481
|
}
|
|
376
482
|
|
|
377
483
|
// MD032: Blank line before lists (- item, * item, N. item, - [ ] item)
|
|
@@ -392,7 +498,7 @@ function normalizeMd(content) {
|
|
|
392
498
|
}
|
|
393
499
|
|
|
394
500
|
// MD031: Blank line after closing fenced code blocks
|
|
395
|
-
if (/^```\s*$/.test(trimmed) &&
|
|
501
|
+
if (/^```\s*$/.test(trimmed) && i > 0 && insideFence[i - 1] && i < lines.length - 1) {
|
|
396
502
|
const next = lines[i + 1];
|
|
397
503
|
if (next !== undefined && next.trimEnd() !== '') {
|
|
398
504
|
result.push('');
|
|
@@ -422,24 +528,6 @@ function normalizeMd(content) {
|
|
|
422
528
|
return text;
|
|
423
529
|
}
|
|
424
530
|
|
|
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
|
-
|
|
443
531
|
function execGit(cwd, args) {
|
|
444
532
|
const result = spawnSync('git', args, {
|
|
445
533
|
cwd,
|
|
@@ -527,8 +615,8 @@ function withPlanningLock(cwd, fn) {
|
|
|
527
615
|
}
|
|
528
616
|
} catch { continue; }
|
|
529
617
|
|
|
530
|
-
// Wait and retry
|
|
531
|
-
|
|
618
|
+
// Wait and retry (cross-platform, no shell dependency)
|
|
619
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
|
|
532
620
|
continue;
|
|
533
621
|
}
|
|
534
622
|
throw err;
|
|
@@ -540,20 +628,43 @@ function withPlanningLock(cwd, fn) {
|
|
|
540
628
|
}
|
|
541
629
|
|
|
542
630
|
/**
|
|
543
|
-
* Get the .planning directory path, workstream-aware.
|
|
544
|
-
*
|
|
545
|
-
*
|
|
631
|
+
* Get the .planning directory path, project- and workstream-aware.
|
|
632
|
+
*
|
|
633
|
+
* Resolution order:
|
|
634
|
+
* 1. If GSD_PROJECT is set (env var or explicit `project` arg), routes to
|
|
635
|
+
* `.planning/{project}/` — supports multi-project workspaces where several
|
|
636
|
+
* independent projects share a single `.planning/` root directory (e.g.,
|
|
637
|
+
* an Obsidian vault or monorepo knowledge base used as a command center).
|
|
638
|
+
* 2. If GSD_WORKSTREAM is set, routes to `.planning/workstreams/{ws}/`.
|
|
639
|
+
* 3. Otherwise returns `.planning/`.
|
|
640
|
+
*
|
|
641
|
+
* GSD_PROJECT and GSD_WORKSTREAM can be combined:
|
|
642
|
+
* `.planning/{project}/workstreams/{ws}/`
|
|
546
643
|
*
|
|
547
644
|
* @param {string} cwd - project root
|
|
548
645
|
* @param {string} [ws] - explicit workstream name; if omitted, checks GSD_WORKSTREAM env var
|
|
646
|
+
* @param {string} [project] - explicit project name; if omitted, checks GSD_PROJECT env var
|
|
549
647
|
*/
|
|
550
|
-
function planningDir(cwd, ws) {
|
|
648
|
+
function planningDir(cwd, ws, project) {
|
|
649
|
+
if (project === undefined) project = process.env.GSD_PROJECT || null;
|
|
551
650
|
if (ws === undefined) ws = process.env.GSD_WORKSTREAM || null;
|
|
552
|
-
|
|
553
|
-
|
|
651
|
+
|
|
652
|
+
// Reject path separators and traversal components in project/workstream names
|
|
653
|
+
const BAD_SEGMENT = /[/\\]|\.\./;
|
|
654
|
+
if (project && BAD_SEGMENT.test(project)) {
|
|
655
|
+
throw new Error(`GSD_PROJECT contains invalid path characters: ${project}`);
|
|
656
|
+
}
|
|
657
|
+
if (ws && BAD_SEGMENT.test(ws)) {
|
|
658
|
+
throw new Error(`GSD_WORKSTREAM contains invalid path characters: ${ws}`);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
let base = path.join(cwd, '.planning');
|
|
662
|
+
if (project) base = path.join(base, project);
|
|
663
|
+
if (ws) base = path.join(base, 'workstreams', ws);
|
|
664
|
+
return base;
|
|
554
665
|
}
|
|
555
666
|
|
|
556
|
-
/** Always returns the root .planning/ path, ignoring workstreams. For shared resources. */
|
|
667
|
+
/** Always returns the root .planning/ path, ignoring workstreams and projects. For shared resources. */
|
|
557
668
|
function planningRoot(cwd) {
|
|
558
669
|
return path.join(cwd, '.planning');
|
|
559
670
|
}
|
|
@@ -579,35 +690,178 @@ function planningPaths(cwd, ws) {
|
|
|
579
690
|
|
|
580
691
|
// ─── Active Workstream Detection ─────────────────────────────────────────────
|
|
581
692
|
|
|
693
|
+
function sanitizeWorkstreamSessionToken(value) {
|
|
694
|
+
if (value === null || value === undefined) return null;
|
|
695
|
+
const token = String(value).trim().replace(/[^a-zA-Z0-9._-]+/g, '_').replace(/^_+|_+$/g, '');
|
|
696
|
+
return token ? token.slice(0, 160) : null;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function probeControllingTtyToken() {
|
|
700
|
+
if (didProbeControllingTtyToken) return cachedControllingTtyToken;
|
|
701
|
+
didProbeControllingTtyToken = true;
|
|
702
|
+
|
|
703
|
+
// `tty` reads stdin. When stdin is already non-interactive, spawning it only
|
|
704
|
+
// adds avoidable failures on the routing hot path and cannot reveal a stable token.
|
|
705
|
+
if (!(process.stdin && process.stdin.isTTY)) {
|
|
706
|
+
return cachedControllingTtyToken;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
try {
|
|
710
|
+
const ttyPath = execFileSync('tty', [], {
|
|
711
|
+
encoding: 'utf-8',
|
|
712
|
+
stdio: ['inherit', 'pipe', 'ignore'],
|
|
713
|
+
}).trim();
|
|
714
|
+
if (ttyPath && ttyPath !== 'not a tty') {
|
|
715
|
+
const token = sanitizeWorkstreamSessionToken(ttyPath.replace(/^\/dev\//, ''));
|
|
716
|
+
if (token) cachedControllingTtyToken = `tty-${token}`;
|
|
717
|
+
}
|
|
718
|
+
} catch {}
|
|
719
|
+
|
|
720
|
+
return cachedControllingTtyToken;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function getControllingTtyToken() {
|
|
724
|
+
for (const envKey of ['TTY', 'SSH_TTY']) {
|
|
725
|
+
const token = sanitizeWorkstreamSessionToken(process.env[envKey]);
|
|
726
|
+
if (token) return `tty-${token.replace(/^dev_/, '')}`;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
return probeControllingTtyToken();
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Resolve a deterministic session key for workstream-local routing.
|
|
734
|
+
*
|
|
735
|
+
* Order:
|
|
736
|
+
* 1. Explicit runtime/session env vars (`GSD_SESSION_KEY`, `CODEX_THREAD_ID`, etc.)
|
|
737
|
+
* 2. Terminal identity exposed via `TTY` or `SSH_TTY`
|
|
738
|
+
* 3. One best-effort `tty` probe when stdin is interactive
|
|
739
|
+
* 4. `null`, which tells callers to use the legacy shared pointer fallback
|
|
740
|
+
*/
|
|
741
|
+
function getWorkstreamSessionKey() {
|
|
742
|
+
for (const envKey of WORKSTREAM_SESSION_ENV_KEYS) {
|
|
743
|
+
const raw = process.env[envKey];
|
|
744
|
+
const token = sanitizeWorkstreamSessionToken(raw);
|
|
745
|
+
if (token) return `${envKey.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${token}`;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
return getControllingTtyToken();
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function getSessionScopedWorkstreamFile(cwd) {
|
|
752
|
+
const sessionKey = getWorkstreamSessionKey();
|
|
753
|
+
if (!sessionKey) return null;
|
|
754
|
+
|
|
755
|
+
// Use realpathSync.native so the hash is derived from the canonical filesystem
|
|
756
|
+
// path. On Windows, path.resolve returns whatever case the caller supplied,
|
|
757
|
+
// while realpathSync.native returns the case the OS recorded — they differ on
|
|
758
|
+
// case-insensitive NTFS, producing different hashes and different tmpdir slots.
|
|
759
|
+
// Fall back to path.resolve when the directory does not yet exist.
|
|
760
|
+
let planningAbs;
|
|
761
|
+
try {
|
|
762
|
+
planningAbs = fs.realpathSync.native(planningRoot(cwd));
|
|
763
|
+
} catch {
|
|
764
|
+
planningAbs = path.resolve(planningRoot(cwd));
|
|
765
|
+
}
|
|
766
|
+
const projectId = crypto
|
|
767
|
+
.createHash('sha1')
|
|
768
|
+
.update(planningAbs)
|
|
769
|
+
.digest('hex')
|
|
770
|
+
.slice(0, 16);
|
|
771
|
+
|
|
772
|
+
const dirPath = path.join(os.tmpdir(), 'gsd-workstream-sessions', projectId);
|
|
773
|
+
return {
|
|
774
|
+
sessionKey,
|
|
775
|
+
dirPath,
|
|
776
|
+
filePath: path.join(dirPath, sessionKey),
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function clearActiveWorkstreamPointer(filePath, cleanupDirPath) {
|
|
781
|
+
try { fs.unlinkSync(filePath); } catch {}
|
|
782
|
+
|
|
783
|
+
// Session-scoped pointers for a repo share one tmp directory. Only remove it
|
|
784
|
+
// when it is empty so clearing or self-healing one session never deletes siblings.
|
|
785
|
+
// Explicitly check remaining entries rather than relying on rmdirSync throwing
|
|
786
|
+
// ENOTEMPTY — that error is not raised reliably on Windows.
|
|
787
|
+
if (cleanupDirPath) {
|
|
788
|
+
try {
|
|
789
|
+
const remaining = fs.readdirSync(cleanupDirPath);
|
|
790
|
+
if (remaining.length === 0) {
|
|
791
|
+
fs.rmdirSync(cleanupDirPath);
|
|
792
|
+
}
|
|
793
|
+
} catch {}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
582
797
|
/**
|
|
583
|
-
*
|
|
584
|
-
*
|
|
798
|
+
* Pointer files are self-healing: invalid names or deleted-workstream pointers
|
|
799
|
+
* are removed on read so the session falls back to `null` instead of carrying
|
|
800
|
+
* silent stale state forward. Session-scoped callers may also prune an empty
|
|
801
|
+
* per-project tmp directory; shared `.planning/active-workstream` callers do not.
|
|
585
802
|
*/
|
|
586
|
-
function
|
|
587
|
-
const filePath = path.join(planningRoot(cwd), 'active-workstream');
|
|
803
|
+
function readActiveWorkstreamPointer(filePath, cwd, cleanupDirPath = null) {
|
|
588
804
|
try {
|
|
589
805
|
const name = fs.readFileSync(filePath, 'utf-8').trim();
|
|
590
|
-
if (!name || !/^[a-zA-Z0-9_-]+$/.test(name))
|
|
806
|
+
if (!name || !/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
807
|
+
clearActiveWorkstreamPointer(filePath, cleanupDirPath);
|
|
808
|
+
return null;
|
|
809
|
+
}
|
|
591
810
|
const wsDir = path.join(planningRoot(cwd), 'workstreams', name);
|
|
592
|
-
if (!fs.existsSync(wsDir))
|
|
811
|
+
if (!fs.existsSync(wsDir)) {
|
|
812
|
+
clearActiveWorkstreamPointer(filePath, cleanupDirPath);
|
|
813
|
+
return null;
|
|
814
|
+
}
|
|
593
815
|
return name;
|
|
594
816
|
} catch {
|
|
595
817
|
return null;
|
|
596
818
|
}
|
|
597
819
|
}
|
|
598
820
|
|
|
821
|
+
/**
|
|
822
|
+
* Get the active workstream name.
|
|
823
|
+
*
|
|
824
|
+
* Resolution priority:
|
|
825
|
+
* 1. Session-scoped pointer (tmpdir) when the runtime exposes a stable session key
|
|
826
|
+
* 2. Legacy shared `.planning/active-workstream` file when no session key is available
|
|
827
|
+
*
|
|
828
|
+
* The shared file is intentionally ignored when a session key exists so multiple
|
|
829
|
+
* concurrent sessions do not overwrite each other's active workstream.
|
|
830
|
+
*/
|
|
831
|
+
function getActiveWorkstream(cwd) {
|
|
832
|
+
const sessionScoped = getSessionScopedWorkstreamFile(cwd);
|
|
833
|
+
if (sessionScoped) {
|
|
834
|
+
return readActiveWorkstreamPointer(sessionScoped.filePath, cwd, sessionScoped.dirPath);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const sharedFilePath = path.join(planningRoot(cwd), 'active-workstream');
|
|
838
|
+
return readActiveWorkstreamPointer(sharedFilePath, cwd);
|
|
839
|
+
}
|
|
840
|
+
|
|
599
841
|
/**
|
|
600
842
|
* Set the active workstream. Pass null to clear.
|
|
843
|
+
*
|
|
844
|
+
* When a stable session key is available, this updates a tmpdir-backed
|
|
845
|
+
* session-scoped pointer. Otherwise it falls back to the legacy shared
|
|
846
|
+
* `.planning/active-workstream` file for backward compatibility.
|
|
601
847
|
*/
|
|
602
848
|
function setActiveWorkstream(cwd, name) {
|
|
603
|
-
const
|
|
849
|
+
const sessionScoped = getSessionScopedWorkstreamFile(cwd);
|
|
850
|
+
const filePath = sessionScoped
|
|
851
|
+
? sessionScoped.filePath
|
|
852
|
+
: path.join(planningRoot(cwd), 'active-workstream');
|
|
853
|
+
|
|
604
854
|
if (!name) {
|
|
605
|
-
|
|
855
|
+
clearActiveWorkstreamPointer(filePath, sessionScoped ? sessionScoped.dirPath : null);
|
|
606
856
|
return;
|
|
607
857
|
}
|
|
608
858
|
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
609
859
|
throw new Error('Invalid workstream name: must be alphanumeric, hyphens, and underscores only');
|
|
610
860
|
}
|
|
861
|
+
|
|
862
|
+
if (sessionScoped) {
|
|
863
|
+
fs.mkdirSync(sessionScoped.dirPath, { recursive: true });
|
|
864
|
+
}
|
|
611
865
|
fs.writeFileSync(filePath, name + '\n', 'utf-8');
|
|
612
866
|
}
|
|
613
867
|
|
|
@@ -619,8 +873,10 @@ function escapeRegex(value) {
|
|
|
619
873
|
|
|
620
874
|
function normalizePhaseName(phase) {
|
|
621
875
|
const str = String(phase);
|
|
876
|
+
// Strip optional project_code prefix (e.g., 'CK-01' → '01')
|
|
877
|
+
const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/, '');
|
|
622
878
|
// Standard numeric phases: 1, 01, 12A, 12.1
|
|
623
|
-
const match =
|
|
879
|
+
const match = stripped.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
|
|
624
880
|
if (match) {
|
|
625
881
|
const padded = match[1].padStart(2, '0');
|
|
626
882
|
const letter = match[2] ? match[2].toUpperCase() : '';
|
|
@@ -632,8 +888,11 @@ function normalizePhaseName(phase) {
|
|
|
632
888
|
}
|
|
633
889
|
|
|
634
890
|
function comparePhaseNum(a, b) {
|
|
635
|
-
|
|
636
|
-
const
|
|
891
|
+
// Strip optional project_code prefix before comparing (e.g., 'CK-01-name' → '01-name')
|
|
892
|
+
const sa = String(a).replace(/^[A-Z]{1,6}-/, '');
|
|
893
|
+
const sb = String(b).replace(/^[A-Z]{1,6}-/, '');
|
|
894
|
+
const pa = sa.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
|
|
895
|
+
const pb = sb.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
|
|
637
896
|
// If either is non-numeric (custom ID), fall back to string comparison
|
|
638
897
|
if (!pa || !pb) return String(a).localeCompare(String(b));
|
|
639
898
|
const intDiff = parseInt(pa[1], 10) - parseInt(pb[1], 10);
|
|
@@ -660,20 +919,50 @@ function comparePhaseNum(a, b) {
|
|
|
660
919
|
return 0;
|
|
661
920
|
}
|
|
662
921
|
|
|
922
|
+
/**
|
|
923
|
+
* Extract the phase token from a directory name.
|
|
924
|
+
* Supports: '01-name', '1009A-name', '999.6-name', 'CK-01-name', 'PROJ-42-name'.
|
|
925
|
+
* Returns the token portion (e.g. '01', '1009A', '999.6', 'PROJ-42') or the full name if no separator.
|
|
926
|
+
*/
|
|
927
|
+
function extractPhaseToken(dirName) {
|
|
928
|
+
// Try project-code-prefixed numeric: CK-01-name → CK-01, CK-01A.2-name → CK-01A.2
|
|
929
|
+
const codePrefixed = dirName.match(/^([A-Z]{1,6}-\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i);
|
|
930
|
+
if (codePrefixed) return codePrefixed[1];
|
|
931
|
+
// Try plain numeric: 01-name, 1009A-name, 999.6-name
|
|
932
|
+
const numeric = dirName.match(/^(\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i);
|
|
933
|
+
if (numeric) return numeric[1];
|
|
934
|
+
// Custom IDs: PROJ-42-name → everything before the last segment that looks like a name
|
|
935
|
+
const custom = dirName.match(/^([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*)(?:-[a-z]|$)/i);
|
|
936
|
+
if (custom) return custom[1];
|
|
937
|
+
return dirName;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* Check if a directory name's phase token matches the normalized phase exactly.
|
|
942
|
+
* Case-insensitive comparison for the token portion.
|
|
943
|
+
*/
|
|
944
|
+
function phaseTokenMatches(dirName, normalized) {
|
|
945
|
+
const token = extractPhaseToken(dirName);
|
|
946
|
+
if (token.toUpperCase() === normalized.toUpperCase()) return true;
|
|
947
|
+
// Strip optional project_code prefix from dir and retry
|
|
948
|
+
const stripped = dirName.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
|
949
|
+
if (stripped !== dirName) {
|
|
950
|
+
const strippedToken = extractPhaseToken(stripped);
|
|
951
|
+
if (strippedToken.toUpperCase() === normalized.toUpperCase()) return true;
|
|
952
|
+
}
|
|
953
|
+
return false;
|
|
954
|
+
}
|
|
955
|
+
|
|
663
956
|
function searchPhaseInDir(baseDir, relBase, normalized) {
|
|
664
957
|
try {
|
|
665
958
|
const dirs = readSubdirectories(baseDir, true);
|
|
666
|
-
// Match:
|
|
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
|
-
});
|
|
959
|
+
// Match: exact phase token comparison (not prefix matching)
|
|
960
|
+
const match = dirs.find(d => phaseTokenMatches(d, normalized));
|
|
673
961
|
if (!match) return null;
|
|
674
962
|
|
|
675
|
-
// Extract phase number and name — supports
|
|
676
|
-
const dirMatch = match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i)
|
|
963
|
+
// Extract phase number and name — supports numeric (01-name), project-code-prefixed (CK-01-name), and custom (PROJ-42-name)
|
|
964
|
+
const dirMatch = match.match(/^(?:[A-Z]{1,6}-)(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i)
|
|
965
|
+
|| match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i)
|
|
677
966
|
|| match.match(/^([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*)-(.+)/i)
|
|
678
967
|
|| [null, match, null];
|
|
679
968
|
const phaseNumber = dirMatch ? dirMatch[1] : normalized;
|
|
@@ -939,9 +1228,15 @@ function getRoadmapPhaseInternal(cwd, phaseNum) {
|
|
|
939
1228
|
* gsd-tools.cjs lives at <configDir>/get-shit-done/bin/gsd-tools.cjs,
|
|
940
1229
|
* so agents/ is at <configDir>/agents/.
|
|
941
1230
|
*
|
|
1231
|
+
* GSD_AGENTS_DIR env var overrides the default path. Used in tests and for
|
|
1232
|
+
* installs where the agents directory is not co-located with gsd-tools.cjs.
|
|
1233
|
+
*
|
|
942
1234
|
* @returns {string} Absolute path to the agents directory
|
|
943
1235
|
*/
|
|
944
1236
|
function getAgentsDir() {
|
|
1237
|
+
if (process.env.GSD_AGENTS_DIR) {
|
|
1238
|
+
return process.env.GSD_AGENTS_DIR;
|
|
1239
|
+
}
|
|
945
1240
|
// __dirname is get-shit-done/bin/lib/ → go up 3 levels to configDir
|
|
946
1241
|
return path.join(__dirname, '..', '..', '..', 'agents');
|
|
947
1242
|
}
|
|
@@ -950,6 +1245,9 @@ function getAgentsDir() {
|
|
|
950
1245
|
* Check which GSD agents are installed on disk.
|
|
951
1246
|
* Returns an object with installation status and details.
|
|
952
1247
|
*
|
|
1248
|
+
* Recognises both standard format (gsd-planner.md) and Copilot format
|
|
1249
|
+
* (gsd-planner.agent.md). Copilot renames agent files during install (#1512).
|
|
1250
|
+
*
|
|
953
1251
|
* @returns {{ agents_installed: boolean, missing_agents: string[], installed_agents: string[], agents_dir: string }}
|
|
954
1252
|
*/
|
|
955
1253
|
function checkAgentsInstalled() {
|
|
@@ -968,8 +1266,10 @@ function checkAgentsInstalled() {
|
|
|
968
1266
|
}
|
|
969
1267
|
|
|
970
1268
|
for (const agent of expectedAgents) {
|
|
1269
|
+
// Check both .md (standard) and .agent.md (Copilot) file formats.
|
|
971
1270
|
const agentFile = path.join(agentsDir, `${agent}.md`);
|
|
972
|
-
|
|
1271
|
+
const agentFileCopilot = path.join(agentsDir, `${agent}.agent.md`);
|
|
1272
|
+
if (fs.existsSync(agentFile) || fs.existsSync(agentFileCopilot)) {
|
|
973
1273
|
installed.push(agent);
|
|
974
1274
|
} else {
|
|
975
1275
|
missing.push(agent);
|
|
@@ -992,9 +1292,9 @@ function checkAgentsInstalled() {
|
|
|
992
1292
|
* Users can override with model_overrides in config.json for custom/latest models.
|
|
993
1293
|
*/
|
|
994
1294
|
const MODEL_ALIAS_MAP = {
|
|
995
|
-
'opus': 'OpenCode-opus-4-
|
|
996
|
-
'sonnet': 'OpenCode-sonnet-4-
|
|
997
|
-
'haiku': 'OpenCode-haiku-
|
|
1295
|
+
'opus': 'OpenCode-opus-4-6',
|
|
1296
|
+
'sonnet': 'OpenCode-sonnet-4-6',
|
|
1297
|
+
'haiku': 'OpenCode-haiku-4-5',
|
|
998
1298
|
};
|
|
999
1299
|
|
|
1000
1300
|
function resolveModelInternal(cwd, agentType) {
|
|
@@ -1061,7 +1361,7 @@ function pathExistsInternal(cwd, targetPath) {
|
|
|
1061
1361
|
|
|
1062
1362
|
function generateSlugInternal(text) {
|
|
1063
1363
|
if (!text) return null;
|
|
1064
|
-
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
1364
|
+
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').substring(0, 60);
|
|
1065
1365
|
}
|
|
1066
1366
|
|
|
1067
1367
|
function getMilestoneInfo(cwd) {
|
|
@@ -1197,6 +1497,8 @@ module.exports = {
|
|
|
1197
1497
|
normalizePhaseName,
|
|
1198
1498
|
comparePhaseNum,
|
|
1199
1499
|
searchPhaseInDir,
|
|
1500
|
+
extractPhaseToken,
|
|
1501
|
+
phaseTokenMatches,
|
|
1200
1502
|
findPhaseInternal,
|
|
1201
1503
|
getArchivedPhaseDirs,
|
|
1202
1504
|
getRoadmapPhaseInternal,
|
|
@@ -1216,6 +1518,7 @@ module.exports = {
|
|
|
1216
1518
|
detectSubRepos,
|
|
1217
1519
|
reapStaleTempFiles,
|
|
1218
1520
|
MODEL_ALIAS_MAP,
|
|
1521
|
+
CONFIG_DEFAULTS,
|
|
1219
1522
|
planningDir,
|
|
1220
1523
|
planningRoot,
|
|
1221
1524
|
planningPaths,
|