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
@@ -177,8 +177,12 @@ const CLAUDE_MD_FALLBACKS = {
177
177
  stack: 'Technology stack not yet documented. Will populate after codebase mapping or first phase.',
178
178
  conventions: 'Conventions not yet established. Will populate as patterns emerge during development.',
179
179
  architecture: 'Architecture not yet mapped. Follow existing patterns found in the codebase.',
180
+ skills: 'No project skills found. Add skills to any of: `.OpenCode/skills/`, `.agents/skills/`, `.cursor/skills/`, or `.github/skills/` with a `SKILL.md` index file.',
180
181
  };
181
182
 
183
+ // Directories where project skills may live (checked in order)
184
+ const SKILL_SEARCH_DIRS = ['.OpenCode/skills', '.agents/skills', '.cursor/skills', '.github/skills'];
185
+
182
186
  const CLAUDE_MD_WORKFLOW_ENFORCEMENT = [
183
187
  'Before using edit, write, or other file-changing tools, start work through a GSD command so planning artifacts and execution context stay in sync.',
184
188
  '',
@@ -375,6 +379,96 @@ function generateWorkflowSection() {
375
379
  };
376
380
  }
377
381
 
382
+ /**
383
+ * Discover project skills from standard directories and extract frontmatter
384
+ * (name + description) for each. Returns a table summary for AGENTS.md so
385
+ * agents know which skills are available at session startup (Layer 1 discovery).
386
+ */
387
+ function generateSkillsSection(cwd) {
388
+ const discovered = [];
389
+
390
+ for (const dir of SKILL_SEARCH_DIRS) {
391
+ const absDir = path.join(cwd, dir);
392
+ if (!fs.existsSync(absDir)) continue;
393
+
394
+ let entries;
395
+ try {
396
+ entries = fs.readdirSync(absDir, { withFileTypes: true });
397
+ } catch {
398
+ continue;
399
+ }
400
+
401
+ for (const entry of entries) {
402
+ if (!entry.isDirectory()) continue;
403
+ // Skip GSD's own installed skills — only surface project-specific skills
404
+ if (entry.name.startsWith('gsd-')) continue;
405
+
406
+ const skillMdPath = path.join(absDir, entry.name, 'SKILL.md');
407
+ if (!fs.existsSync(skillMdPath)) continue;
408
+
409
+ const content = safeReadFile(skillMdPath);
410
+ if (!content) continue;
411
+
412
+ const frontmatter = extractSkillFrontmatter(content);
413
+ const name = frontmatter.name || entry.name;
414
+ const description = frontmatter.description || '';
415
+
416
+ // Avoid duplicates when same skill dir is symlinked from multiple locations
417
+ if (discovered.some(s => s.name === name)) continue;
418
+
419
+ discovered.push({ name, description, path: `${dir}/${entry.name}` });
420
+ }
421
+ }
422
+
423
+ if (discovered.length === 0) {
424
+ return { content: CLAUDE_MD_FALLBACKS.skills, source: 'skills/', hasFallback: true };
425
+ }
426
+
427
+ const lines = ['| skill | Description | Path |', '|-------|-------------|------|'];
428
+ for (const skill of discovered) {
429
+ // Sanitize table cell content (escape pipes)
430
+ const desc = skill.description.replace(/\|/g, '\\|').replace(/\n/g, ' ').trim();
431
+ const safeName = skill.name.replace(/\|/g, '\\|');
432
+ lines.push(`| ${safeName} | ${desc} | \`${skill.path}/SKILL.md\` |`);
433
+ }
434
+
435
+ return { content: lines.join('\n'), source: 'skills/', hasFallback: false };
436
+ }
437
+
438
+ /**
439
+ * Extract name and description from YAML-like frontmatter in a SKILL.md file.
440
+ * Handles multi-line description values (continuation lines indented with spaces).
441
+ */
442
+ function extractSkillFrontmatter(content) {
443
+ const result = { name: '', description: '' };
444
+ const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
445
+ if (!fmMatch) return result;
446
+
447
+ const fmBlock = fmMatch[1];
448
+ const lines = fmBlock.split('\n');
449
+
450
+ let currentKey = '';
451
+ for (const line of lines) {
452
+ // Top-level key: value
453
+ const kvMatch = line.match(/^(\w[\w-]*):\s*(.*)/);
454
+ if (kvMatch) {
455
+ currentKey = kvMatch[1];
456
+ const value = kvMatch[2].trim();
457
+ if (currentKey === 'name') result.name = value;
458
+ if (currentKey === 'description') result.description = value;
459
+ continue;
460
+ }
461
+ // Continuation line (indented) for multi-line values
462
+ if (currentKey === 'description' && /^\s+/.test(line)) {
463
+ result.description += ' ' + line.trim();
464
+ } else {
465
+ currentKey = '';
466
+ }
467
+ }
468
+
469
+ return result;
470
+ }
471
+
378
472
  // ─── Commands ─────────────────────────────────────────────────────────────────
379
473
 
380
474
  function cmdWriteProfile(cwd, options, raw) {
@@ -815,12 +909,13 @@ function cmdGenerateClaudeProfile(cwd, options, raw) {
815
909
  }
816
910
 
817
911
  function cmdGenerateClaudeMd(cwd, options, raw) {
818
- const MANAGED_SECTIONS = ['project', 'stack', 'conventions', 'architecture', 'workflow'];
912
+ const MANAGED_SECTIONS = ['project', 'stack', 'conventions', 'architecture', 'skills', 'workflow'];
819
913
  const generators = {
820
914
  project: generateProjectSection,
821
915
  stack: generateStackSection,
822
916
  conventions: generateConventionsSection,
823
917
  architecture: generateArchitectureSection,
918
+ skills: generateSkillsSection,
824
919
  workflow: generateWorkflowSection,
825
920
  };
826
921
  const sectionHeadings = {
@@ -828,6 +923,7 @@ function cmdGenerateClaudeMd(cwd, options, raw) {
828
923
  stack: '## Technology Stack',
829
924
  conventions: '## Conventions',
830
925
  architecture: '## Architecture',
926
+ skills: '## Project Skills',
831
927
  workflow: '## GSD Workflow Enforcement',
832
928
  };
833
929
 
@@ -4,7 +4,73 @@
4
4
 
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
- const { escapeRegex, normalizePhaseName, planningPaths, output, error, findPhaseInternal, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone } = require('./core.cjs');
7
+ const { escapeRegex, normalizePhaseName, planningPaths, withPlanningLock, output, error, findPhaseInternal, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, phaseTokenMatches } = require('./core.cjs');
8
+
9
+ /**
10
+ * Search for a phase header (and its section) within the given content string.
11
+ * Returns a result object if found (either a full match or a malformed_roadmap
12
+ * checklist-only match), or null if the phase is not present at all.
13
+ */
14
+ function searchPhaseInContent(content, escapedPhase, phaseNum) {
15
+ // Match "## Phase X:", "### Phase X:", or "#### Phase X:" with optional name
16
+ const phasePattern = new RegExp(
17
+ `#{2,4}\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`,
18
+ 'i'
19
+ );
20
+ const headerMatch = content.match(phasePattern);
21
+
22
+ if (!headerMatch) {
23
+ // Fallback: check if phase exists in summary list but missing detail section
24
+ const checklistPattern = new RegExp(
25
+ `-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+${escapedPhase}:\\s*([^*]+)\\*\\*`,
26
+ 'i'
27
+ );
28
+ const checklistMatch = content.match(checklistPattern);
29
+
30
+ if (checklistMatch) {
31
+ return {
32
+ found: false,
33
+ phase_number: phaseNum,
34
+ phase_name: checklistMatch[1].trim(),
35
+ error: 'malformed_roadmap',
36
+ message: `Phase ${phaseNum} exists in summary list but missing "### Phase ${phaseNum}:" detail section. ROADMAP.md needs both formats.`
37
+ };
38
+ }
39
+
40
+ return null;
41
+ }
42
+
43
+ const phaseName = headerMatch[1].trim();
44
+ const headerIndex = headerMatch.index;
45
+
46
+ // Find the end of this section (next ## or ### phase header, or end of file)
47
+ const restOfContent = content.slice(headerIndex);
48
+ const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+Phase\s+\d/i);
49
+ const sectionEnd = nextHeaderMatch
50
+ ? headerIndex + nextHeaderMatch.index
51
+ : content.length;
52
+
53
+ const section = content.slice(headerIndex, sectionEnd).trim();
54
+
55
+ // Extract goal if present (supports both **Goal:** and **Goal**: formats)
56
+ const goalMatch = section.match(/\*\*Goal(?::\*\*|\*\*:)\s*([^\n]+)/i);
57
+ const goal = goalMatch ? goalMatch[1].trim() : null;
58
+
59
+ // Extract success criteria as structured array
60
+ const criteriaMatch = section.match(/\*\*Success Criteria\*\*[^\n]*:\s*\n((?:\s*\d+\.\s*[^\n]+\n?)+)/i);
61
+ const success_criteria = criteriaMatch
62
+ ? criteriaMatch[1].trim().split('\n').map(line => line.replace(/^\s*\d+\.\s*/, '').trim()).filter(Boolean)
63
+ : [];
64
+
65
+ return {
66
+ found: true,
67
+ phase_number: phaseNum,
68
+ phase_name: phaseName,
69
+ goal,
70
+ success_criteria,
71
+ section,
72
+ };
73
+ }
8
74
 
9
75
  function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
10
76
  const roadmapPath = planningPaths(cwd).roadmap;
@@ -15,76 +81,32 @@ function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
15
81
  }
16
82
 
17
83
  try {
18
- const content = extractCurrentMilestone(fs.readFileSync(roadmapPath, 'utf-8'), cwd);
84
+ const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
85
+ const milestoneContent = extractCurrentMilestone(rawContent, cwd);
19
86
 
20
87
  // Escape special regex chars in phase number, handle decimal
21
88
  const escapedPhase = escapeRegex(phaseNum);
22
89
 
23
- // Match "## Phase X:", "### Phase X:", or "#### Phase X:" with optional name
24
- const phasePattern = new RegExp(
25
- `#{2,4}\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`,
26
- 'i'
27
- );
28
- const headerMatch = content.match(phasePattern);
29
-
30
- if (!headerMatch) {
31
- // Fallback: check if phase exists in summary list but missing detail section
32
- const checklistPattern = new RegExp(
33
- `-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+${escapedPhase}:\\s*([^*]+)\\*\\*`,
34
- 'i'
35
- );
36
- const checklistMatch = content.match(checklistPattern);
37
-
38
- if (checklistMatch) {
39
- // Phase exists in summary but missing detail section - malformed ROADMAP
40
- output({
41
- found: false,
42
- phase_number: phaseNum,
43
- phase_name: checklistMatch[1].trim(),
44
- error: 'malformed_roadmap',
45
- message: `Phase ${phaseNum} exists in summary list but missing "### Phase ${phaseNum}:" detail section. ROADMAP.md needs both formats.`
46
- }, raw, '');
47
- return;
48
- }
90
+ // Search the current milestone slice first, then fall back to full roadmap.
91
+ // A malformed_roadmap result (checklist-only) from the milestone should not
92
+ // block finding a full header match in the wider roadmap content.
93
+ const fullContent = stripShippedMilestones(rawContent);
94
+ const milestoneResult = searchPhaseInContent(milestoneContent, escapedPhase, phaseNum);
95
+ const result = (milestoneResult && !milestoneResult.error)
96
+ ? milestoneResult
97
+ : searchPhaseInContent(fullContent, escapedPhase, phaseNum) || milestoneResult;
49
98
 
99
+ if (!result) {
50
100
  output({ found: false, phase_number: phaseNum }, raw, '');
51
101
  return;
52
102
  }
53
103
 
54
- const phaseName = headerMatch[1].trim();
55
- const headerIndex = headerMatch.index;
56
-
57
- // Find the end of this section (next ## or ### phase header, or end of file)
58
- const restOfContent = content.slice(headerIndex);
59
- const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+Phase\s+\d/i);
60
- const sectionEnd = nextHeaderMatch
61
- ? headerIndex + nextHeaderMatch.index
62
- : content.length;
63
-
64
- const section = content.slice(headerIndex, sectionEnd).trim();
65
-
66
- // Extract goal if present (supports both **Goal:** and **Goal**: formats)
67
- const goalMatch = section.match(/\*\*Goal(?::\*\*|\*\*:)\s*([^\n]+)/i);
68
- const goal = goalMatch ? goalMatch[1].trim() : null;
69
-
70
- // Extract success criteria as structured array
71
- const criteriaMatch = section.match(/\*\*Success Criteria\*\*[^\n]*:\s*\n((?:\s*\d+\.\s*[^\n]+\n?)+)/i);
72
- const success_criteria = criteriaMatch
73
- ? criteriaMatch[1].trim().split('\n').map(line => line.replace(/^\s*\d+\.\s*/, '').trim()).filter(Boolean)
74
- : [];
104
+ if (result.error) {
105
+ output(result, raw, '');
106
+ return;
107
+ }
75
108
 
76
- output(
77
- {
78
- found: true,
79
- phase_number: phaseNum,
80
- phase_name: phaseName,
81
- goal,
82
- success_criteria,
83
- section,
84
- },
85
- raw,
86
- section
87
- );
109
+ output(result, raw, result.section);
88
110
  } catch (e) {
89
111
  error('Failed to read ROADMAP.md: ' + e.message);
90
112
  }
@@ -135,7 +157,7 @@ function cmdRoadmapAnalyze(cwd, raw) {
135
157
  try {
136
158
  const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
137
159
  const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
138
- const dirMatch = dirs.find(d => d.startsWith(normalized + '-') || d === normalized);
160
+ const dirMatch = dirs.find(d => phaseTokenMatches(d, normalized));
139
161
 
140
162
  if (dirMatch) {
141
163
  const phaseFiles = fs.readdirSync(path.join(phasesDir, dirMatch));
@@ -254,64 +276,66 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
254
276
  return;
255
277
  }
256
278
 
257
- let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
258
- const phaseEscaped = escapeRegex(phaseNum);
279
+ // Wrap entire read-modify-write in lock to prevent concurrent corruption
280
+ withPlanningLock(cwd, () => {
281
+ let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
282
+ const phaseEscaped = escapeRegex(phaseNum);
259
283
 
260
- // Progress table row: update Plans/Status/Date columns (handles 4 or 5 column tables)
261
- const tableRowPattern = new RegExp(
262
- `^(\\|\\s*${phaseEscaped}\\.?\\s[^|]*(?:\\|[^\\n]*))$`,
263
- 'im'
264
- );
265
- const dateField = isComplete ? ` ${today} ` : ' ';
266
- roadmapContent = roadmapContent.replace(tableRowPattern, (fullRow) => {
267
- const cells = fullRow.split('|').slice(1, -1); // drop leading/trailing empty from split
268
- if (cells.length === 5) {
269
- // 5-col: Phase | Milestone | Plans | Status | Completed
270
- cells[2] = ` ${summaryCount}/${planCount} `;
271
- cells[3] = ` ${status.padEnd(11)}`;
272
- cells[4] = dateField;
273
- } else if (cells.length === 4) {
274
- // 4-col: Phase | Plans | Status | Completed
275
- cells[1] = ` ${summaryCount}/${planCount} `;
276
- cells[2] = ` ${status.padEnd(11)}`;
277
- cells[3] = dateField;
278
- }
279
- return '|' + cells.join('|') + '|';
280
- });
281
-
282
- // Update plan count in phase detail section
283
- const planCountPattern = new RegExp(
284
- `(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
285
- 'i'
286
- );
287
- const planCountText = isComplete
288
- ? `${summaryCount}/${planCount} plans complete`
289
- : `${summaryCount}/${planCount} plans executed`;
290
- roadmapContent = replaceInCurrentMilestone(roadmapContent, planCountPattern, `$1${planCountText}`);
291
-
292
- // If complete: check checkbox
293
- if (isComplete) {
294
- const checkboxPattern = new RegExp(
295
- `(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseEscaped}[:\\s][^\\n]*)`,
296
- 'i'
284
+ // Progress table row: update Plans/Status/Date columns (handles 4 or 5 column tables)
285
+ const tableRowPattern = new RegExp(
286
+ `^(\\|\\s*${phaseEscaped}\\.?\\s[^|]*(?:\\|[^\\n]*))$`,
287
+ 'im'
297
288
  );
298
- roadmapContent = replaceInCurrentMilestone(roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`);
299
- }
289
+ const dateField = isComplete ? ` ${today} ` : ' ';
290
+ roadmapContent = roadmapContent.replace(tableRowPattern, (fullRow) => {
291
+ const cells = fullRow.split('|').slice(1, -1); // drop leading/trailing empty from split
292
+ if (cells.length === 5) {
293
+ // 5-col: Phase | Milestone | Plans | Status | Completed
294
+ cells[2] = ` ${summaryCount}/${planCount} `;
295
+ cells[3] = ` ${status.padEnd(11)}`;
296
+ cells[4] = dateField;
297
+ } else if (cells.length === 4) {
298
+ // 4-col: Phase | Plans | Status | Completed
299
+ cells[1] = ` ${summaryCount}/${planCount} `;
300
+ cells[2] = ` ${status.padEnd(11)}`;
301
+ cells[3] = dateField;
302
+ }
303
+ return '|' + cells.join('|') + '|';
304
+ });
300
305
 
301
- // Mark completed plan checkboxes (e.g. "- [ ] 50-01-PLAN.md" or "- [ ] 50-01:")
302
- for (const summaryFile of phaseInfo.summaries) {
303
- const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
304
- if (!planId) continue;
305
- const planEscaped = escapeRegex(planId);
306
- const planCheckboxPattern = new RegExp(
307
- `(-\\s*\\[) (\\]\\s*${planEscaped})`,
306
+ // Update plan count in phase detail section
307
+ const planCountPattern = new RegExp(
308
+ `(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
308
309
  'i'
309
310
  );
310
- roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2');
311
- }
311
+ const planCountText = isComplete
312
+ ? `${summaryCount}/${planCount} plans complete`
313
+ : `${summaryCount}/${planCount} plans executed`;
314
+ roadmapContent = replaceInCurrentMilestone(roadmapContent, planCountPattern, `$1${planCountText}`);
315
+
316
+ // If complete: check checkbox
317
+ if (isComplete) {
318
+ const checkboxPattern = new RegExp(
319
+ `(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseEscaped}[:\\s][^\\n]*)`,
320
+ 'i'
321
+ );
322
+ roadmapContent = replaceInCurrentMilestone(roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`);
323
+ }
312
324
 
313
- fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8');
325
+ // Mark completed plan checkboxes (e.g. "- [ ] 50-01-PLAN.md", "- [ ] 50-01:", or "- [ ] **50-01**")
326
+ for (const summaryFile of phaseInfo.summaries) {
327
+ const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
328
+ if (!planId) continue;
329
+ const planEscaped = escapeRegex(planId);
330
+ const planCheckboxPattern = new RegExp(
331
+ `(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`,
332
+ 'i'
333
+ );
334
+ roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2');
335
+ }
314
336
 
337
+ fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8');
338
+ });
315
339
  output({
316
340
  updated: true,
317
341
  phase: phaseNum,
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Schema Drift Detection — Detects schema-relevant file changes and verifies
3
+ * that the appropriate database push command was executed during a phase.
4
+ *
5
+ * Prevents false-positive verification when schema files change but no push
6
+ * occurs — TypeScript types come from config, not the live database, so
7
+ * build/types pass on a broken state.
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ // ─── ORM Patterns ────────────────────────────────────────────────────────────
13
+ //
14
+ // Each entry maps a glob-like pattern to an ORM name. Patterns use forward
15
+ // slashes internally — Windows backslash paths are normalized before matching.
16
+
17
+ const SCHEMA_PATTERNS = [
18
+ // Payload CMS
19
+ { pattern: /^src\/collections\/.*\.ts$/, orm: 'payload' },
20
+ { pattern: /^src\/globals\/.*\.ts$/, orm: 'payload' },
21
+
22
+ // Prisma
23
+ { pattern: /^prisma\/schema\.prisma$/, orm: 'prisma' },
24
+ { pattern: /^prisma\/schema\/.*\.prisma$/, orm: 'prisma' },
25
+
26
+ // Drizzle
27
+ { pattern: /^drizzle\/schema\.ts$/, orm: 'drizzle' },
28
+ { pattern: /^src\/db\/schema\.ts$/, orm: 'drizzle' },
29
+ { pattern: /^drizzle\/.*\.ts$/, orm: 'drizzle' },
30
+
31
+ // Supabase
32
+ { pattern: /^supabase\/migrations\/.*\.sql$/, orm: 'supabase' },
33
+
34
+ // TypeORM
35
+ { pattern: /^src\/entities\/.*\.ts$/, orm: 'typeorm' },
36
+ { pattern: /^src\/migrations\/.*\.ts$/, orm: 'typeorm' },
37
+ ];
38
+
39
+ // ─── Push Commands & Evidence Patterns ───────────────────────────────────────
40
+ //
41
+ // For each ORM, the push command that agents should run, plus regex patterns
42
+ // that indicate the push was actually executed (matched against execution logs,
43
+ // SUMMARY.md content, and git commit messages).
44
+
45
+ const ORM_INFO = {
46
+ payload: {
47
+ pushCommand: 'npx payload migrate',
48
+ envHint: 'CI=true PAYLOAD_MIGRATING=true npx payload migrate',
49
+ interactiveWarning: 'Payload migrate may require interactive prompts — use CI=true PAYLOAD_MIGRATING=true to suppress',
50
+ evidencePatterns: [
51
+ /payload\s+migrate/i,
52
+ /PAYLOAD_MIGRATING/,
53
+ ],
54
+ },
55
+ prisma: {
56
+ pushCommand: 'npx prisma db push',
57
+ envHint: 'npx prisma db push --accept-data-loss (if destructive changes are intended)',
58
+ interactiveWarning: 'Prisma db push may prompt for confirmation on destructive changes — use --accept-data-loss to bypass',
59
+ evidencePatterns: [
60
+ /prisma\s+db\s+push/i,
61
+ /prisma\s+migrate\s+deploy/i,
62
+ /prisma\s+migrate\s+dev/i,
63
+ ],
64
+ },
65
+ drizzle: {
66
+ pushCommand: 'npx drizzle-kit push',
67
+ envHint: 'npx drizzle-kit push',
68
+ interactiveWarning: null,
69
+ evidencePatterns: [
70
+ /drizzle-kit\s+push/i,
71
+ /drizzle-kit\s+migrate/i,
72
+ ],
73
+ },
74
+ supabase: {
75
+ pushCommand: 'supabase db push',
76
+ envHint: 'supabase db push',
77
+ interactiveWarning: 'Supabase db push may require authentication — ensure SUPABASE_ACCESS_TOKEN is set',
78
+ evidencePatterns: [
79
+ /supabase\s+db\s+push/i,
80
+ /supabase\s+migration\s+up/i,
81
+ ],
82
+ },
83
+ typeorm: {
84
+ pushCommand: 'npx typeorm migration:run',
85
+ envHint: 'npx typeorm migration:run -d src/data-source.ts',
86
+ interactiveWarning: null,
87
+ evidencePatterns: [
88
+ /typeorm\s+migration:run/i,
89
+ /typeorm\s+schema:sync/i,
90
+ ],
91
+ },
92
+ };
93
+
94
+ // ─── Public API ──────────────────────────────────────────────────────────────
95
+
96
+ /**
97
+ * Detect schema-relevant files in a list of file paths.
98
+ *
99
+ * @param {string[]} files - List of file paths (relative to project root)
100
+ * @returns {{ detected: boolean, matches: string[], orms: string[] }}
101
+ */
102
+ function detectSchemaFiles(files) {
103
+ const matches = [];
104
+ const orms = new Set();
105
+
106
+ for (const rawFile of files) {
107
+ // Normalize Windows backslash paths
108
+ const file = rawFile.replace(/\\/g, '/');
109
+
110
+ for (const { pattern, orm } of SCHEMA_PATTERNS) {
111
+ if (pattern.test(file)) {
112
+ matches.push(rawFile);
113
+ orms.add(orm);
114
+ break; // One match per file is enough
115
+ }
116
+ }
117
+ }
118
+
119
+ return {
120
+ detected: matches.length > 0,
121
+ matches,
122
+ orms: Array.from(orms),
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Get ORM-specific push command info.
128
+ *
129
+ * @param {string} ormName - ORM identifier (payload, prisma, drizzle, supabase, typeorm)
130
+ * @returns {{ pushCommand: string, envHint: string, interactiveWarning: string|null, evidencePatterns: RegExp[] } | null}
131
+ */
132
+ function detectSchemaOrm(ormName) {
133
+ return ORM_INFO[ormName] || null;
134
+ }
135
+
136
+ /**
137
+ * Check for schema drift: schema files changed but no push evidence found.
138
+ *
139
+ * @param {string[]} changedFiles - Files changed during the phase
140
+ * @param {string} executionLog - Combined text from SUMMARY.md, commit messages, and execution logs
141
+ * @param {{ skipCheck?: boolean }} [options] - Options
142
+ * @returns {{ driftDetected: boolean, blocking: boolean, schemaFiles: string[], orms: string[], unpushedOrms: string[], message: string, skipped?: boolean }}
143
+ */
144
+ function checkSchemaDrift(changedFiles, executionLog, options = {}) {
145
+ const { skipCheck = false } = options;
146
+
147
+ const detection = detectSchemaFiles(changedFiles);
148
+
149
+ if (!detection.detected) {
150
+ return {
151
+ driftDetected: false,
152
+ blocking: false,
153
+ schemaFiles: [],
154
+ orms: [],
155
+ unpushedOrms: [],
156
+ message: '',
157
+ };
158
+ }
159
+
160
+ // Check which ORMs have push evidence in the execution log
161
+ const pushedOrms = new Set();
162
+ const unpushedOrms = [];
163
+
164
+ for (const orm of detection.orms) {
165
+ const info = ORM_INFO[orm];
166
+ if (!info) continue;
167
+
168
+ const hasPushEvidence = info.evidencePatterns.some(p => p.test(executionLog));
169
+ if (hasPushEvidence) {
170
+ pushedOrms.add(orm);
171
+ } else {
172
+ unpushedOrms.push(orm);
173
+ }
174
+ }
175
+
176
+ const driftDetected = unpushedOrms.length > 0;
177
+
178
+ if (!driftDetected) {
179
+ return {
180
+ driftDetected: false,
181
+ blocking: false,
182
+ schemaFiles: detection.matches,
183
+ orms: detection.orms,
184
+ unpushedOrms: [],
185
+ message: '',
186
+ };
187
+ }
188
+
189
+ // Build actionable message
190
+ const pushCommands = unpushedOrms
191
+ .map(orm => {
192
+ const info = ORM_INFO[orm];
193
+ return info ? ` ${orm}: ${info.envHint || info.pushCommand}` : null;
194
+ })
195
+ .filter(Boolean)
196
+ .join('\n');
197
+
198
+ const message = [
199
+ 'Schema drift detected: schema-relevant files changed but no database push was executed.',
200
+ '',
201
+ `Schema files changed: ${detection.matches.join(', ')}`,
202
+ `ORMs requiring push: ${unpushedOrms.join(', ')}`,
203
+ '',
204
+ 'Required push commands:',
205
+ pushCommands,
206
+ '',
207
+ 'Run the appropriate push command, or set GSD_SKIP_SCHEMA_CHECK=true to bypass this gate.',
208
+ ].join('\n');
209
+
210
+ if (skipCheck) {
211
+ return {
212
+ driftDetected: true,
213
+ blocking: false,
214
+ skipped: true,
215
+ schemaFiles: detection.matches,
216
+ orms: detection.orms,
217
+ unpushedOrms,
218
+ message: 'Schema drift detected but check was skipped (GSD_SKIP_SCHEMA_CHECK=true).',
219
+ };
220
+ }
221
+
222
+ return {
223
+ driftDetected: true,
224
+ blocking: true,
225
+ schemaFiles: detection.matches,
226
+ orms: detection.orms,
227
+ unpushedOrms,
228
+ message,
229
+ };
230
+ }
231
+
232
+ module.exports = {
233
+ SCHEMA_PATTERNS,
234
+ ORM_INFO,
235
+ detectSchemaFiles,
236
+ detectSchemaOrm,
237
+ checkSchemaDrift,
238
+ };
@@ -181,9 +181,11 @@ function scanForInjection(text, opts = {}) {
181
181
  findings.push('Contains suspicious zero-width or invisible Unicode characters');
182
182
  }
183
183
 
184
- // Check for extremely long strings that could be prompt stuffing
185
- if (text.length > 50000) {
186
- findings.push(`Suspicious text length: ${text.length} chars (potential prompt stuffing)`);
184
+ // Check for extremely long strings that could be prompt stuffing.
185
+ // Normalize CRLF LF before measuring so Windows checkouts don't inflate the count.
186
+ const normalizedLength = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').length;
187
+ if (normalizedLength > 50000) {
188
+ findings.push(`Suspicious text length: ${normalizedLength} chars (potential prompt stuffing)`);
187
189
  }
188
190
  }
189
191