gsd-opencode 1.30.0 → 1.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/agents/gsd-debugger.md +0 -1
  2. package/agents/gsd-doc-verifier.md +207 -0
  3. package/agents/gsd-doc-writer.md +608 -0
  4. package/agents/gsd-executor.md +22 -1
  5. package/agents/gsd-phase-researcher.md +41 -0
  6. package/agents/gsd-plan-checker.md +82 -0
  7. package/agents/gsd-planner.md +123 -194
  8. package/agents/gsd-security-auditor.md +129 -0
  9. package/agents/gsd-ui-auditor.md +40 -0
  10. package/agents/gsd-user-profiler.md +2 -2
  11. package/agents/gsd-verifier.md +84 -18
  12. package/commands/gsd/gsd-add-backlog.md +1 -1
  13. package/commands/gsd/gsd-analyze-dependencies.md +34 -0
  14. package/commands/gsd/gsd-autonomous.md +6 -2
  15. package/commands/gsd/gsd-cleanup.md +5 -0
  16. package/commands/gsd/gsd-debug.md +24 -21
  17. package/commands/gsd/gsd-discuss-phase.md +7 -2
  18. package/commands/gsd/gsd-docs-update.md +48 -0
  19. package/commands/gsd/gsd-execute-phase.md +4 -0
  20. package/commands/gsd/gsd-help.md +2 -0
  21. package/commands/gsd/gsd-join-discord.md +2 -1
  22. package/commands/gsd/gsd-manager.md +1 -0
  23. package/commands/gsd/gsd-new-project.md +4 -0
  24. package/commands/gsd/gsd-plan-phase.md +5 -0
  25. package/commands/gsd/gsd-quick.md +5 -3
  26. package/commands/gsd/gsd-reapply-patches.md +171 -39
  27. package/commands/gsd/gsd-research-phase.md +2 -12
  28. package/commands/gsd/gsd-review-backlog.md +1 -0
  29. package/commands/gsd/gsd-review.md +3 -2
  30. package/commands/gsd/gsd-secure-phase.md +35 -0
  31. package/commands/gsd/gsd-thread.md +1 -1
  32. package/commands/gsd/gsd-workstreams.md +7 -2
  33. package/get-shit-done/bin/gsd-tools.cjs +42 -8
  34. package/get-shit-done/bin/lib/commands.cjs +68 -14
  35. package/get-shit-done/bin/lib/config.cjs +18 -10
  36. package/get-shit-done/bin/lib/core.cjs +383 -80
  37. package/get-shit-done/bin/lib/docs.cjs +267 -0
  38. package/get-shit-done/bin/lib/frontmatter.cjs +47 -2
  39. package/get-shit-done/bin/lib/init.cjs +85 -5
  40. package/get-shit-done/bin/lib/milestone.cjs +21 -0
  41. package/get-shit-done/bin/lib/model-profiles.cjs +2 -0
  42. package/get-shit-done/bin/lib/phase.cjs +232 -189
  43. package/get-shit-done/bin/lib/profile-output.cjs +97 -1
  44. package/get-shit-done/bin/lib/roadmap.cjs +137 -113
  45. package/get-shit-done/bin/lib/schema-detect.cjs +238 -0
  46. package/get-shit-done/bin/lib/security.cjs +5 -3
  47. package/get-shit-done/bin/lib/state.cjs +366 -44
  48. package/get-shit-done/bin/lib/verify.cjs +158 -14
  49. package/get-shit-done/bin/lib/workstream.cjs +6 -2
  50. package/get-shit-done/references/agent-contracts.md +79 -0
  51. package/get-shit-done/references/artifact-types.md +113 -0
  52. package/get-shit-done/references/context-budget.md +49 -0
  53. package/get-shit-done/references/continuation-format.md +15 -15
  54. package/get-shit-done/references/domain-probes.md +125 -0
  55. package/get-shit-done/references/gate-prompts.md +100 -0
  56. package/get-shit-done/references/model-profiles.md +2 -2
  57. package/get-shit-done/references/planner-gap-closure.md +62 -0
  58. package/get-shit-done/references/planner-reviews.md +39 -0
  59. package/get-shit-done/references/planner-revision.md +87 -0
  60. package/get-shit-done/references/planning-config.md +15 -0
  61. package/get-shit-done/references/revision-loop.md +97 -0
  62. package/get-shit-done/references/ui-brand.md +2 -2
  63. package/get-shit-done/references/universal-anti-patterns.md +58 -0
  64. package/get-shit-done/references/workstream-flag.md +56 -3
  65. package/get-shit-done/templates/SECURITY.md +61 -0
  66. package/get-shit-done/templates/VALIDATION.md +3 -3
  67. package/get-shit-done/templates/claude-md.md +27 -4
  68. package/get-shit-done/templates/config.json +4 -0
  69. package/get-shit-done/templates/debug-subagent-prompt.md +2 -6
  70. package/get-shit-done/templates/planner-subagent-prompt.md +2 -10
  71. package/get-shit-done/workflows/add-phase.md +2 -2
  72. package/get-shit-done/workflows/add-todo.md +1 -1
  73. package/get-shit-done/workflows/analyze-dependencies.md +96 -0
  74. package/get-shit-done/workflows/audit-milestone.md +8 -12
  75. package/get-shit-done/workflows/autonomous.md +158 -13
  76. package/get-shit-done/workflows/check-todos.md +2 -2
  77. package/get-shit-done/workflows/complete-milestone.md +13 -4
  78. package/get-shit-done/workflows/diagnose-issues.md +8 -6
  79. package/get-shit-done/workflows/discovery-phase.md +1 -1
  80. package/get-shit-done/workflows/discuss-phase-assumptions.md +22 -4
  81. package/get-shit-done/workflows/discuss-phase-power.md +291 -0
  82. package/get-shit-done/workflows/discuss-phase.md +149 -11
  83. package/get-shit-done/workflows/docs-update.md +1093 -0
  84. package/get-shit-done/workflows/execute-phase.md +362 -66
  85. package/get-shit-done/workflows/execute-plan.md +1 -1
  86. package/get-shit-done/workflows/help.md +9 -6
  87. package/get-shit-done/workflows/insert-phase.md +2 -2
  88. package/get-shit-done/workflows/manager.md +27 -26
  89. package/get-shit-done/workflows/map-codebase.md +10 -32
  90. package/get-shit-done/workflows/new-milestone.md +14 -8
  91. package/get-shit-done/workflows/new-project.md +48 -25
  92. package/get-shit-done/workflows/next.md +1 -1
  93. package/get-shit-done/workflows/note.md +1 -1
  94. package/get-shit-done/workflows/pause-work.md +73 -10
  95. package/get-shit-done/workflows/plan-milestone-gaps.md +2 -2
  96. package/get-shit-done/workflows/plan-phase.md +184 -32
  97. package/get-shit-done/workflows/progress.md +20 -20
  98. package/get-shit-done/workflows/quick.md +102 -84
  99. package/get-shit-done/workflows/research-phase.md +2 -6
  100. package/get-shit-done/workflows/resume-project.md +4 -4
  101. package/get-shit-done/workflows/review.md +56 -3
  102. package/get-shit-done/workflows/secure-phase.md +154 -0
  103. package/get-shit-done/workflows/settings.md +13 -2
  104. package/get-shit-done/workflows/ship.md +13 -4
  105. package/get-shit-done/workflows/transition.md +6 -6
  106. package/get-shit-done/workflows/ui-phase.md +4 -14
  107. package/get-shit-done/workflows/ui-review.md +25 -7
  108. package/get-shit-done/workflows/update.md +165 -16
  109. package/get-shit-done/workflows/validate-phase.md +1 -11
  110. package/get-shit-done/workflows/verify-phase.md +127 -6
  111. package/get-shit-done/workflows/verify-work.md +69 -21
  112. package/package.json +1 -1
@@ -141,29 +141,28 @@ function cmdStatePatch(cwd, patches, raw) {
141
141
 
142
142
  const statePath = planningPaths(cwd).state;
143
143
  try {
144
- let content = fs.readFileSync(statePath, 'utf-8');
145
144
  const results = { updated: [], failed: [] };
146
145
 
147
- for (const [field, value] of Object.entries(patches)) {
148
- const fieldEscaped = escapeRegex(field);
149
- // Try **Field:** bold format first, then plain Field: format
150
- const boldPattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
151
- const plainPattern = new RegExp(`(^${fieldEscaped}:\\s*)(.*)`, 'im');
152
-
153
- if (boldPattern.test(content)) {
154
- content = content.replace(boldPattern, (_match, prefix) => `${prefix}${value}`);
155
- results.updated.push(field);
156
- } else if (plainPattern.test(content)) {
157
- content = content.replace(plainPattern, (_match, prefix) => `${prefix}${value}`);
158
- results.updated.push(field);
159
- } else {
160
- results.failed.push(field);
146
+ // Use atomic read-modify-write to prevent lost updates from concurrent agents
147
+ readModifyWriteStateMd(statePath, (content) => {
148
+ for (const [field, value] of Object.entries(patches)) {
149
+ const fieldEscaped = escapeRegex(field);
150
+ // Try **Field:** bold format first, then plain Field: format
151
+ const boldPattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
152
+ const plainPattern = new RegExp(`(^${fieldEscaped}:\\s*)(.*)`, 'im');
153
+
154
+ if (boldPattern.test(content)) {
155
+ content = content.replace(boldPattern, (_match, prefix) => `${prefix}${value}`);
156
+ results.updated.push(field);
157
+ } else if (plainPattern.test(content)) {
158
+ content = content.replace(plainPattern, (_match, prefix) => `${prefix}${value}`);
159
+ results.updated.push(field);
160
+ } else {
161
+ results.failed.push(field);
162
+ }
161
163
  }
162
- }
163
-
164
- if (results.updated.length > 0) {
165
- writeStateMd(statePath, content, cwd);
166
- }
164
+ return content;
165
+ }, cwd);
167
166
 
168
167
  output(results, raw, results.updated.length > 0 ? 'true' : 'false');
169
168
  } catch {
@@ -236,6 +235,12 @@ function stateReplaceFieldWithFallback(content, primary, fallback, value) {
236
235
  result = stateReplaceField(content, fallback, value);
237
236
  if (result) return result;
238
237
  }
238
+ // Neither pattern matched — field may have been reformatted or removed.
239
+ // Log diagnostic so template drift is detected early rather than silently swallowed.
240
+ process.stderr.write(
241
+ `[gsd-tools] WARNING: STATE.md field "${primary}"${fallback ? ` (fallback: "${fallback}")` : ''} not found — update skipped. ` +
242
+ `This may indicate STATE.md was externally modified or uses an unexpected format.\n`
243
+ );
239
244
  return content;
240
245
  }
241
246
 
@@ -699,8 +704,14 @@ function buildStateFrontmatter(bodyContent, cwd) {
699
704
  } catch { /* intentionally empty */ }
700
705
  }
701
706
 
707
+ // Derive percent from disk counts when available (ground truth).
708
+ // Only falls back to the body Progress: field when no plan files exist on disk
709
+ // (phases directory empty or absent), which means disk has no authoritative data.
710
+ // This prevents a stale body "0%" from overriding the real 100% completion state.
702
711
  let progressPercent = null;
703
- if (progressRaw) {
712
+ if (totalPlans !== null && totalPlans > 0 && completedPlans !== null) {
713
+ progressPercent = Math.min(100, Math.round(completedPlans / totalPlans * 100));
714
+ } else if (progressRaw) {
704
715
  const pctMatch = progressRaw.match(/(\d+)%/);
705
716
  if (pctMatch) progressPercent = parseInt(pctMatch[1], 10);
706
717
  }
@@ -781,55 +792,80 @@ function syncStateFrontmatter(content, cwd) {
781
792
  }
782
793
 
783
794
  /**
784
- * write STATE.md with synchronized YAML frontmatter.
785
- * All STATE.md writes should use this instead of raw writeFileSync.
786
- * Uses a simple lockfile to prevent parallel agents from overwriting
787
- * each other's changes (race condition with read-modify-write cycle).
795
+ * Acquire a lockfile for STATE.md operations.
796
+ * Returns the lock path for later release.
788
797
  */
789
- function writeStateMd(statePath, content, cwd) {
790
- const synced = syncStateFrontmatter(content, cwd);
798
+ function acquireStateLock(statePath) {
791
799
  const lockPath = statePath + '.lock';
792
800
  const maxRetries = 10;
793
801
  const retryDelay = 200; // ms
794
802
 
795
- // Acquire lock (spin with backoff)
796
803
  for (let i = 0; i < maxRetries; i++) {
797
804
  try {
798
- // O_EXCL fails if file already exists — atomic lock
799
805
  const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
800
806
  fs.writeSync(fd, String(process.pid));
801
807
  fs.closeSync(fd);
802
- break;
808
+ return lockPath;
803
809
  } catch (err) {
804
810
  if (err.code === 'EEXIST') {
805
- // Check for stale lock (> 10s old)
806
811
  try {
807
812
  const stat = fs.statSync(lockPath);
808
813
  if (Date.now() - stat.mtimeMs > 10000) {
809
814
  fs.unlinkSync(lockPath);
810
- continue; // retry immediately after clearing stale lock
815
+ continue;
811
816
  }
812
817
  } catch { /* lock was released between check — retry */ }
813
818
 
814
819
  if (i === maxRetries - 1) {
815
- // Last resort: write anyway rather than losing data
816
820
  try { fs.unlinkSync(lockPath); } catch {}
817
- break;
821
+ return lockPath;
818
822
  }
819
- // Spin-wait with small jitter
820
823
  const jitter = Math.floor(Math.random() * 50);
821
824
  const start = Date.now();
822
825
  while (Date.now() - start < retryDelay + jitter) { /* busy wait */ }
823
826
  continue;
824
827
  }
825
- break; // non-EEXIST error — proceed without lock
828
+ return lockPath; // non-EEXIST error — proceed without lock
826
829
  }
827
830
  }
831
+ return statePath + '.lock';
832
+ }
833
+
834
+ function releaseStateLock(lockPath) {
835
+ try { fs.unlinkSync(lockPath); } catch { /* lock already gone */ }
836
+ }
837
+
838
+ /**
839
+ * write STATE.md with synchronized YAML frontmatter.
840
+ * All STATE.md writes should use this instead of raw writeFileSync.
841
+ * Uses a simple lockfile to prevent parallel agents from overwriting
842
+ * each other's changes (race condition with read-modify-write cycle).
843
+ */
844
+ function writeStateMd(statePath, content, cwd) {
845
+ const synced = syncStateFrontmatter(content, cwd);
846
+ const lockPath = acquireStateLock(statePath);
847
+ try {
848
+ fs.writeFileSync(statePath, normalizeMd(synced), 'utf-8');
849
+ } finally {
850
+ releaseStateLock(lockPath);
851
+ }
852
+ }
828
853
 
854
+ /**
855
+ * Atomic read-modify-write for STATE.md.
856
+ * Holds the lock across the entire read -> transform -> write cycle,
857
+ * preventing the lost-update problem where two agents read the same
858
+ * content and the second write clobbers the first.
859
+ */
860
+ function readModifyWriteStateMd(statePath, transformFn, cwd) {
861
+ const lockPath = acquireStateLock(statePath);
829
862
  try {
863
+ const content = fs.existsSync(statePath) ? fs.readFileSync(statePath, 'utf-8') : '';
864
+ const modified = transformFn(content);
865
+ const synced = syncStateFrontmatter(modified, cwd);
830
866
  fs.writeFileSync(statePath, normalizeMd(synced), 'utf-8');
831
867
  } finally {
832
- try { fs.unlinkSync(lockPath); } catch { /* lock already gone */ }
868
+ releaseStateLock(lockPath);
833
869
  }
834
870
  }
835
871
 
@@ -841,16 +877,27 @@ function cmdStateJson(cwd, raw) {
841
877
  }
842
878
 
843
879
  const content = fs.readFileSync(statePath, 'utf-8');
844
- const fm = extractFrontmatter(content);
880
+ const existingFm = extractFrontmatter(content);
881
+ const body = stripFrontmatter(content);
845
882
 
846
- if (!fm || Object.keys(fm).length === 0) {
847
- const body = stripFrontmatter(content);
848
- const built = buildStateFrontmatter(body, cwd);
849
- output(built, raw, JSON.stringify(built, null, 2));
850
- return;
883
+ // Always rebuild from body + disk so progress counters reflect current state.
884
+ // Returning cached frontmatter directly causes stale percent/completed_plans
885
+ // when SUMMARY files were added after the last STATE.md write (#1589).
886
+ const built = buildStateFrontmatter(body, cwd);
887
+
888
+ // Preserve frontmatter-only fields that cannot be recovered from the body.
889
+ if (existingFm && existingFm.stopped_at && !built.stopped_at) {
890
+ built.stopped_at = existingFm.stopped_at;
891
+ }
892
+ if (existingFm && existingFm.paused_at && !built.paused_at) {
893
+ built.paused_at = existingFm.paused_at;
894
+ }
895
+ // Preserve existing status when body-derived status is 'unknown' (same logic as syncStateFrontmatter).
896
+ if (built.status === 'unknown' && existingFm && existingFm.status && existingFm.status !== 'unknown') {
897
+ built.status = existingFm.status;
851
898
  }
852
899
 
853
- output(fm, raw, JSON.stringify(fm, null, 2));
900
+ output(built, raw, JSON.stringify(built, null, 2));
854
901
  }
855
902
 
856
903
  /**
@@ -1007,11 +1054,283 @@ function cmdSignalResume(cwd, raw) {
1007
1054
  output({ resumed: true, removed }, raw, removed ? 'true' : 'false');
1008
1055
  }
1009
1056
 
1057
+ // ─── Gate Functions (STATE.md consistency enforcement) ────────────────────────
1058
+
1059
+ /**
1060
+ * Update the ## Performance Metrics section in STATE.md content.
1061
+ * Increments Velocity totals and upserts a By Phase table row.
1062
+ * Returns modified content string.
1063
+ */
1064
+ function updatePerformanceMetricsSection(content, cwd, phaseNum, planCount, summaryCount) {
1065
+ // Update Velocity: Total plans completed
1066
+ const totalMatch = content.match(/Total plans completed:\s*(\d+|\[N\])/);
1067
+ const prevTotal = totalMatch && totalMatch[1] !== '[N]' ? parseInt(totalMatch[1], 10) : 0;
1068
+ const newTotal = prevTotal + summaryCount;
1069
+ content = content.replace(
1070
+ /Total plans completed:\s*(\d+|\[N\])/,
1071
+ `Total plans completed: ${newTotal}`
1072
+ );
1073
+
1074
+ // Update By Phase table — upsert row for this phase
1075
+ const byPhaseTablePattern = /(\|\s*Phase\s*\|\s*Plans\s*\|\s*Total\s*\|\s*Avg\/Plan\s*\|[ \t]*\n\|(?:[- :\t]+\|)+[ \t]*\n)((?:[ \t]*\|[^\n]*\n)*)(?=\n|$)/i;
1076
+ const byPhaseMatch = content.match(byPhaseTablePattern);
1077
+ if (byPhaseMatch) {
1078
+ let tableBody = byPhaseMatch[2].trim();
1079
+ const phaseRowPattern = new RegExp(`^\\|\\s*${escapeRegex(String(phaseNum))}\\s*\\|.*$`, 'm');
1080
+ const newRow = `| ${phaseNum} | ${summaryCount} | - | - |`;
1081
+
1082
+ if (phaseRowPattern.test(tableBody)) {
1083
+ // Update existing row
1084
+ tableBody = tableBody.replace(phaseRowPattern, newRow);
1085
+ } else {
1086
+ // Remove placeholder row and add new row
1087
+ tableBody = tableBody.replace(/^\|\s*-\s*\|\s*-\s*\|\s*-\s*\|\s*-\s*\|$/m, '').trim();
1088
+ tableBody = tableBody ? tableBody + '\n' + newRow : newRow;
1089
+ }
1090
+
1091
+ content = content.replace(byPhaseTablePattern, `$1${tableBody}\n`);
1092
+ }
1093
+
1094
+ return content;
1095
+ }
1096
+
1097
+ /**
1098
+ * Gate 3a: Record state after plan-phase completes.
1099
+ * Updates Status to "Ready to execute", Total Plans, Last Activity.
1100
+ */
1101
+ function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) {
1102
+ const statePath = planningPaths(cwd).state;
1103
+ if (!fs.existsSync(statePath)) {
1104
+ output({ error: 'STATE.md not found' }, raw);
1105
+ return;
1106
+ }
1107
+
1108
+ let content = fs.readFileSync(statePath, 'utf-8');
1109
+ const today = new Date().toISOString().split('T')[0];
1110
+ const updated = [];
1111
+
1112
+ // Update Status
1113
+ let result = stateReplaceField(content, 'Status', 'Ready to execute');
1114
+ if (result) { content = result; updated.push('Status'); }
1115
+
1116
+ // Update Total Plans in Phase
1117
+ if (planCount !== null && planCount !== undefined) {
1118
+ result = stateReplaceField(content, 'Total Plans in Phase', String(planCount));
1119
+ if (result) { content = result; updated.push('Total Plans in Phase'); }
1120
+ }
1121
+
1122
+ // Update Last Activity
1123
+ result = stateReplaceField(content, 'Last Activity', today);
1124
+ if (result) { content = result; updated.push('Last Activity'); }
1125
+
1126
+ // Update Last Activity Description
1127
+ result = stateReplaceField(content, 'Last Activity Description', `Phase ${phaseNumber} planning complete — ${planCount || '?'} plans ready`);
1128
+ if (result) { content = result; updated.push('Last Activity Description'); }
1129
+
1130
+ // Update Current Position section
1131
+ content = updateCurrentPositionFields(content, {
1132
+ status: 'Ready to execute',
1133
+ lastActivity: `${today} -- Phase ${phaseNumber} planning complete`,
1134
+ });
1135
+
1136
+ if (updated.length > 0) {
1137
+ writeStateMd(statePath, content, cwd);
1138
+ }
1139
+
1140
+ output({ updated, phase: phaseNumber, plan_count: planCount }, raw, updated.length > 0 ? 'true' : 'false');
1141
+ }
1142
+
1143
+ /**
1144
+ * Gate 1: Validate STATE.md against filesystem.
1145
+ * Returns { valid, warnings, drift } JSON.
1146
+ */
1147
+ function cmdStateValidate(cwd, raw) {
1148
+ const statePath = planningPaths(cwd).state;
1149
+ if (!fs.existsSync(statePath)) {
1150
+ output({ error: 'STATE.md not found' }, raw);
1151
+ return;
1152
+ }
1153
+
1154
+ const content = fs.readFileSync(statePath, 'utf-8');
1155
+ const warnings = [];
1156
+ const drift = {};
1157
+
1158
+ const status = stateExtractField(content, 'Status') || '';
1159
+ const currentPhase = stateExtractField(content, 'Current Phase');
1160
+ const totalPlansRaw = stateExtractField(content, 'Total Plans in Phase');
1161
+ const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
1162
+
1163
+ const phasesDir = planningPaths(cwd).phases;
1164
+
1165
+ // Scan disk for current phase
1166
+ if (currentPhase && fs.existsSync(phasesDir)) {
1167
+ const normalized = currentPhase.replace(/\s+of\s+\d+.*/, '').trim();
1168
+ try {
1169
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
1170
+ const phaseDir = entries.find(e => e.isDirectory() && e.name.startsWith(normalized.replace(/^0+/, '').padStart(2, '0')));
1171
+ if (phaseDir) {
1172
+ const phaseDirPath = path.join(phasesDir, phaseDir.name);
1173
+ const files = fs.readdirSync(phaseDirPath);
1174
+ const diskPlans = files.filter(f => f.match(/-PLAN\.md$/i)).length;
1175
+ const diskSummaries = files.filter(f => f.match(/-SUMMARY\.md$/i)).length;
1176
+
1177
+ // Check plan count mismatch
1178
+ if (totalPlansInPhase !== null && diskPlans !== totalPlansInPhase) {
1179
+ warnings.push(`Plan count mismatch: STATE.md says ${totalPlansInPhase} plans, disk has ${diskPlans}`);
1180
+ drift.plan_count = { state: totalPlansInPhase, disk: diskPlans };
1181
+ }
1182
+
1183
+ // Check for VERIFICATION.md
1184
+ const verificationFiles = files.filter(f => f.includes('VERIFICATION') && f.endsWith('.md'));
1185
+ for (const vf of verificationFiles) {
1186
+ try {
1187
+ const vContent = fs.readFileSync(path.join(phaseDirPath, vf), 'utf-8');
1188
+ if (/status:\s*passed/i.test(vContent) && /executing/i.test(status)) {
1189
+ warnings.push(`Status drift: STATE.md says "${status}" but ${vf} shows verification passed — phase may be complete`);
1190
+ drift.verification_status = { state_status: status, verification: 'passed' };
1191
+ }
1192
+ } catch { /* intentionally empty */ }
1193
+ }
1194
+
1195
+ // Check if all plans have summaries but status still says executing
1196
+ if (diskPlans > 0 && diskSummaries >= diskPlans && /executing/i.test(status)) {
1197
+ // Only warn if no verification exists (if verification passed, the above warning covers it)
1198
+ if (verificationFiles.length === 0) {
1199
+ warnings.push(`All ${diskPlans} plans have summaries but status is still "${status}" — phase may be ready for verification`);
1200
+ }
1201
+ }
1202
+ }
1203
+ } catch { /* intentionally empty */ }
1204
+ }
1205
+
1206
+ const valid = warnings.length === 0;
1207
+ output({ valid, warnings, drift }, raw);
1208
+ }
1209
+
1210
+ /**
1211
+ * Gate 2: Sync STATE.md from filesystem ground truth.
1212
+ * Scans phase dirs, reconstructs counters, progress, metrics.
1213
+ * Supports --verify for dry-run mode.
1214
+ */
1215
+ function cmdStateSync(cwd, options, raw) {
1216
+ const statePath = planningPaths(cwd).state;
1217
+ if (!fs.existsSync(statePath)) {
1218
+ output({ error: 'STATE.md not found' }, raw);
1219
+ return;
1220
+ }
1221
+
1222
+ const verify = options && options.verify;
1223
+ const content = fs.readFileSync(statePath, 'utf-8');
1224
+ const changes = [];
1225
+ let modified = content;
1226
+ const today = new Date().toISOString().split('T')[0];
1227
+
1228
+ const phasesDir = planningPaths(cwd).phases;
1229
+ if (!fs.existsSync(phasesDir)) {
1230
+ output({ synced: true, changes: [], dry_run: !!verify }, raw);
1231
+ return;
1232
+ }
1233
+
1234
+ // Scan all phases
1235
+ let entries;
1236
+ try {
1237
+ entries = fs.readdirSync(phasesDir, { withFileTypes: true })
1238
+ .filter(e => e.isDirectory())
1239
+ .map(e => e.name)
1240
+ .sort();
1241
+ } catch {
1242
+ output({ synced: true, changes: [], dry_run: !!verify }, raw);
1243
+ return;
1244
+ }
1245
+
1246
+ let totalDiskPlans = 0;
1247
+ let totalDiskSummaries = 0;
1248
+ let highestIncompletePhase = null;
1249
+ let highestIncompletePhaseNum = null;
1250
+ let highestIncompletePhaseplanCount = 0;
1251
+ let highestIncompletePhaseSummaryCount = 0;
1252
+
1253
+ for (const dir of entries) {
1254
+ const dirPath = path.join(phasesDir, dir);
1255
+ const files = fs.readdirSync(dirPath);
1256
+ const plans = files.filter(f => f.match(/-PLAN\.md$/i)).length;
1257
+ const summaries = files.filter(f => f.match(/-SUMMARY\.md$/i)).length;
1258
+ totalDiskPlans += plans;
1259
+ totalDiskSummaries += summaries;
1260
+
1261
+ // Track the highest phase with incomplete plans (or any plans)
1262
+ const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
1263
+ if (phaseMatch && plans > 0) {
1264
+ if (summaries < plans) {
1265
+ // Incomplete phase — this is likely the current one
1266
+ highestIncompletePhase = dir;
1267
+ highestIncompletePhaseNum = phaseMatch[1];
1268
+ highestIncompletePhaseplanCount = plans;
1269
+ highestIncompletePhaseSummaryCount = summaries;
1270
+ } else if (!highestIncompletePhase) {
1271
+ // All complete, track as potential current
1272
+ highestIncompletePhase = dir;
1273
+ highestIncompletePhaseNum = phaseMatch[1];
1274
+ highestIncompletePhaseplanCount = plans;
1275
+ highestIncompletePhaseSummaryCount = summaries;
1276
+ }
1277
+ }
1278
+ }
1279
+
1280
+ // Sync Total Plans in Phase
1281
+ if (highestIncompletePhase) {
1282
+ const currentPlansField = stateExtractField(modified, 'Total Plans in Phase');
1283
+ if (currentPlansField && parseInt(currentPlansField, 10) !== highestIncompletePhaseplanCount) {
1284
+ changes.push(`Total Plans in Phase: ${currentPlansField} -> ${highestIncompletePhaseplanCount}`);
1285
+ const result = stateReplaceField(modified, 'Total Plans in Phase', String(highestIncompletePhaseplanCount));
1286
+ if (result) modified = result;
1287
+ }
1288
+ }
1289
+
1290
+ // Sync Progress
1291
+ const percent = totalDiskPlans > 0 ? Math.min(100, Math.round(totalDiskSummaries / totalDiskPlans * 100)) : 0;
1292
+ const currentProgress = stateExtractField(modified, 'Progress');
1293
+ if (currentProgress) {
1294
+ const currentPercent = parseInt(currentProgress.replace(/[^\d]/g, ''), 10);
1295
+ if (currentPercent !== percent) {
1296
+ const barWidth = 10;
1297
+ const filled = Math.round(percent / 100 * barWidth);
1298
+ const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
1299
+ const progressStr = `[${bar}] ${percent}%`;
1300
+ changes.push(`Progress: ${currentProgress} -> ${progressStr}`);
1301
+ const result = stateReplaceField(modified, 'Progress', progressStr);
1302
+ if (result) modified = result;
1303
+ }
1304
+ }
1305
+
1306
+ // Sync Last Activity
1307
+ const result = stateReplaceField(modified, 'Last Activity', today);
1308
+ if (result) {
1309
+ const oldActivity = stateExtractField(modified, 'Last Activity');
1310
+ if (oldActivity !== today) {
1311
+ changes.push(`Last Activity: ${oldActivity} -> ${today}`);
1312
+ }
1313
+ modified = result;
1314
+ }
1315
+
1316
+ if (verify) {
1317
+ output({ synced: false, changes, dry_run: true }, raw);
1318
+ return;
1319
+ }
1320
+
1321
+ if (changes.length > 0 || modified !== content) {
1322
+ writeStateMd(statePath, modified, cwd);
1323
+ }
1324
+
1325
+ output({ synced: true, changes, dry_run: false }, raw);
1326
+ }
1327
+
1010
1328
  module.exports = {
1011
1329
  stateExtractField,
1012
1330
  stateReplaceField,
1013
1331
  stateReplaceFieldWithFallback,
1014
1332
  writeStateMd,
1333
+ updatePerformanceMetricsSection,
1015
1334
  cmdStateLoad,
1016
1335
  cmdStateGet,
1017
1336
  cmdStatePatch,
@@ -1026,6 +1345,9 @@ module.exports = {
1026
1345
  cmdStateSnapshot,
1027
1346
  cmdStateJson,
1028
1347
  cmdStateBeginPhase,
1348
+ cmdStatePlannedPhase,
1349
+ cmdStateValidate,
1350
+ cmdStateSync,
1029
1351
  cmdSignalWaiting,
1030
1352
  cmdSignalResume,
1031
1353
  };