@ktpartners/dgs-platform 2.6.2

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 (256) hide show
  1. package/LICENSE +38 -0
  2. package/README.md +851 -0
  3. package/agents/dgs-codebase-cross-analyzer.md +183 -0
  4. package/agents/dgs-codebase-mapper.md +782 -0
  5. package/agents/dgs-codebase-synthesizer.md +156 -0
  6. package/agents/dgs-debugger.md +1256 -0
  7. package/agents/dgs-executor.md +550 -0
  8. package/agents/dgs-integration-checker.md +481 -0
  9. package/agents/dgs-nyquist-auditor.md +178 -0
  10. package/agents/dgs-phase-researcher.md +563 -0
  11. package/agents/dgs-phase-verifier.md +450 -0
  12. package/agents/dgs-plan-checker.md +708 -0
  13. package/agents/dgs-planner.md +1324 -0
  14. package/agents/dgs-project-researcher.md +631 -0
  15. package/agents/dgs-research-synthesizer.md +249 -0
  16. package/agents/dgs-roadmapper.md +652 -0
  17. package/agents/dgs-verifier.md +607 -0
  18. package/bin/install.js +2073 -0
  19. package/commands/dgs/add-doc.md +45 -0
  20. package/commands/dgs/add-idea.md +38 -0
  21. package/commands/dgs/add-phase.md +43 -0
  22. package/commands/dgs/add-repo.md +54 -0
  23. package/commands/dgs/add-tests.md +41 -0
  24. package/commands/dgs/add-todo.md +47 -0
  25. package/commands/dgs/approve-spec.md +38 -0
  26. package/commands/dgs/audit-milestone.md +36 -0
  27. package/commands/dgs/audit-phase.md +37 -0
  28. package/commands/dgs/cancel-job.md +23 -0
  29. package/commands/dgs/capture-principle.md +143 -0
  30. package/commands/dgs/check-todos.md +45 -0
  31. package/commands/dgs/cleanup.md +18 -0
  32. package/commands/dgs/complete-milestone.md +136 -0
  33. package/commands/dgs/complete-project.md +70 -0
  34. package/commands/dgs/consolidate-ideas.md +50 -0
  35. package/commands/dgs/create-milestone-job.md +37 -0
  36. package/commands/dgs/debug.md +164 -0
  37. package/commands/dgs/develop-idea.md +53 -0
  38. package/commands/dgs/discuss-idea.md +41 -0
  39. package/commands/dgs/discuss-phase.md +83 -0
  40. package/commands/dgs/execute-phase.md +41 -0
  41. package/commands/dgs/fast.md +38 -0
  42. package/commands/dgs/find-related-ideas.md +43 -0
  43. package/commands/dgs/health.md +28 -0
  44. package/commands/dgs/help.md +22 -0
  45. package/commands/dgs/import-spec.md +36 -0
  46. package/commands/dgs/init-product.md +28 -0
  47. package/commands/dgs/insert-phase.md +32 -0
  48. package/commands/dgs/join-discord.md +18 -0
  49. package/commands/dgs/list-docs.md +40 -0
  50. package/commands/dgs/list-ideas.md +42 -0
  51. package/commands/dgs/list-jobs.md +22 -0
  52. package/commands/dgs/list-phase-assumptions.md +46 -0
  53. package/commands/dgs/list-projects.md +57 -0
  54. package/commands/dgs/list-specs.md +40 -0
  55. package/commands/dgs/map-codebase.md +92 -0
  56. package/commands/dgs/new-milestone.md +44 -0
  57. package/commands/dgs/new-project.md +42 -0
  58. package/commands/dgs/node-repair.md +26 -0
  59. package/commands/dgs/overlap-check.md +20 -0
  60. package/commands/dgs/pause-work.md +38 -0
  61. package/commands/dgs/plan-milestone-gaps.md +34 -0
  62. package/commands/dgs/plan-phase.md +44 -0
  63. package/commands/dgs/progress.md +24 -0
  64. package/commands/dgs/quick.md +41 -0
  65. package/commands/dgs/reactivate-project.md +70 -0
  66. package/commands/dgs/reapply-patches.md +110 -0
  67. package/commands/dgs/refine-spec.md +38 -0
  68. package/commands/dgs/reject-idea.md +43 -0
  69. package/commands/dgs/remove-doc.md +44 -0
  70. package/commands/dgs/remove-phase.md +31 -0
  71. package/commands/dgs/remove-repo.md +69 -0
  72. package/commands/dgs/research-idea.md +43 -0
  73. package/commands/dgs/research-phase.md +189 -0
  74. package/commands/dgs/restore-idea.md +45 -0
  75. package/commands/dgs/resume-work.md +40 -0
  76. package/commands/dgs/rollback-job.md +24 -0
  77. package/commands/dgs/run-job.md +35 -0
  78. package/commands/dgs/search.md +40 -0
  79. package/commands/dgs/set-profile.md +34 -0
  80. package/commands/dgs/settings.md +38 -0
  81. package/commands/dgs/switch-project.md +58 -0
  82. package/commands/dgs/undo-consolidation.md +42 -0
  83. package/commands/dgs/update-idea.md +44 -0
  84. package/commands/dgs/update.md +37 -0
  85. package/commands/dgs/validate-phase.md +35 -0
  86. package/commands/dgs/verify-work.md +39 -0
  87. package/commands/dgs/write-spec.md +49 -0
  88. package/deliver-great-systems/.planning/phases/09-backend-wiring-and-error-handling/09-01-SUMMARY.md +84 -0
  89. package/deliver-great-systems/.planning/phases/09-backend-wiring-and-error-handling/09-02-SUMMARY.md +86 -0
  90. package/deliver-great-systems/.planning/phases/10-v1-to-v2-migration-flow/10-01-SUMMARY.md +85 -0
  91. package/deliver-great-systems/bin/dgs-tools.cjs +1444 -0
  92. package/deliver-great-systems/bin/lib/auto-test.cjs +1365 -0
  93. package/deliver-great-systems/bin/lib/commands.cjs +570 -0
  94. package/deliver-great-systems/bin/lib/config.cjs +417 -0
  95. package/deliver-great-systems/bin/lib/conflict-agent.cjs +1063 -0
  96. package/deliver-great-systems/bin/lib/conflict-agent.test.cjs +554 -0
  97. package/deliver-great-systems/bin/lib/context.cjs +929 -0
  98. package/deliver-great-systems/bin/lib/context.test.cjs +693 -0
  99. package/deliver-great-systems/bin/lib/core.cjs +744 -0
  100. package/deliver-great-systems/bin/lib/core.test.cjs +822 -0
  101. package/deliver-great-systems/bin/lib/docs.cjs +919 -0
  102. package/deliver-great-systems/bin/lib/docs.test.cjs +211 -0
  103. package/deliver-great-systems/bin/lib/execution.cjs +705 -0
  104. package/deliver-great-systems/bin/lib/execution.test.cjs +1472 -0
  105. package/deliver-great-systems/bin/lib/frontmatter.cjs +324 -0
  106. package/deliver-great-systems/bin/lib/ideas.cjs +1406 -0
  107. package/deliver-great-systems/bin/lib/ideas.test.cjs +1417 -0
  108. package/deliver-great-systems/bin/lib/identity.cjs +125 -0
  109. package/deliver-great-systems/bin/lib/init.cjs +1114 -0
  110. package/deliver-great-systems/bin/lib/init.test.cjs +1271 -0
  111. package/deliver-great-systems/bin/lib/jobs.cjs +2015 -0
  112. package/deliver-great-systems/bin/lib/jobs.test.cjs +2619 -0
  113. package/deliver-great-systems/bin/lib/merge-conflicts.cjs +654 -0
  114. package/deliver-great-systems/bin/lib/merge-conflicts.test.cjs +370 -0
  115. package/deliver-great-systems/bin/lib/migration.cjs +352 -0
  116. package/deliver-great-systems/bin/lib/migration.test.cjs +582 -0
  117. package/deliver-great-systems/bin/lib/milestone.cjs +243 -0
  118. package/deliver-great-systems/bin/lib/overlap.cjs +437 -0
  119. package/deliver-great-systems/bin/lib/overlap.test.cjs +747 -0
  120. package/deliver-great-systems/bin/lib/path-audit.test.cjs +384 -0
  121. package/deliver-great-systems/bin/lib/paths.cjs +144 -0
  122. package/deliver-great-systems/bin/lib/paths.test.cjs +486 -0
  123. package/deliver-great-systems/bin/lib/phase.cjs +910 -0
  124. package/deliver-great-systems/bin/lib/projects.cjs +691 -0
  125. package/deliver-great-systems/bin/lib/projects.test.cjs +871 -0
  126. package/deliver-great-systems/bin/lib/repos.cjs +1432 -0
  127. package/deliver-great-systems/bin/lib/repos.test.cjs +1882 -0
  128. package/deliver-great-systems/bin/lib/roadmap.cjs +305 -0
  129. package/deliver-great-systems/bin/lib/search.cjs +570 -0
  130. package/deliver-great-systems/bin/lib/specs.cjs +1303 -0
  131. package/deliver-great-systems/bin/lib/state.cjs +893 -0
  132. package/deliver-great-systems/bin/lib/template.cjs +228 -0
  133. package/deliver-great-systems/bin/lib/test-helpers.cjs +291 -0
  134. package/deliver-great-systems/bin/lib/verify.cjs +796 -0
  135. package/deliver-great-systems/references/checkpoints.md +776 -0
  136. package/deliver-great-systems/references/conflict-resolution.md +66 -0
  137. package/deliver-great-systems/references/context-tiers.md +166 -0
  138. package/deliver-great-systems/references/continuation-format.md +249 -0
  139. package/deliver-great-systems/references/decimal-phase-calculation.md +67 -0
  140. package/deliver-great-systems/references/git-integration.md +250 -0
  141. package/deliver-great-systems/references/git-planning-commit.md +40 -0
  142. package/deliver-great-systems/references/model-profile-resolution.md +36 -0
  143. package/deliver-great-systems/references/model-profiles.md +95 -0
  144. package/deliver-great-systems/references/phase-argument-parsing.md +61 -0
  145. package/deliver-great-systems/references/planning-config.md +224 -0
  146. package/deliver-great-systems/references/questioning.md +162 -0
  147. package/deliver-great-systems/references/spec-review-loop.md +177 -0
  148. package/deliver-great-systems/references/tdd.md +265 -0
  149. package/deliver-great-systems/references/ui-brand.md +160 -0
  150. package/deliver-great-systems/references/verification-patterns.md +612 -0
  151. package/deliver-great-systems/templates/DEBUG.md +166 -0
  152. package/deliver-great-systems/templates/UAT.md +251 -0
  153. package/deliver-great-systems/templates/VALIDATION.md +95 -0
  154. package/deliver-great-systems/templates/claude-md.md +74 -0
  155. package/deliver-great-systems/templates/codebase/architecture.md +257 -0
  156. package/deliver-great-systems/templates/codebase/concerns.md +312 -0
  157. package/deliver-great-systems/templates/codebase/conventions.md +309 -0
  158. package/deliver-great-systems/templates/codebase/integrations.md +282 -0
  159. package/deliver-great-systems/templates/codebase/stack.md +188 -0
  160. package/deliver-great-systems/templates/codebase/structure.md +287 -0
  161. package/deliver-great-systems/templates/codebase/testing.md +482 -0
  162. package/deliver-great-systems/templates/config.json +38 -0
  163. package/deliver-great-systems/templates/context.md +354 -0
  164. package/deliver-great-systems/templates/continue-here.md +80 -0
  165. package/deliver-great-systems/templates/debug-subagent-prompt.md +93 -0
  166. package/deliver-great-systems/templates/discovery.md +148 -0
  167. package/deliver-great-systems/templates/milestone-archive.md +125 -0
  168. package/deliver-great-systems/templates/milestone.md +117 -0
  169. package/deliver-great-systems/templates/phase-prompt.md +615 -0
  170. package/deliver-great-systems/templates/planner-subagent-prompt.md +119 -0
  171. package/deliver-great-systems/templates/project.md +186 -0
  172. package/deliver-great-systems/templates/requirements.md +233 -0
  173. package/deliver-great-systems/templates/research-project/ARCHITECTURE.md +206 -0
  174. package/deliver-great-systems/templates/research-project/FEATURES.md +149 -0
  175. package/deliver-great-systems/templates/research-project/PITFALLS.md +202 -0
  176. package/deliver-great-systems/templates/research-project/STACK.md +122 -0
  177. package/deliver-great-systems/templates/research-project/SUMMARY.md +172 -0
  178. package/deliver-great-systems/templates/research.md +554 -0
  179. package/deliver-great-systems/templates/retrospective.md +54 -0
  180. package/deliver-great-systems/templates/roadmap.md +204 -0
  181. package/deliver-great-systems/templates/state.md +178 -0
  182. package/deliver-great-systems/templates/summary-complex.md +59 -0
  183. package/deliver-great-systems/templates/summary-minimal.md +41 -0
  184. package/deliver-great-systems/templates/summary-standard.md +48 -0
  185. package/deliver-great-systems/templates/summary.md +253 -0
  186. package/deliver-great-systems/templates/user-setup.md +313 -0
  187. package/deliver-great-systems/templates/verification-report.md +324 -0
  188. package/deliver-great-systems/workflows/add-doc.md +151 -0
  189. package/deliver-great-systems/workflows/add-idea.md +96 -0
  190. package/deliver-great-systems/workflows/add-phase.md +120 -0
  191. package/deliver-great-systems/workflows/add-tests.md +359 -0
  192. package/deliver-great-systems/workflows/add-todo.md +162 -0
  193. package/deliver-great-systems/workflows/approve-spec.md +194 -0
  194. package/deliver-great-systems/workflows/audit-milestone.md +364 -0
  195. package/deliver-great-systems/workflows/audit-phase.md +462 -0
  196. package/deliver-great-systems/workflows/cancel-job.md +108 -0
  197. package/deliver-great-systems/workflows/check-todos.md +181 -0
  198. package/deliver-great-systems/workflows/cleanup.md +247 -0
  199. package/deliver-great-systems/workflows/codereview.md +526 -0
  200. package/deliver-great-systems/workflows/complete-milestone.md +1298 -0
  201. package/deliver-great-systems/workflows/consolidate-ideas.md +365 -0
  202. package/deliver-great-systems/workflows/create-milestone-job.md +177 -0
  203. package/deliver-great-systems/workflows/develop-idea.md +544 -0
  204. package/deliver-great-systems/workflows/diagnose-issues.md +231 -0
  205. package/deliver-great-systems/workflows/discovery-phase.md +301 -0
  206. package/deliver-great-systems/workflows/discuss-idea.md +263 -0
  207. package/deliver-great-systems/workflows/discuss-phase.md +733 -0
  208. package/deliver-great-systems/workflows/execute-phase.md +571 -0
  209. package/deliver-great-systems/workflows/execute-plan.md +592 -0
  210. package/deliver-great-systems/workflows/find-related-ideas.md +271 -0
  211. package/deliver-great-systems/workflows/health.md +173 -0
  212. package/deliver-great-systems/workflows/help.md +997 -0
  213. package/deliver-great-systems/workflows/import-spec.md +381 -0
  214. package/deliver-great-systems/workflows/init-product.md +767 -0
  215. package/deliver-great-systems/workflows/insert-phase.md +138 -0
  216. package/deliver-great-systems/workflows/list-docs.md +119 -0
  217. package/deliver-great-systems/workflows/list-ideas.md +154 -0
  218. package/deliver-great-systems/workflows/list-jobs.md +89 -0
  219. package/deliver-great-systems/workflows/list-phase-assumptions.md +192 -0
  220. package/deliver-great-systems/workflows/list-specs.md +101 -0
  221. package/deliver-great-systems/workflows/map-codebase.md +621 -0
  222. package/deliver-great-systems/workflows/new-milestone.md +591 -0
  223. package/deliver-great-systems/workflows/new-project.md +1113 -0
  224. package/deliver-great-systems/workflows/node-repair.md +94 -0
  225. package/deliver-great-systems/workflows/overlap-check.md +86 -0
  226. package/deliver-great-systems/workflows/pause-work.md +134 -0
  227. package/deliver-great-systems/workflows/plan-milestone-gaps.md +306 -0
  228. package/deliver-great-systems/workflows/plan-phase.md +698 -0
  229. package/deliver-great-systems/workflows/progress.md +386 -0
  230. package/deliver-great-systems/workflows/quick.md +845 -0
  231. package/deliver-great-systems/workflows/refine-spec.md +275 -0
  232. package/deliver-great-systems/workflows/reject-idea.md +109 -0
  233. package/deliver-great-systems/workflows/remove-doc.md +117 -0
  234. package/deliver-great-systems/workflows/remove-phase.md +163 -0
  235. package/deliver-great-systems/workflows/research-idea.md +325 -0
  236. package/deliver-great-systems/workflows/research-phase.md +81 -0
  237. package/deliver-great-systems/workflows/restore-idea.md +101 -0
  238. package/deliver-great-systems/workflows/resume-project.md +311 -0
  239. package/deliver-great-systems/workflows/rollback-job.md +130 -0
  240. package/deliver-great-systems/workflows/run-job.md +498 -0
  241. package/deliver-great-systems/workflows/search.md +130 -0
  242. package/deliver-great-systems/workflows/set-profile.md +83 -0
  243. package/deliver-great-systems/workflows/settings.md +470 -0
  244. package/deliver-great-systems/workflows/transition.md +563 -0
  245. package/deliver-great-systems/workflows/undo-consolidation.md +155 -0
  246. package/deliver-great-systems/workflows/update-idea.md +157 -0
  247. package/deliver-great-systems/workflows/update.md +242 -0
  248. package/deliver-great-systems/workflows/validate-phase.md +177 -0
  249. package/deliver-great-systems/workflows/verify-phase.md +253 -0
  250. package/deliver-great-systems/workflows/verify-work.md +671 -0
  251. package/deliver-great-systems/workflows/write-spec.md +450 -0
  252. package/hooks/dist/dgs-check-update.js +62 -0
  253. package/hooks/dist/dgs-context-monitor.js +141 -0
  254. package/hooks/dist/dgs-statusline.js +115 -0
  255. package/package.json +60 -0
  256. package/scripts/build-hooks.js +43 -0
@@ -0,0 +1,1472 @@
1
+ /**
2
+ * Tests for execution.cjs — Multi-repo execution engine:
3
+ * preflight checks, per-repo commits, planning commit body, change detection
4
+ */
5
+
6
+ const { describe, it, beforeEach, afterEach } = require('node:test');
7
+ const assert = require('node:assert');
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const os = require('os');
11
+ const { execSync } = require('child_process');
12
+
13
+ const { createTempDir, cleanupDir, initGitRepo } = require('./test-helpers.cjs');
14
+
15
+ /**
16
+ * Helper: create a product root with .planning/REPOS.md and git repos.
17
+ */
18
+ function setupProductRoot(tmpDir, repoNames) {
19
+ // Create .planning with REPOS.md
20
+ const planningDir = path.join(tmpDir, '.planning');
21
+ fs.mkdirSync(planningDir, { recursive: true });
22
+
23
+ let reposMd = '# Repos\n\n';
24
+ reposMd += '| Name | Path | GitHub URL | Description |\n';
25
+ reposMd += '|------|------|------------|-------------|\n';
26
+
27
+ for (const name of repoNames) {
28
+ reposMd += `| ${name} | ./${name} | | Test repo |\n`;
29
+ initGitRepo(path.join(tmpDir, name));
30
+ // Add README.md as a tracked file (tests depend on modifying it)
31
+ fs.writeFileSync(path.join(tmpDir, name, 'README.md'), '# Repo\n');
32
+ execSync('git add README.md', { cwd: path.join(tmpDir, name), stdio: 'pipe' });
33
+ execSync('git commit -m "add readme"', { cwd: path.join(tmpDir, name), stdio: 'pipe' });
34
+ }
35
+
36
+ fs.writeFileSync(path.join(planningDir, 'REPOS.md'), reposMd);
37
+ return tmpDir;
38
+ }
39
+
40
+ const {
41
+ resolveRepoPath,
42
+ resolveRepoRelativePath,
43
+ preflightCheck,
44
+ commitPerRepo,
45
+ retryCommitPerRepo,
46
+ detectManualResolution,
47
+ buildPlanningCommitBody,
48
+ detectRepoChanges,
49
+ createRepoBranches,
50
+ reportBranchesToMerge,
51
+ updateRepoStatus,
52
+ cmdCommitMultiRepo,
53
+ cmdCommitPreflight,
54
+ } = require('./execution.cjs');
55
+
56
+ // ─── resolveRepoRelativePath ─────────────────────────────────────────────────
57
+
58
+ describe('resolveRepoRelativePath', () => {
59
+ let tmpDir;
60
+ beforeEach(() => { tmpDir = createTempDir(); });
61
+ afterEach(() => { cleanupDir(tmpDir); });
62
+
63
+ it('resolves src/index.ts in repo web-app to correct absolute path', () => {
64
+ setupProductRoot(tmpDir, ['web-app']);
65
+ const result = resolveRepoRelativePath(tmpDir, 'web-app', 'src/index.ts');
66
+ assert.ok(result);
67
+ assert.strictEqual(result.absFilePath, path.join(tmpDir, 'web-app', 'src', 'index.ts'));
68
+ assert.strictEqual(result.repoAbsPath, path.join(tmpDir, 'web-app'));
69
+ assert.strictEqual(result.repo.name, 'web-app');
70
+ });
71
+
72
+ it('returns null for unknown repo name', () => {
73
+ setupProductRoot(tmpDir, ['web-app']);
74
+ const result = resolveRepoRelativePath(tmpDir, 'nonexistent', 'src/index.ts');
75
+ assert.strictEqual(result, null);
76
+ });
77
+
78
+ it('works with ../repo paths in REPOS.md (sibling layout)', () => {
79
+ // Create a sibling-layout product root
80
+ const productRoot = path.join(tmpDir, 'product-root');
81
+ const planningDir = path.join(productRoot, '.planning');
82
+ fs.mkdirSync(planningDir, { recursive: true });
83
+
84
+ let reposMd = '# Repos\n\n';
85
+ reposMd += '| Name | Path | GitHub URL | Description |\n';
86
+ reposMd += '|------|------|------------|-------------|\n';
87
+ reposMd += '| api-service | ../api-service | | Test repo |\n';
88
+
89
+ fs.writeFileSync(path.join(planningDir, 'REPOS.md'), reposMd);
90
+ fs.mkdirSync(path.join(tmpDir, 'api-service'), { recursive: true });
91
+
92
+ const result = resolveRepoRelativePath(productRoot, 'api-service', 'src/server.ts');
93
+ assert.ok(result);
94
+ assert.strictEqual(result.absFilePath, path.join(tmpDir, 'api-service', 'src', 'server.ts'));
95
+ assert.strictEqual(result.repoAbsPath, path.join(tmpDir, 'api-service'));
96
+ });
97
+
98
+ it('handles nested paths like src/components/Button.tsx', () => {
99
+ setupProductRoot(tmpDir, ['web-app']);
100
+ const result = resolveRepoRelativePath(tmpDir, 'web-app', 'src/components/Button.tsx');
101
+ assert.ok(result);
102
+ assert.strictEqual(result.absFilePath, path.join(tmpDir, 'web-app', 'src', 'components', 'Button.tsx'));
103
+ });
104
+
105
+ it('accepts pre-loaded repos array', () => {
106
+ setupProductRoot(tmpDir, ['web-app']);
107
+ const repos = [{ name: 'web-app', path: './web-app' }];
108
+ const result = resolveRepoRelativePath(tmpDir, 'web-app', 'src/index.ts', repos);
109
+ assert.ok(result);
110
+ assert.strictEqual(result.absFilePath, path.join(tmpDir, 'web-app', 'src', 'index.ts'));
111
+ });
112
+ });
113
+
114
+ // ─── preflightCheck ─────────────────────────────────────────────────────────
115
+
116
+ describe('preflightCheck', () => {
117
+ let tmpDir;
118
+ beforeEach(() => { tmpDir = createTempDir(); });
119
+ afterEach(() => { cleanupDir(tmpDir); });
120
+
121
+ it('all repos clean — passed: true', () => {
122
+ setupProductRoot(tmpDir, ['repo-a', 'repo-b']);
123
+ const result = preflightCheck(tmpDir, ['repo-a', 'repo-b']);
124
+ assert.strictEqual(result.passed, true);
125
+ assert.strictEqual(result.dirty_repos.length, 0);
126
+ });
127
+
128
+ it('one dirty repo (modified tracked files) — passed: false', () => {
129
+ setupProductRoot(tmpDir, ['repo-a', 'repo-b']);
130
+ // Make repo-a dirty by modifying a tracked file
131
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'README.md'), '# Modified\n');
132
+ const result = preflightCheck(tmpDir, ['repo-a', 'repo-b']);
133
+ assert.strictEqual(result.passed, false);
134
+ assert.ok(result.dirty_repos.length > 0);
135
+ assert.ok(result.dirty_repos.some(d => d.name === 'repo-a'));
136
+ });
137
+
138
+ it('multiple dirty repos — all listed in dirty_repos', () => {
139
+ setupProductRoot(tmpDir, ['repo-a', 'repo-b']);
140
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'README.md'), '# Mod A\n');
141
+ fs.writeFileSync(path.join(tmpDir, 'repo-b', 'README.md'), '# Mod B\n');
142
+ const result = preflightCheck(tmpDir, ['repo-a', 'repo-b']);
143
+ assert.strictEqual(result.passed, false);
144
+ assert.strictEqual(result.dirty_repos.length, 2);
145
+ });
146
+
147
+ it('untracked-only files (??) — NOT dirty (passed: true)', () => {
148
+ setupProductRoot(tmpDir, ['repo-a']);
149
+ // Add untracked file (not staged, not committed)
150
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'newfile.txt'), 'untracked');
151
+ const result = preflightCheck(tmpDir, ['repo-a']);
152
+ assert.strictEqual(result.passed, true);
153
+ });
154
+
155
+ it('missing repo (not on disk) — missing_repos populated', () => {
156
+ setupProductRoot(tmpDir, ['repo-a']);
157
+ const result = preflightCheck(tmpDir, ['repo-a', 'repo-missing']);
158
+ assert.ok(result.missing_repos.includes('repo-missing'));
159
+ });
160
+
161
+ it('no SUMMARY.md file exists — partial_execution is null', () => {
162
+ setupProductRoot(tmpDir, ['repo-a']);
163
+ const result = preflightCheck(tmpDir, ['repo-a']);
164
+ assert.strictEqual(result.partial_execution, null);
165
+ });
166
+
167
+ it('SUMMARY.md with status: partial — passed: false, partial_execution set', () => {
168
+ setupProductRoot(tmpDir, ['repo-a']);
169
+ const phaseDir = path.join(tmpDir, '.planning', 'phases', '04-test');
170
+ fs.mkdirSync(phaseDir, { recursive: true });
171
+ fs.writeFileSync(path.join(phaseDir, '04-01-SUMMARY.md'),
172
+ '---\nphase: 04-test\nplan: 01\nstatus: partial\n---\n# Summary\n');
173
+ const result = preflightCheck(tmpDir, ['repo-a'], phaseDir);
174
+ assert.strictEqual(result.passed, false);
175
+ assert.ok(result.partial_execution !== null);
176
+ });
177
+
178
+ it('SUMMARY.md with status: complete — passed: true', () => {
179
+ setupProductRoot(tmpDir, ['repo-a']);
180
+ const phaseDir = path.join(tmpDir, '.planning', 'phases', '04-test');
181
+ fs.mkdirSync(phaseDir, { recursive: true });
182
+ fs.writeFileSync(path.join(phaseDir, '04-01-SUMMARY.md'),
183
+ '---\nphase: 04-test\nplan: 01\nstatus: complete\n---\n# Summary\n');
184
+ const result = preflightCheck(tmpDir, ['repo-a'], phaseDir);
185
+ assert.strictEqual(result.passed, true);
186
+ assert.strictEqual(result.partial_execution, null);
187
+ });
188
+ });
189
+
190
+ // ─── commitPerRepo ──────────────────────────────────────────────────────────
191
+
192
+ describe('commitPerRepo', () => {
193
+ let tmpDir;
194
+ beforeEach(() => { tmpDir = createTempDir(); });
195
+ afterEach(() => { cleanupDir(tmpDir); });
196
+
197
+ const options = {
198
+ type: 'feat',
199
+ project: 'test-project',
200
+ phase: '04',
201
+ plan: '01',
202
+ description: 'add feature',
203
+ };
204
+
205
+ it('single repo with one changed file — committed, SHA returned', () => {
206
+ setupProductRoot(tmpDir, ['repo-a']);
207
+ // Create a new file to commit
208
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'new.js'), 'console.log("hi");\n');
209
+ const repoChanges = [{ repoName: 'repo-a', repoPath: './repo-a', files: ['new.js'] }];
210
+ const results = commitPerRepo(tmpDir, repoChanges, options);
211
+ assert.strictEqual(results.length, 1);
212
+ assert.strictEqual(results[0].repo, 'repo-a');
213
+ assert.strictEqual(results[0].status, 'complete');
214
+ assert.ok(results[0].sha);
215
+ });
216
+
217
+ it('two repos with changes — both committed in alphabetical order', () => {
218
+ setupProductRoot(tmpDir, ['repo-b', 'repo-a']);
219
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'a.js'), 'a');
220
+ fs.writeFileSync(path.join(tmpDir, 'repo-b', 'b.js'), 'b');
221
+ const repoChanges = [
222
+ { repoName: 'repo-b', repoPath: './repo-b', files: ['b.js'] },
223
+ { repoName: 'repo-a', repoPath: './repo-a', files: ['a.js'] },
224
+ ];
225
+ const results = commitPerRepo(tmpDir, repoChanges, options);
226
+ assert.strictEqual(results.length, 2);
227
+ // Should be sorted alphabetically
228
+ assert.strictEqual(results[0].repo, 'repo-a');
229
+ assert.strictEqual(results[1].repo, 'repo-b');
230
+ });
231
+
232
+ it('commit message format: type(project/phase-plan): description', () => {
233
+ setupProductRoot(tmpDir, ['repo-a']);
234
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'new.js'), 'code');
235
+ const repoChanges = [{ repoName: 'repo-a', repoPath: './repo-a', files: ['new.js'] }];
236
+ commitPerRepo(tmpDir, repoChanges, options);
237
+ // Check the actual git log
238
+ const log = execSync('git log --oneline -1', {
239
+ cwd: path.join(tmpDir, 'repo-a'),
240
+ encoding: 'utf-8',
241
+ }).trim();
242
+ assert.ok(log.includes('feat(test-project/04-01): add feature'));
243
+ });
244
+
245
+ it('repo with no staged changes after add — status: failed or skipped', () => {
246
+ setupProductRoot(tmpDir, ['repo-a']);
247
+ // Don't create any new file — nothing to commit
248
+ const repoChanges = [{ repoName: 'repo-a', repoPath: './repo-a', files: ['nonexistent.js'] }];
249
+ const results = commitPerRepo(tmpDir, repoChanges, options);
250
+ assert.strictEqual(results.length, 1);
251
+ assert.ok(results[0].status === 'failed' || results[0].status === 'skipped');
252
+ });
253
+
254
+ it('verify actual git log shows correct commit message', () => {
255
+ setupProductRoot(tmpDir, ['repo-a']);
256
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'file.js'), 'x');
257
+ const repoChanges = [{ repoName: 'repo-a', repoPath: './repo-a', files: ['file.js'] }];
258
+ const results = commitPerRepo(tmpDir, repoChanges, options);
259
+ const sha = results[0].sha;
260
+ const log = execSync(`git log --format="%H %s" -1`, {
261
+ cwd: path.join(tmpDir, 'repo-a'),
262
+ encoding: 'utf-8',
263
+ }).trim();
264
+ assert.ok(log.includes('feat(test-project/04-01): add feature'));
265
+ });
266
+
267
+ it('multiple files in single repo — all staged and committed', () => {
268
+ setupProductRoot(tmpDir, ['repo-a']);
269
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'a.js'), 'a');
270
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'b.js'), 'b');
271
+ const repoChanges = [{ repoName: 'repo-a', repoPath: './repo-a', files: ['a.js', 'b.js'] }];
272
+ const results = commitPerRepo(tmpDir, repoChanges, options);
273
+ assert.strictEqual(results[0].status, 'complete');
274
+ // Check both files are in the commit
275
+ const diff = execSync('git diff-tree --no-commit-id --name-only -r HEAD', {
276
+ cwd: path.join(tmpDir, 'repo-a'),
277
+ encoding: 'utf-8',
278
+ }).trim();
279
+ assert.ok(diff.includes('a.js'));
280
+ assert.ok(diff.includes('b.js'));
281
+ });
282
+ });
283
+
284
+ // ─── buildPlanningCommitBody ────────────────────────────────────────────────
285
+
286
+ describe('buildPlanningCommitBody', () => {
287
+ it('single completed repo — single line', () => {
288
+ const results = [{ repo: 'my-app', status: 'complete', sha: 'abc1234' }];
289
+ const body = buildPlanningCommitBody(results);
290
+ assert.ok(body.includes('Repo: my-app SHA: abc1234'));
291
+ });
292
+
293
+ it('multiple completed repos — multiple lines, alphabetical', () => {
294
+ const results = [
295
+ { repo: 'web-app', status: 'complete', sha: 'def5678' },
296
+ { repo: 'api-service', status: 'complete', sha: 'abc1234' },
297
+ ];
298
+ const body = buildPlanningCommitBody(results);
299
+ const lines = body.split('\n').filter(l => l.trim());
300
+ assert.ok(lines[0].includes('api-service'));
301
+ assert.ok(lines[1].includes('web-app'));
302
+ });
303
+
304
+ it('mix of complete and failed — only complete repos in body', () => {
305
+ const results = [
306
+ { repo: 'my-app', status: 'complete', sha: 'abc1234' },
307
+ { repo: 'broken', status: 'failed', sha: null, error: 'git error' },
308
+ ];
309
+ const body = buildPlanningCommitBody(results);
310
+ assert.ok(body.includes('my-app'));
311
+ assert.ok(!body.includes('broken'));
312
+ });
313
+
314
+ it('no completed repos — empty string', () => {
315
+ const results = [
316
+ { repo: 'broken', status: 'failed', sha: null, error: 'git error' },
317
+ ];
318
+ const body = buildPlanningCommitBody(results);
319
+ assert.strictEqual(body, '');
320
+ });
321
+
322
+ it('empty results array — empty string', () => {
323
+ const body = buildPlanningCommitBody([]);
324
+ assert.strictEqual(body, '');
325
+ });
326
+ });
327
+
328
+ // ─── detectRepoChanges ──────────────────────────────────────────────────────
329
+
330
+ describe('detectRepoChanges', () => {
331
+ let tmpDir;
332
+ beforeEach(() => { tmpDir = createTempDir(); });
333
+ afterEach(() => { cleanupDir(tmpDir); });
334
+
335
+ it('repo with modified file — listed', () => {
336
+ setupProductRoot(tmpDir, ['repo-a']);
337
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'README.md'), '# Modified\n');
338
+ const result = detectRepoChanges(tmpDir, ['repo-a']);
339
+ assert.strictEqual(result.length, 1);
340
+ assert.strictEqual(result[0].repoName, 'repo-a');
341
+ assert.ok(result[0].files.length > 0);
342
+ });
343
+
344
+ it('repo with new (untracked) file — listed', () => {
345
+ setupProductRoot(tmpDir, ['repo-a']);
346
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'newfile.txt'), 'new content');
347
+ const result = detectRepoChanges(tmpDir, ['repo-a']);
348
+ assert.strictEqual(result.length, 1);
349
+ assert.ok(result[0].files.some(f => f.includes('newfile.txt')));
350
+ });
351
+
352
+ it('repo with no changes — excluded from results', () => {
353
+ setupProductRoot(tmpDir, ['repo-a', 'repo-b']);
354
+ // Only modify repo-a
355
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'README.md'), '# Changed\n');
356
+ const result = detectRepoChanges(tmpDir, ['repo-a', 'repo-b']);
357
+ assert.strictEqual(result.length, 1);
358
+ assert.strictEqual(result[0].repoName, 'repo-a');
359
+ });
360
+
361
+ it('multiple repos with mixed changes — correctly grouped', () => {
362
+ setupProductRoot(tmpDir, ['repo-a', 'repo-b']);
363
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'a.js'), 'a');
364
+ fs.writeFileSync(path.join(tmpDir, 'repo-b', 'b.js'), 'b');
365
+ const result = detectRepoChanges(tmpDir, ['repo-a', 'repo-b']);
366
+ assert.strictEqual(result.length, 2);
367
+ });
368
+
369
+ it('file paths are relative to repo root, not product root', () => {
370
+ setupProductRoot(tmpDir, ['repo-a']);
371
+ fs.mkdirSync(path.join(tmpDir, 'repo-a', 'src'), { recursive: true });
372
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'src', 'index.js'), 'code');
373
+ const result = detectRepoChanges(tmpDir, ['repo-a']);
374
+ // Files should NOT start with 'repo-a/'
375
+ for (const f of result[0].files) {
376
+ assert.ok(!f.startsWith('repo-a/'), `Expected relative to repo root, got: ${f}`);
377
+ }
378
+ });
379
+
380
+ it('returns empty array when no repos have changes', () => {
381
+ setupProductRoot(tmpDir, ['repo-a', 'repo-b']);
382
+ const result = detectRepoChanges(tmpDir, ['repo-a', 'repo-b']);
383
+ assert.strictEqual(result.length, 0);
384
+ });
385
+ });
386
+
387
+ // ─── Edge Cases ─────────────────────────────────────────────────────────────
388
+
389
+ describe('preflightCheck edge cases', () => {
390
+ let tmpDir;
391
+ beforeEach(() => { tmpDir = createTempDir(); });
392
+ afterEach(() => { cleanupDir(tmpDir); });
393
+
394
+ it('empty repoNames array — passed: true (vacuously)', () => {
395
+ setupProductRoot(tmpDir, ['repo-a']);
396
+ const result = preflightCheck(tmpDir, []);
397
+ assert.strictEqual(result.passed, true);
398
+ });
399
+
400
+ it('repo with staged but not committed changes — dirty', () => {
401
+ setupProductRoot(tmpDir, ['repo-a']);
402
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'staged.js'), 'staged content');
403
+ execSync('git add staged.js', { cwd: path.join(tmpDir, 'repo-a'), stdio: 'pipe' });
404
+ const result = preflightCheck(tmpDir, ['repo-a']);
405
+ assert.strictEqual(result.passed, false);
406
+ assert.ok(result.dirty_repos.length > 0);
407
+ });
408
+ });
409
+
410
+ describe('commitPerRepo edge cases', () => {
411
+ let tmpDir;
412
+ beforeEach(() => { tmpDir = createTempDir(); });
413
+ afterEach(() => { cleanupDir(tmpDir); });
414
+
415
+ const options = {
416
+ type: 'feat',
417
+ project: 'test-project',
418
+ phase: '04',
419
+ plan: '02',
420
+ description: 'edge case test',
421
+ };
422
+
423
+ it('empty repoChanges array — empty results', () => {
424
+ setupProductRoot(tmpDir, ['repo-a']);
425
+ const results = commitPerRepo(tmpDir, [], options);
426
+ assert.strictEqual(results.length, 0);
427
+ });
428
+ });
429
+
430
+ describe('detectRepoChanges edge cases', () => {
431
+ let tmpDir;
432
+ beforeEach(() => { tmpDir = createTempDir(); });
433
+ afterEach(() => { cleanupDir(tmpDir); });
434
+
435
+ it('repo with deleted file — listed', () => {
436
+ setupProductRoot(tmpDir, ['repo-a']);
437
+ // Delete a tracked file
438
+ fs.unlinkSync(path.join(tmpDir, 'repo-a', 'README.md'));
439
+ const result = detectRepoChanges(tmpDir, ['repo-a']);
440
+ assert.strictEqual(result.length, 1);
441
+ assert.ok(result[0].files.some(f => f.includes('README.md')));
442
+ });
443
+ });
444
+
445
+ describe('roundtrip integration', () => {
446
+ let tmpDir;
447
+ beforeEach(() => { tmpDir = createTempDir(); });
448
+ afterEach(() => { cleanupDir(tmpDir); });
449
+
450
+ it('detectRepoChanges -> commitPerRepo -> buildPlanningCommitBody produces coherent result', () => {
451
+ setupProductRoot(tmpDir, ['repo-a', 'repo-b']);
452
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'new.js'), 'a');
453
+ fs.writeFileSync(path.join(tmpDir, 'repo-b', 'new.js'), 'b');
454
+
455
+ const options = {
456
+ type: 'feat',
457
+ project: 'integration',
458
+ phase: '04',
459
+ plan: '02',
460
+ description: 'roundtrip test',
461
+ };
462
+
463
+ // Step 1: detect changes
464
+ const changes = detectRepoChanges(tmpDir, ['repo-a', 'repo-b']);
465
+ assert.strictEqual(changes.length, 2);
466
+
467
+ // Step 2: commit per repo
468
+ const results = commitPerRepo(tmpDir, changes, options);
469
+ assert.strictEqual(results.length, 2);
470
+ assert.ok(results.every(r => r.status === 'complete'));
471
+
472
+ // Step 3: build planning commit body
473
+ const body = buildPlanningCommitBody(results);
474
+ assert.ok(body.includes('repo-a'));
475
+ assert.ok(body.includes('repo-b'));
476
+ assert.ok(body.includes('SHA:'));
477
+ });
478
+ });
479
+
480
+ // ─── createRepoBranches ─────────────────────────────────────────────────────
481
+
482
+ describe('createRepoBranches', () => {
483
+ let tmpDir;
484
+ beforeEach(() => { tmpDir = createTempDir(); });
485
+ afterEach(() => { cleanupDir(tmpDir); });
486
+
487
+ it('branching_strategy none — created: false', () => {
488
+ setupProductRoot(tmpDir, ['repo-a']);
489
+ const result = createRepoBranches(tmpDir, ['repo-a'], 'dgs/test/01-setup', { branching_strategy: 'none' });
490
+ assert.strictEqual(result.created, false);
491
+ assert.strictEqual(result.reason, 'branching_disabled');
492
+ });
493
+
494
+ it('new branch created successfully', () => {
495
+ setupProductRoot(tmpDir, ['repo-a']);
496
+ const result = createRepoBranches(tmpDir, ['repo-a'], 'dgs/test/01-setup', { branching_strategy: 'phase' });
497
+ assert.strictEqual(result.created, true);
498
+ assert.strictEqual(result.branches.length, 1);
499
+ assert.strictEqual(result.branches[0].action, 'created');
500
+ assert.strictEqual(result.branches[0].branch, 'dgs/test/01-setup');
501
+ });
502
+
503
+ it('existing branch reused (no error)', () => {
504
+ setupProductRoot(tmpDir, ['repo-a']);
505
+ // Create branch first time
506
+ createRepoBranches(tmpDir, ['repo-a'], 'dgs/test/01-setup', { branching_strategy: 'phase' });
507
+ // Switch back to main
508
+ execSync('git checkout main', { cwd: path.join(tmpDir, 'repo-a'), stdio: 'pipe' });
509
+ // Create again — should reuse
510
+ const result = createRepoBranches(tmpDir, ['repo-a'], 'dgs/test/01-setup', { branching_strategy: 'phase' });
511
+ assert.strictEqual(result.branches[0].action, 'reused');
512
+ });
513
+
514
+ it('multiple repos all get same branch name', () => {
515
+ setupProductRoot(tmpDir, ['repo-a', 'repo-b']);
516
+ const result = createRepoBranches(tmpDir, ['repo-a', 'repo-b'], 'dgs/test/01-setup', { branching_strategy: 'phase' });
517
+ assert.strictEqual(result.branches.length, 2);
518
+ assert.ok(result.branches.every(b => b.branch === 'dgs/test/01-setup'));
519
+ });
520
+
521
+ it('null config treated as branching enabled', () => {
522
+ setupProductRoot(tmpDir, ['repo-a']);
523
+ const result = createRepoBranches(tmpDir, ['repo-a'], 'dgs/test/01-setup', null);
524
+ assert.strictEqual(result.created, true);
525
+ });
526
+ });
527
+
528
+ // ─── createRepoBranches prefix collision ─────────────────────────────────────
529
+
530
+ describe('createRepoBranches prefix collision', () => {
531
+ let tmpDir;
532
+ beforeEach(() => { tmpDir = createTempDir(); });
533
+ afterEach(() => { cleanupDir(tmpDir); });
534
+
535
+ it('detects prefix collision when creating similar branch name', () => {
536
+ setupProductRoot(tmpDir, ['repo-a']);
537
+ // Create first branch
538
+ createRepoBranches(tmpDir, ['repo-a'], 'dgs/api-v2/phase-01', { branching_strategy: 'phase' });
539
+ // Switch back to main
540
+ execSync('git checkout main', { cwd: path.join(tmpDir, 'repo-a'), stdio: 'pipe' });
541
+ // Create a branch with a similar prefix — should detect collision
542
+ const result = createRepoBranches(tmpDir, ['repo-a'], 'dgs/api-v2-hotfix/phase-01', { branching_strategy: 'phase' });
543
+ assert.strictEqual(result.created, true);
544
+ assert.ok(result.warnings, 'Expected warnings array');
545
+ // The prefix collision check uses dgs/<projectSlug>* which is dgs/api-v2-hotfix*
546
+ // This would NOT match dgs/api-v2/phase-01 since "api-v2-hotfix" is the slug
547
+ // But dgs/api-v2/phase-01 starts with dgs/api-v2 which is a prefix of dgs/api-v2-hotfix
548
+ // The --list pattern is dgs/api-v2-hotfix* which won't match dgs/api-v2/phase-01
549
+ // So this specific direction won't trigger. Let's test the other direction.
550
+ });
551
+
552
+ it('no false positive when existing branch has different slug sharing prefix', () => {
553
+ setupProductRoot(tmpDir, ['repo-a']);
554
+ // Create branch for api-v2-hotfix first
555
+ createRepoBranches(tmpDir, ['repo-a'], 'dgs/api-v2-hotfix/phase-01', { branching_strategy: 'phase' });
556
+ // Switch back to main
557
+ execSync('git checkout main', { cwd: path.join(tmpDir, 'repo-a'), stdio: 'pipe' });
558
+ // Create a branch with shorter slug — dgs/api-v2/* won't match dgs/api-v2-hotfix/phase-01
559
+ // because the '/' delimiter after the slug prevents partial matches
560
+ const result = createRepoBranches(tmpDir, ['repo-a'], 'dgs/api-v2/phase-01', { branching_strategy: 'phase' });
561
+ assert.strictEqual(result.created, true);
562
+ assert.ok(result.warnings !== undefined, 'warnings should exist');
563
+ assert.strictEqual(result.warnings.length, 0, 'no collision — different project slugs separated by /');
564
+ });
565
+
566
+ it('existing branch reuse returns empty warnings array', () => {
567
+ setupProductRoot(tmpDir, ['repo-a']);
568
+ createRepoBranches(tmpDir, ['repo-a'], 'dgs/test/01-setup', { branching_strategy: 'phase' });
569
+ // Switch back to main
570
+ execSync('git checkout main', { cwd: path.join(tmpDir, 'repo-a'), stdio: 'pipe' });
571
+ // Reuse the same branch
572
+ const result = createRepoBranches(tmpDir, ['repo-a'], 'dgs/test/01-setup', { branching_strategy: 'phase' });
573
+ assert.ok(result.warnings !== undefined, 'warnings should exist');
574
+ assert.strictEqual(result.warnings.length, 0, 'reused branch should have no warnings');
575
+ });
576
+
577
+ it('new branch with no similar prefix returns empty warnings', () => {
578
+ setupProductRoot(tmpDir, ['repo-a']);
579
+ const result = createRepoBranches(tmpDir, ['repo-a'], 'dgs/unique-project/phase-01', { branching_strategy: 'phase' });
580
+ assert.strictEqual(result.created, true);
581
+ assert.ok(result.warnings !== undefined, 'warnings should exist');
582
+ assert.strictEqual(result.warnings.length, 0);
583
+ });
584
+ });
585
+
586
+ // ─── createRepoBranches project-scoped prefix collision ──────────────────────
587
+
588
+ describe('createRepoBranches project-scoped prefix collision', () => {
589
+ let tmpDir;
590
+ beforeEach(() => { tmpDir = createTempDir(); });
591
+ afterEach(() => { cleanupDir(tmpDir); });
592
+
593
+ it('no collision when project slugs differ by / delimiter (checkout vs checkout-v2)', () => {
594
+ setupProductRoot(tmpDir, ['repo-a']);
595
+ // Create branch for checkout-v2 project
596
+ createRepoBranches(tmpDir, ['repo-a'], 'dgs/checkout-v2/phase-01-init', { branching_strategy: 'phase' });
597
+ execSync('git checkout main', { cwd: path.join(tmpDir, 'repo-a'), stdio: 'pipe' });
598
+ // Create branch for checkout project — should NOT collide
599
+ const result = createRepoBranches(tmpDir, ['repo-a'], 'dgs/checkout/phase-02-auth', { branching_strategy: 'phase' });
600
+ assert.strictEqual(result.created, true);
601
+ assert.ok(result.warnings !== undefined, 'warnings should exist');
602
+ assert.strictEqual(result.warnings.length, 0, 'no collision — dgs/checkout/ does NOT match dgs/checkout-v2/');
603
+ });
604
+
605
+ it('collision detected for branches within same project scope', () => {
606
+ setupProductRoot(tmpDir, ['repo-a']);
607
+ // Create branch for checkout project phase 01
608
+ createRepoBranches(tmpDir, ['repo-a'], 'dgs/checkout/phase-01-init', { branching_strategy: 'phase' });
609
+ execSync('git checkout main', { cwd: path.join(tmpDir, 'repo-a'), stdio: 'pipe' });
610
+ // Create another branch for same checkout project — should detect collision
611
+ const result = createRepoBranches(tmpDir, ['repo-a'], 'dgs/checkout/phase-02-auth', { branching_strategy: 'phase' });
612
+ assert.strictEqual(result.created, true);
613
+ assert.ok(result.warnings !== undefined, 'warnings should exist');
614
+ assert.ok(result.warnings.length > 0, 'should have collision warning for same-project branch');
615
+ // The warning object should list the existing branch
616
+ const collisionWarning = result.warnings.find(w => w.type === 'prefix_collision');
617
+ assert.ok(collisionWarning, 'should have prefix_collision warning');
618
+ assert.ok(collisionWarning.existing_branches.includes('dgs/checkout/phase-01-init'),
619
+ 'warning should list the existing branch');
620
+ });
621
+
622
+ it('multi-project collision detection only matches own project', () => {
623
+ setupProductRoot(tmpDir, ['repo-a']);
624
+ // Create branches for two different projects
625
+ createRepoBranches(tmpDir, ['repo-a'], 'dgs/alpha/phase-01-init', { branching_strategy: 'phase' });
626
+ execSync('git checkout main', { cwd: path.join(tmpDir, 'repo-a'), stdio: 'pipe' });
627
+ createRepoBranches(tmpDir, ['repo-a'], 'dgs/beta/phase-01-init', { branching_strategy: 'phase' });
628
+ execSync('git checkout main', { cwd: path.join(tmpDir, 'repo-a'), stdio: 'pipe' });
629
+ // Create another branch for alpha project — should only see alpha branches in collision
630
+ const result = createRepoBranches(tmpDir, ['repo-a'], 'dgs/alpha/phase-02-auth', { branching_strategy: 'phase' });
631
+ assert.strictEqual(result.created, true);
632
+ assert.ok(result.warnings !== undefined, 'warnings should exist');
633
+ assert.ok(result.warnings.length > 0, 'should have collision warning');
634
+ const collisionWarning = result.warnings.find(w => w.type === 'prefix_collision');
635
+ assert.ok(collisionWarning, 'should have prefix_collision warning');
636
+ assert.ok(collisionWarning.existing_branches.includes('dgs/alpha/phase-01-init'),
637
+ 'warning should list alpha phase-01 branch');
638
+ // Beta project branches should NOT appear in the collision warning
639
+ const allExistingBranches = collisionWarning.existing_branches.join(' ');
640
+ assert.ok(!allExistingBranches.includes('dgs/beta/'),
641
+ 'warning should NOT include beta project branches');
642
+ });
643
+ });
644
+
645
+ // ─── createRepoBranches base_branch ──────────────────────────────────────────
646
+
647
+ describe('createRepoBranches base_branch', () => {
648
+ let tmpDir;
649
+ beforeEach(() => { tmpDir = createTempDir(); });
650
+ afterEach(() => { cleanupDir(tmpDir); });
651
+
652
+ it('without baseBranch creates branch from HEAD (backwards compat)', () => {
653
+ setupProductRoot(tmpDir, ['repo-a']);
654
+ const result = createRepoBranches(tmpDir, ['repo-a'], 'dgs/phase-01-test', { branching_strategy: 'phase' });
655
+ assert.strictEqual(result.created, true);
656
+ assert.strictEqual(result.branches.length, 1);
657
+ assert.strictEqual(result.branches[0].action, 'created');
658
+ });
659
+
660
+ it('with baseBranch checks out base before creating branch', () => {
661
+ setupProductRoot(tmpDir, ['repo-a']);
662
+ const repoPath = path.join(tmpDir, 'repo-a');
663
+
664
+ // Create a 'develop' branch with an additional commit
665
+ execSync('git checkout -b develop', { cwd: repoPath, stdio: 'pipe' });
666
+ fs.writeFileSync(path.join(repoPath, 'feature.txt'), 'develop feature\n');
667
+ execSync('git add feature.txt', { cwd: repoPath, stdio: 'pipe' });
668
+ execSync('git commit -m "develop commit"', { cwd: repoPath, stdio: 'pipe' });
669
+ const developHead = execSync('git rev-parse develop', { cwd: repoPath, encoding: 'utf-8', stdio: 'pipe' }).trim();
670
+
671
+ // Switch back to main
672
+ execSync('git checkout main', { cwd: repoPath, stdio: 'pipe' });
673
+
674
+ // Create branch from develop baseBranch
675
+ const result = createRepoBranches(tmpDir, ['repo-a'], 'dgs/phase-01-test', { branching_strategy: 'phase' }, 'develop');
676
+ assert.strictEqual(result.created, true);
677
+ assert.strictEqual(result.branches[0].action, 'created');
678
+
679
+ // Verify the new branch's HEAD matches develop's HEAD (not main's)
680
+ const branchHead = execSync('git rev-parse dgs/phase-01-test', { cwd: repoPath, encoding: 'utf-8', stdio: 'pipe' }).trim();
681
+ assert.strictEqual(branchHead, developHead, 'new branch should start from develop');
682
+ });
683
+
684
+ it('returns error when baseBranch does not exist', () => {
685
+ setupProductRoot(tmpDir, ['repo-a']);
686
+ const result = createRepoBranches(tmpDir, ['repo-a'], 'dgs/phase-01-test', { branching_strategy: 'phase' }, 'nonexistent-branch');
687
+ assert.strictEqual(result.created, false);
688
+ assert.strictEqual(result.reason, 'base_branch_missing');
689
+ assert.ok(result.error.includes('nonexistent-branch'), 'error should mention the missing branch name');
690
+ assert.ok(result.error.includes('does not exist'), 'error should say branch does not exist');
691
+ });
692
+
693
+ it('validates all repos before creating any branches', () => {
694
+ setupProductRoot(tmpDir, ['repo-a', 'repo-b']);
695
+ const repoAPath = path.join(tmpDir, 'repo-a');
696
+ const repoBPath = path.join(tmpDir, 'repo-b');
697
+
698
+ // Create 'develop' branch in repo-a only
699
+ execSync('git checkout -b develop', { cwd: repoAPath, stdio: 'pipe' });
700
+ fs.writeFileSync(path.join(repoAPath, 'feature.txt'), 'feature\n');
701
+ execSync('git add feature.txt', { cwd: repoAPath, stdio: 'pipe' });
702
+ execSync('git commit -m "develop commit"', { cwd: repoAPath, stdio: 'pipe' });
703
+ execSync('git checkout main', { cwd: repoAPath, stdio: 'pipe' });
704
+
705
+ // repo-b does NOT have 'develop' branch
706
+ const result = createRepoBranches(tmpDir, ['repo-a', 'repo-b'], 'dgs/phase-01-test', { branching_strategy: 'phase' }, 'develop');
707
+ assert.strictEqual(result.created, false);
708
+ assert.strictEqual(result.reason, 'base_branch_missing');
709
+
710
+ // Verify NO branch was created in repo-a (two-pass validation catches repo-b failure before creating anything)
711
+ try {
712
+ execSync('git rev-parse --verify dgs/phase-01-test', { cwd: repoAPath, stdio: 'pipe' });
713
+ assert.fail('branch should NOT exist in repo-a since repo-b failed validation');
714
+ } catch (e) {
715
+ // Expected: branch does not exist
716
+ assert.ok(true, 'branch correctly not created in repo-a');
717
+ }
718
+ });
719
+
720
+ it('with baseBranch reuses existing branch', () => {
721
+ setupProductRoot(tmpDir, ['repo-a']);
722
+ const repoPath = path.join(tmpDir, 'repo-a');
723
+
724
+ // Create the target branch first
725
+ createRepoBranches(tmpDir, ['repo-a'], 'dgs/phase-01-test', { branching_strategy: 'phase' });
726
+ // Switch back to main
727
+ execSync('git checkout main', { cwd: repoPath, stdio: 'pipe' });
728
+
729
+ // Call again with baseBranch — should reuse the existing branch
730
+ const result = createRepoBranches(tmpDir, ['repo-a'], 'dgs/phase-01-test', { branching_strategy: 'phase' }, 'main');
731
+ assert.strictEqual(result.created, true);
732
+ assert.strictEqual(result.branches[0].action, 'reused');
733
+ });
734
+ });
735
+
736
+ // ─── reportBranchesToMerge ──────────────────────────────────────────────────
737
+
738
+ describe('reportBranchesToMerge', () => {
739
+ let tmpDir;
740
+ beforeEach(() => { tmpDir = createTempDir(); });
741
+ afterEach(() => { cleanupDir(tmpDir); });
742
+
743
+ it('repos with the branch — listed', () => {
744
+ setupProductRoot(tmpDir, ['repo-a']);
745
+ createRepoBranches(tmpDir, ['repo-a'], 'dgs/test/01-setup', { branching_strategy: 'phase' });
746
+ const result = reportBranchesToMerge(tmpDir, ['repo-a'], 'dgs/test/01-setup');
747
+ assert.strictEqual(result.branches.length, 1);
748
+ assert.strictEqual(result.branches[0].exists, true);
749
+ });
750
+
751
+ it('repos without the branch — excluded', () => {
752
+ setupProductRoot(tmpDir, ['repo-a']);
753
+ const result = reportBranchesToMerge(tmpDir, ['repo-a'], 'dgs/nonexistent/branch');
754
+ assert.strictEqual(result.branches.length, 0);
755
+ });
756
+
757
+ it('returns empty array when no repos have the branch', () => {
758
+ setupProductRoot(tmpDir, ['repo-a', 'repo-b']);
759
+ const result = reportBranchesToMerge(tmpDir, ['repo-a', 'repo-b'], 'dgs/missing/branch');
760
+ assert.strictEqual(result.branches.length, 0);
761
+ });
762
+ });
763
+
764
+ // ─── updateRepoStatus ───────────────────────────────────────────────────────
765
+
766
+ describe('updateRepoStatus', () => {
767
+ let tmpDir;
768
+ beforeEach(() => { tmpDir = createTempDir(); });
769
+ afterEach(() => { cleanupDir(tmpDir); });
770
+
771
+ it('creates Repo Status section in STATE.md', () => {
772
+ setupProductRoot(tmpDir, ['repo-a']);
773
+ // Create initial STATE.md
774
+ fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '# Project State\n\n## Current Position\nPhase: 4\n');
775
+ const repoResults = [{ repo: 'repo-a', status: 'complete', sha: 'abc1234' }];
776
+ updateRepoStatus(tmpDir, null, repoResults);
777
+ const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
778
+ assert.ok(content.includes('## Repo Status'));
779
+ assert.ok(content.includes('repo-a'));
780
+ assert.ok(content.includes('abc1234'));
781
+ });
782
+
783
+ it('updates existing Repo Status section (replace, not duplicate)', () => {
784
+ setupProductRoot(tmpDir, ['repo-a']);
785
+ fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'),
786
+ '# State\n\n## Repo Status\n\n| Repo | Branch | Last Commit | Touched By |\n|------|--------|-------------|------------|\n| old | main | old123 | - |\n\n## Other\n');
787
+ const repoResults = [{ repo: 'repo-a', status: 'complete', sha: 'new456' }];
788
+ updateRepoStatus(tmpDir, null, repoResults);
789
+ const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
790
+ assert.ok(content.includes('new456'));
791
+ assert.ok(!content.includes('old123'));
792
+ // Should only have one Repo Status section
793
+ const count = (content.match(/## Repo Status/g) || []).length;
794
+ assert.strictEqual(count, 1);
795
+ });
796
+
797
+ it('handles repo with no branch (shows current branch)', () => {
798
+ setupProductRoot(tmpDir, ['repo-a']);
799
+ fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '# State\n');
800
+ const repoResults = [{ repo: 'repo-a', status: 'complete', sha: 'abc1234' }];
801
+ updateRepoStatus(tmpDir, null, repoResults);
802
+ const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
803
+ // Should show 'main' or 'master' as current branch
804
+ assert.ok(content.includes('main') || content.includes('master'));
805
+ });
806
+
807
+ it('works when STATE.md has no existing Repo Status section', () => {
808
+ setupProductRoot(tmpDir, ['repo-a']);
809
+ fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '# State\n\n## Current Position\nPhase: 4\n');
810
+ const repoResults = [{ repo: 'repo-a', status: 'complete', sha: 'abc1234' }];
811
+ updateRepoStatus(tmpDir, null, repoResults);
812
+ const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
813
+ assert.ok(content.includes('## Repo Status'));
814
+ });
815
+ });
816
+
817
+ // ─── CLI Integration Tests ──────────────────────────────────────────────────
818
+
819
+ describe('CLI integration: commit preflight', () => {
820
+ let tmpDir;
821
+ beforeEach(() => { tmpDir = createTempDir(); });
822
+ afterEach(() => { cleanupDir(tmpDir); });
823
+
824
+ it('commit preflight with clean repos — returns passed: true via CLI', () => {
825
+ setupProductRoot(tmpDir, ['repo-a', 'repo-b']);
826
+ const dgsTools = path.resolve(__dirname, '..', 'dgs-tools.cjs');
827
+ const result = execSync(
828
+ `node ${dgsTools} commit preflight repo-a repo-b --raw`,
829
+ { cwd: tmpDir, encoding: 'utf-8' }
830
+ );
831
+ const json = JSON.parse(result.trim());
832
+ assert.strictEqual(json.passed, true);
833
+ assert.strictEqual(json.dirty_repos.length, 0);
834
+ assert.strictEqual(json.missing_repos.length, 0);
835
+ });
836
+
837
+ it('commit preflight with dirty repo — returns passed: false via CLI', () => {
838
+ setupProductRoot(tmpDir, ['repo-a', 'repo-b']);
839
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'README.md'), '# Changed\n');
840
+ const dgsTools = path.resolve(__dirname, '..', 'dgs-tools.cjs');
841
+ const result = execSync(
842
+ `node ${dgsTools} commit preflight repo-a repo-b --raw`,
843
+ { cwd: tmpDir, encoding: 'utf-8' }
844
+ );
845
+ const json = JSON.parse(result.trim());
846
+ assert.strictEqual(json.passed, false);
847
+ assert.ok(json.dirty_repos.length > 0);
848
+ assert.ok(json.dirty_repos.some(d => d.name === 'repo-a'));
849
+ });
850
+
851
+ it('commit preflight with missing repo — returns missing_repos', () => {
852
+ setupProductRoot(tmpDir, ['repo-a']);
853
+ const dgsTools = path.resolve(__dirname, '..', 'dgs-tools.cjs');
854
+ const result = execSync(
855
+ `node ${dgsTools} commit preflight repo-a repo-missing --raw`,
856
+ { cwd: tmpDir, encoding: 'utf-8' }
857
+ );
858
+ const json = JSON.parse(result.trim());
859
+ assert.ok(json.missing_repos.includes('repo-missing'));
860
+ });
861
+ });
862
+
863
+ describe('CLI integration: commit --multi-repo', () => {
864
+ let tmpDir;
865
+ beforeEach(() => { tmpDir = createTempDir(); });
866
+ afterEach(() => { cleanupDir(tmpDir); });
867
+
868
+ it('commit --multi-repo with changes in 2 repos — both committed', () => {
869
+ setupProductRoot(tmpDir, ['repo-a', 'repo-b']);
870
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'feature.js'), 'console.log("a");\n');
871
+ fs.writeFileSync(path.join(tmpDir, 'repo-b', 'feature.js'), 'console.log("b");\n');
872
+
873
+ const dgsTools = path.resolve(__dirname, '..', 'dgs-tools.cjs');
874
+ const result = execSync(
875
+ `node ${dgsTools} commit --multi-repo --type feat --project test-proj --phase 04 --plan 03 --desc "integration test" --repos repo-a,repo-b --raw`,
876
+ { cwd: tmpDir, encoding: 'utf-8' }
877
+ );
878
+ const json = JSON.parse(result.trim());
879
+ assert.strictEqual(json.success, true);
880
+ assert.strictEqual(json.commits.length, 2);
881
+ assert.ok(json.commits.every(c => c.status === 'complete'));
882
+ assert.ok(json.commits.every(c => c.sha));
883
+ assert.ok(json.planning_body.includes('repo-a'));
884
+ assert.ok(json.planning_body.includes('repo-b'));
885
+
886
+ // Verify actual git commits in each repo
887
+ const logA = execSync('git log --oneline -1', { cwd: path.join(tmpDir, 'repo-a'), encoding: 'utf-8' }).trim();
888
+ assert.ok(logA.includes('feat(test-proj/04-03): integration test'));
889
+ const logB = execSync('git log --oneline -1', { cwd: path.join(tmpDir, 'repo-b'), encoding: 'utf-8' }).trim();
890
+ assert.ok(logB.includes('feat(test-proj/04-03): integration test'));
891
+ });
892
+
893
+ it('commit --multi-repo with no changes — returns success with empty commits', () => {
894
+ setupProductRoot(tmpDir, ['repo-a']);
895
+ const dgsTools = path.resolve(__dirname, '..', 'dgs-tools.cjs');
896
+ const result = execSync(
897
+ `node ${dgsTools} commit --multi-repo --type feat --project test-proj --phase 04 --plan 03 --desc "no changes" --repos repo-a --raw`,
898
+ { cwd: tmpDir, encoding: 'utf-8' }
899
+ );
900
+ const json = JSON.parse(result.trim());
901
+ assert.strictEqual(json.success, true);
902
+ assert.ok(json.message.includes('No changes'));
903
+ });
904
+
905
+ it('full lifecycle: preflight clean -> modify -> commit --multi-repo -> preflight clean again', () => {
906
+ setupProductRoot(tmpDir, ['repo-a', 'repo-b']);
907
+ const dgsTools = path.resolve(__dirname, '..', 'dgs-tools.cjs');
908
+
909
+ // Step 1: Preflight — should be clean
910
+ const pre1 = JSON.parse(execSync(
911
+ `node ${dgsTools} commit preflight repo-a repo-b --raw`,
912
+ { cwd: tmpDir, encoding: 'utf-8' }
913
+ ).trim());
914
+ assert.strictEqual(pre1.passed, true);
915
+
916
+ // Step 2: Modify files
917
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'new-feature.js'), 'export default {};');
918
+ fs.writeFileSync(path.join(tmpDir, 'repo-b', 'new-feature.js'), 'module.exports = {};');
919
+
920
+ // Step 3: Commit --multi-repo
921
+ const commitResult = JSON.parse(execSync(
922
+ `node ${dgsTools} commit --multi-repo --type feat --project lifecycle --phase 04 --plan 03 --desc "lifecycle test" --repos repo-a,repo-b --raw`,
923
+ { cwd: tmpDir, encoding: 'utf-8' }
924
+ ).trim());
925
+ assert.strictEqual(commitResult.success, true);
926
+ assert.strictEqual(commitResult.commits.length, 2);
927
+
928
+ // Step 4: Preflight again — should be clean (commits consumed the changes)
929
+ const pre2 = JSON.parse(execSync(
930
+ `node ${dgsTools} commit preflight repo-a repo-b --raw`,
931
+ { cwd: tmpDir, encoding: 'utf-8' }
932
+ ).trim());
933
+ assert.strictEqual(pre2.passed, true);
934
+ });
935
+ });
936
+
937
+ // ─── retryCommitPerRepo ─────────────────────────────────────────────────────
938
+
939
+ describe('retryCommitPerRepo', () => {
940
+ let tmpDir;
941
+ beforeEach(() => { tmpDir = createTempDir(); });
942
+ afterEach(() => { cleanupDir(tmpDir); });
943
+
944
+ const options = {
945
+ type: 'feat',
946
+ project: 'test-project',
947
+ phase: '04',
948
+ plan: '01',
949
+ description: 'retry test',
950
+ };
951
+
952
+ it('repo with prior complete status and valid SHA -- skipped, prior result carried forward', () => {
953
+ setupProductRoot(tmpDir, ['repo-a', 'repo-b']);
954
+
955
+ // Commit something in repo-a to get a real SHA
956
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'initial.js'), 'init');
957
+ const repoChanges1 = [{ repoName: 'repo-a', repoPath: './repo-a', files: ['initial.js'] }];
958
+ const firstResults = commitPerRepo(tmpDir, repoChanges1, options);
959
+ const repoASha = firstResults[0].sha;
960
+
961
+ // Now modify repo-b only
962
+ fs.writeFileSync(path.join(tmpDir, 'repo-b', 'feature.js'), 'new');
963
+
964
+ // Create prior results showing repo-a as complete
965
+ const priorResults = [
966
+ { repo: 'repo-a', status: 'complete', sha: repoASha },
967
+ { repo: 'repo-b', status: 'failed', sha: null, error: 'some error' },
968
+ ];
969
+
970
+ // Retry with both repos but repo-a should be skipped
971
+ const repoChanges2 = [
972
+ { repoName: 'repo-a', repoPath: './repo-a', files: ['initial.js'] },
973
+ { repoName: 'repo-b', repoPath: './repo-b', files: ['feature.js'] },
974
+ ];
975
+ const results = retryCommitPerRepo(tmpDir, repoChanges2, options, priorResults);
976
+
977
+ assert.strictEqual(results.length, 2);
978
+ const repoA = results.find(r => r.repo === 'repo-a');
979
+ const repoB = results.find(r => r.repo === 'repo-b');
980
+ // repo-a carried forward with same SHA
981
+ assert.strictEqual(repoA.status, 'complete');
982
+ assert.strictEqual(repoA.sha, repoASha);
983
+ // repo-b committed fresh
984
+ assert.strictEqual(repoB.status, 'complete');
985
+ assert.ok(repoB.sha);
986
+ assert.notStrictEqual(repoB.sha, repoASha);
987
+ });
988
+
989
+ it('repo with prior failed status -- retried normally', () => {
990
+ setupProductRoot(tmpDir, ['repo-a', 'repo-b']);
991
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'a.js'), 'a');
992
+ fs.writeFileSync(path.join(tmpDir, 'repo-b', 'b.js'), 'b');
993
+
994
+ const priorResults = [
995
+ { repo: 'repo-a', status: 'failed', sha: null, error: 'disk full' },
996
+ { repo: 'repo-b', status: 'failed', sha: null, error: 'disk full' },
997
+ ];
998
+
999
+ const repoChanges = [
1000
+ { repoName: 'repo-a', repoPath: './repo-a', files: ['a.js'] },
1001
+ { repoName: 'repo-b', repoPath: './repo-b', files: ['b.js'] },
1002
+ ];
1003
+ const results = retryCommitPerRepo(tmpDir, repoChanges, options, priorResults);
1004
+
1005
+ assert.strictEqual(results.length, 2);
1006
+ assert.ok(results.every(r => r.status === 'complete'));
1007
+ assert.ok(results.every(r => r.sha));
1008
+ });
1009
+
1010
+ it('repo with prior SHA that no longer exists (force-push/rebase) -- retried', () => {
1011
+ setupProductRoot(tmpDir, ['repo-a']);
1012
+
1013
+ // Create a second commit to have something to reset away
1014
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'temp.txt'), 'temp');
1015
+ execSync('git add temp.txt && git commit -m "temp commit"', {
1016
+ cwd: path.join(tmpDir, 'repo-a'), stdio: 'pipe',
1017
+ });
1018
+ // Record the SHA of the second commit
1019
+ const staleSha = execSync('git rev-parse --short HEAD', {
1020
+ cwd: path.join(tmpDir, 'repo-a'), encoding: 'utf-8',
1021
+ }).trim();
1022
+
1023
+ // Reset to remove that commit (makes staleSha unreachable after gc, but
1024
+ // rev-parse --verify can still find it via reflog; use a clearly bogus SHA instead)
1025
+ execSync('git reset --hard HEAD~1', {
1026
+ cwd: path.join(tmpDir, 'repo-a'), stdio: 'pipe',
1027
+ });
1028
+
1029
+ // Use a fabricated SHA that definitely doesn't exist
1030
+ const fakeSha = 'aaaa000';
1031
+
1032
+ // Write a new file to commit
1033
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'retry.js'), 'retry');
1034
+
1035
+ const priorResults = [
1036
+ { repo: 'repo-a', status: 'complete', sha: fakeSha },
1037
+ ];
1038
+ const repoChanges = [
1039
+ { repoName: 'repo-a', repoPath: './repo-a', files: ['retry.js'] },
1040
+ ];
1041
+ const results = retryCommitPerRepo(tmpDir, repoChanges, options, priorResults);
1042
+
1043
+ assert.strictEqual(results.length, 1);
1044
+ assert.strictEqual(results[0].status, 'complete');
1045
+ assert.ok(results[0].sha);
1046
+ assert.notStrictEqual(results[0].sha, fakeSha);
1047
+ });
1048
+
1049
+ it('empty priorResults -- all repos committed normally (same as commitPerRepo)', () => {
1050
+ setupProductRoot(tmpDir, ['repo-a', 'repo-b']);
1051
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'a.js'), 'a');
1052
+ fs.writeFileSync(path.join(tmpDir, 'repo-b', 'b.js'), 'b');
1053
+
1054
+ const repoChanges = [
1055
+ { repoName: 'repo-a', repoPath: './repo-a', files: ['a.js'] },
1056
+ { repoName: 'repo-b', repoPath: './repo-b', files: ['b.js'] },
1057
+ ];
1058
+ const results = retryCommitPerRepo(tmpDir, repoChanges, options, []);
1059
+
1060
+ assert.strictEqual(results.length, 2);
1061
+ assert.ok(results.every(r => r.status === 'complete'));
1062
+ });
1063
+
1064
+ it('all repos already complete in priorResults -- returns carried-forward results, no new commits', () => {
1065
+ setupProductRoot(tmpDir, ['repo-a', 'repo-b']);
1066
+
1067
+ // Create real commits to have valid SHAs
1068
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'done.js'), 'done');
1069
+ fs.writeFileSync(path.join(tmpDir, 'repo-b', 'done.js'), 'done');
1070
+ const initialChanges = [
1071
+ { repoName: 'repo-a', repoPath: './repo-a', files: ['done.js'] },
1072
+ { repoName: 'repo-b', repoPath: './repo-b', files: ['done.js'] },
1073
+ ];
1074
+ const initialResults = commitPerRepo(tmpDir, initialChanges, options);
1075
+ const shaA = initialResults.find(r => r.repo === 'repo-a').sha;
1076
+ const shaB = initialResults.find(r => r.repo === 'repo-b').sha;
1077
+
1078
+ // Get commit counts before retry
1079
+ const countBeforeA = execSync('git rev-list --count HEAD', {
1080
+ cwd: path.join(tmpDir, 'repo-a'), encoding: 'utf-8',
1081
+ }).trim();
1082
+ const countBeforeB = execSync('git rev-list --count HEAD', {
1083
+ cwd: path.join(tmpDir, 'repo-b'), encoding: 'utf-8',
1084
+ }).trim();
1085
+
1086
+ const priorResults = [
1087
+ { repo: 'repo-a', status: 'complete', sha: shaA },
1088
+ { repo: 'repo-b', status: 'complete', sha: shaB },
1089
+ ];
1090
+
1091
+ // repoChanges still references the files but they should not be re-committed
1092
+ const results = retryCommitPerRepo(tmpDir, initialChanges, options, priorResults);
1093
+
1094
+ assert.strictEqual(results.length, 2);
1095
+ assert.ok(results.every(r => r.status === 'complete'));
1096
+ assert.strictEqual(results.find(r => r.repo === 'repo-a').sha, shaA);
1097
+ assert.strictEqual(results.find(r => r.repo === 'repo-b').sha, shaB);
1098
+
1099
+ // No new commits created
1100
+ const countAfterA = execSync('git rev-list --count HEAD', {
1101
+ cwd: path.join(tmpDir, 'repo-a'), encoding: 'utf-8',
1102
+ }).trim();
1103
+ const countAfterB = execSync('git rev-list --count HEAD', {
1104
+ cwd: path.join(tmpDir, 'repo-b'), encoding: 'utf-8',
1105
+ }).trim();
1106
+ assert.strictEqual(countAfterA, countBeforeA);
1107
+ assert.strictEqual(countAfterB, countBeforeB);
1108
+ });
1109
+
1110
+ it('selective staging -- only plan-specified files are committed, other changed files excluded', () => {
1111
+ setupProductRoot(tmpDir, ['repo-a']);
1112
+
1113
+ // Write two files but only include one in repoChanges
1114
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'plan-file.txt'), 'plan content');
1115
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'other-file.txt'), 'other content');
1116
+
1117
+ const repoChanges = [
1118
+ { repoName: 'repo-a', repoPath: './repo-a', files: ['plan-file.txt'] },
1119
+ ];
1120
+ const results = retryCommitPerRepo(tmpDir, repoChanges, options, []);
1121
+
1122
+ assert.strictEqual(results.length, 1);
1123
+ assert.strictEqual(results[0].status, 'complete');
1124
+
1125
+ // Verify only plan-file.txt was committed
1126
+ const committed = execSync('git show --name-only --format="" HEAD', {
1127
+ cwd: path.join(tmpDir, 'repo-a'), encoding: 'utf-8',
1128
+ }).trim();
1129
+ assert.ok(committed.includes('plan-file.txt'), `Expected plan-file.txt in commit, got: ${committed}`);
1130
+ assert.ok(!committed.includes('other-file.txt'), `Expected other-file.txt NOT in commit, got: ${committed}`);
1131
+
1132
+ // other-file.txt should still be untracked
1133
+ const status = execSync('git status --porcelain', {
1134
+ cwd: path.join(tmpDir, 'repo-a'), encoding: 'utf-8',
1135
+ }).trim();
1136
+ assert.ok(status.includes('other-file.txt'), `Expected other-file.txt still untracked, got: ${status}`);
1137
+ });
1138
+ });
1139
+
1140
+ // ─── detectManualResolution ─────────────────────────────────────────────────
1141
+
1142
+ describe('detectManualResolution', () => {
1143
+ let tmpDir;
1144
+ beforeEach(() => { tmpDir = createTempDir(); });
1145
+ afterEach(() => { cleanupDir(tmpDir); });
1146
+
1147
+ it('clean working tree with all expected files present -- resolved: true, reason: manually_resolved', () => {
1148
+ setupProductRoot(tmpDir, ['repo-a']);
1149
+
1150
+ // Create and commit the expected files (simulating manual user commit)
1151
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'feature.js'), 'manual fix');
1152
+ execSync('git add feature.js && git commit -m "manual fix"', {
1153
+ cwd: path.join(tmpDir, 'repo-a'), stdio: 'pipe',
1154
+ });
1155
+
1156
+ const result = detectManualResolution(tmpDir, 'repo-a', ['feature.js']);
1157
+ assert.strictEqual(result.resolved, true);
1158
+ assert.strictEqual(result.reason, 'manually_resolved');
1159
+ });
1160
+
1161
+ it('expected files still modified (dirty) -- resolved: false, reason: unresolved', () => {
1162
+ setupProductRoot(tmpDir, ['repo-a']);
1163
+
1164
+ // Create file but leave it uncommitted (dirty)
1165
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'feature.js'), 'uncommitted');
1166
+
1167
+ const result = detectManualResolution(tmpDir, 'repo-a', ['feature.js']);
1168
+ assert.strictEqual(result.resolved, false);
1169
+ assert.strictEqual(result.reason, 'unresolved');
1170
+ });
1171
+
1172
+ it('prior SHA verified -- resolved: true, reason: sha_verified', () => {
1173
+ setupProductRoot(tmpDir, ['repo-a']);
1174
+
1175
+ // Create and commit file, then check with its SHA
1176
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'feature.js'), 'committed');
1177
+ execSync('git add feature.js && git commit -m "dgs commit"', {
1178
+ cwd: path.join(tmpDir, 'repo-a'), stdio: 'pipe',
1179
+ });
1180
+ const sha = execSync('git rev-parse --short HEAD', {
1181
+ cwd: path.join(tmpDir, 'repo-a'), encoding: 'utf-8',
1182
+ }).trim();
1183
+
1184
+ const result = detectManualResolution(tmpDir, 'repo-a', ['feature.js'], sha);
1185
+ assert.strictEqual(result.resolved, true);
1186
+ assert.strictEqual(result.reason, 'sha_verified');
1187
+ });
1188
+
1189
+ it('expected file missing from disk -- resolved: false', () => {
1190
+ setupProductRoot(tmpDir, ['repo-a']);
1191
+
1192
+ // Do NOT create the expected file
1193
+ const result = detectManualResolution(tmpDir, 'repo-a', ['missing-file.js']);
1194
+ assert.strictEqual(result.resolved, false);
1195
+ assert.strictEqual(result.reason, 'unresolved');
1196
+ });
1197
+ });
1198
+
1199
+ // ─── cmdCommitMultiRepo advisory preflight ──────────────────────────────────
1200
+
1201
+ describe('cmdCommitMultiRepo advisory preflight', () => {
1202
+ let tmpDir;
1203
+ beforeEach(() => { tmpDir = createTempDir(); });
1204
+ afterEach(() => { cleanupDir(tmpDir); });
1205
+
1206
+ it('dirty repo produces warning but commit proceeds for other repos', () => {
1207
+ setupProductRoot(tmpDir, ['repo-a', 'repo-b']);
1208
+
1209
+ // Make repo-a dirty (modify tracked file) -- this would have blocked before
1210
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'README.md'), '# Dirty changes\n');
1211
+ // Give repo-b a new untracked file to commit
1212
+ fs.writeFileSync(path.join(tmpDir, 'repo-b', 'feature.js'), 'new feature');
1213
+
1214
+ const dgsTools = path.resolve(__dirname, '..', 'dgs-tools.cjs');
1215
+ const result = execSync(
1216
+ `node ${dgsTools} commit --multi-repo --type feat --project test-proj --phase 04 --plan 03 --desc "advisory test" --repos repo-a,repo-b --raw`,
1217
+ { cwd: tmpDir, encoding: 'utf-8' }
1218
+ );
1219
+ const json = JSON.parse(result.trim());
1220
+
1221
+ // Should have warnings about repo-a being dirty
1222
+ assert.ok(json.warnings, 'Expected warnings array in output');
1223
+ assert.ok(json.warnings.length > 0, 'Expected at least one warning');
1224
+ assert.ok(json.warnings.some(w => w.type === 'dirty_repo' && w.repo === 'repo-a'),
1225
+ 'Expected dirty_repo warning for repo-a');
1226
+
1227
+ // repo-b should have been committed successfully
1228
+ assert.strictEqual(json.success, true);
1229
+ assert.ok(json.commits.length > 0);
1230
+ assert.ok(json.commits.some(c => c.repo === 'repo-b' && c.status === 'complete'));
1231
+ });
1232
+
1233
+ it('missing repo still blocks execution (hard error)', () => {
1234
+ setupProductRoot(tmpDir, ['repo-a']);
1235
+
1236
+ // Reference a repo that doesn't exist
1237
+ const dgsTools = path.resolve(__dirname, '..', 'dgs-tools.cjs');
1238
+ const result = execSync(
1239
+ `node ${dgsTools} commit --multi-repo --type feat --project test-proj --phase 04 --plan 03 --desc "missing test" --repos repo-a,nonexistent --raw`,
1240
+ { cwd: tmpDir, encoding: 'utf-8' }
1241
+ );
1242
+ const json = JSON.parse(result.trim());
1243
+
1244
+ assert.strictEqual(json.success, false);
1245
+ assert.ok(json.preflight.missing_repos.includes('nonexistent'));
1246
+ });
1247
+
1248
+ it('partial_execution detected -- included as info, does not block', () => {
1249
+ setupProductRoot(tmpDir, ['repo-a']);
1250
+
1251
+ // Create a phase dir with a partial SUMMARY.md
1252
+ const phaseDir = path.join(tmpDir, '.planning', 'phases', '04-test');
1253
+ fs.mkdirSync(phaseDir, { recursive: true });
1254
+ fs.writeFileSync(path.join(phaseDir, '04-01-SUMMARY.md'),
1255
+ '---\nphase: 04-test\nplan: 01\nstatus: partial\n---\n# Summary\n');
1256
+
1257
+ // Add new file to repo-a so there's something to commit
1258
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'feature.js'), 'code');
1259
+
1260
+ // Test at the preflight level: partial_execution is detected but we verify
1261
+ // the cmdCommitMultiRepo advisory behavior via the preflight result shape.
1262
+ // cmdCommitMultiRepo calls process.exit(0) via output(), so we test the
1263
+ // advisory logic indirectly: preflight detects partial but cmdCommitMultiRepo
1264
+ // no longer blocks on it — only missing_repos blocks.
1265
+ const preflight = preflightCheck(tmpDir, ['repo-a'], phaseDir);
1266
+ assert.strictEqual(preflight.passed, false, 'Preflight with partial should report passed: false');
1267
+ assert.ok(preflight.partial_execution !== null, 'Expected partial_execution info');
1268
+ assert.strictEqual(preflight.missing_repos.length, 0, 'No missing repos');
1269
+ assert.strictEqual(preflight.dirty_repos.length, 0, 'No dirty repos');
1270
+
1271
+ // Since missing_repos is empty, cmdCommitMultiRepo would proceed (advisory).
1272
+ // Verify by running the commit through CLI via a helper script that passes phaseDir
1273
+ // We write a small script to invoke cmdCommitMultiRepo with phaseDir
1274
+ const helperScript = path.join(tmpDir, '_test_helper.cjs');
1275
+ const executionPath = path.resolve(__dirname, 'execution.cjs');
1276
+ fs.writeFileSync(helperScript, `
1277
+ const execution = require('${executionPath.replace(/\\/g, '\\\\')}');
1278
+ const origExit = process.exit;
1279
+ let exitCode = null;
1280
+ process.exit = (code) => { exitCode = code; };
1281
+ const chunks = [];
1282
+ const origWrite = process.stdout.write;
1283
+ process.stdout.write = (data) => { chunks.push(data); };
1284
+ try {
1285
+ execution.cmdCommitMultiRepo('${tmpDir.replace(/\\/g, '\\\\')}', {
1286
+ type: 'feat', project: 'test-proj', phase: '04', plan: '01',
1287
+ description: 'partial test', repos: ['repo-a'],
1288
+ phaseDir: '${phaseDir.replace(/\\/g, '\\\\')}',
1289
+ }, true);
1290
+ } catch(e) {}
1291
+ process.stdout.write = origWrite;
1292
+ process.exit = origExit;
1293
+ const output = chunks.join('');
1294
+ try {
1295
+ const json = JSON.parse(output);
1296
+ console.log(JSON.stringify(json));
1297
+ } catch(e) { console.log(output); }
1298
+ `);
1299
+
1300
+ const result = execSync(`node ${helperScript}`, { encoding: 'utf-8' }).trim();
1301
+ const json = JSON.parse(result);
1302
+ assert.strictEqual(json.success, true, 'Expected success despite partial_execution');
1303
+ assert.ok(json.warnings, 'Expected warnings array');
1304
+ assert.ok(json.warnings.some(w => w.type === 'partial_execution'),
1305
+ 'Expected partial_execution warning');
1306
+ assert.ok(json.commits.length > 0, 'Expected commits despite partial_execution');
1307
+ });
1308
+ });
1309
+
1310
+ // ─── Flat layout helpers ─────────────────────────────────────────────────────
1311
+
1312
+ /**
1313
+ * Helper: create a flat layout with planning-repo and sibling repos using ../path format.
1314
+ * Returns { rootDir, productRoot } where productRoot is the planning repo cwd.
1315
+ */
1316
+ function setupFlatLayout(tmpDir, repoNames) {
1317
+ // Create planning repo (product root)
1318
+ const productRoot = path.join(tmpDir, 'planning-repo');
1319
+ const planningDir = path.join(productRoot, '.planning');
1320
+ fs.mkdirSync(planningDir, { recursive: true });
1321
+
1322
+ let reposMd = '# Repos\n\n';
1323
+ reposMd += '| Name | Path | GitHub URL | Description |\n';
1324
+ reposMd += '|------|------|------------|-------------|\n';
1325
+
1326
+ for (const name of repoNames) {
1327
+ reposMd += `| ${name} | ../${name} | | Test repo |\n`;
1328
+ // Create sibling repos as real git repos
1329
+ initGitRepo(path.join(tmpDir, name));
1330
+ // Add README.md as a tracked file
1331
+ fs.writeFileSync(path.join(tmpDir, name, 'README.md'), '# Repo\n');
1332
+ execSync('git add README.md', { cwd: path.join(tmpDir, name), stdio: 'pipe' });
1333
+ execSync('git commit -m "add readme"', { cwd: path.join(tmpDir, name), stdio: 'pipe' });
1334
+ }
1335
+
1336
+ fs.writeFileSync(path.join(planningDir, 'REPOS.md'), reposMd);
1337
+ return { rootDir: tmpDir, productRoot };
1338
+ }
1339
+
1340
+ // ─── resolveRepoPath with ../repo paths ──────────────────────────────────────
1341
+
1342
+ describe('resolveRepoPath with ../repo paths', () => {
1343
+ let tmpDir;
1344
+ beforeEach(() => { tmpDir = createTempDir(); });
1345
+ afterEach(() => { cleanupDir(tmpDir); });
1346
+
1347
+ it('resolves ../api-service to absolute path of sibling directory', () => {
1348
+ const { productRoot } = setupFlatLayout(tmpDir, ['api-service']);
1349
+ const result = resolveRepoPath(productRoot, 'api-service');
1350
+ assert.ok(result, 'Expected result to be non-null');
1351
+ assert.strictEqual(result.absPath, path.join(tmpDir, 'api-service'));
1352
+ assert.strictEqual(result.repo.name, 'api-service');
1353
+ assert.strictEqual(result.repo.path, '../api-service');
1354
+ });
1355
+
1356
+ it('returns null for unknown repo name', () => {
1357
+ const { productRoot } = setupFlatLayout(tmpDir, ['api-service']);
1358
+ const result = resolveRepoPath(productRoot, 'nonexistent');
1359
+ assert.strictEqual(result, null);
1360
+ });
1361
+
1362
+ it('resolves multiple sibling repos to correct absolute paths', () => {
1363
+ const { productRoot } = setupFlatLayout(tmpDir, ['web-app', 'api-service', 'shared-lib']);
1364
+ const web = resolveRepoPath(productRoot, 'web-app');
1365
+ const api = resolveRepoPath(productRoot, 'api-service');
1366
+ const lib = resolveRepoPath(productRoot, 'shared-lib');
1367
+ assert.strictEqual(web.absPath, path.join(tmpDir, 'web-app'));
1368
+ assert.strictEqual(api.absPath, path.join(tmpDir, 'api-service'));
1369
+ assert.strictEqual(lib.absPath, path.join(tmpDir, 'shared-lib'));
1370
+ });
1371
+
1372
+ it('resolved absPath actually exists on disk', () => {
1373
+ const { productRoot } = setupFlatLayout(tmpDir, ['real-repo']);
1374
+ const result = resolveRepoPath(productRoot, 'real-repo');
1375
+ assert.ok(fs.existsSync(result.absPath), `Expected ${result.absPath} to exist on disk`);
1376
+ });
1377
+ });
1378
+
1379
+ // ─── multi-repo execution with ../repo paths ────────────────────────────────
1380
+
1381
+ describe('multi-repo execution with ../repo paths', () => {
1382
+ let tmpDir;
1383
+ beforeEach(() => { tmpDir = createTempDir(); });
1384
+ afterEach(() => { cleanupDir(tmpDir); });
1385
+
1386
+ it('preflightCheck passes for clean sibling repos', () => {
1387
+ const { productRoot } = setupFlatLayout(tmpDir, ['repo-a', 'repo-b']);
1388
+ const result = preflightCheck(productRoot, ['repo-a', 'repo-b']);
1389
+ assert.strictEqual(result.passed, true);
1390
+ assert.strictEqual(result.dirty_repos.length, 0);
1391
+ assert.strictEqual(result.missing_repos.length, 0);
1392
+ });
1393
+
1394
+ it('preflightCheck detects dirty sibling repo', () => {
1395
+ const { productRoot } = setupFlatLayout(tmpDir, ['repo-a']);
1396
+ // Modify a tracked file in the sibling repo
1397
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'README.md'), '# Modified\n');
1398
+ const result = preflightCheck(productRoot, ['repo-a']);
1399
+ assert.strictEqual(result.passed, false);
1400
+ assert.ok(result.dirty_repos.length > 0);
1401
+ assert.ok(result.dirty_repos.some(d => d.name === 'repo-a'));
1402
+ });
1403
+
1404
+ it('detectRepoChanges finds changes in sibling repos', () => {
1405
+ const { productRoot } = setupFlatLayout(tmpDir, ['repo-a', 'repo-b']);
1406
+ // Create new files in sibling repos
1407
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'new-file.js'), 'console.log("a");\n');
1408
+ const result = detectRepoChanges(productRoot, ['repo-a', 'repo-b']);
1409
+ assert.strictEqual(result.length, 1);
1410
+ assert.strictEqual(result[0].repoName, 'repo-a');
1411
+ assert.ok(result[0].files.some(f => f.includes('new-file.js')));
1412
+ });
1413
+
1414
+ it('commitPerRepo successfully commits in sibling repos', () => {
1415
+ const { productRoot } = setupFlatLayout(tmpDir, ['repo-a']);
1416
+ // Create a new file
1417
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'feature.js'), 'module.exports = {};\n');
1418
+ const repoChanges = [{ repoName: 'repo-a', repoPath: '../repo-a', files: ['feature.js'] }];
1419
+ const options = {
1420
+ type: 'feat',
1421
+ project: 'flat-test',
1422
+ phase: '18',
1423
+ plan: '02',
1424
+ description: 'test sibling commit',
1425
+ };
1426
+ const results = commitPerRepo(productRoot, repoChanges, options);
1427
+ assert.strictEqual(results.length, 1);
1428
+ assert.strictEqual(results[0].repo, 'repo-a');
1429
+ assert.strictEqual(results[0].status, 'complete');
1430
+ assert.ok(results[0].sha);
1431
+ // Verify actual commit in sibling repo
1432
+ const log = execSync('git log --oneline -1', {
1433
+ cwd: path.join(tmpDir, 'repo-a'),
1434
+ encoding: 'utf-8',
1435
+ }).trim();
1436
+ assert.ok(log.includes('feat(flat-test/18-02): test sibling commit'));
1437
+ });
1438
+
1439
+ it('full lifecycle: preflight -> modify -> detect -> commit in flat layout', () => {
1440
+ const { productRoot } = setupFlatLayout(tmpDir, ['repo-a', 'repo-b']);
1441
+ const options = {
1442
+ type: 'feat',
1443
+ project: 'lifecycle',
1444
+ phase: '18',
1445
+ plan: '02',
1446
+ description: 'flat layout lifecycle',
1447
+ };
1448
+
1449
+ // Step 1: preflight clean
1450
+ const pre = preflightCheck(productRoot, ['repo-a', 'repo-b']);
1451
+ assert.strictEqual(pre.passed, true);
1452
+
1453
+ // Step 2: modify files
1454
+ fs.writeFileSync(path.join(tmpDir, 'repo-a', 'a.js'), 'a');
1455
+ fs.writeFileSync(path.join(tmpDir, 'repo-b', 'b.js'), 'b');
1456
+
1457
+ // Step 3: detect changes
1458
+ const changes = detectRepoChanges(productRoot, ['repo-a', 'repo-b']);
1459
+ assert.strictEqual(changes.length, 2);
1460
+
1461
+ // Step 4: commit
1462
+ const results = commitPerRepo(productRoot, changes, options);
1463
+ assert.strictEqual(results.length, 2);
1464
+ assert.ok(results.every(r => r.status === 'complete'));
1465
+ assert.ok(results.every(r => r.sha));
1466
+
1467
+ // Step 5: build planning body
1468
+ const body = buildPlanningCommitBody(results);
1469
+ assert.ok(body.includes('repo-a'));
1470
+ assert.ok(body.includes('repo-b'));
1471
+ });
1472
+ });