@bhargavvc/sdd-cc 1.30.0 → 1.35.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 (242) hide show
  1. package/README.ja-JP.md +144 -110
  2. package/README.ko-KR.md +143 -107
  3. package/README.md +183 -112
  4. package/README.pt-BR.md +90 -52
  5. package/README.zh-CN.md +141 -101
  6. package/agents/sdd-advisor-researcher.md +23 -0
  7. package/agents/sdd-ai-researcher.md +133 -0
  8. package/agents/sdd-code-fixer.md +516 -0
  9. package/agents/sdd-code-reviewer.md +355 -0
  10. package/agents/sdd-codebase-mapper.md +3 -3
  11. package/agents/sdd-debugger.md +17 -5
  12. package/agents/sdd-doc-verifier.md +201 -0
  13. package/agents/sdd-doc-writer.md +602 -0
  14. package/agents/sdd-domain-researcher.md +153 -0
  15. package/agents/sdd-eval-auditor.md +164 -0
  16. package/agents/sdd-eval-planner.md +154 -0
  17. package/agents/sdd-executor.md +87 -4
  18. package/agents/sdd-framework-selector.md +160 -0
  19. package/agents/sdd-intel-updater.md +314 -0
  20. package/agents/sdd-nyquist-auditor.md +1 -1
  21. package/agents/sdd-phase-researcher.md +71 -4
  22. package/agents/sdd-plan-checker.md +100 -6
  23. package/agents/sdd-planner.md +145 -206
  24. package/agents/sdd-project-researcher.md +25 -2
  25. package/agents/sdd-research-synthesizer.md +3 -3
  26. package/agents/sdd-roadmapper.md +6 -6
  27. package/agents/sdd-security-auditor.md +128 -0
  28. package/agents/sdd-ui-auditor.md +43 -3
  29. package/agents/sdd-ui-checker.md +5 -5
  30. package/agents/sdd-ui-researcher.md +27 -4
  31. package/agents/sdd-user-profiler.md +2 -2
  32. package/agents/sdd-verifier.md +142 -22
  33. package/bin/install.js +2151 -551
  34. package/commands/sdd/add-backlog.md +5 -5
  35. package/commands/sdd/add-tests.md +2 -2
  36. package/commands/sdd/ai-integration-phase.md +36 -0
  37. package/commands/sdd/analyze-dependencies.md +34 -0
  38. package/commands/sdd/audit-fix.md +33 -0
  39. package/commands/sdd/autonomous.md +7 -2
  40. package/commands/sdd/cleanup.md +5 -0
  41. package/commands/sdd/code-review-fix.md +52 -0
  42. package/commands/sdd/code-review.md +55 -0
  43. package/commands/sdd/complete-milestone.md +6 -6
  44. package/commands/sdd/debug.md +22 -9
  45. package/commands/sdd/discuss-phase.md +7 -2
  46. package/commands/sdd/do.md +1 -1
  47. package/commands/sdd/docs-update.md +48 -0
  48. package/commands/sdd/eval-review.md +32 -0
  49. package/commands/sdd/execute-phase.md +4 -0
  50. package/commands/sdd/explore.md +27 -0
  51. package/commands/sdd/fast.md +2 -2
  52. package/commands/sdd/from-sdd2.md +45 -0
  53. package/commands/sdd/help.md +2 -0
  54. package/commands/sdd/import.md +36 -0
  55. package/commands/sdd/intel.md +179 -0
  56. package/commands/sdd/join-discord.md +2 -1
  57. package/commands/sdd/manager.md +1 -0
  58. package/commands/sdd/map-codebase.md +3 -3
  59. package/commands/sdd/new-milestone.md +1 -1
  60. package/commands/sdd/new-project.md +5 -1
  61. package/commands/sdd/new-workspace.md +1 -1
  62. package/commands/sdd/next.md +2 -0
  63. package/commands/sdd/plan-milestone-gaps.md +2 -2
  64. package/commands/sdd/plan-phase.md +6 -1
  65. package/commands/sdd/plant-seed.md +1 -1
  66. package/commands/sdd/profile-user.md +1 -1
  67. package/commands/sdd/quick.md +5 -3
  68. package/commands/sdd/reapply-patches.md +230 -42
  69. package/commands/sdd/research-phase.md +3 -3
  70. package/commands/sdd/review-backlog.md +1 -0
  71. package/commands/sdd/review.md +6 -3
  72. package/commands/sdd/scan.md +26 -0
  73. package/commands/sdd/secure-phase.md +35 -0
  74. package/commands/sdd/ship.md +1 -1
  75. package/commands/sdd/thread.md +5 -5
  76. package/commands/sdd/undo.md +34 -0
  77. package/commands/sdd/verify-work.md +1 -1
  78. package/commands/sdd/workstreams.md +17 -11
  79. package/hooks/dist/sdd-check-update.js +33 -8
  80. package/hooks/dist/sdd-context-monitor.js +17 -8
  81. package/hooks/dist/sdd-phase-boundary.sh +27 -0
  82. package/hooks/dist/sdd-prompt-guard.js +1 -0
  83. package/hooks/dist/sdd-read-guard.js +82 -0
  84. package/hooks/dist/sdd-session-state.sh +33 -0
  85. package/hooks/dist/sdd-statusline.js +137 -15
  86. package/hooks/dist/sdd-validate-commit.sh +47 -0
  87. package/hooks/dist/sdd-workflow-guard.js +4 -4
  88. package/hooks/sdd-check-update.js +139 -0
  89. package/hooks/sdd-context-monitor.js +165 -0
  90. package/hooks/sdd-phase-boundary.sh +27 -0
  91. package/hooks/sdd-prompt-guard.js +97 -0
  92. package/hooks/sdd-read-guard.js +82 -0
  93. package/hooks/sdd-session-state.sh +33 -0
  94. package/hooks/sdd-statusline.js +241 -0
  95. package/hooks/sdd-validate-commit.sh +47 -0
  96. package/hooks/sdd-workflow-guard.js +94 -0
  97. package/package.json +3 -3
  98. package/scripts/build-hooks.js +18 -7
  99. package/scripts/prompt-injection-scan.sh +1 -0
  100. package/scripts/rebrand-gsd-to-sdd.sh +221 -220
  101. package/scripts/run-tests.cjs +5 -1
  102. package/scripts/sync-upstream.sh +1 -1
  103. package/sdd/bin/lib/commands.cjs +79 -17
  104. package/sdd/bin/lib/config.cjs +90 -48
  105. package/sdd/bin/lib/core.cjs +452 -87
  106. package/sdd/bin/lib/docs.cjs +267 -0
  107. package/sdd/bin/lib/frontmatter.cjs +381 -336
  108. package/sdd/bin/lib/init.cjs +110 -16
  109. package/sdd/bin/lib/intel.cjs +660 -0
  110. package/sdd/bin/lib/learnings.cjs +378 -0
  111. package/sdd/bin/lib/milestone.cjs +42 -11
  112. package/sdd/bin/lib/model-profiles.cjs +17 -15
  113. package/sdd/bin/lib/phase.cjs +367 -288
  114. package/sdd/bin/lib/profile-output.cjs +106 -10
  115. package/sdd/bin/lib/roadmap.cjs +146 -115
  116. package/sdd/bin/lib/schema-detect.cjs +238 -0
  117. package/sdd/bin/lib/sdd2-import.cjs +511 -0
  118. package/sdd/bin/lib/security.cjs +124 -3
  119. package/sdd/bin/lib/state.cjs +648 -264
  120. package/sdd/bin/lib/template.cjs +8 -4
  121. package/sdd/bin/lib/verify.cjs +209 -28
  122. package/sdd/bin/lib/workstream.cjs +7 -3
  123. package/sdd/bin/sdd-tools.cjs +184 -12
  124. package/sdd/contexts/dev.md +21 -0
  125. package/sdd/contexts/research.md +22 -0
  126. package/sdd/contexts/review.md +22 -0
  127. package/sdd/references/agent-contracts.md +79 -0
  128. package/sdd/references/ai-evals.md +156 -0
  129. package/sdd/references/ai-frameworks.md +186 -0
  130. package/sdd/references/artifact-types.md +113 -0
  131. package/sdd/references/common-bug-patterns.md +114 -0
  132. package/sdd/references/context-budget.md +49 -0
  133. package/sdd/references/continuation-format.md +25 -25
  134. package/sdd/references/domain-probes.md +125 -0
  135. package/sdd/references/few-shot-examples/plan-checker.md +73 -0
  136. package/sdd/references/few-shot-examples/verifier.md +109 -0
  137. package/sdd/references/gate-prompts.md +100 -0
  138. package/sdd/references/gates.md +70 -0
  139. package/sdd/references/git-integration.md +1 -1
  140. package/sdd/references/ios-scaffold.md +123 -0
  141. package/sdd/references/model-profile-resolution.md +2 -0
  142. package/sdd/references/model-profiles.md +24 -18
  143. package/sdd/references/planner-gap-closure.md +62 -0
  144. package/sdd/references/planner-reviews.md +39 -0
  145. package/sdd/references/planner-revision.md +87 -0
  146. package/sdd/references/planning-config.md +252 -0
  147. package/sdd/references/revision-loop.md +97 -0
  148. package/sdd/references/thinking-models-debug.md +44 -0
  149. package/sdd/references/thinking-models-execution.md +50 -0
  150. package/sdd/references/thinking-models-planning.md +62 -0
  151. package/sdd/references/thinking-models-research.md +50 -0
  152. package/sdd/references/thinking-models-verification.md +55 -0
  153. package/sdd/references/thinking-partner.md +96 -0
  154. package/sdd/references/ui-brand.md +4 -4
  155. package/sdd/references/universal-anti-patterns.md +63 -0
  156. package/sdd/references/verification-overrides.md +227 -0
  157. package/sdd/references/workstream-flag.md +56 -3
  158. package/sdd/templates/AI-SPEC.md +246 -0
  159. package/sdd/templates/DEBUG.md +1 -1
  160. package/sdd/templates/SECURITY.md +61 -0
  161. package/sdd/templates/UAT.md +4 -4
  162. package/sdd/templates/VALIDATION.md +4 -4
  163. package/sdd/templates/claude-md.md +32 -9
  164. package/sdd/templates/config.json +4 -0
  165. package/sdd/templates/debug-subagent-prompt.md +1 -1
  166. package/sdd/templates/dev-preferences.md +1 -1
  167. package/sdd/templates/discovery.md +2 -2
  168. package/sdd/templates/phase-prompt.md +1 -1
  169. package/sdd/templates/planner-subagent-prompt.md +3 -3
  170. package/sdd/templates/project.md +1 -1
  171. package/sdd/templates/research.md +1 -1
  172. package/sdd/templates/state.md +2 -2
  173. package/sdd/workflows/add-phase.md +8 -8
  174. package/sdd/workflows/add-tests.md +12 -9
  175. package/sdd/workflows/add-todo.md +5 -3
  176. package/sdd/workflows/ai-integration-phase.md +284 -0
  177. package/sdd/workflows/analyze-dependencies.md +96 -0
  178. package/sdd/workflows/audit-fix.md +157 -0
  179. package/sdd/workflows/audit-milestone.md +11 -11
  180. package/sdd/workflows/audit-uat.md +2 -2
  181. package/sdd/workflows/autonomous.md +195 -27
  182. package/sdd/workflows/check-todos.md +12 -10
  183. package/sdd/workflows/cleanup.md +2 -0
  184. package/sdd/workflows/code-review-fix.md +497 -0
  185. package/sdd/workflows/code-review.md +515 -0
  186. package/sdd/workflows/complete-milestone.md +56 -22
  187. package/sdd/workflows/diagnose-issues.md +10 -3
  188. package/sdd/workflows/discovery-phase.md +5 -3
  189. package/sdd/workflows/discuss-phase-assumptions.md +24 -6
  190. package/sdd/workflows/discuss-phase-power.md +291 -0
  191. package/sdd/workflows/discuss-phase.md +173 -21
  192. package/sdd/workflows/do.md +23 -21
  193. package/sdd/workflows/docs-update.md +1155 -0
  194. package/sdd/workflows/eval-review.md +155 -0
  195. package/sdd/workflows/execute-phase.md +594 -38
  196. package/sdd/workflows/execute-plan.md +67 -96
  197. package/sdd/workflows/explore.md +139 -0
  198. package/sdd/workflows/fast.md +5 -5
  199. package/sdd/workflows/forensics.md +2 -2
  200. package/sdd/workflows/health.md +4 -4
  201. package/sdd/workflows/help.md +122 -119
  202. package/sdd/workflows/import.md +276 -0
  203. package/sdd/workflows/inbox.md +387 -0
  204. package/sdd/workflows/insert-phase.md +7 -7
  205. package/sdd/workflows/list-phase-assumptions.md +4 -4
  206. package/sdd/workflows/list-workspaces.md +2 -2
  207. package/sdd/workflows/manager.md +35 -32
  208. package/sdd/workflows/map-codebase.md +7 -5
  209. package/sdd/workflows/milestone-summary.md +2 -2
  210. package/sdd/workflows/new-milestone.md +17 -9
  211. package/sdd/workflows/new-project.md +50 -25
  212. package/sdd/workflows/new-workspace.md +7 -5
  213. package/sdd/workflows/next.md +67 -11
  214. package/sdd/workflows/note.md +9 -7
  215. package/sdd/workflows/pause-work.md +75 -12
  216. package/sdd/workflows/plan-milestone-gaps.md +8 -8
  217. package/sdd/workflows/plan-phase.md +294 -42
  218. package/sdd/workflows/plant-seed.md +6 -3
  219. package/sdd/workflows/pr-branch.md +42 -14
  220. package/sdd/workflows/profile-user.md +9 -7
  221. package/sdd/workflows/progress.md +45 -45
  222. package/sdd/workflows/quick.md +195 -47
  223. package/sdd/workflows/remove-phase.md +6 -6
  224. package/sdd/workflows/remove-workspace.md +3 -1
  225. package/sdd/workflows/research-phase.md +2 -2
  226. package/sdd/workflows/resume-project.md +12 -12
  227. package/sdd/workflows/review.md +109 -9
  228. package/sdd/workflows/scan.md +102 -0
  229. package/sdd/workflows/secure-phase.md +166 -0
  230. package/sdd/workflows/session-report.md +2 -2
  231. package/sdd/workflows/settings.md +38 -12
  232. package/sdd/workflows/ship.md +21 -9
  233. package/sdd/workflows/stats.md +1 -1
  234. package/sdd/workflows/transition.md +23 -23
  235. package/sdd/workflows/ui-phase.md +15 -7
  236. package/sdd/workflows/ui-review.md +29 -4
  237. package/sdd/workflows/undo.md +314 -0
  238. package/sdd/workflows/update.md +171 -20
  239. package/sdd/workflows/validate-phase.md +6 -4
  240. package/sdd/workflows/verify-phase.md +210 -6
  241. package/sdd/workflows/verify-work.md +83 -9
  242. package/sdd/commands/sdd/workstreams.md +0 -63
@@ -3,10 +3,40 @@
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
+ 'SDD_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
+
30
+ // Track all .planning/.lock files held by this process so they can be removed
31
+ // on exit. process.on('exit') fires even on process.exit(1), unlike try/finally
32
+ // which is skipped when error() calls process.exit(1) inside a locked region (#1916).
33
+ const _heldPlanningLocks = new Set();
34
+ process.on('exit', () => {
35
+ for (const lockPath of _heldPlanningLocks) {
36
+ try { fs.unlinkSync(lockPath); } catch { /* already gone */ }
37
+ }
38
+ });
39
+
10
40
  // ─── Path helpers ────────────────────────────────────────────────────────────
11
41
 
12
42
  /** Normalize a relative path to always use forward slashes (cross-platform). */
@@ -194,30 +224,38 @@ function safeReadFile(filePath) {
194
224
  }
195
225
  }
196
226
 
227
+ /**
228
+ * Canonical config defaults. Single source of truth — imported by config.cjs and verify.cjs.
229
+ */
230
+ const CONFIG_DEFAULTS = {
231
+ model_profile: 'balanced',
232
+ commit_docs: true,
233
+ search_gitignored: false,
234
+ branching_strategy: 'none',
235
+ phase_branch_template: 'sdd/phase-{phase}-{slug}',
236
+ milestone_branch_template: 'sdd/{milestone}-{slug}',
237
+ quick_branch_template: null,
238
+ research: true,
239
+ plan_checker: true,
240
+ verifier: true,
241
+ nyquist_validation: true,
242
+ ai_integration_phase: true,
243
+ parallelization: true,
244
+ brave_search: false,
245
+ firecrawl: false,
246
+ exa_search: false,
247
+ text_mode: false, // when true, use plain-text numbered lists instead of AskUserQuestion menus
248
+ sub_repos: [],
249
+ resolve_model_ids: false, // false: return alias as-is | true: map to full Claude model ID | "omit": return '' (runtime uses its default)
250
+ context_window: 200000, // default 200k; set to 1000000 for Opus/Sonnet 4.6 1M models
251
+ phase_naming: 'sequential', // 'sequential' (default, auto-increment) or 'custom' (arbitrary string IDs)
252
+ project_code: null, // optional short prefix for phase dirs (e.g., 'CK' → 'CK-01-foundation')
253
+ subagent_timeout: 300000, // 5 min default; increase for large codebases or slower models (ms)
254
+ };
255
+
197
256
  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: 'sdd/phase-{phase}-{slug}',
205
- milestone_branch_template: 'sdd/{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 AskUserQuestion menus
216
- sub_repos: [],
217
- resolve_model_ids: false, // false: return alias as-is | true: map to full Claude 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
- };
257
+ const configPath = path.join(planningDir(cwd), 'config.json');
258
+ const defaults = CONFIG_DEFAULTS;
221
259
 
222
260
  try {
223
261
  const raw = fs.readFileSync(configPath, 'utf-8');
@@ -264,6 +302,28 @@ function loadConfig(cwd) {
264
302
  try { fs.writeFileSync(configPath, JSON.stringify(parsed, null, 2), 'utf-8'); } catch {}
265
303
  }
266
304
 
305
+ // Warn about unrecognized top-level keys so users don't silently lose config.
306
+ // Derived from config-set's VALID_CONFIG_KEYS (canonical source) plus internal-only
307
+ // keys that loadConfig handles but config-set doesn't expose. This avoids maintaining
308
+ // a hardcoded duplicate that drifts when new config keys are added.
309
+ const { VALID_CONFIG_KEYS } = require('./config.cjs');
310
+ const KNOWN_TOP_LEVEL = new Set([
311
+ // Extract top-level key names from dot-notation paths (e.g., 'workflow.research' → 'workflow')
312
+ ...[...VALID_CONFIG_KEYS].map(k => k.split('.')[0]),
313
+ // Section containers that hold nested sub-keys
314
+ 'git', 'workflow', 'planning', 'hooks', 'features',
315
+ // Internal keys loadConfig reads but config-set doesn't expose
316
+ 'model_overrides', 'agent_skills', 'context_window', 'resolve_model_ids',
317
+ // Deprecated keys (still accepted for migration, not in config-set)
318
+ 'depth', 'multiRepo',
319
+ ]);
320
+ const unknownKeys = Object.keys(parsed).filter(k => !KNOWN_TOP_LEVEL.has(k));
321
+ if (unknownKeys.length > 0) {
322
+ process.stderr.write(
323
+ `sdd-tools: warning: unknown config key(s) in .planning/config.json: ${unknownKeys.join(', ')} — these will be ignored\n`
324
+ );
325
+ }
326
+
267
327
  const get = (key, nested) => {
268
328
  if (parsed[key] !== undefined) return parsed[key];
269
329
  if (nested && parsed[nested.section] && parsed[nested.section][nested.field] !== undefined) {
@@ -308,17 +368,54 @@ function loadConfig(cwd) {
308
368
  resolve_model_ids: get('resolve_model_ids') ?? defaults.resolve_model_ids,
309
369
  context_window: get('context_window') ?? defaults.context_window,
310
370
  phase_naming: get('phase_naming') ?? defaults.phase_naming,
371
+ project_code: get('project_code') ?? defaults.project_code,
372
+ subagent_timeout: get('subagent_timeout', { section: 'workflow', field: 'subagent_timeout' }) ?? defaults.subagent_timeout,
311
373
  model_overrides: parsed.model_overrides || null,
312
374
  agent_skills: parsed.agent_skills || {},
375
+ manager: parsed.manager || {},
376
+ response_language: get('response_language') || null,
313
377
  };
314
378
  } catch {
315
- return defaults;
379
+ // Fall back to ~/.sdd/defaults.json only for truly pre-project contexts (#1683)
380
+ // If .planning/ exists, the project is initialized — just missing config.json
381
+ if (fs.existsSync(planningDir(cwd))) {
382
+ return defaults;
383
+ }
384
+ try {
385
+ const home = process.env.SDD_HOME || os.homedir();
386
+ const globalDefaultsPath = path.join(home, '.sdd', 'defaults.json');
387
+ const raw = fs.readFileSync(globalDefaultsPath, 'utf-8');
388
+ const globalDefaults = JSON.parse(raw);
389
+ return {
390
+ ...defaults,
391
+ model_profile: globalDefaults.model_profile ?? defaults.model_profile,
392
+ commit_docs: globalDefaults.commit_docs ?? defaults.commit_docs,
393
+ research: globalDefaults.research ?? defaults.research,
394
+ plan_checker: globalDefaults.plan_checker ?? defaults.plan_checker,
395
+ verifier: globalDefaults.verifier ?? defaults.verifier,
396
+ nyquist_validation: globalDefaults.nyquist_validation ?? defaults.nyquist_validation,
397
+ parallelization: globalDefaults.parallelization ?? defaults.parallelization,
398
+ text_mode: globalDefaults.text_mode ?? defaults.text_mode,
399
+ resolve_model_ids: globalDefaults.resolve_model_ids ?? defaults.resolve_model_ids,
400
+ context_window: globalDefaults.context_window ?? defaults.context_window,
401
+ subagent_timeout: globalDefaults.subagent_timeout ?? defaults.subagent_timeout,
402
+ model_overrides: globalDefaults.model_overrides || null,
403
+ agent_skills: globalDefaults.agent_skills || {},
404
+ response_language: globalDefaults.response_language || null,
405
+ };
406
+ } catch {
407
+ return defaults;
408
+ }
316
409
  }
317
410
  }
318
411
 
319
412
  // ─── Git utilities ────────────────────────────────────────────────────────────
320
413
 
414
+ const _gitIgnoredCache = new Map();
415
+
321
416
  function isGitIgnored(cwd, targetPath) {
417
+ const key = cwd + '::' + targetPath;
418
+ if (_gitIgnoredCache.has(key)) return _gitIgnoredCache.get(key);
322
419
  try {
323
420
  // --no-index checks .gitignore rules regardless of whether the file is tracked.
324
421
  // Without it, git check-ignore returns "not ignored" for tracked files even when
@@ -330,8 +427,10 @@ function isGitIgnored(cwd, targetPath) {
330
427
  cwd,
331
428
  stdio: 'pipe',
332
429
  });
430
+ _gitIgnoredCache.set(key, true);
333
431
  return true;
334
432
  } catch {
433
+ _gitIgnoredCache.set(key, false);
335
434
  return false;
336
435
  }
337
436
  }
@@ -358,20 +457,44 @@ function normalizeMd(content) {
358
457
  const lines = text.split('\n');
359
458
  const result = [];
360
459
 
460
+ // Pre-compute fence state in a single O(n) pass instead of O(n^2) per-line scanning
461
+ const fenceRegex = /^```/;
462
+ const insideFence = new Array(lines.length);
463
+ let fenceOpen = false;
464
+ for (let i = 0; i < lines.length; i++) {
465
+ if (fenceRegex.test(lines[i].trimEnd())) {
466
+ if (fenceOpen) {
467
+ // This is a closing fence — mark as NOT inside (it's the boundary)
468
+ insideFence[i] = false;
469
+ fenceOpen = false;
470
+ } else {
471
+ // This is an opening fence
472
+ insideFence[i] = false;
473
+ fenceOpen = true;
474
+ }
475
+ } else {
476
+ insideFence[i] = fenceOpen;
477
+ }
478
+ }
479
+
361
480
  for (let i = 0; i < lines.length; i++) {
362
481
  const line = lines[i];
363
482
  const prev = i > 0 ? lines[i - 1] : '';
364
483
  const prevTrimmed = prev.trimEnd();
365
484
  const trimmed = line.trimEnd();
485
+ const isFenceLine = fenceRegex.test(trimmed);
366
486
 
367
487
  // MD022: Blank line before headings (skip first line and frontmatter delimiters)
368
488
  if (/^#{1,6}\s/.test(trimmed) && i > 0 && prevTrimmed !== '' && prevTrimmed !== '---') {
369
489
  result.push('');
370
490
  }
371
491
 
372
- // MD031: Blank line before fenced code blocks
373
- if (/^```/.test(trimmed) && i > 0 && prevTrimmed !== '' && !isInsideFencedBlock(lines, i)) {
374
- result.push('');
492
+ // MD031: Blank line before fenced code blocks (opening fences only)
493
+ if (isFenceLine && i > 0 && prevTrimmed !== '' && !insideFence[i] && (i === 0 || !insideFence[i - 1] || isFenceLine)) {
494
+ // Only add blank before opening fences (not closing ones)
495
+ if (i === 0 || !insideFence[i - 1]) {
496
+ result.push('');
497
+ }
375
498
  }
376
499
 
377
500
  // MD032: Blank line before lists (- item, * item, N. item, - [ ] item)
@@ -392,7 +515,7 @@ function normalizeMd(content) {
392
515
  }
393
516
 
394
517
  // MD031: Blank line after closing fenced code blocks
395
- if (/^```\s*$/.test(trimmed) && isClosingFence(lines, i) && i < lines.length - 1) {
518
+ if (/^```\s*$/.test(trimmed) && i > 0 && insideFence[i - 1] && i < lines.length - 1) {
396
519
  const next = lines[i + 1];
397
520
  if (next !== undefined && next.trimEnd() !== '') {
398
521
  result.push('');
@@ -422,24 +545,6 @@ function normalizeMd(content) {
422
545
  return text;
423
546
  }
424
547
 
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
548
  function execGit(cwd, args) {
444
549
  const result = spawnSync('git', args, {
445
550
  cwd,
@@ -510,10 +615,15 @@ function withPlanningLock(cwd, fn) {
510
615
  acquired: new Date().toISOString(),
511
616
  }), { flag: 'wx' });
512
617
 
618
+ // Register for exit-time cleanup so process.exit(1) inside a locked region
619
+ // cannot leave a stale lock file (#1916).
620
+ _heldPlanningLocks.add(lockPath);
621
+
513
622
  // Lock acquired — run the function
514
623
  try {
515
624
  return fn();
516
625
  } finally {
626
+ _heldPlanningLocks.delete(lockPath);
517
627
  try { fs.unlinkSync(lockPath); } catch { /* already released */ }
518
628
  }
519
629
  } catch (err) {
@@ -527,8 +637,8 @@ function withPlanningLock(cwd, fn) {
527
637
  }
528
638
  } catch { continue; }
529
639
 
530
- // Wait and retry
531
- spawnSync('sleep', ['0.1'], { stdio: 'ignore' });
640
+ // Wait and retry (cross-platform, no shell dependency)
641
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
532
642
  continue;
533
643
  }
534
644
  throw err;
@@ -540,38 +650,65 @@ function withPlanningLock(cwd, fn) {
540
650
  }
541
651
 
542
652
  /**
543
- * Get the .planning directory path, workstream-aware.
544
- * When a workstream is active (via explicit ws arg or SDD_WORKSTREAM env var),
545
- * returns `.planning/workstreams/{ws}/`. Otherwise returns `.planning/`.
653
+ * Get the .planning directory path, project- and workstream-aware.
654
+ *
655
+ * Resolution order:
656
+ * 1. If SDD_PROJECT is set (env var or explicit `project` arg), routes to
657
+ * `.planning/{project}/` — supports multi-project workspaces where several
658
+ * independent projects share a single `.planning/` root directory (e.g.,
659
+ * an Obsidian vault or monorepo knowledge base used as a command center).
660
+ * 2. If SDD_WORKSTREAM is set, routes to `.planning/workstreams/{ws}/`.
661
+ * 3. Otherwise returns `.planning/`.
662
+ *
663
+ * SDD_PROJECT and SDD_WORKSTREAM can be combined:
664
+ * `.planning/{project}/workstreams/{ws}/`
546
665
  *
547
666
  * @param {string} cwd - project root
548
667
  * @param {string} [ws] - explicit workstream name; if omitted, checks SDD_WORKSTREAM env var
668
+ * @param {string} [project] - explicit project name; if omitted, checks SDD_PROJECT env var
549
669
  */
550
- function planningDir(cwd, ws) {
670
+ function planningDir(cwd, ws, project) {
671
+ if (project === undefined) project = process.env.SDD_PROJECT || null;
551
672
  if (ws === undefined) ws = process.env.SDD_WORKSTREAM || null;
552
- if (!ws) return path.join(cwd, '.planning');
553
- return path.join(cwd, '.planning', 'workstreams', ws);
673
+
674
+ // Reject path separators and traversal components in project/workstream names
675
+ const BAD_SEGMENT = /[/\\]|\.\./;
676
+ if (project && BAD_SEGMENT.test(project)) {
677
+ throw new Error(`SDD_PROJECT contains invalid path characters: ${project}`);
678
+ }
679
+ if (ws && BAD_SEGMENT.test(ws)) {
680
+ throw new Error(`SDD_WORKSTREAM contains invalid path characters: ${ws}`);
681
+ }
682
+
683
+ let base = path.join(cwd, '.planning');
684
+ if (project) base = path.join(base, project);
685
+ if (ws) base = path.join(base, 'workstreams', ws);
686
+ return base;
554
687
  }
555
688
 
556
- /** Always returns the root .planning/ path, ignoring workstreams. For shared resources. */
689
+ /** Always returns the root .planning/ path, ignoring workstreams and projects. For shared resources. */
557
690
  function planningRoot(cwd) {
558
691
  return path.join(cwd, '.planning');
559
692
  }
560
693
 
561
694
  /**
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/.
695
+ * Get common .planning file paths, project-and-workstream-aware.
696
+ *
697
+ * All paths route through planningDir(cwd, ws), which honors the SDD_PROJECT
698
+ * env var and active workstream. This matches loadConfig() above (line 256),
699
+ * which has always read config.json via planningDir(cwd). Previously project
700
+ * and config were resolved against the unrouted .planning/ root, which broke
701
+ * `sdd-tools config-get` in multi-project layouts (the CRUD writers and the
702
+ * reader pointed at different files).
565
703
  */
566
704
  function planningPaths(cwd, ws) {
567
705
  const base = planningDir(cwd, ws);
568
- const root = path.join(cwd, '.planning');
569
706
  return {
570
707
  planning: base,
571
708
  state: path.join(base, 'STATE.md'),
572
709
  roadmap: path.join(base, 'ROADMAP.md'),
573
- project: path.join(root, 'PROJECT.md'),
574
- config: path.join(root, 'config.json'),
710
+ project: path.join(base, 'PROJECT.md'),
711
+ config: path.join(base, 'config.json'),
575
712
  phases: path.join(base, 'phases'),
576
713
  requirements: path.join(base, 'REQUIREMENTS.md'),
577
714
  };
@@ -579,35 +716,178 @@ function planningPaths(cwd, ws) {
579
716
 
580
717
  // ─── Active Workstream Detection ─────────────────────────────────────────────
581
718
 
719
+ function sanitizeWorkstreamSessionToken(value) {
720
+ if (value === null || value === undefined) return null;
721
+ const token = String(value).trim().replace(/[^a-zA-Z0-9._-]+/g, '_').replace(/^_+|_+$/g, '');
722
+ return token ? token.slice(0, 160) : null;
723
+ }
724
+
725
+ function probeControllingTtyToken() {
726
+ if (didProbeControllingTtyToken) return cachedControllingTtyToken;
727
+ didProbeControllingTtyToken = true;
728
+
729
+ // `tty` reads stdin. When stdin is already non-interactive, spawning it only
730
+ // adds avoidable failures on the routing hot path and cannot reveal a stable token.
731
+ if (!(process.stdin && process.stdin.isTTY)) {
732
+ return cachedControllingTtyToken;
733
+ }
734
+
735
+ try {
736
+ const ttyPath = execFileSync('tty', [], {
737
+ encoding: 'utf-8',
738
+ stdio: ['inherit', 'pipe', 'ignore'],
739
+ }).trim();
740
+ if (ttyPath && ttyPath !== 'not a tty') {
741
+ const token = sanitizeWorkstreamSessionToken(ttyPath.replace(/^\/dev\//, ''));
742
+ if (token) cachedControllingTtyToken = `tty-${token}`;
743
+ }
744
+ } catch {}
745
+
746
+ return cachedControllingTtyToken;
747
+ }
748
+
749
+ function getControllingTtyToken() {
750
+ for (const envKey of ['TTY', 'SSH_TTY']) {
751
+ const token = sanitizeWorkstreamSessionToken(process.env[envKey]);
752
+ if (token) return `tty-${token.replace(/^dev_/, '')}`;
753
+ }
754
+
755
+ return probeControllingTtyToken();
756
+ }
757
+
582
758
  /**
583
- * Get the active workstream name from .planning/active-workstream file.
584
- * Returns null if no active workstream or file doesn't exist.
759
+ * Resolve a deterministic session key for workstream-local routing.
760
+ *
761
+ * Order:
762
+ * 1. Explicit runtime/session env vars (`SDD_SESSION_KEY`, `CODEX_THREAD_ID`, etc.)
763
+ * 2. Terminal identity exposed via `TTY` or `SSH_TTY`
764
+ * 3. One best-effort `tty` probe when stdin is interactive
765
+ * 4. `null`, which tells callers to use the legacy shared pointer fallback
585
766
  */
586
- function getActiveWorkstream(cwd) {
587
- const filePath = path.join(planningRoot(cwd), 'active-workstream');
767
+ function getWorkstreamSessionKey() {
768
+ for (const envKey of WORKSTREAM_SESSION_ENV_KEYS) {
769
+ const raw = process.env[envKey];
770
+ const token = sanitizeWorkstreamSessionToken(raw);
771
+ if (token) return `${envKey.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${token}`;
772
+ }
773
+
774
+ return getControllingTtyToken();
775
+ }
776
+
777
+ function getSessionScopedWorkstreamFile(cwd) {
778
+ const sessionKey = getWorkstreamSessionKey();
779
+ if (!sessionKey) return null;
780
+
781
+ // Use realpathSync.native so the hash is derived from the canonical filesystem
782
+ // path. On Windows, path.resolve returns whatever case the caller supplied,
783
+ // while realpathSync.native returns the case the OS recorded — they differ on
784
+ // case-insensitive NTFS, producing different hashes and different tmpdir slots.
785
+ // Fall back to path.resolve when the directory does not yet exist.
786
+ let planningAbs;
787
+ try {
788
+ planningAbs = fs.realpathSync.native(planningRoot(cwd));
789
+ } catch {
790
+ planningAbs = path.resolve(planningRoot(cwd));
791
+ }
792
+ const projectId = crypto
793
+ .createHash('sha1')
794
+ .update(planningAbs)
795
+ .digest('hex')
796
+ .slice(0, 16);
797
+
798
+ const dirPath = path.join(os.tmpdir(), 'sdd-workstream-sessions', projectId);
799
+ return {
800
+ sessionKey,
801
+ dirPath,
802
+ filePath: path.join(dirPath, sessionKey),
803
+ };
804
+ }
805
+
806
+ function clearActiveWorkstreamPointer(filePath, cleanupDirPath) {
807
+ try { fs.unlinkSync(filePath); } catch {}
808
+
809
+ // Session-scoped pointers for a repo share one tmp directory. Only remove it
810
+ // when it is empty so clearing or self-healing one session never deletes siblings.
811
+ // Explicitly check remaining entries rather than relying on rmdirSync throwing
812
+ // ENOTEMPTY — that error is not raised reliably on Windows.
813
+ if (cleanupDirPath) {
814
+ try {
815
+ const remaining = fs.readdirSync(cleanupDirPath);
816
+ if (remaining.length === 0) {
817
+ fs.rmdirSync(cleanupDirPath);
818
+ }
819
+ } catch {}
820
+ }
821
+ }
822
+
823
+ /**
824
+ * Pointer files are self-healing: invalid names or deleted-workstream pointers
825
+ * are removed on read so the session falls back to `null` instead of carrying
826
+ * silent stale state forward. Session-scoped callers may also prune an empty
827
+ * per-project tmp directory; shared `.planning/active-workstream` callers do not.
828
+ */
829
+ function readActiveWorkstreamPointer(filePath, cwd, cleanupDirPath = null) {
588
830
  try {
589
831
  const name = fs.readFileSync(filePath, 'utf-8').trim();
590
- if (!name || !/^[a-zA-Z0-9_-]+$/.test(name)) return null;
832
+ if (!name || !/^[a-zA-Z0-9_-]+$/.test(name)) {
833
+ clearActiveWorkstreamPointer(filePath, cleanupDirPath);
834
+ return null;
835
+ }
591
836
  const wsDir = path.join(planningRoot(cwd), 'workstreams', name);
592
- if (!fs.existsSync(wsDir)) return null;
837
+ if (!fs.existsSync(wsDir)) {
838
+ clearActiveWorkstreamPointer(filePath, cleanupDirPath);
839
+ return null;
840
+ }
593
841
  return name;
594
842
  } catch {
595
843
  return null;
596
844
  }
597
845
  }
598
846
 
847
+ /**
848
+ * Get the active workstream name.
849
+ *
850
+ * Resolution priority:
851
+ * 1. Session-scoped pointer (tmpdir) when the runtime exposes a stable session key
852
+ * 2. Legacy shared `.planning/active-workstream` file when no session key is available
853
+ *
854
+ * The shared file is intentionally ignored when a session key exists so multiple
855
+ * concurrent sessions do not overwrite each other's active workstream.
856
+ */
857
+ function getActiveWorkstream(cwd) {
858
+ const sessionScoped = getSessionScopedWorkstreamFile(cwd);
859
+ if (sessionScoped) {
860
+ return readActiveWorkstreamPointer(sessionScoped.filePath, cwd, sessionScoped.dirPath);
861
+ }
862
+
863
+ const sharedFilePath = path.join(planningRoot(cwd), 'active-workstream');
864
+ return readActiveWorkstreamPointer(sharedFilePath, cwd);
865
+ }
866
+
599
867
  /**
600
868
  * Set the active workstream. Pass null to clear.
869
+ *
870
+ * When a stable session key is available, this updates a tmpdir-backed
871
+ * session-scoped pointer. Otherwise it falls back to the legacy shared
872
+ * `.planning/active-workstream` file for backward compatibility.
601
873
  */
602
874
  function setActiveWorkstream(cwd, name) {
603
- const filePath = path.join(planningRoot(cwd), 'active-workstream');
875
+ const sessionScoped = getSessionScopedWorkstreamFile(cwd);
876
+ const filePath = sessionScoped
877
+ ? sessionScoped.filePath
878
+ : path.join(planningRoot(cwd), 'active-workstream');
879
+
604
880
  if (!name) {
605
- try { fs.unlinkSync(filePath); } catch {}
881
+ clearActiveWorkstreamPointer(filePath, sessionScoped ? sessionScoped.dirPath : null);
606
882
  return;
607
883
  }
608
884
  if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
609
885
  throw new Error('Invalid workstream name: must be alphanumeric, hyphens, and underscores only');
610
886
  }
887
+
888
+ if (sessionScoped) {
889
+ fs.mkdirSync(sessionScoped.dirPath, { recursive: true });
890
+ }
611
891
  fs.writeFileSync(filePath, name + '\n', 'utf-8');
612
892
  }
613
893
 
@@ -619,11 +899,16 @@ function escapeRegex(value) {
619
899
 
620
900
  function normalizePhaseName(phase) {
621
901
  const str = String(phase);
902
+ // Strip optional project_code prefix (e.g., 'CK-01' → '01')
903
+ const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/, '');
622
904
  // Standard numeric phases: 1, 01, 12A, 12.1
623
- const match = str.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
905
+ const match = stripped.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
624
906
  if (match) {
625
907
  const padded = match[1].padStart(2, '0');
626
- const letter = match[2] ? match[2].toUpperCase() : '';
908
+ // Preserve original case of letter suffix (#1962).
909
+ // Uppercasing causes directory/roadmap mismatches on case-sensitive filesystems
910
+ // (e.g., "16c" in ROADMAP.md → directory "16C-name" → progress can't match).
911
+ const letter = match[2] || '';
627
912
  const decimal = match[3] || '';
628
913
  return padded + letter + decimal;
629
914
  }
@@ -632,8 +917,11 @@ function normalizePhaseName(phase) {
632
917
  }
633
918
 
634
919
  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);
920
+ // Strip optional project_code prefix before comparing (e.g., 'CK-01-name' → '01-name')
921
+ const sa = String(a).replace(/^[A-Z]{1,6}-/, '');
922
+ const sb = String(b).replace(/^[A-Z]{1,6}-/, '');
923
+ const pa = sa.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
924
+ const pb = sb.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
637
925
  // If either is non-numeric (custom ID), fall back to string comparison
638
926
  if (!pa || !pb) return String(a).localeCompare(String(b));
639
927
  const intDiff = parseInt(pa[1], 10) - parseInt(pb[1], 10);
@@ -660,20 +948,50 @@ function comparePhaseNum(a, b) {
660
948
  return 0;
661
949
  }
662
950
 
951
+ /**
952
+ * Extract the phase token from a directory name.
953
+ * Supports: '01-name', '1009A-name', '999.6-name', 'CK-01-name', 'PROJ-42-name'.
954
+ * Returns the token portion (e.g. '01', '1009A', '999.6', 'PROJ-42') or the full name if no separator.
955
+ */
956
+ function extractPhaseToken(dirName) {
957
+ // Try project-code-prefixed numeric: CK-01-name → CK-01, CK-01A.2-name → CK-01A.2
958
+ const codePrefixed = dirName.match(/^([A-Z]{1,6}-\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i);
959
+ if (codePrefixed) return codePrefixed[1];
960
+ // Try plain numeric: 01-name, 1009A-name, 999.6-name
961
+ const numeric = dirName.match(/^(\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i);
962
+ if (numeric) return numeric[1];
963
+ // Custom IDs: PROJ-42-name → everything before the last segment that looks like a name
964
+ const custom = dirName.match(/^([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*)(?:-[a-z]|$)/i);
965
+ if (custom) return custom[1];
966
+ return dirName;
967
+ }
968
+
969
+ /**
970
+ * Check if a directory name's phase token matches the normalized phase exactly.
971
+ * Case-insensitive comparison for the token portion.
972
+ */
973
+ function phaseTokenMatches(dirName, normalized) {
974
+ const token = extractPhaseToken(dirName);
975
+ if (token.toUpperCase() === normalized.toUpperCase()) return true;
976
+ // Strip optional project_code prefix from dir and retry
977
+ const stripped = dirName.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
978
+ if (stripped !== dirName) {
979
+ const strippedToken = extractPhaseToken(stripped);
980
+ if (strippedToken.toUpperCase() === normalized.toUpperCase()) return true;
981
+ }
982
+ return false;
983
+ }
984
+
663
985
  function searchPhaseInDir(baseDir, relBase, normalized) {
664
986
  try {
665
987
  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
- });
988
+ // Match: exact phase token comparison (not prefix matching)
989
+ const match = dirs.find(d => phaseTokenMatches(d, normalized));
673
990
  if (!match) return null;
674
991
 
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)
992
+ // Extract phase number and name — supports numeric (01-name), project-code-prefixed (CK-01-name), and custom (PROJ-42-name)
993
+ const dirMatch = match.match(/^(?:[A-Z]{1,6}-)(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i)
994
+ || match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i)
677
995
  || match.match(/^([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*)-(.+)/i)
678
996
  || [null, match, null];
679
997
  const phaseNumber = dirMatch ? dirMatch[1] : normalized;
@@ -939,9 +1257,15 @@ function getRoadmapPhaseInternal(cwd, phaseNum) {
939
1257
  * sdd-tools.cjs lives at <configDir>/sdd/bin/sdd-tools.cjs,
940
1258
  * so agents/ is at <configDir>/agents/.
941
1259
  *
1260
+ * SDD_AGENTS_DIR env var overrides the default path. Used in tests and for
1261
+ * installs where the agents directory is not co-located with sdd-tools.cjs.
1262
+ *
942
1263
  * @returns {string} Absolute path to the agents directory
943
1264
  */
944
1265
  function getAgentsDir() {
1266
+ if (process.env.SDD_AGENTS_DIR) {
1267
+ return process.env.SDD_AGENTS_DIR;
1268
+ }
945
1269
  // __dirname is sdd/bin/lib/ → go up 3 levels to configDir
946
1270
  return path.join(__dirname, '..', '..', '..', 'agents');
947
1271
  }
@@ -950,6 +1274,9 @@ function getAgentsDir() {
950
1274
  * Check which SDD agents are installed on disk.
951
1275
  * Returns an object with installation status and details.
952
1276
  *
1277
+ * Recognises both standard format (sdd-planner.md) and Copilot format
1278
+ * (sdd-planner.agent.md). Copilot renames agent files during install (#1512).
1279
+ *
953
1280
  * @returns {{ agents_installed: boolean, missing_agents: string[], installed_agents: string[], agents_dir: string }}
954
1281
  */
955
1282
  function checkAgentsInstalled() {
@@ -968,8 +1295,10 @@ function checkAgentsInstalled() {
968
1295
  }
969
1296
 
970
1297
  for (const agent of expectedAgents) {
1298
+ // Check both .md (standard) and .agent.md (Copilot) file formats.
971
1299
  const agentFile = path.join(agentsDir, `${agent}.md`);
972
- if (fs.existsSync(agentFile)) {
1300
+ const agentFileCopilot = path.join(agentsDir, `${agent}.agent.md`);
1301
+ if (fs.existsSync(agentFile) || fs.existsSync(agentFileCopilot)) {
973
1302
  installed.push(agent);
974
1303
  } else {
975
1304
  missing.push(agent);
@@ -992,9 +1321,9 @@ function checkAgentsInstalled() {
992
1321
  * Users can override with model_overrides in config.json for custom/latest models.
993
1322
  */
994
1323
  const MODEL_ALIAS_MAP = {
995
- 'opus': 'claude-opus-4-0',
996
- 'sonnet': 'claude-sonnet-4-5',
997
- 'haiku': 'claude-haiku-3-5',
1324
+ 'opus': 'claude-opus-4-6',
1325
+ 'sonnet': 'claude-sonnet-4-6',
1326
+ 'haiku': 'claude-haiku-4-5',
998
1327
  };
999
1328
 
1000
1329
  function resolveModelInternal(cwd, agentType) {
@@ -1061,7 +1390,7 @@ function pathExistsInternal(cwd, targetPath) {
1061
1390
 
1062
1391
  function generateSlugInternal(text) {
1063
1392
  if (!text) return null;
1064
- return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
1393
+ return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').substring(0, 60);
1065
1394
  }
1066
1395
 
1067
1396
  function getMilestoneInfo(cwd) {
@@ -1185,6 +1514,38 @@ function readSubdirectories(dirPath, sort = false) {
1185
1514
  }
1186
1515
  }
1187
1516
 
1517
+ // ─── Atomic file writes ───────────────────────────────────────────────────────
1518
+
1519
+ /**
1520
+ * Write a file atomically using write-to-temp-then-rename.
1521
+ *
1522
+ * On POSIX systems, `fs.renameSync` is atomic when the source and destination
1523
+ * are on the same filesystem. This prevents a process killed mid-write from
1524
+ * leaving a truncated file that is unparseable on next read.
1525
+ *
1526
+ * The temp file is placed alongside the target so it is guaranteed to be on
1527
+ * the same filesystem (required for rename atomicity). The PID is embedded in
1528
+ * the temp file name so concurrent writers use distinct paths.
1529
+ *
1530
+ * If `renameSync` fails (e.g. cross-device move), the function falls back to a
1531
+ * direct `writeFileSync` so callers always get a best-effort write.
1532
+ *
1533
+ * @param {string} filePath Absolute path to write.
1534
+ * @param {string|Buffer} content File content.
1535
+ * @param {string} [encoding='utf-8'] Encoding passed to writeFileSync.
1536
+ */
1537
+ function atomicWriteFileSync(filePath, content, encoding = 'utf-8') {
1538
+ const tmpPath = filePath + '.tmp.' + process.pid;
1539
+ try {
1540
+ fs.writeFileSync(tmpPath, content, encoding);
1541
+ fs.renameSync(tmpPath, filePath);
1542
+ } catch (renameErr) {
1543
+ // Clean up the temp file if rename failed, then fall back to direct write.
1544
+ try { fs.unlinkSync(tmpPath); } catch { /* already gone or never created */ }
1545
+ fs.writeFileSync(filePath, content, encoding);
1546
+ }
1547
+ }
1548
+
1188
1549
  module.exports = {
1189
1550
  output,
1190
1551
  error,
@@ -1197,6 +1558,8 @@ module.exports = {
1197
1558
  normalizePhaseName,
1198
1559
  comparePhaseNum,
1199
1560
  searchPhaseInDir,
1561
+ extractPhaseToken,
1562
+ phaseTokenMatches,
1200
1563
  findPhaseInternal,
1201
1564
  getArchivedPhaseDirs,
1202
1565
  getRoadmapPhaseInternal,
@@ -1216,6 +1579,7 @@ module.exports = {
1216
1579
  detectSubRepos,
1217
1580
  reapStaleTempFiles,
1218
1581
  MODEL_ALIAS_MAP,
1582
+ CONFIG_DEFAULTS,
1219
1583
  planningDir,
1220
1584
  planningRoot,
1221
1585
  planningPaths,
@@ -1227,4 +1591,5 @@ module.exports = {
1227
1591
  readSubdirectories,
1228
1592
  getAgentsDir,
1229
1593
  checkAgentsInstalled,
1594
+ atomicWriteFileSync,
1230
1595
  };