gsd-opencode 1.30.0 → 1.33.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.
Files changed (112) hide show
  1. package/agents/gsd-debugger.md +0 -1
  2. package/agents/gsd-doc-verifier.md +207 -0
  3. package/agents/gsd-doc-writer.md +608 -0
  4. package/agents/gsd-executor.md +22 -1
  5. package/agents/gsd-phase-researcher.md +41 -0
  6. package/agents/gsd-plan-checker.md +82 -0
  7. package/agents/gsd-planner.md +123 -194
  8. package/agents/gsd-security-auditor.md +129 -0
  9. package/agents/gsd-ui-auditor.md +40 -0
  10. package/agents/gsd-user-profiler.md +2 -2
  11. package/agents/gsd-verifier.md +84 -18
  12. package/commands/gsd/gsd-add-backlog.md +1 -1
  13. package/commands/gsd/gsd-analyze-dependencies.md +34 -0
  14. package/commands/gsd/gsd-autonomous.md +6 -2
  15. package/commands/gsd/gsd-cleanup.md +5 -0
  16. package/commands/gsd/gsd-debug.md +24 -21
  17. package/commands/gsd/gsd-discuss-phase.md +7 -2
  18. package/commands/gsd/gsd-docs-update.md +48 -0
  19. package/commands/gsd/gsd-execute-phase.md +4 -0
  20. package/commands/gsd/gsd-help.md +2 -0
  21. package/commands/gsd/gsd-join-discord.md +2 -1
  22. package/commands/gsd/gsd-manager.md +1 -0
  23. package/commands/gsd/gsd-new-project.md +4 -0
  24. package/commands/gsd/gsd-plan-phase.md +5 -0
  25. package/commands/gsd/gsd-quick.md +5 -3
  26. package/commands/gsd/gsd-reapply-patches.md +171 -39
  27. package/commands/gsd/gsd-research-phase.md +2 -12
  28. package/commands/gsd/gsd-review-backlog.md +1 -0
  29. package/commands/gsd/gsd-review.md +3 -2
  30. package/commands/gsd/gsd-secure-phase.md +35 -0
  31. package/commands/gsd/gsd-thread.md +1 -1
  32. package/commands/gsd/gsd-workstreams.md +7 -2
  33. package/get-shit-done/bin/gsd-tools.cjs +42 -8
  34. package/get-shit-done/bin/lib/commands.cjs +68 -14
  35. package/get-shit-done/bin/lib/config.cjs +18 -10
  36. package/get-shit-done/bin/lib/core.cjs +383 -80
  37. package/get-shit-done/bin/lib/docs.cjs +267 -0
  38. package/get-shit-done/bin/lib/frontmatter.cjs +47 -2
  39. package/get-shit-done/bin/lib/init.cjs +85 -5
  40. package/get-shit-done/bin/lib/milestone.cjs +21 -0
  41. package/get-shit-done/bin/lib/model-profiles.cjs +2 -0
  42. package/get-shit-done/bin/lib/phase.cjs +232 -189
  43. package/get-shit-done/bin/lib/profile-output.cjs +97 -1
  44. package/get-shit-done/bin/lib/roadmap.cjs +137 -113
  45. package/get-shit-done/bin/lib/schema-detect.cjs +238 -0
  46. package/get-shit-done/bin/lib/security.cjs +5 -3
  47. package/get-shit-done/bin/lib/state.cjs +366 -44
  48. package/get-shit-done/bin/lib/verify.cjs +158 -14
  49. package/get-shit-done/bin/lib/workstream.cjs +6 -2
  50. package/get-shit-done/references/agent-contracts.md +79 -0
  51. package/get-shit-done/references/artifact-types.md +113 -0
  52. package/get-shit-done/references/context-budget.md +49 -0
  53. package/get-shit-done/references/continuation-format.md +15 -15
  54. package/get-shit-done/references/domain-probes.md +125 -0
  55. package/get-shit-done/references/gate-prompts.md +100 -0
  56. package/get-shit-done/references/model-profiles.md +2 -2
  57. package/get-shit-done/references/planner-gap-closure.md +62 -0
  58. package/get-shit-done/references/planner-reviews.md +39 -0
  59. package/get-shit-done/references/planner-revision.md +87 -0
  60. package/get-shit-done/references/planning-config.md +15 -0
  61. package/get-shit-done/references/revision-loop.md +97 -0
  62. package/get-shit-done/references/ui-brand.md +2 -2
  63. package/get-shit-done/references/universal-anti-patterns.md +58 -0
  64. package/get-shit-done/references/workstream-flag.md +56 -3
  65. package/get-shit-done/templates/SECURITY.md +61 -0
  66. package/get-shit-done/templates/VALIDATION.md +3 -3
  67. package/get-shit-done/templates/claude-md.md +27 -4
  68. package/get-shit-done/templates/config.json +4 -0
  69. package/get-shit-done/templates/debug-subagent-prompt.md +2 -6
  70. package/get-shit-done/templates/planner-subagent-prompt.md +2 -10
  71. package/get-shit-done/workflows/add-phase.md +2 -2
  72. package/get-shit-done/workflows/add-todo.md +1 -1
  73. package/get-shit-done/workflows/analyze-dependencies.md +96 -0
  74. package/get-shit-done/workflows/audit-milestone.md +8 -12
  75. package/get-shit-done/workflows/autonomous.md +158 -13
  76. package/get-shit-done/workflows/check-todos.md +2 -2
  77. package/get-shit-done/workflows/complete-milestone.md +13 -4
  78. package/get-shit-done/workflows/diagnose-issues.md +8 -6
  79. package/get-shit-done/workflows/discovery-phase.md +1 -1
  80. package/get-shit-done/workflows/discuss-phase-assumptions.md +22 -4
  81. package/get-shit-done/workflows/discuss-phase-power.md +291 -0
  82. package/get-shit-done/workflows/discuss-phase.md +149 -11
  83. package/get-shit-done/workflows/docs-update.md +1093 -0
  84. package/get-shit-done/workflows/execute-phase.md +362 -66
  85. package/get-shit-done/workflows/execute-plan.md +1 -1
  86. package/get-shit-done/workflows/help.md +9 -6
  87. package/get-shit-done/workflows/insert-phase.md +2 -2
  88. package/get-shit-done/workflows/manager.md +27 -26
  89. package/get-shit-done/workflows/map-codebase.md +10 -32
  90. package/get-shit-done/workflows/new-milestone.md +14 -8
  91. package/get-shit-done/workflows/new-project.md +48 -25
  92. package/get-shit-done/workflows/next.md +1 -1
  93. package/get-shit-done/workflows/note.md +1 -1
  94. package/get-shit-done/workflows/pause-work.md +73 -10
  95. package/get-shit-done/workflows/plan-milestone-gaps.md +2 -2
  96. package/get-shit-done/workflows/plan-phase.md +184 -32
  97. package/get-shit-done/workflows/progress.md +20 -20
  98. package/get-shit-done/workflows/quick.md +102 -84
  99. package/get-shit-done/workflows/research-phase.md +2 -6
  100. package/get-shit-done/workflows/resume-project.md +4 -4
  101. package/get-shit-done/workflows/review.md +56 -3
  102. package/get-shit-done/workflows/secure-phase.md +154 -0
  103. package/get-shit-done/workflows/settings.md +13 -2
  104. package/get-shit-done/workflows/ship.md +13 -4
  105. package/get-shit-done/workflows/transition.md +6 -6
  106. package/get-shit-done/workflows/ui-phase.md +4 -14
  107. package/get-shit-done/workflows/ui-review.md +25 -7
  108. package/get-shit-done/workflows/update.md +165 -16
  109. package/get-shit-done/workflows/validate-phase.md +1 -11
  110. package/get-shit-done/workflows/verify-phase.md +127 -6
  111. package/get-shit-done/workflows/verify-work.md +69 -21
  112. 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, '.planning', 'config.json');
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
- return defaults;
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 (/^```/.test(trimmed) && i > 0 && prevTrimmed !== '' && !isInsideFencedBlock(lines, i)) {
374
- result.push('');
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) && isClosingFence(lines, i) && i < lines.length - 1) {
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
- spawnSync('sleep', ['0.1'], { stdio: 'ignore' });
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
- * When a workstream is active (via explicit ws arg or GSD_WORKSTREAM env var),
545
- * returns `.planning/workstreams/{ws}/`. Otherwise returns `.planning/`.
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
- if (!ws) return path.join(cwd, '.planning');
553
- return path.join(cwd, '.planning', 'workstreams', ws);
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
- * Get the active workstream name from .planning/active-workstream file.
584
- * Returns null if no active workstream or file doesn't exist.
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 getActiveWorkstream(cwd) {
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)) return null;
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)) return null;
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 filePath = path.join(planningRoot(cwd), 'active-workstream');
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
- try { fs.unlinkSync(filePath); } catch {}
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 = str.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
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
- const pa = String(a).match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
636
- const pb = String(b).match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
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: 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
- });
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 both numeric (01-name) and custom (PROJ-42-name)
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
- if (fs.existsSync(agentFile)) {
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-0',
996
- 'sonnet': 'OpenCode-sonnet-4-5',
997
- 'haiku': 'OpenCode-haiku-3-5',
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,