gsd-opencode 1.33.2 → 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 (130) 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-add-backlog.md +1 -1
  19. package/commands/gsd/gsd-add-phase.md +1 -1
  20. package/commands/gsd/gsd-add-todo.md +1 -1
  21. package/commands/gsd/gsd-ai-integration-phase.md +36 -0
  22. package/commands/gsd/gsd-audit-fix.md +33 -0
  23. package/commands/gsd/gsd-autonomous.md +1 -0
  24. package/commands/gsd/gsd-check-todos.md +1 -1
  25. package/commands/gsd/gsd-code-review-fix.md +52 -0
  26. package/commands/gsd/gsd-code-review.md +55 -0
  27. package/commands/gsd/gsd-complete-milestone.md +1 -1
  28. package/commands/gsd/gsd-debug.md +1 -1
  29. package/commands/gsd/gsd-eval-review.md +32 -0
  30. package/commands/gsd/gsd-explore.md +27 -0
  31. package/commands/gsd/gsd-from-gsd2.md +45 -0
  32. package/commands/gsd/gsd-health.md +1 -1
  33. package/commands/gsd/gsd-import.md +36 -0
  34. package/commands/gsd/gsd-insert-phase.md +1 -1
  35. package/commands/gsd/gsd-intel.md +183 -0
  36. package/commands/gsd/gsd-manager.md +1 -1
  37. package/commands/gsd/gsd-next.md +2 -0
  38. package/commands/gsd/gsd-reapply-patches.md +58 -3
  39. package/commands/gsd/gsd-remove-phase.md +1 -1
  40. package/commands/gsd/gsd-review.md +4 -2
  41. package/commands/gsd/gsd-scan.md +26 -0
  42. package/commands/gsd/gsd-set-profile.md +1 -1
  43. package/commands/gsd/gsd-thread.md +1 -1
  44. package/commands/gsd/gsd-undo.md +34 -0
  45. package/commands/gsd/gsd-workstreams.md +6 -6
  46. package/get-shit-done/bin/gsd-tools.cjs +143 -5
  47. package/get-shit-done/bin/lib/commands.cjs +10 -2
  48. package/get-shit-done/bin/lib/config.cjs +71 -37
  49. package/get-shit-done/bin/lib/core.cjs +70 -8
  50. package/get-shit-done/bin/lib/gsd2-import.cjs +511 -0
  51. package/get-shit-done/bin/lib/init.cjs +20 -6
  52. package/get-shit-done/bin/lib/intel.cjs +660 -0
  53. package/get-shit-done/bin/lib/learnings.cjs +378 -0
  54. package/get-shit-done/bin/lib/milestone.cjs +25 -15
  55. package/get-shit-done/bin/lib/model-profiles.cjs +17 -17
  56. package/get-shit-done/bin/lib/phase.cjs +148 -112
  57. package/get-shit-done/bin/lib/roadmap.cjs +12 -5
  58. package/get-shit-done/bin/lib/security.cjs +119 -0
  59. package/get-shit-done/bin/lib/state.cjs +283 -221
  60. package/get-shit-done/bin/lib/template.cjs +8 -4
  61. package/get-shit-done/bin/lib/verify.cjs +42 -5
  62. package/get-shit-done/references/ai-evals.md +156 -0
  63. package/get-shit-done/references/ai-frameworks.md +186 -0
  64. package/get-shit-done/references/common-bug-patterns.md +114 -0
  65. package/get-shit-done/references/few-shot-examples/plan-checker.md +73 -0
  66. package/get-shit-done/references/few-shot-examples/verifier.md +109 -0
  67. package/get-shit-done/references/gates.md +70 -0
  68. package/get-shit-done/references/ios-scaffold.md +123 -0
  69. package/get-shit-done/references/model-profile-resolution.md +6 -7
  70. package/get-shit-done/references/model-profiles.md +20 -14
  71. package/get-shit-done/references/planning-config.md +237 -0
  72. package/get-shit-done/references/thinking-models-debug.md +44 -0
  73. package/get-shit-done/references/thinking-models-execution.md +50 -0
  74. package/get-shit-done/references/thinking-models-planning.md +62 -0
  75. package/get-shit-done/references/thinking-models-research.md +50 -0
  76. package/get-shit-done/references/thinking-models-verification.md +55 -0
  77. package/get-shit-done/references/thinking-partner.md +96 -0
  78. package/get-shit-done/references/universal-anti-patterns.md +6 -1
  79. package/get-shit-done/references/verification-overrides.md +227 -0
  80. package/get-shit-done/templates/AI-SPEC.md +246 -0
  81. package/get-shit-done/workflows/add-tests.md +3 -0
  82. package/get-shit-done/workflows/add-todo.md +2 -0
  83. package/get-shit-done/workflows/ai-integration-phase.md +284 -0
  84. package/get-shit-done/workflows/audit-fix.md +154 -0
  85. package/get-shit-done/workflows/autonomous.md +33 -2
  86. package/get-shit-done/workflows/check-todos.md +2 -0
  87. package/get-shit-done/workflows/cleanup.md +2 -0
  88. package/get-shit-done/workflows/code-review-fix.md +497 -0
  89. package/get-shit-done/workflows/code-review.md +515 -0
  90. package/get-shit-done/workflows/complete-milestone.md +40 -15
  91. package/get-shit-done/workflows/diagnose-issues.md +1 -1
  92. package/get-shit-done/workflows/discovery-phase.md +3 -1
  93. package/get-shit-done/workflows/discuss-phase-assumptions.md +1 -1
  94. package/get-shit-done/workflows/discuss-phase.md +21 -7
  95. package/get-shit-done/workflows/do.md +2 -0
  96. package/get-shit-done/workflows/docs-update.md +2 -0
  97. package/get-shit-done/workflows/eval-review.md +155 -0
  98. package/get-shit-done/workflows/execute-phase.md +307 -57
  99. package/get-shit-done/workflows/execute-plan.md +64 -93
  100. package/get-shit-done/workflows/explore.md +136 -0
  101. package/get-shit-done/workflows/help.md +1 -1
  102. package/get-shit-done/workflows/import.md +273 -0
  103. package/get-shit-done/workflows/inbox.md +387 -0
  104. package/get-shit-done/workflows/manager.md +4 -10
  105. package/get-shit-done/workflows/new-milestone.md +3 -1
  106. package/get-shit-done/workflows/new-project.md +2 -0
  107. package/get-shit-done/workflows/new-workspace.md +2 -0
  108. package/get-shit-done/workflows/next.md +56 -0
  109. package/get-shit-done/workflows/note.md +2 -0
  110. package/get-shit-done/workflows/plan-phase.md +97 -17
  111. package/get-shit-done/workflows/plant-seed.md +3 -0
  112. package/get-shit-done/workflows/pr-branch.md +41 -13
  113. package/get-shit-done/workflows/profile-user.md +4 -2
  114. package/get-shit-done/workflows/quick.md +99 -4
  115. package/get-shit-done/workflows/remove-workspace.md +2 -0
  116. package/get-shit-done/workflows/review.md +53 -6
  117. package/get-shit-done/workflows/scan.md +98 -0
  118. package/get-shit-done/workflows/secure-phase.md +2 -0
  119. package/get-shit-done/workflows/settings.md +18 -3
  120. package/get-shit-done/workflows/ship.md +3 -0
  121. package/get-shit-done/workflows/ui-phase.md +10 -2
  122. package/get-shit-done/workflows/ui-review.md +2 -0
  123. package/get-shit-done/workflows/undo.md +314 -0
  124. package/get-shit-done/workflows/update.md +2 -0
  125. package/get-shit-done/workflows/validate-phase.md +2 -0
  126. package/get-shit-done/workflows/verify-phase.md +83 -0
  127. package/get-shit-done/workflows/verify-work.md +12 -1
  128. package/package.json +1 -1
  129. package/skills/gsd-code-review/SKILL.md +48 -0
  130. package/skills/gsd-code-review-fix/SKILL.md +44 -0
@@ -6,7 +6,7 @@ const fs = require('fs');
6
6
  const path = require('path');
7
7
  const { escapeRegex, loadConfig, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, toPosixPath, planningDir, withPlanningLock, output, error, readSubdirectories, phaseTokenMatches } = require('./core.cjs');
8
8
  const { extractFrontmatter } = require('./frontmatter.cjs');
9
- const { writeStateMd, stateExtractField, stateReplaceField, stateReplaceFieldWithFallback, updatePerformanceMetricsSection } = require('./state.cjs');
9
+ const { writeStateMd, readModifyWriteStateMd, stateExtractField, stateReplaceField, stateReplaceFieldWithFallback, updatePerformanceMetricsSection } = require('./state.cjs');
10
10
 
11
11
  function cmdPhasesList(cwd, options, raw) {
12
12
  const phasesDir = path.join(planningDir(cwd), 'phases');
@@ -88,50 +88,49 @@ function cmdPhaseNextDecimal(cwd, basePhase, raw) {
88
88
  const phasesDir = path.join(planningDir(cwd), 'phases');
89
89
  const normalized = normalizePhaseName(basePhase);
90
90
 
91
- // Check if phases directory exists
92
- if (!fs.existsSync(phasesDir)) {
93
- output(
94
- {
95
- found: false,
96
- base_phase: normalized,
97
- next: `${normalized}.1`,
98
- existing: [],
99
- },
100
- raw,
101
- `${normalized}.1`
102
- );
103
- return;
104
- }
105
-
106
91
  try {
107
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
108
- const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
109
-
110
- // Check if base phase exists
111
- const baseExists = dirs.some(d => phaseTokenMatches(d, normalized));
92
+ let baseExists = false;
93
+ const decimalSet = new Set();
112
94
 
113
- // Find existing decimal phases for this base
114
- const decimalPattern = new RegExp(`^${normalized}\\.(\\d+)`);
115
- const existingDecimals = [];
95
+ // Scan directory names for existing decimal phases
96
+ if (fs.existsSync(phasesDir)) {
97
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
98
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
99
+ baseExists = dirs.some(d => phaseTokenMatches(d, normalized));
116
100
 
117
- for (const dir of dirs) {
118
- const match = dir.match(decimalPattern);
119
- if (match) {
120
- existingDecimals.push(`${normalized}.${match[1]}`);
101
+ const dirPattern = new RegExp(`^(?:[A-Z]{1,6}-)?${escapeRegex(normalized)}\\.(\\d+)`);
102
+ for (const dir of dirs) {
103
+ const match = dir.match(dirPattern);
104
+ if (match) decimalSet.add(parseInt(match[1], 10));
121
105
  }
122
106
  }
123
107
 
124
- // Sort numerically
125
- existingDecimals.sort((a, b) => comparePhaseNum(a, b));
108
+ // Also scan ROADMAP.md for phase entries that may not have directories yet
109
+ const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
110
+ if (fs.existsSync(roadmapPath)) {
111
+ try {
112
+ const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
113
+ const phasePattern = new RegExp(
114
+ `#{2,4}\\s*Phase\\s+0*${escapeRegex(normalized)}\\.(\\d+)\\s*:`, 'gi'
115
+ );
116
+ let pm;
117
+ while ((pm = phasePattern.exec(roadmapContent)) !== null) {
118
+ decimalSet.add(parseInt(pm[1], 10));
119
+ }
120
+ } catch { /* ROADMAP.md read failure is non-fatal */ }
121
+ }
122
+
123
+ // Build sorted list of existing decimals
124
+ const existingDecimals = Array.from(decimalSet)
125
+ .sort((a, b) => a - b)
126
+ .map(n => `${normalized}.${n}`);
126
127
 
127
128
  // Calculate next decimal
128
129
  let nextDecimal;
129
- if (existingDecimals.length === 0) {
130
+ if (decimalSet.size === 0) {
130
131
  nextDecimal = `${normalized}.1`;
131
132
  } else {
132
- const lastDecimal = existingDecimals[existingDecimals.length - 1];
133
- const lastNum = parseInt(lastDecimal.split('.')[1], 10);
134
- nextDecimal = `${normalized}.${lastNum + 1}`;
133
+ nextDecimal = `${normalized}.${Math.max(...decimalSet) + 1}`;
135
134
  }
136
135
 
137
136
  output(
@@ -341,15 +340,34 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
341
340
  if (!_newPhaseId) error('--id required when phase_naming is "custom"');
342
341
  _dirName = `${prefix}${_newPhaseId}-${slug}`;
343
342
  } else {
344
- // Sequential mode: find highest integer phase number (in current milestone only)
343
+ // Sequential mode: find highest integer phase number from two sources:
344
+ // 1. ROADMAP.md (current milestone only)
345
+ // 2. .planning/phases/ on disk (orphan directories not tracked in roadmap)
346
+ // Skip 999.x backlog phases — they live outside the active sequence
345
347
  const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
346
348
  let maxPhase = 0;
347
349
  let m;
348
350
  while ((m = phasePattern.exec(content)) !== null) {
349
351
  const num = parseInt(m[1], 10);
352
+ if (num >= 999) continue; // backlog phases use 999.x numbering
350
353
  if (num > maxPhase) maxPhase = num;
351
354
  }
352
355
 
356
+ // Also scan .planning/phases/ for orphan directories not tracked in ROADMAP.
357
+ // Directory names follow: [PREFIX-]NN-slug (e.g. 03-api or CK-05-old-feature).
358
+ // Strip the optional project_code prefix before extracting the leading integer.
359
+ const phasesOnDisk = path.join(planningDir(cwd), 'phases');
360
+ if (fs.existsSync(phasesOnDisk)) {
361
+ const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)-/;
362
+ for (const entry of fs.readdirSync(phasesOnDisk)) {
363
+ const match = entry.match(dirNumPattern);
364
+ if (!match) continue;
365
+ const num = parseInt(match[1], 10);
366
+ if (num >= 999) continue; // skip backlog orphans
367
+ if (num > maxPhase) maxPhase = num;
368
+ }
369
+ }
370
+
353
371
  _newPhaseId = maxPhase + 1;
354
372
  const paddedNum = String(_newPhaseId).padStart(2, '0');
355
373
  _dirName = `${prefix}${paddedNum}-${slug}`;
@@ -416,22 +434,31 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
416
434
  error(`Phase ${afterPhase} not found in ROADMAP.md`);
417
435
  }
418
436
 
419
- // Calculate next decimal using existing logic
437
+ // Calculate next decimal by scanning both directories AND ROADMAP.md entries
420
438
  const phasesDir = path.join(planningDir(cwd), 'phases');
421
439
  const normalizedBase = normalizePhaseName(afterPhase);
422
- let existingDecimals = [];
440
+ const decimalSet = new Set();
423
441
 
424
442
  try {
425
443
  const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
426
444
  const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
427
- const decimalPattern = new RegExp(`^(?:[A-Z]{1,6}-)?${normalizedBase}\\.(\\d+)`);
445
+ const decimalPattern = new RegExp(`^(?:[A-Z]{1,6}-)?${escapeRegex(normalizedBase)}\\.(\\d+)`);
428
446
  for (const dir of dirs) {
429
447
  const dm = dir.match(decimalPattern);
430
- if (dm) existingDecimals.push(parseInt(dm[1], 10));
448
+ if (dm) decimalSet.add(parseInt(dm[1], 10));
431
449
  }
432
450
  } catch { /* intentionally empty */ }
433
451
 
434
- const nextDecimal = existingDecimals.length === 0 ? 1 : Math.max(...existingDecimals) + 1;
452
+ // Also scan ROADMAP.md content (already loaded) for decimal entries
453
+ const rmPhasePattern = new RegExp(
454
+ `#{2,4}\\s*Phase\\s+0*${escapeRegex(normalizedBase)}\\.(\\d+)\\s*:`, 'gi'
455
+ );
456
+ let rmMatch;
457
+ while ((rmMatch = rmPhasePattern.exec(rawContent)) !== null) {
458
+ decimalSet.add(parseInt(rmMatch[1], 10));
459
+ }
460
+
461
+ const nextDecimal = decimalSet.size === 0 ? 1 : Math.max(...decimalSet) + 1;
435
462
  const _decimalPhase = `${normalizedBase}.${nextDecimal}`;
436
463
  // Optional project code prefix
437
464
  const insertConfig = loadConfig(cwd);
@@ -624,19 +651,20 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
624
651
  // Update ROADMAP.md
625
652
  updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, parseInt(normalized, 10), cwd);
626
653
 
627
- // Update STATE.md phase count
654
+ // Update STATE.md phase count atomically (#P4.4)
628
655
  const statePath = path.join(planningDir(cwd), 'STATE.md');
629
656
  if (fs.existsSync(statePath)) {
630
- let stateContent = fs.readFileSync(statePath, 'utf-8');
631
- const totalRaw = stateExtractField(stateContent, 'Total Phases');
632
- if (totalRaw) {
633
- stateContent = stateReplaceField(stateContent, 'Total Phases', String(parseInt(totalRaw, 10) - 1)) || stateContent;
634
- }
635
- const ofMatch = stateContent.match(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i);
636
- if (ofMatch) {
637
- stateContent = stateContent.replace(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i, `$1${parseInt(ofMatch[2], 10) - 1}$3`);
638
- }
639
- writeStateMd(statePath, stateContent, cwd);
657
+ readModifyWriteStateMd(statePath, (stateContent) => {
658
+ const totalRaw = stateExtractField(stateContent, 'Total Phases');
659
+ if (totalRaw) {
660
+ stateContent = stateReplaceField(stateContent, 'Total Phases', String(parseInt(totalRaw, 10) - 1)) || stateContent;
661
+ }
662
+ const ofMatch = stateContent.match(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i);
663
+ if (ofMatch) {
664
+ stateContent = stateContent.replace(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i, `$1${parseInt(ofMatch[2], 10) - 1}$3`);
665
+ }
666
+ return stateContent;
667
+ }, cwd);
640
668
  }
641
669
 
642
670
  output({
@@ -701,7 +729,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
701
729
  `(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${escapeRegex(phaseNum)}[:\\s][^\\n]*)`,
702
730
  'i'
703
731
  );
704
- roadmapContent = replaceInCurrentMilestone(roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`);
732
+ roadmapContent = roadmapContent.replace(checkboxPattern, `$1x$2 (completed ${today})`);
705
733
 
706
734
  // Progress table: update Status to Complete, add date (handles 4 or 5 column tables)
707
735
  const phaseEscaped = escapeRegex(phaseNum);
@@ -725,13 +753,20 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
725
753
  return '|' + cells.join('|') + '|';
726
754
  });
727
755
 
728
- // Update plan count in phase section
756
+ // Update plan count in phase section.
757
+ // Use direct .replace() rather than replaceInCurrentMilestone() so this
758
+ // works when the current milestone section is itself inside a <details>
759
+ // block (the standard /gsd-new-project layout). replaceInCurrentMilestone
760
+ // scopes to content after the last </details>, which misses content inside
761
+ // the current milestone's own <details> wrapper (#2005).
762
+ // The phase-scoped heading pattern is specific enough to avoid matching
763
+ // archived phases (which belong to different milestones).
729
764
  const planCountPattern = new RegExp(
730
765
  `(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
731
766
  'i'
732
767
  );
733
- roadmapContent = replaceInCurrentMilestone(
734
- roadmapContent, planCountPattern,
768
+ roadmapContent = roadmapContent.replace(
769
+ planCountPattern,
735
770
  `$1${summaryCount}/${planCount} plans complete`
736
771
  );
737
772
 
@@ -834,71 +869,72 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
834
869
  } catch { /* intentionally empty */ }
835
870
  }
836
871
 
837
- // Update STATE.md — use shared helpers that handle both **bold:** and plain Field: formats
872
+ // Update STATE.md atomically hold lock across read-modify-write (#P4.4).
873
+ // Previously read outside the lock; a crash between the ROADMAP update
874
+ // (locked above) and this write left ROADMAP/STATE inconsistent.
838
875
  if (fs.existsSync(statePath)) {
839
- let stateContent = fs.readFileSync(statePath, 'utf-8');
840
-
841
- // Update Current Phase preserve "X of Y (Name)" compound format
842
- const phaseValue = nextPhaseNum || phaseNum;
843
- const existingPhaseField = stateExtractField(stateContent, 'Current Phase')
844
- || stateExtractField(stateContent, 'Phase');
845
- let newPhaseValue = String(phaseValue);
846
- if (existingPhaseField) {
847
- const totalMatch = existingPhaseField.match(/of\s+(\d+)/);
848
- const nameMatch = existingPhaseField.match(/\(([^)]+)\)/);
849
- if (totalMatch) {
850
- const total = totalMatch[1];
851
- const nameStr = nextPhaseName ? ` (${nextPhaseName.replace(/-/g, ' ')})` : (nameMatch ? ` (${nameMatch[1]})` : '');
852
- newPhaseValue = `${phaseValue} of ${total}${nameStr}`;
876
+ readModifyWriteStateMd(statePath, (stateContent) => {
877
+ // Update Current Phase — preserve "X of Y (Name)" compound format
878
+ const phaseValue = nextPhaseNum || phaseNum;
879
+ const existingPhaseField = stateExtractField(stateContent, 'Current Phase')
880
+ || stateExtractField(stateContent, 'Phase');
881
+ let newPhaseValue = String(phaseValue);
882
+ if (existingPhaseField) {
883
+ const totalMatch = existingPhaseField.match(/of\s+(\d+)/);
884
+ const nameMatch = existingPhaseField.match(/\(([^)]+)\)/);
885
+ if (totalMatch) {
886
+ const total = totalMatch[1];
887
+ const nameStr = nextPhaseName ? ` (${nextPhaseName.replace(/-/g, ' ')})` : (nameMatch ? ` (${nameMatch[1]})` : '');
888
+ newPhaseValue = `${phaseValue} of ${total}${nameStr}`;
889
+ }
853
890
  }
854
- }
855
- stateContent = stateReplaceFieldWithFallback(stateContent, 'Current Phase', 'Phase', newPhaseValue);
891
+ stateContent = stateReplaceFieldWithFallback(stateContent, 'Current Phase', 'Phase', newPhaseValue);
856
892
 
857
- // Update Current Phase Name
858
- if (nextPhaseName) {
859
- stateContent = stateReplaceFieldWithFallback(stateContent, 'Current Phase Name', null, nextPhaseName.replace(/-/g, ' '));
860
- }
861
-
862
- // Update Status
863
- stateContent = stateReplaceFieldWithFallback(stateContent, 'Status', null,
864
- isLastPhase ? 'Milestone complete' : 'Ready to plan');
865
-
866
- // Update Current Plan
867
- stateContent = stateReplaceFieldWithFallback(stateContent, 'Current Plan', 'Plan', 'Not started');
868
-
869
- // Update Last Activity
870
- stateContent = stateReplaceFieldWithFallback(stateContent, 'Last Activity', 'Last activity', today);
871
-
872
- // Update Last Activity Description
873
- stateContent = stateReplaceFieldWithFallback(stateContent, 'Last Activity Description', null,
874
- `Phase ${phaseNum} complete${nextPhaseNum ? `, transitioned to Phase ${nextPhaseNum}` : ''}`);
875
-
876
- // Increment Completed Phases counter (#956)
877
- const completedRaw = stateExtractField(stateContent, 'Completed Phases');
878
- if (completedRaw) {
879
- const newCompleted = parseInt(completedRaw, 10) + 1;
880
- stateContent = stateReplaceField(stateContent, 'Completed Phases', String(newCompleted)) || stateContent;
893
+ // Update Current Phase Name
894
+ if (nextPhaseName) {
895
+ stateContent = stateReplaceFieldWithFallback(stateContent, 'Current Phase Name', null, nextPhaseName.replace(/-/g, ' '));
896
+ }
881
897
 
882
- // Recalculate percent based on completed / total (#956)
883
- const totalRaw = stateExtractField(stateContent, 'Total Phases');
884
- if (totalRaw) {
885
- const totalPhases = parseInt(totalRaw, 10);
886
- if (totalPhases > 0) {
887
- const newPercent = Math.round((newCompleted / totalPhases) * 100);
888
- stateContent = stateReplaceField(stateContent, 'Progress', `${newPercent}%`) || stateContent;
889
- // Also update percent field if it exists separately
890
- stateContent = stateContent.replace(
891
- /(percent:\s*)\d+/,
892
- `$1${newPercent}`
893
- );
898
+ // Update Status
899
+ stateContent = stateReplaceFieldWithFallback(stateContent, 'Status', null,
900
+ isLastPhase ? 'Milestone complete' : 'Ready to plan');
901
+
902
+ // Update Current Plan
903
+ stateContent = stateReplaceFieldWithFallback(stateContent, 'Current Plan', 'Plan', 'Not started');
904
+
905
+ // Update Last Activity
906
+ stateContent = stateReplaceFieldWithFallback(stateContent, 'Last Activity', 'Last activity', today);
907
+
908
+ // Update Last Activity Description
909
+ stateContent = stateReplaceFieldWithFallback(stateContent, 'Last Activity Description', null,
910
+ `Phase ${phaseNum} complete${nextPhaseNum ? `, transitioned to Phase ${nextPhaseNum}` : ''}`);
911
+
912
+ // Increment Completed Phases counter (#956)
913
+ const completedRaw = stateExtractField(stateContent, 'Completed Phases');
914
+ if (completedRaw) {
915
+ const newCompleted = parseInt(completedRaw, 10) + 1;
916
+ stateContent = stateReplaceField(stateContent, 'Completed Phases', String(newCompleted)) || stateContent;
917
+
918
+ // Recalculate percent based on completed / total (#956)
919
+ const totalRaw = stateExtractField(stateContent, 'Total Phases');
920
+ if (totalRaw) {
921
+ const totalPhases = parseInt(totalRaw, 10);
922
+ if (totalPhases > 0) {
923
+ const newPercent = Math.round((newCompleted / totalPhases) * 100);
924
+ stateContent = stateReplaceField(stateContent, 'Progress', `${newPercent}%`) || stateContent;
925
+ stateContent = stateContent.replace(
926
+ /(percent:\s*)\d+/,
927
+ `$1${newPercent}`
928
+ );
929
+ }
894
930
  }
895
931
  }
896
- }
897
932
 
898
- // Gate 4: Update Performance Metrics section (#1627)
899
- stateContent = updatePerformanceMetricsSection(stateContent, cwd, phaseNum, planCount, summaryCount);
933
+ // Gate 4: Update Performance Metrics section (#1627)
934
+ stateContent = updatePerformanceMetricsSection(stateContent, cwd, phaseNum, planCount, summaryCount);
900
935
 
901
- writeStateMd(statePath, stateContent, cwd);
936
+ return stateContent;
937
+ }, cwd);
902
938
  }
903
939
 
904
940
  const result = {
@@ -4,7 +4,7 @@
4
4
 
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
- const { escapeRegex, normalizePhaseName, planningPaths, withPlanningLock, output, error, findPhaseInternal, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, phaseTokenMatches } = require('./core.cjs');
7
+ const { escapeRegex, normalizePhaseName, planningPaths, withPlanningLock, output, error, findPhaseInternal, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, phaseTokenMatches, atomicWriteFileSync } = require('./core.cjs');
8
8
 
9
9
  /**
10
10
  * Search for a phase header (and its section) within the given content string.
@@ -129,6 +129,15 @@ function cmdRoadmapAnalyze(cwd, raw) {
129
129
  const phases = [];
130
130
  let match;
131
131
 
132
+ // Build phase directory lookup once (O(1) readdir instead of O(N) per phase)
133
+ const _phaseDirNames = (() => {
134
+ try {
135
+ return fs.readdirSync(phasesDir, { withFileTypes: true })
136
+ .filter(e => e.isDirectory())
137
+ .map(e => e.name);
138
+ } catch { return []; }
139
+ })();
140
+
132
141
  while ((match = phasePattern.exec(content)) !== null) {
133
142
  const phaseNum = match[1];
134
143
  const phaseName = match[2].replace(/\(INSERTED\)/i, '').trim();
@@ -155,9 +164,7 @@ function cmdRoadmapAnalyze(cwd, raw) {
155
164
  let hasResearch = false;
156
165
 
157
166
  try {
158
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
159
- const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
160
- const dirMatch = dirs.find(d => phaseTokenMatches(d, normalized));
167
+ const dirMatch = _phaseDirNames.find(d => phaseTokenMatches(d, normalized));
161
168
 
162
169
  if (dirMatch) {
163
170
  const phaseFiles = fs.readdirSync(path.join(phasesDir, dirMatch));
@@ -334,7 +341,7 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
334
341
  roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2');
335
342
  }
336
343
 
337
- fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8');
344
+ atomicWriteFileSync(roadmapPath, roadmapContent, 'utf-8');
338
345
  });
339
346
  output({
340
347
  updated: true,
@@ -152,6 +152,25 @@ const INJECTION_PATTERNS = [
152
152
  /(?:run|execute|call|invoke)\s+(?:the\s+)?(?:bash|shell|exec|spawn)\s+(?:tool|command)/i,
153
153
  ];
154
154
 
155
+ /**
156
+ * Layer 2: Encoding-obfuscation patterns with custom finding messages.
157
+ * Each entry: { pattern: RegExp, message: string }
158
+ */
159
+ const OBFUSCATION_PATTERN_ENTRIES = [
160
+ {
161
+ pattern: /\b(\w\s){4,}\w\b/,
162
+ message: 'Character-spacing obfuscation pattern detected (e.g. "i g n o r e")',
163
+ },
164
+ {
165
+ pattern: /<\/?(system|human|assistant|user)\s*>/i,
166
+ message: 'Delimiter injection pattern: <system>/<assistant>/<user> tag detected',
167
+ },
168
+ {
169
+ pattern: /0x[0-9a-fA-F]{16,}/,
170
+ message: 'Long hex sequence detected — possible encoded payload',
171
+ },
172
+ ];
173
+
155
174
  /**
156
175
  * Scan text for potential prompt injection patterns.
157
176
  * Returns an array of findings (empty = clean).
@@ -174,6 +193,13 @@ function scanForInjection(text, opts = {}) {
174
193
  }
175
194
  }
176
195
 
196
+ // Layer 2: encoding-obfuscation patterns with custom messages
197
+ for (const entry of OBFUSCATION_PATTERN_ENTRIES) {
198
+ if (entry.pattern.test(text)) {
199
+ findings.push(entry.message);
200
+ }
201
+ }
202
+
177
203
  if (opts.strict) {
178
204
  // Check for suspicious Unicode that could hide instructions
179
205
  // (zero-width chars, RTL override, homoglyph attacks)
@@ -181,6 +207,12 @@ function scanForInjection(text, opts = {}) {
181
207
  findings.push('Contains suspicious zero-width or invisible Unicode characters');
182
208
  }
183
209
 
210
+ // Layer 1: Unicode tag block U+E0000–U+E007F (2025 supply-chain attack vector)
211
+ // These characters are invisible and can embed hidden instructions
212
+ if (/[\uDB40\uDC00-\uDB40\uDC7F]/u.test(text) || /[\u{E0000}-\u{E007F}]/u.test(text)) {
213
+ findings.push('Contains Unicode tag block characters (U+E0000–E007F) — invisible instruction injection vector');
214
+ }
215
+
184
216
  // Check for extremely long strings that could be prompt stuffing.
185
217
  // Normalize CRLF → LF before measuring so Windows checkouts don't inflate the count.
186
218
  const normalizedLength = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').length;
@@ -361,6 +393,87 @@ function validateFieldName(field) {
361
393
  return { valid: false, error: `Invalid field name: "${field}"` };
362
394
  }
363
395
 
396
+ // ─── Layer 3: Structural Schema Validation ───────────────────────────────────
397
+
398
+ const KNOWN_VALID_TAGS = new Set([
399
+ 'objective', 'process', 'step', 'success_criteria', 'critical_rules',
400
+ 'available_agent_types', 'purpose', 'required_reading',
401
+ ]);
402
+
403
+ /**
404
+ * Validate the XML structure of a prompt file.
405
+ * For agent/workflow files, flags any XML tag not in the known-valid set.
406
+ *
407
+ * @param {string} text - The file content to validate
408
+ * @param {'agent'|'workflow'|'unknown'} fileType - The type of prompt file
409
+ * @returns {{ valid: boolean, violations: string[] }}
410
+ */
411
+ function validatePromptStructure(text, fileType) {
412
+ if (!text || typeof text !== 'string') {
413
+ return { valid: true, violations: [] };
414
+ }
415
+
416
+ if (fileType !== 'agent' && fileType !== 'workflow') {
417
+ return { valid: true, violations: [] };
418
+ }
419
+
420
+ const violations = [];
421
+ const tagRegex = /<([A-Za-z][A-Za-z0-9_-]*)/g;
422
+ let match;
423
+ while ((match = tagRegex.exec(text)) !== null) {
424
+ const tag = match[1].toLowerCase();
425
+ if (!KNOWN_VALID_TAGS.has(tag)) {
426
+ violations.push(`Unknown XML tag in ${fileType} file: <${tag}>`);
427
+ }
428
+ }
429
+
430
+ return { valid: violations.length === 0, violations };
431
+ }
432
+
433
+ // ─── Layer 4: Paragraph-Level Entropy Anomaly Detection ─────────────────────
434
+
435
+ function shannonEntropy(text) {
436
+ if (!text || text.length === 0) return 0;
437
+ const freq = {};
438
+ for (const ch of text) {
439
+ freq[ch] = (freq[ch] || 0) + 1;
440
+ }
441
+ const len = text.length;
442
+ let entropy = 0;
443
+ for (const count of Object.values(freq)) {
444
+ const p = count / len;
445
+ entropy -= p * Math.log2(p);
446
+ }
447
+ return entropy;
448
+ }
449
+
450
+ /**
451
+ * Scan text for paragraphs with anomalously high Shannon entropy.
452
+ *
453
+ * @param {string} text - The text to scan
454
+ * @returns {{ clean: boolean, findings: string[] }}
455
+ */
456
+ function scanEntropyAnomalies(text) {
457
+ if (!text || typeof text !== 'string') {
458
+ return { clean: true, findings: [] };
459
+ }
460
+
461
+ const findings = [];
462
+ const paragraphs = text.split(/\n\n+/);
463
+
464
+ for (const para of paragraphs) {
465
+ if (para.length <= 50) continue;
466
+ const entropy = shannonEntropy(para);
467
+ if (entropy > 5.5) {
468
+ findings.push(
469
+ `High-entropy paragraph detected (${entropy.toFixed(2)} bits/char) — possible encoded payload`
470
+ );
471
+ }
472
+ }
473
+
474
+ return { clean: findings.length === 0, findings };
475
+ }
476
+
364
477
  module.exports = {
365
478
  // Path safety
366
479
  validatePath,
@@ -381,4 +494,10 @@ module.exports = {
381
494
  // Input validation
382
495
  validatePhaseNumber,
383
496
  validateFieldName,
497
+
498
+ // Structural validation (Layer 3)
499
+ validatePromptStructure,
500
+
501
+ // Entropy anomaly detection (Layer 4)
502
+ scanEntropyAnomalies,
384
503
  };