@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.
- package/README.ja-JP.md +144 -110
- package/README.ko-KR.md +143 -107
- package/README.md +183 -112
- package/README.pt-BR.md +90 -52
- package/README.zh-CN.md +141 -101
- package/agents/sdd-advisor-researcher.md +23 -0
- package/agents/sdd-ai-researcher.md +133 -0
- package/agents/sdd-code-fixer.md +516 -0
- package/agents/sdd-code-reviewer.md +355 -0
- package/agents/sdd-codebase-mapper.md +3 -3
- package/agents/sdd-debugger.md +17 -5
- package/agents/sdd-doc-verifier.md +201 -0
- package/agents/sdd-doc-writer.md +602 -0
- package/agents/sdd-domain-researcher.md +153 -0
- package/agents/sdd-eval-auditor.md +164 -0
- package/agents/sdd-eval-planner.md +154 -0
- package/agents/sdd-executor.md +87 -4
- package/agents/sdd-framework-selector.md +160 -0
- package/agents/sdd-intel-updater.md +314 -0
- package/agents/sdd-nyquist-auditor.md +1 -1
- package/agents/sdd-phase-researcher.md +71 -4
- package/agents/sdd-plan-checker.md +100 -6
- package/agents/sdd-planner.md +145 -206
- package/agents/sdd-project-researcher.md +25 -2
- package/agents/sdd-research-synthesizer.md +3 -3
- package/agents/sdd-roadmapper.md +6 -6
- package/agents/sdd-security-auditor.md +128 -0
- package/agents/sdd-ui-auditor.md +43 -3
- package/agents/sdd-ui-checker.md +5 -5
- package/agents/sdd-ui-researcher.md +27 -4
- package/agents/sdd-user-profiler.md +2 -2
- package/agents/sdd-verifier.md +142 -22
- package/bin/install.js +2151 -551
- package/commands/sdd/add-backlog.md +5 -5
- package/commands/sdd/add-tests.md +2 -2
- package/commands/sdd/ai-integration-phase.md +36 -0
- package/commands/sdd/analyze-dependencies.md +34 -0
- package/commands/sdd/audit-fix.md +33 -0
- package/commands/sdd/autonomous.md +7 -2
- package/commands/sdd/cleanup.md +5 -0
- package/commands/sdd/code-review-fix.md +52 -0
- package/commands/sdd/code-review.md +55 -0
- package/commands/sdd/complete-milestone.md +6 -6
- package/commands/sdd/debug.md +22 -9
- package/commands/sdd/discuss-phase.md +7 -2
- package/commands/sdd/do.md +1 -1
- package/commands/sdd/docs-update.md +48 -0
- package/commands/sdd/eval-review.md +32 -0
- package/commands/sdd/execute-phase.md +4 -0
- package/commands/sdd/explore.md +27 -0
- package/commands/sdd/fast.md +2 -2
- package/commands/sdd/from-sdd2.md +45 -0
- package/commands/sdd/help.md +2 -0
- package/commands/sdd/import.md +36 -0
- package/commands/sdd/intel.md +179 -0
- package/commands/sdd/join-discord.md +2 -1
- package/commands/sdd/manager.md +1 -0
- package/commands/sdd/map-codebase.md +3 -3
- package/commands/sdd/new-milestone.md +1 -1
- package/commands/sdd/new-project.md +5 -1
- package/commands/sdd/new-workspace.md +1 -1
- package/commands/sdd/next.md +2 -0
- package/commands/sdd/plan-milestone-gaps.md +2 -2
- package/commands/sdd/plan-phase.md +6 -1
- package/commands/sdd/plant-seed.md +1 -1
- package/commands/sdd/profile-user.md +1 -1
- package/commands/sdd/quick.md +5 -3
- package/commands/sdd/reapply-patches.md +230 -42
- package/commands/sdd/research-phase.md +3 -3
- package/commands/sdd/review-backlog.md +1 -0
- package/commands/sdd/review.md +6 -3
- package/commands/sdd/scan.md +26 -0
- package/commands/sdd/secure-phase.md +35 -0
- package/commands/sdd/ship.md +1 -1
- package/commands/sdd/thread.md +5 -5
- package/commands/sdd/undo.md +34 -0
- package/commands/sdd/verify-work.md +1 -1
- package/commands/sdd/workstreams.md +17 -11
- package/hooks/dist/sdd-check-update.js +33 -8
- package/hooks/dist/sdd-context-monitor.js +17 -8
- package/hooks/dist/sdd-phase-boundary.sh +27 -0
- package/hooks/dist/sdd-prompt-guard.js +1 -0
- package/hooks/dist/sdd-read-guard.js +82 -0
- package/hooks/dist/sdd-session-state.sh +33 -0
- package/hooks/dist/sdd-statusline.js +137 -15
- package/hooks/dist/sdd-validate-commit.sh +47 -0
- package/hooks/dist/sdd-workflow-guard.js +4 -4
- package/hooks/sdd-check-update.js +139 -0
- package/hooks/sdd-context-monitor.js +165 -0
- package/hooks/sdd-phase-boundary.sh +27 -0
- package/hooks/sdd-prompt-guard.js +97 -0
- package/hooks/sdd-read-guard.js +82 -0
- package/hooks/sdd-session-state.sh +33 -0
- package/hooks/sdd-statusline.js +241 -0
- package/hooks/sdd-validate-commit.sh +47 -0
- package/hooks/sdd-workflow-guard.js +94 -0
- package/package.json +3 -3
- package/scripts/build-hooks.js +18 -7
- package/scripts/prompt-injection-scan.sh +1 -0
- package/scripts/rebrand-gsd-to-sdd.sh +221 -220
- package/scripts/run-tests.cjs +5 -1
- package/scripts/sync-upstream.sh +1 -1
- package/sdd/bin/lib/commands.cjs +79 -17
- package/sdd/bin/lib/config.cjs +90 -48
- package/sdd/bin/lib/core.cjs +452 -87
- package/sdd/bin/lib/docs.cjs +267 -0
- package/sdd/bin/lib/frontmatter.cjs +381 -336
- package/sdd/bin/lib/init.cjs +110 -16
- package/sdd/bin/lib/intel.cjs +660 -0
- package/sdd/bin/lib/learnings.cjs +378 -0
- package/sdd/bin/lib/milestone.cjs +42 -11
- package/sdd/bin/lib/model-profiles.cjs +17 -15
- package/sdd/bin/lib/phase.cjs +367 -288
- package/sdd/bin/lib/profile-output.cjs +106 -10
- package/sdd/bin/lib/roadmap.cjs +146 -115
- package/sdd/bin/lib/schema-detect.cjs +238 -0
- package/sdd/bin/lib/sdd2-import.cjs +511 -0
- package/sdd/bin/lib/security.cjs +124 -3
- package/sdd/bin/lib/state.cjs +648 -264
- package/sdd/bin/lib/template.cjs +8 -4
- package/sdd/bin/lib/verify.cjs +209 -28
- package/sdd/bin/lib/workstream.cjs +7 -3
- package/sdd/bin/sdd-tools.cjs +184 -12
- package/sdd/contexts/dev.md +21 -0
- package/sdd/contexts/research.md +22 -0
- package/sdd/contexts/review.md +22 -0
- package/sdd/references/agent-contracts.md +79 -0
- package/sdd/references/ai-evals.md +156 -0
- package/sdd/references/ai-frameworks.md +186 -0
- package/sdd/references/artifact-types.md +113 -0
- package/sdd/references/common-bug-patterns.md +114 -0
- package/sdd/references/context-budget.md +49 -0
- package/sdd/references/continuation-format.md +25 -25
- package/sdd/references/domain-probes.md +125 -0
- package/sdd/references/few-shot-examples/plan-checker.md +73 -0
- package/sdd/references/few-shot-examples/verifier.md +109 -0
- package/sdd/references/gate-prompts.md +100 -0
- package/sdd/references/gates.md +70 -0
- package/sdd/references/git-integration.md +1 -1
- package/sdd/references/ios-scaffold.md +123 -0
- package/sdd/references/model-profile-resolution.md +2 -0
- package/sdd/references/model-profiles.md +24 -18
- package/sdd/references/planner-gap-closure.md +62 -0
- package/sdd/references/planner-reviews.md +39 -0
- package/sdd/references/planner-revision.md +87 -0
- package/sdd/references/planning-config.md +252 -0
- package/sdd/references/revision-loop.md +97 -0
- package/sdd/references/thinking-models-debug.md +44 -0
- package/sdd/references/thinking-models-execution.md +50 -0
- package/sdd/references/thinking-models-planning.md +62 -0
- package/sdd/references/thinking-models-research.md +50 -0
- package/sdd/references/thinking-models-verification.md +55 -0
- package/sdd/references/thinking-partner.md +96 -0
- package/sdd/references/ui-brand.md +4 -4
- package/sdd/references/universal-anti-patterns.md +63 -0
- package/sdd/references/verification-overrides.md +227 -0
- package/sdd/references/workstream-flag.md +56 -3
- package/sdd/templates/AI-SPEC.md +246 -0
- package/sdd/templates/DEBUG.md +1 -1
- package/sdd/templates/SECURITY.md +61 -0
- package/sdd/templates/UAT.md +4 -4
- package/sdd/templates/VALIDATION.md +4 -4
- package/sdd/templates/claude-md.md +32 -9
- package/sdd/templates/config.json +4 -0
- package/sdd/templates/debug-subagent-prompt.md +1 -1
- package/sdd/templates/dev-preferences.md +1 -1
- package/sdd/templates/discovery.md +2 -2
- package/sdd/templates/phase-prompt.md +1 -1
- package/sdd/templates/planner-subagent-prompt.md +3 -3
- package/sdd/templates/project.md +1 -1
- package/sdd/templates/research.md +1 -1
- package/sdd/templates/state.md +2 -2
- package/sdd/workflows/add-phase.md +8 -8
- package/sdd/workflows/add-tests.md +12 -9
- package/sdd/workflows/add-todo.md +5 -3
- package/sdd/workflows/ai-integration-phase.md +284 -0
- package/sdd/workflows/analyze-dependencies.md +96 -0
- package/sdd/workflows/audit-fix.md +157 -0
- package/sdd/workflows/audit-milestone.md +11 -11
- package/sdd/workflows/audit-uat.md +2 -2
- package/sdd/workflows/autonomous.md +195 -27
- package/sdd/workflows/check-todos.md +12 -10
- package/sdd/workflows/cleanup.md +2 -0
- package/sdd/workflows/code-review-fix.md +497 -0
- package/sdd/workflows/code-review.md +515 -0
- package/sdd/workflows/complete-milestone.md +56 -22
- package/sdd/workflows/diagnose-issues.md +10 -3
- package/sdd/workflows/discovery-phase.md +5 -3
- package/sdd/workflows/discuss-phase-assumptions.md +24 -6
- package/sdd/workflows/discuss-phase-power.md +291 -0
- package/sdd/workflows/discuss-phase.md +173 -21
- package/sdd/workflows/do.md +23 -21
- package/sdd/workflows/docs-update.md +1155 -0
- package/sdd/workflows/eval-review.md +155 -0
- package/sdd/workflows/execute-phase.md +594 -38
- package/sdd/workflows/execute-plan.md +67 -96
- package/sdd/workflows/explore.md +139 -0
- package/sdd/workflows/fast.md +5 -5
- package/sdd/workflows/forensics.md +2 -2
- package/sdd/workflows/health.md +4 -4
- package/sdd/workflows/help.md +122 -119
- package/sdd/workflows/import.md +276 -0
- package/sdd/workflows/inbox.md +387 -0
- package/sdd/workflows/insert-phase.md +7 -7
- package/sdd/workflows/list-phase-assumptions.md +4 -4
- package/sdd/workflows/list-workspaces.md +2 -2
- package/sdd/workflows/manager.md +35 -32
- package/sdd/workflows/map-codebase.md +7 -5
- package/sdd/workflows/milestone-summary.md +2 -2
- package/sdd/workflows/new-milestone.md +17 -9
- package/sdd/workflows/new-project.md +50 -25
- package/sdd/workflows/new-workspace.md +7 -5
- package/sdd/workflows/next.md +67 -11
- package/sdd/workflows/note.md +9 -7
- package/sdd/workflows/pause-work.md +75 -12
- package/sdd/workflows/plan-milestone-gaps.md +8 -8
- package/sdd/workflows/plan-phase.md +294 -42
- package/sdd/workflows/plant-seed.md +6 -3
- package/sdd/workflows/pr-branch.md +42 -14
- package/sdd/workflows/profile-user.md +9 -7
- package/sdd/workflows/progress.md +45 -45
- package/sdd/workflows/quick.md +195 -47
- package/sdd/workflows/remove-phase.md +6 -6
- package/sdd/workflows/remove-workspace.md +3 -1
- package/sdd/workflows/research-phase.md +2 -2
- package/sdd/workflows/resume-project.md +12 -12
- package/sdd/workflows/review.md +109 -9
- package/sdd/workflows/scan.md +102 -0
- package/sdd/workflows/secure-phase.md +166 -0
- package/sdd/workflows/session-report.md +2 -2
- package/sdd/workflows/settings.md +38 -12
- package/sdd/workflows/ship.md +21 -9
- package/sdd/workflows/stats.md +1 -1
- package/sdd/workflows/transition.md +23 -23
- package/sdd/workflows/ui-phase.md +15 -7
- package/sdd/workflows/ui-review.md +29 -4
- package/sdd/workflows/undo.md +314 -0
- package/sdd/workflows/update.md +171 -20
- package/sdd/workflows/validate-phase.md +6 -4
- package/sdd/workflows/verify-phase.md +210 -6
- package/sdd/workflows/verify-work.md +83 -9
- package/sdd/commands/sdd/workstreams.md +0 -63
package/sdd/bin/lib/core.cjs
CHANGED
|
@@ -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, '
|
|
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
|
-
|
|
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 (
|
|
374
|
-
|
|
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) &&
|
|
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
|
-
|
|
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
|
-
*
|
|
545
|
-
*
|
|
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
|
-
|
|
553
|
-
|
|
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
|
-
*
|
|
564
|
-
*
|
|
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(
|
|
574
|
-
config: path.join(
|
|
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
|
-
*
|
|
584
|
-
*
|
|
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
|
|
587
|
-
const
|
|
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))
|
|
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))
|
|
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
|
|
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
|
-
|
|
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 =
|
|
905
|
+
const match = stripped.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
|
|
624
906
|
if (match) {
|
|
625
907
|
const padded = match[1].padStart(2, '0');
|
|
626
|
-
|
|
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
|
-
|
|
636
|
-
const
|
|
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:
|
|
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
|
|
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
|
-
|
|
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-
|
|
996
|
-
'sonnet': 'claude-sonnet-4-
|
|
997
|
-
'haiku': 'claude-haiku-
|
|
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
|
};
|