gsd-opencode 1.33.3 → 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 (118) hide show
  1. package/agents/gsd-advisor-researcher.md +23 -0
  2. package/agents/gsd-ai-researcher.md +142 -0
  3. package/agents/gsd-code-fixer.md +523 -0
  4. package/agents/gsd-code-reviewer.md +361 -0
  5. package/agents/gsd-debugger.md +14 -1
  6. package/agents/gsd-domain-researcher.md +162 -0
  7. package/agents/gsd-eval-auditor.md +170 -0
  8. package/agents/gsd-eval-planner.md +161 -0
  9. package/agents/gsd-executor.md +70 -7
  10. package/agents/gsd-framework-selector.md +167 -0
  11. package/agents/gsd-intel-updater.md +320 -0
  12. package/agents/gsd-phase-researcher.md +26 -0
  13. package/agents/gsd-plan-checker.md +12 -0
  14. package/agents/gsd-planner.md +16 -6
  15. package/agents/gsd-project-researcher.md +23 -0
  16. package/agents/gsd-ui-researcher.md +23 -0
  17. package/agents/gsd-verifier.md +55 -1
  18. package/commands/gsd/gsd-ai-integration-phase.md +36 -0
  19. package/commands/gsd/gsd-audit-fix.md +33 -0
  20. package/commands/gsd/gsd-autonomous.md +1 -0
  21. package/commands/gsd/gsd-code-review-fix.md +52 -0
  22. package/commands/gsd/gsd-code-review.md +55 -0
  23. package/commands/gsd/gsd-eval-review.md +32 -0
  24. package/commands/gsd/gsd-explore.md +27 -0
  25. package/commands/gsd/gsd-from-gsd2.md +45 -0
  26. package/commands/gsd/gsd-import.md +36 -0
  27. package/commands/gsd/gsd-intel.md +183 -0
  28. package/commands/gsd/gsd-next.md +2 -0
  29. package/commands/gsd/gsd-reapply-patches.md +58 -3
  30. package/commands/gsd/gsd-review.md +4 -2
  31. package/commands/gsd/gsd-scan.md +26 -0
  32. package/commands/gsd/gsd-undo.md +34 -0
  33. package/commands/gsd/gsd-workstreams.md +6 -6
  34. package/get-shit-done/bin/gsd-tools.cjs +143 -5
  35. package/get-shit-done/bin/lib/commands.cjs +10 -2
  36. package/get-shit-done/bin/lib/config.cjs +71 -37
  37. package/get-shit-done/bin/lib/core.cjs +70 -8
  38. package/get-shit-done/bin/lib/gsd2-import.cjs +511 -0
  39. package/get-shit-done/bin/lib/init.cjs +20 -6
  40. package/get-shit-done/bin/lib/intel.cjs +660 -0
  41. package/get-shit-done/bin/lib/learnings.cjs +378 -0
  42. package/get-shit-done/bin/lib/milestone.cjs +25 -15
  43. package/get-shit-done/bin/lib/model-profiles.cjs +17 -17
  44. package/get-shit-done/bin/lib/phase.cjs +148 -112
  45. package/get-shit-done/bin/lib/roadmap.cjs +12 -5
  46. package/get-shit-done/bin/lib/security.cjs +119 -0
  47. package/get-shit-done/bin/lib/state.cjs +283 -221
  48. package/get-shit-done/bin/lib/template.cjs +8 -4
  49. package/get-shit-done/bin/lib/verify.cjs +42 -5
  50. package/get-shit-done/references/ai-evals.md +156 -0
  51. package/get-shit-done/references/ai-frameworks.md +186 -0
  52. package/get-shit-done/references/common-bug-patterns.md +114 -0
  53. package/get-shit-done/references/few-shot-examples/plan-checker.md +73 -0
  54. package/get-shit-done/references/few-shot-examples/verifier.md +109 -0
  55. package/get-shit-done/references/gates.md +70 -0
  56. package/get-shit-done/references/ios-scaffold.md +123 -0
  57. package/get-shit-done/references/model-profile-resolution.md +6 -7
  58. package/get-shit-done/references/model-profiles.md +20 -14
  59. package/get-shit-done/references/planning-config.md +237 -0
  60. package/get-shit-done/references/thinking-models-debug.md +44 -0
  61. package/get-shit-done/references/thinking-models-execution.md +50 -0
  62. package/get-shit-done/references/thinking-models-planning.md +62 -0
  63. package/get-shit-done/references/thinking-models-research.md +50 -0
  64. package/get-shit-done/references/thinking-models-verification.md +55 -0
  65. package/get-shit-done/references/thinking-partner.md +96 -0
  66. package/get-shit-done/references/universal-anti-patterns.md +6 -1
  67. package/get-shit-done/references/verification-overrides.md +227 -0
  68. package/get-shit-done/templates/AI-SPEC.md +246 -0
  69. package/get-shit-done/workflows/add-tests.md +3 -0
  70. package/get-shit-done/workflows/add-todo.md +2 -0
  71. package/get-shit-done/workflows/ai-integration-phase.md +284 -0
  72. package/get-shit-done/workflows/audit-fix.md +154 -0
  73. package/get-shit-done/workflows/autonomous.md +33 -2
  74. package/get-shit-done/workflows/check-todos.md +2 -0
  75. package/get-shit-done/workflows/cleanup.md +2 -0
  76. package/get-shit-done/workflows/code-review-fix.md +497 -0
  77. package/get-shit-done/workflows/code-review.md +515 -0
  78. package/get-shit-done/workflows/complete-milestone.md +40 -15
  79. package/get-shit-done/workflows/diagnose-issues.md +1 -1
  80. package/get-shit-done/workflows/discovery-phase.md +3 -1
  81. package/get-shit-done/workflows/discuss-phase-assumptions.md +1 -1
  82. package/get-shit-done/workflows/discuss-phase.md +21 -7
  83. package/get-shit-done/workflows/do.md +2 -0
  84. package/get-shit-done/workflows/docs-update.md +2 -0
  85. package/get-shit-done/workflows/eval-review.md +155 -0
  86. package/get-shit-done/workflows/execute-phase.md +307 -57
  87. package/get-shit-done/workflows/execute-plan.md +64 -93
  88. package/get-shit-done/workflows/explore.md +136 -0
  89. package/get-shit-done/workflows/help.md +1 -1
  90. package/get-shit-done/workflows/import.md +273 -0
  91. package/get-shit-done/workflows/inbox.md +387 -0
  92. package/get-shit-done/workflows/manager.md +4 -10
  93. package/get-shit-done/workflows/new-milestone.md +3 -1
  94. package/get-shit-done/workflows/new-project.md +2 -0
  95. package/get-shit-done/workflows/new-workspace.md +2 -0
  96. package/get-shit-done/workflows/next.md +56 -0
  97. package/get-shit-done/workflows/note.md +2 -0
  98. package/get-shit-done/workflows/plan-phase.md +97 -17
  99. package/get-shit-done/workflows/plant-seed.md +3 -0
  100. package/get-shit-done/workflows/pr-branch.md +41 -13
  101. package/get-shit-done/workflows/profile-user.md +4 -2
  102. package/get-shit-done/workflows/quick.md +99 -4
  103. package/get-shit-done/workflows/remove-workspace.md +2 -0
  104. package/get-shit-done/workflows/review.md +53 -6
  105. package/get-shit-done/workflows/scan.md +98 -0
  106. package/get-shit-done/workflows/secure-phase.md +2 -0
  107. package/get-shit-done/workflows/settings.md +18 -3
  108. package/get-shit-done/workflows/ship.md +3 -0
  109. package/get-shit-done/workflows/ui-phase.md +10 -2
  110. package/get-shit-done/workflows/ui-review.md +2 -0
  111. package/get-shit-done/workflows/undo.md +314 -0
  112. package/get-shit-done/workflows/update.md +2 -0
  113. package/get-shit-done/workflows/validate-phase.md +2 -0
  114. package/get-shit-done/workflows/verify-phase.md +83 -0
  115. package/get-shit-done/workflows/verify-work.md +12 -1
  116. package/package.json +1 -1
  117. package/skills/gsd-code-review/SKILL.md +48 -0
  118. package/skills/gsd-code-review-fix/SKILL.md +44 -0
@@ -70,6 +70,16 @@
70
70
  * audit-uat Scan all phases for unresolved UAT/verification items
71
71
  * uat render-checkpoint --file <path> Render the current UAT checkpoint block
72
72
  *
73
+ * Intel:
74
+ * intel query <term> Query intel files for a term
75
+ * intel status Show intel file freshness
76
+ * intel update Trigger intel refresh (returns agent spawn hint)
77
+ * intel diff Show changed intel entries since last snapshot
78
+ * intel snapshot Save current intel state as diff baseline
79
+ * intel patch-meta <file> Update _meta.updated_at in an intel file
80
+ * intel validate Validate intel file structure
81
+ * intel extract-exports <file> Extract exported symbols from a source file
82
+ *
73
83
  * Scaffolding:
74
84
  * scaffold context --phase <N> Create CONTEXT.md template
75
85
  * scaffold uat --phase <N> Create UAT.md template
@@ -137,6 +147,17 @@
137
147
  *
138
148
  * Documentation:
139
149
  * docs-init Project context for docs-update workflow
150
+ *
151
+ * Learnings:
152
+ * learnings list List all global learnings (JSON)
153
+ * learnings query --tag <tag> Query learnings by tag
154
+ * learnings copy Copy from current project's LEARNINGS.md
155
+ * learnings prune --older-than <dur> Remove entries older than duration (e.g. 90d)
156
+ * learnings delete <id> Delete a learning by ID
157
+ *
158
+ * GSD-2 Migration:
159
+ * from-gsd2 [--path <dir>] [--force] [--dry-run]
160
+ * Import a GSD-2 (.gsd/) project back to GSD v1 (.planning/) format
140
161
  */
141
162
 
142
163
  const fs = require('fs');
@@ -157,6 +178,7 @@ const profilePipeline = require('./lib/profile-pipeline.cjs');
157
178
  const profileOutput = require('./lib/profile-output.cjs');
158
179
  const workstream = require('./lib/workstream.cjs');
159
180
  const docs = require('./lib/docs.cjs');
181
+ const learnings = require('./lib/learnings.cjs');
160
182
 
161
183
  // ─── Arg parsing helpers ──────────────────────────────────────────────────────
162
184
 
@@ -276,12 +298,33 @@ async function main() {
276
298
  args.splice(pickIdx, 2);
277
299
  }
278
300
 
301
+ // --default <value>: for config-get, return this value instead of erroring
302
+ // when the key is absent. Allows workflows to express optional config reads
303
+ // without defensive `2>/dev/null || true` boilerplate (#1893).
304
+ const defaultIdx = args.indexOf('--default');
305
+ let defaultValue = undefined;
306
+ if (defaultIdx !== -1) {
307
+ defaultValue = args[defaultIdx + 1];
308
+ if (defaultValue === undefined) defaultValue = '';
309
+ args.splice(defaultIdx, 2);
310
+ }
311
+
279
312
  const command = args[0];
280
313
 
281
314
  if (!command) {
282
315
  error('Usage: gsd-tools <command> [args] [--raw] [--pick <field>] [--cwd <path>] [--ws <name>]\nCommands: state, resolve-model, find-phase, commit, verify-summary, verify, frontmatter, template, generate-slug, current-timestamp, list-todos, verify-path-exists, config-ensure-section, config-new-project, init, workstream, docs-init');
283
316
  }
284
317
 
318
+ // Reject flags that are never valid for any gsd-tools command. AI agents
319
+ // sometimes hallucinate --help or --version on tool invocations; silently
320
+ // ignoring them can cause destructive operations to proceed unchecked.
321
+ const NEVER_VALID_FLAGS = new Set(['-h', '--help', '-?', '--h', '--version', '-v', '--usage']);
322
+ for (const arg of args) {
323
+ if (NEVER_VALID_FLAGS.has(arg)) {
324
+ error(`Unknown flag: ${arg}\ngsd-tools does not accept help or version flags. Run "gsd-tools" with no arguments for usage.`);
325
+ }
326
+ }
327
+
285
328
  // Multi-repo guard: resolve project root for commands that read/write .planning/.
286
329
  // Skip for pure-utility commands that don't touch .planning/ to avoid unnecessary
287
330
  // filesystem traversal on every invocation.
@@ -318,7 +361,7 @@ async function main() {
318
361
  }
319
362
  };
320
363
  try {
321
- await runCommand(command, args, cwd, raw);
364
+ await runCommand(command, args, cwd, raw, defaultValue);
322
365
  cleanup();
323
366
  } catch (e) {
324
367
  fs.writeSync = origWriteSync;
@@ -327,7 +370,27 @@ async function main() {
327
370
  return;
328
371
  }
329
372
 
330
- await runCommand(command, args, cwd, raw);
373
+ // Intercept stdout to transparently resolve @file: references (#1891).
374
+ // core.cjs output() writes @file:<path> when JSON > 50KB. The --pick path
375
+ // already resolves this, but the normal path wrote @file: to stdout, forcing
376
+ // every workflow to have a bash-specific `if [[ "$INIT" == @file:* ]]` check
377
+ // that breaks on PowerShell and other non-bash shells.
378
+ const origWriteSync2 = fs.writeSync;
379
+ const outChunks = [];
380
+ fs.writeSync = function (fd, data, ...rest) {
381
+ if (fd === 1) { outChunks.push(String(data)); return; }
382
+ return origWriteSync2.call(fs, fd, data, ...rest);
383
+ };
384
+ try {
385
+ await runCommand(command, args, cwd, raw, defaultValue);
386
+ } finally {
387
+ fs.writeSync = origWriteSync2;
388
+ }
389
+ let captured = outChunks.join('');
390
+ if (captured.startsWith('@file:')) {
391
+ captured = fs.readFileSync(captured.slice(6), 'utf-8');
392
+ }
393
+ origWriteSync2.call(fs, 1, captured);
331
394
  }
332
395
 
333
396
  /**
@@ -353,7 +416,7 @@ function extractField(obj, fieldPath) {
353
416
  return current;
354
417
  }
355
418
 
356
- async function runCommand(command, args, cwd, raw) {
419
+ async function runCommand(command, args, cwd, raw, defaultValue) {
357
420
  switch (command) {
358
421
  case 'state': {
359
422
  const subcommand = args[1];
@@ -561,7 +624,7 @@ async function runCommand(command, args, cwd, raw) {
561
624
  }
562
625
 
563
626
  case 'config-get': {
564
- config.cmdConfigGet(cwd, args[1], raw);
627
+ config.cmdConfigGet(cwd, args[1], raw, defaultValue);
565
628
  break;
566
629
  }
567
630
 
@@ -592,7 +655,7 @@ async function runCommand(command, args, cwd, raw) {
592
655
  };
593
656
  phase.cmdPhasesList(cwd, options, raw);
594
657
  } else if (subcommand === 'clear') {
595
- milestone.cmdPhasesClear(cwd, raw);
658
+ milestone.cmdPhasesClear(cwd, raw, args.slice(2));
596
659
  } else {
597
660
  error('Unknown phases subcommand. Available: list, clear');
598
661
  }
@@ -937,6 +1000,45 @@ async function runCommand(command, args, cwd, raw) {
937
1000
  break;
938
1001
  }
939
1002
 
1003
+ // ─── Intel ────────────────────────────────────────────────────────────
1004
+
1005
+ case 'intel': {
1006
+ const intel = require('./lib/intel.cjs');
1007
+ const subcommand = args[1];
1008
+ if (subcommand === 'query') {
1009
+ const term = args[2];
1010
+ if (!term) error('Usage: gsd-tools intel query <term>');
1011
+ const planningDir = path.join(cwd, '.planning');
1012
+ core.output(intel.intelQuery(term, planningDir), raw);
1013
+ } else if (subcommand === 'status') {
1014
+ const planningDir = path.join(cwd, '.planning');
1015
+ core.output(intel.intelStatus(planningDir), raw);
1016
+ } else if (subcommand === 'diff') {
1017
+ const planningDir = path.join(cwd, '.planning');
1018
+ core.output(intel.intelDiff(planningDir), raw);
1019
+ } else if (subcommand === 'snapshot') {
1020
+ const planningDir = path.join(cwd, '.planning');
1021
+ core.output(intel.intelSnapshot(planningDir), raw);
1022
+ } else if (subcommand === 'patch-meta') {
1023
+ const filePath = args[2];
1024
+ if (!filePath) error('Usage: gsd-tools intel patch-meta <file-path>');
1025
+ core.output(intel.intelPatchMeta(path.resolve(cwd, filePath)), raw);
1026
+ } else if (subcommand === 'validate') {
1027
+ const planningDir = path.join(cwd, '.planning');
1028
+ core.output(intel.intelValidate(planningDir), raw);
1029
+ } else if (subcommand === 'extract-exports') {
1030
+ const filePath = args[2];
1031
+ if (!filePath) error('Usage: gsd-tools intel extract-exports <file-path>');
1032
+ core.output(intel.intelExtractExports(path.resolve(cwd, filePath)), raw);
1033
+ } else if (subcommand === 'update') {
1034
+ const planningDir = path.join(cwd, '.planning');
1035
+ core.output(intel.intelUpdate(planningDir), raw);
1036
+ } else {
1037
+ error('Unknown intel subcommand. Available: query, status, update, diff, snapshot, patch-meta, validate, extract-exports');
1038
+ }
1039
+ break;
1040
+ }
1041
+
940
1042
  // ─── Documentation ────────────────────────────────────────────────────
941
1043
 
942
1044
  case 'docs-init': {
@@ -944,6 +1046,42 @@ async function runCommand(command, args, cwd, raw) {
944
1046
  break;
945
1047
  }
946
1048
 
1049
+ // ─── Learnings ─────────────────────────────────────────────────────────
1050
+
1051
+ case 'learnings': {
1052
+ const subcommand = args[1];
1053
+ if (subcommand === 'list') {
1054
+ learnings.cmdLearningsList(raw);
1055
+ } else if (subcommand === 'query') {
1056
+ const tagIdx = args.indexOf('--tag');
1057
+ const tag = tagIdx !== -1 ? args[tagIdx + 1] : null;
1058
+ if (!tag) error('Usage: gsd-tools learnings query --tag <tag>');
1059
+ learnings.cmdLearningsQuery(tag, raw);
1060
+ } else if (subcommand === 'copy') {
1061
+ learnings.cmdLearningsCopy(cwd, raw);
1062
+ } else if (subcommand === 'prune') {
1063
+ const olderIdx = args.indexOf('--older-than');
1064
+ const olderThan = olderIdx !== -1 ? args[olderIdx + 1] : null;
1065
+ if (!olderThan) error('Usage: gsd-tools learnings prune --older-than <duration>');
1066
+ learnings.cmdLearningsPrune(olderThan, raw);
1067
+ } else if (subcommand === 'delete') {
1068
+ const id = args[2];
1069
+ if (!id) error('Usage: gsd-tools learnings delete <id>');
1070
+ learnings.cmdLearningsDelete(id, raw);
1071
+ } else {
1072
+ error('Unknown learnings subcommand. Available: list, query, copy, prune, delete');
1073
+ }
1074
+ break;
1075
+ }
1076
+
1077
+ // ─── GSD-2 Reverse Migration ───────────────────────────────────────────
1078
+
1079
+ case 'from-gsd2': {
1080
+ const gsd2Import = require('./lib/gsd2-import.cjs');
1081
+ gsd2Import.cmdFromGsd2(args.slice(1), cwd, raw);
1082
+ break;
1083
+ }
1084
+
947
1085
  default:
948
1086
  error(`Unknown command: ${command}`);
949
1087
  }
@@ -313,11 +313,19 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
313
313
  }
314
314
 
315
315
  // Stage files
316
- const filesToStage = files && files.length > 0 ? files : ['.planning/'];
316
+ const explicitFiles = files && files.length > 0;
317
+ const filesToStage = explicitFiles ? files : ['.planning/'];
317
318
  for (const file of filesToStage) {
318
319
  const fullPath = path.join(cwd, file);
319
320
  if (!fs.existsSync(fullPath)) {
320
- // File was deleted/moved — stage the deletion
321
+ if (explicitFiles) {
322
+ // Caller passed an explicit --files list: missing files are skipped.
323
+ // Staging a deletion here would silently remove tracked planning files
324
+ // (e.g. STATE.md, ROADMAP.md) when they are temporarily absent (#2014).
325
+ continue;
326
+ }
327
+ // Default mode (staging all of .planning/): stage the deletion so
328
+ // removed planning files are not left dangling in the index.
321
329
  execGit(cwd, ['rm', '--cached', '--ignore-unmatch', file]);
322
330
  } else {
323
331
  execGit(cwd, ['add', file]);
@@ -4,7 +4,7 @@
4
4
 
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
- const { output, error, planningRoot, CONFIG_DEFAULTS } = require('./core.cjs');
7
+ const { output, error, planningDir, withPlanningLock, CONFIG_DEFAULTS, atomicWriteFileSync } = require('./core.cjs');
8
8
  const {
9
9
  VALID_PROFILES,
10
10
  getAgentToModelMapForProfile,
@@ -15,7 +15,7 @@ const VALID_CONFIG_KEYS = new Set([
15
15
  'mode', 'granularity', 'parallelization', 'commit_docs', 'model_profile',
16
16
  'search_gitignored', 'brave_search', 'firecrawl', 'exa_search',
17
17
  'workflow.research', 'workflow.plan_check', 'workflow.verifier',
18
- 'workflow.nyquist_validation', 'workflow.ui_phase', 'workflow.ui_safety_gate',
18
+ 'workflow.nyquist_validation', 'workflow.ai_integration_phase', 'workflow.ui_phase', 'workflow.ui_safety_gate',
19
19
  'workflow.auto_advance', 'workflow.node_repair', 'workflow.node_repair_budget',
20
20
  'workflow.text_mode',
21
21
  'workflow.research_before_questions',
@@ -23,13 +23,20 @@ const VALID_CONFIG_KEYS = new Set([
23
23
  'workflow.skip_discuss',
24
24
  'workflow._auto_chain_active',
25
25
  'workflow.use_worktrees',
26
+ 'workflow.code_review',
27
+ 'workflow.code_review_depth',
26
28
  'git.branching_strategy', 'git.base_branch', 'git.phase_branch_template', 'git.milestone_branch_template', 'git.quick_branch_template',
27
29
  'planning.commit_docs', 'planning.search_gitignored',
28
30
  'workflow.subagent_timeout',
29
31
  'hooks.context_warnings',
32
+ 'features.thinking_partner',
33
+ 'context',
34
+ 'features.global_learnings',
35
+ 'learnings.max_inject',
30
36
  'project_code', 'phase_naming',
31
37
  'manager.flags.discuss', 'manager.flags.plan', 'manager.flags.execute',
32
38
  'response_language',
39
+ 'intel.enabled',
33
40
  ]);
34
41
 
35
42
  /**
@@ -41,6 +48,12 @@ function isValidConfigKey(keyPath) {
41
48
  if (VALID_CONFIG_KEYS.has(keyPath)) return true;
42
49
  // Allow agent_skills.<agent-type> with any agent type string
43
50
  if (/^agent_skills\.[a-zA-Z0-9_-]+$/.test(keyPath)) return true;
51
+ // Allow review.models.<cli-name> for per-CLI model selection in /gsd-review
52
+ if (/^review\.models\.[a-zA-Z0-9_-]+$/.test(keyPath)) return true;
53
+ // Allow features.<feature_name> — dynamic namespace for feature flags.
54
+ // Intentionally open-ended so new flags (e.g., features.global_learnings) work
55
+ // without updating VALID_CONFIG_KEYS each time.
56
+ if (/^features\.[a-zA-Z0-9_]+$/.test(keyPath)) return true;
44
57
  return false;
45
58
  }
46
59
 
@@ -50,6 +63,11 @@ const CONFIG_KEY_SUGGESTIONS = {
50
63
  'nyquist.validation_enabled': 'workflow.nyquist_validation',
51
64
  'hooks.research_questions': 'workflow.research_before_questions',
52
65
  'workflow.research_questions': 'workflow.research_before_questions',
66
+ 'workflow.codereview': 'workflow.code_review',
67
+ 'workflow.review': 'workflow.code_review',
68
+ 'workflow.code_review_level': 'workflow.code_review_depth',
69
+ 'workflow.review_depth': 'workflow.code_review_depth',
70
+ 'review.model': 'review.models.<cli-name>',
53
71
  };
54
72
 
55
73
  function validateKnownConfigKeyPath(keyPath) {
@@ -129,10 +147,13 @@ function buildNewProjectConfig(userChoices) {
129
147
  node_repair_budget: 2,
130
148
  ui_phase: true,
131
149
  ui_safety_gate: true,
150
+ ai_integration_phase: true,
132
151
  text_mode: false,
133
152
  research_before_questions: false,
134
153
  discuss_mode: 'discuss',
135
154
  skip_discuss: false,
155
+ code_review: true,
156
+ code_review_depth: 'standard',
136
157
  },
137
158
  hooks: {
138
159
  context_warnings: true,
@@ -180,7 +201,7 @@ function buildNewProjectConfig(userChoices) {
180
201
  * Idempotent: if config.json already exists, returns { created: false }.
181
202
  */
182
203
  function cmdConfigNewProject(cwd, choicesJson, raw) {
183
- const planningBase = planningRoot(cwd);
204
+ const planningBase = planningDir(cwd);
184
205
  const configPath = path.join(planningBase, 'config.json');
185
206
 
186
207
  // Idempotent: don't overwrite existing config
@@ -211,7 +232,7 @@ function cmdConfigNewProject(cwd, choicesJson, raw) {
211
232
  const config = buildNewProjectConfig(userChoices);
212
233
 
213
234
  try {
214
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
235
+ atomicWriteFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
215
236
  output({ created: true, path: '.planning/config.json' }, raw, 'created');
216
237
  } catch (err) {
217
238
  error('Failed to write config.json: ' + err.message);
@@ -225,7 +246,7 @@ function cmdConfigNewProject(cwd, choicesJson, raw) {
225
246
  * the happy path. But note that `error()` will still `exit(1)` out of the process.
226
247
  */
227
248
  function ensureConfigFile(cwd) {
228
- const planningBase = planningRoot(cwd);
249
+ const planningBase = planningDir(cwd);
229
250
  const configPath = path.join(planningBase, 'config.json');
230
251
 
231
252
  // Ensure .planning directory exists
@@ -245,7 +266,7 @@ function ensureConfigFile(cwd) {
245
266
  const config = buildNewProjectConfig({});
246
267
 
247
268
  try {
248
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
269
+ atomicWriteFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
249
270
  return { created: true, path: '.planning/config.json' };
250
271
  } catch (err) {
251
272
  error('Failed to create config.json: ' + err.message);
@@ -275,38 +296,40 @@ function cmdConfigEnsureSection(cwd, raw) {
275
296
  * the happy path. But note that `error()` will still `exit(1)` out of the process.
276
297
  */
277
298
  function setConfigValue(cwd, keyPath, parsedValue) {
278
- const configPath = path.join(planningRoot(cwd), 'config.json');
299
+ const configPath = path.join(planningDir(cwd), 'config.json');
279
300
 
280
- // Load existing config or start with empty object
281
- let config = {};
282
- try {
283
- if (fs.existsSync(configPath)) {
284
- config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
301
+ return withPlanningLock(cwd, () => {
302
+ // Load existing config or start with empty object
303
+ let config = {};
304
+ try {
305
+ if (fs.existsSync(configPath)) {
306
+ config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
307
+ }
308
+ } catch (err) {
309
+ error('Failed to read config.json: ' + err.message);
285
310
  }
286
- } catch (err) {
287
- error('Failed to read config.json: ' + err.message);
288
- }
289
311
 
290
- // Set nested value using dot notation (e.g., "workflow.research")
291
- const keys = keyPath.split('.');
292
- let current = config;
293
- for (let i = 0; i < keys.length - 1; i++) {
294
- const key = keys[i];
295
- if (current[key] === undefined || typeof current[key] !== 'object') {
296
- current[key] = {};
312
+ // Set nested value using dot notation (e.g., "workflow.research")
313
+ const keys = keyPath.split('.');
314
+ let current = config;
315
+ for (let i = 0; i < keys.length - 1; i++) {
316
+ const key = keys[i];
317
+ if (current[key] === undefined || typeof current[key] !== 'object') {
318
+ current[key] = {};
319
+ }
320
+ current = current[key];
297
321
  }
298
- current = current[key];
299
- }
300
- const previousValue = current[keys[keys.length - 1]]; // Capture previous value before overwriting
301
- current[keys[keys.length - 1]] = parsedValue;
322
+ const previousValue = current[keys[keys.length - 1]]; // Capture previous value before overwriting
323
+ current[keys[keys.length - 1]] = parsedValue;
302
324
 
303
- // write back
304
- try {
305
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
306
- return { updated: true, key: keyPath, value: parsedValue, previousValue };
307
- } catch (err) {
308
- error('Failed to write config.json: ' + err.message);
309
- }
325
+ // write back
326
+ try {
327
+ atomicWriteFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
328
+ return { updated: true, key: keyPath, value: parsedValue, previousValue };
329
+ } catch (err) {
330
+ error('Failed to write config.json: ' + err.message);
331
+ }
332
+ });
310
333
  }
311
334
 
312
335
  /**
@@ -324,7 +347,7 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
324
347
  validateKnownConfigKeyPath(keyPath);
325
348
 
326
349
  if (!isValidConfigKey(keyPath)) {
327
- error(`Unknown config key: "${keyPath}". Valid keys: ${[...VALID_CONFIG_KEYS].sort().join(', ')}, agent_skills.<agent-type>`);
350
+ error(`Unknown config key: "${keyPath}". Valid keys: ${[...VALID_CONFIG_KEYS].sort().join(', ')}, agent_skills.<agent-type>, features.<feature_name>`);
328
351
  }
329
352
 
330
353
  // Parse value (handle booleans, numbers, and JSON arrays/objects)
@@ -336,21 +359,30 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
336
359
  try { parsedValue = JSON.parse(value); } catch { /* keep as string */ }
337
360
  }
338
361
 
362
+ const VALID_CONTEXT_VALUES = ['dev', 'research', 'review'];
363
+ if (keyPath === 'context' && !VALID_CONTEXT_VALUES.includes(String(parsedValue))) {
364
+ error(`Invalid context value '${value}'. Valid values: ${VALID_CONTEXT_VALUES.join(', ')}`);
365
+ }
366
+
339
367
  const setConfigValueResult = setConfigValue(cwd, keyPath, parsedValue);
340
368
  output(setConfigValueResult, raw, `${keyPath}=${parsedValue}`);
341
369
  }
342
370
 
343
- function cmdConfigGet(cwd, keyPath, raw) {
344
- const configPath = path.join(planningRoot(cwd), 'config.json');
371
+ function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
372
+ const configPath = path.join(planningDir(cwd), 'config.json');
373
+ const hasDefault = defaultValue !== undefined;
345
374
 
346
375
  if (!keyPath) {
347
- error('Usage: config-get <key.path>');
376
+ error('Usage: config-get <key.path> [--default <value>]');
348
377
  }
349
378
 
350
379
  let config = {};
351
380
  try {
352
381
  if (fs.existsSync(configPath)) {
353
382
  config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
383
+ } else if (hasDefault) {
384
+ output(defaultValue, raw, String(defaultValue));
385
+ return;
354
386
  } else {
355
387
  error('No config.json found at ' + configPath);
356
388
  }
@@ -364,12 +396,14 @@ function cmdConfigGet(cwd, keyPath, raw) {
364
396
  let current = config;
365
397
  for (const key of keys) {
366
398
  if (current === undefined || current === null || typeof current !== 'object') {
399
+ if (hasDefault) { output(defaultValue, raw, String(defaultValue)); return; }
367
400
  error(`Key not found: ${keyPath}`);
368
401
  }
369
402
  current = current[key];
370
403
  }
371
404
 
372
405
  if (current === undefined) {
406
+ if (hasDefault) { output(defaultValue, raw, String(defaultValue)); return; }
373
407
  error(`Key not found: ${keyPath}`);
374
408
  }
375
409
 
@@ -27,6 +27,16 @@ const WORKSTREAM_SESSION_ENV_KEYS = [
27
27
  let cachedControllingTtyToken = null;
28
28
  let didProbeControllingTtyToken = false;
29
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
+
30
40
  // ─── Path helpers ────────────────────────────────────────────────────────────
31
41
 
32
42
  /** Normalize a relative path to always use forward slashes (cross-platform). */
@@ -229,6 +239,7 @@ const CONFIG_DEFAULTS = {
229
239
  plan_checker: true,
230
240
  verifier: true,
231
241
  nyquist_validation: true,
242
+ ai_integration_phase: true,
232
243
  parallelization: true,
233
244
  brave_search: false,
234
245
  firecrawl: false,
@@ -300,7 +311,7 @@ function loadConfig(cwd) {
300
311
  // Extract top-level key names from dot-notation paths (e.g., 'workflow.research' → 'workflow')
301
312
  ...[...VALID_CONFIG_KEYS].map(k => k.split('.')[0]),
302
313
  // Section containers that hold nested sub-keys
303
- 'git', 'workflow', 'planning', 'hooks',
314
+ 'git', 'workflow', 'planning', 'hooks', 'features',
304
315
  // Internal keys loadConfig reads but config-set doesn't expose
305
316
  'model_overrides', 'agent_skills', 'context_window', 'resolve_model_ids',
306
317
  // Deprecated keys (still accepted for migration, not in config-set)
@@ -400,7 +411,11 @@ function loadConfig(cwd) {
400
411
 
401
412
  // ─── Git utilities ────────────────────────────────────────────────────────────
402
413
 
414
+ const _gitIgnoredCache = new Map();
415
+
403
416
  function isGitIgnored(cwd, targetPath) {
417
+ const key = cwd + '::' + targetPath;
418
+ if (_gitIgnoredCache.has(key)) return _gitIgnoredCache.get(key);
404
419
  try {
405
420
  // --no-index checks .gitignore rules regardless of whether the file is tracked.
406
421
  // Without it, git check-ignore returns "not ignored" for tracked files even when
@@ -412,8 +427,10 @@ function isGitIgnored(cwd, targetPath) {
412
427
  cwd,
413
428
  stdio: 'pipe',
414
429
  });
430
+ _gitIgnoredCache.set(key, true);
415
431
  return true;
416
432
  } catch {
433
+ _gitIgnoredCache.set(key, false);
417
434
  return false;
418
435
  }
419
436
  }
@@ -598,10 +615,15 @@ function withPlanningLock(cwd, fn) {
598
615
  acquired: new Date().toISOString(),
599
616
  }), { flag: 'wx' });
600
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
+
601
622
  // Lock acquired — run the function
602
623
  try {
603
624
  return fn();
604
625
  } finally {
626
+ _heldPlanningLocks.delete(lockPath);
605
627
  try { fs.unlinkSync(lockPath); } catch { /* already released */ }
606
628
  }
607
629
  } catch (err) {
@@ -670,19 +692,23 @@ function planningRoot(cwd) {
670
692
  }
671
693
 
672
694
  /**
673
- * Get common .planning file paths, workstream-aware.
674
- * Scoped paths (state, roadmap, phases, requirements) resolve to the active workstream.
675
- * 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 GSD_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
+ * `gsd-tools config-get` in multi-project layouts (the CRUD writers and the
702
+ * reader pointed at different files).
676
703
  */
677
704
  function planningPaths(cwd, ws) {
678
705
  const base = planningDir(cwd, ws);
679
- const root = path.join(cwd, '.planning');
680
706
  return {
681
707
  planning: base,
682
708
  state: path.join(base, 'STATE.md'),
683
709
  roadmap: path.join(base, 'ROADMAP.md'),
684
- project: path.join(root, 'PROJECT.md'),
685
- config: path.join(root, 'config.json'),
710
+ project: path.join(base, 'PROJECT.md'),
711
+ config: path.join(base, 'config.json'),
686
712
  phases: path.join(base, 'phases'),
687
713
  requirements: path.join(base, 'REQUIREMENTS.md'),
688
714
  };
@@ -879,7 +905,10 @@ function normalizePhaseName(phase) {
879
905
  const match = stripped.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
880
906
  if (match) {
881
907
  const padded = match[1].padStart(2, '0');
882
- 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] || '';
883
912
  const decimal = match[3] || '';
884
913
  return padded + letter + decimal;
885
914
  }
@@ -1485,6 +1514,38 @@ function readSubdirectories(dirPath, sort = false) {
1485
1514
  }
1486
1515
  }
1487
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
+
1488
1549
  module.exports = {
1489
1550
  output,
1490
1551
  error,
@@ -1530,4 +1591,5 @@ module.exports = {
1530
1591
  readSubdirectories,
1531
1592
  getAgentsDir,
1532
1593
  checkAgentsInstalled,
1594
+ atomicWriteFileSync,
1533
1595
  };