nubos-pilot 0.1.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 (273) hide show
  1. package/agents/np-ai-researcher.md +140 -0
  2. package/agents/np-code-fixer.md +363 -0
  3. package/agents/np-code-reviewer.md +351 -0
  4. package/agents/np-domain-researcher.md +136 -0
  5. package/agents/np-eval-auditor.md +167 -0
  6. package/agents/np-eval-planner.md +153 -0
  7. package/agents/np-executor.md +72 -0
  8. package/agents/np-framework-selector.md +171 -0
  9. package/agents/np-nyquist-auditor.md +185 -0
  10. package/agents/np-plan-checker.md +165 -0
  11. package/agents/np-planner.md +199 -0
  12. package/agents/np-researcher.md +150 -0
  13. package/agents/np-security-auditor.md +206 -0
  14. package/agents/np-ui-auditor.md +369 -0
  15. package/agents/np-ui-checker.md +192 -0
  16. package/agents/np-ui-researcher.md +324 -0
  17. package/agents/np-verifier.md +79 -0
  18. package/bin/check-coverage.cjs +40 -0
  19. package/bin/check-workflows.cjs +171 -0
  20. package/bin/check-workflows.test.cjs +208 -0
  21. package/bin/install.js +500 -0
  22. package/bin/np-tools/_commands.cjs +70 -0
  23. package/bin/np-tools/add-tests.cjs +171 -0
  24. package/bin/np-tools/add-tests.test.cjs +122 -0
  25. package/bin/np-tools/add-todo.cjs +108 -0
  26. package/bin/np-tools/add-todo.test.cjs +112 -0
  27. package/bin/np-tools/agent-skills.cjs +14 -0
  28. package/bin/np-tools/agent-skills.test.cjs +42 -0
  29. package/bin/np-tools/ai-integration-phase.cjs +109 -0
  30. package/bin/np-tools/ai-integration-phase.test.cjs +123 -0
  31. package/bin/np-tools/askuser.cjs +53 -0
  32. package/bin/np-tools/askuser.test.cjs +49 -0
  33. package/bin/np-tools/autonomous.cjs +69 -0
  34. package/bin/np-tools/autonomous.test.cjs +74 -0
  35. package/bin/np-tools/checkpoint.cjs +101 -0
  36. package/bin/np-tools/checkpoint.test.cjs +119 -0
  37. package/bin/np-tools/code-review.cjs +133 -0
  38. package/bin/np-tools/code-review.test.cjs +96 -0
  39. package/bin/np-tools/commit-task.cjs +120 -0
  40. package/bin/np-tools/commit-task.test.cjs +160 -0
  41. package/bin/np-tools/commit.cjs +103 -0
  42. package/bin/np-tools/commit.test.cjs +93 -0
  43. package/bin/np-tools/config.cjs +101 -0
  44. package/bin/np-tools/config.test.cjs +71 -0
  45. package/bin/np-tools/discuss-phase-power.cjs +265 -0
  46. package/bin/np-tools/discuss-phase-power.test.cjs +242 -0
  47. package/bin/np-tools/discuss-phase.cjs +132 -0
  48. package/bin/np-tools/discuss-phase.test.cjs +148 -0
  49. package/bin/np-tools/dispatch.cjs +116 -0
  50. package/bin/np-tools/doctor.cjs +242 -0
  51. package/bin/np-tools/eval-review.cjs +116 -0
  52. package/bin/np-tools/eval-review.test.cjs +123 -0
  53. package/bin/np-tools/execute-phase.cjs +182 -0
  54. package/bin/np-tools/execute-phase.test.cjs +116 -0
  55. package/bin/np-tools/execute-plan.cjs +124 -0
  56. package/bin/np-tools/execute-plan.test.cjs +82 -0
  57. package/bin/np-tools/help.cjs +28 -0
  58. package/bin/np-tools/help.test.cjs +29 -0
  59. package/bin/np-tools/init-dispatch.test.cjs +91 -0
  60. package/bin/np-tools/metrics.cjs +97 -0
  61. package/bin/np-tools/metrics.test.cjs +188 -0
  62. package/bin/np-tools/new-milestone.cjs +288 -0
  63. package/bin/np-tools/new-milestone.test.cjs +166 -0
  64. package/bin/np-tools/new-project.cjs +284 -0
  65. package/bin/np-tools/new-project.test.cjs +165 -0
  66. package/bin/np-tools/next.cjs +7 -0
  67. package/bin/np-tools/next.test.cjs +30 -0
  68. package/bin/np-tools/park.cjs +48 -0
  69. package/bin/np-tools/park.test.cjs +50 -0
  70. package/bin/np-tools/pause-work.cjs +24 -0
  71. package/bin/np-tools/pause-work.test.cjs +74 -0
  72. package/bin/np-tools/phase.cjs +71 -0
  73. package/bin/np-tools/phase.test.cjs +81 -0
  74. package/bin/np-tools/plan-diff.cjs +57 -0
  75. package/bin/np-tools/plan-diff.test.cjs +134 -0
  76. package/bin/np-tools/plan-milestone-gaps.cjs +115 -0
  77. package/bin/np-tools/plan-milestone-gaps.test.cjs +122 -0
  78. package/bin/np-tools/plan-phase.cjs +350 -0
  79. package/bin/np-tools/plan-phase.test.cjs +263 -0
  80. package/bin/np-tools/progress.cjs +7 -0
  81. package/bin/np-tools/progress.test.cjs +44 -0
  82. package/bin/np-tools/queue.cjs +213 -0
  83. package/bin/np-tools/research-phase.cjs +144 -0
  84. package/bin/np-tools/research-phase.test.cjs +154 -0
  85. package/bin/np-tools/reset-slice.cjs +17 -0
  86. package/bin/np-tools/reset-slice.test.cjs +96 -0
  87. package/bin/np-tools/resolve-model.cjs +110 -0
  88. package/bin/np-tools/resolve-model.test.cjs +200 -0
  89. package/bin/np-tools/resume-work.cjs +76 -0
  90. package/bin/np-tools/resume-work.test.cjs +91 -0
  91. package/bin/np-tools/skip.cjs +48 -0
  92. package/bin/np-tools/skip.test.cjs +66 -0
  93. package/bin/np-tools/slug.cjs +34 -0
  94. package/bin/np-tools/slug.test.cjs +46 -0
  95. package/bin/np-tools/state.cjs +16 -0
  96. package/bin/np-tools/state.test.cjs +40 -0
  97. package/bin/np-tools/stats.cjs +151 -0
  98. package/bin/np-tools/stats.test.cjs +118 -0
  99. package/bin/np-tools/triage.cjs +128 -0
  100. package/bin/np-tools/ui-phase.cjs +108 -0
  101. package/bin/np-tools/ui-phase.test.cjs +121 -0
  102. package/bin/np-tools/ui-review.cjs +108 -0
  103. package/bin/np-tools/ui-review.test.cjs +120 -0
  104. package/bin/np-tools/undo-task.cjs +31 -0
  105. package/bin/np-tools/undo-task.test.cjs +117 -0
  106. package/bin/np-tools/undo.cjs +43 -0
  107. package/bin/np-tools/undo.test.cjs +120 -0
  108. package/bin/np-tools/unpark.cjs +48 -0
  109. package/bin/np-tools/unpark.test.cjs +50 -0
  110. package/bin/np-tools/verify-work.cjs +186 -0
  111. package/bin/np-tools/verify-work.test.cjs +97 -0
  112. package/docs/adr/0001-no-daemon-invariant.md +82 -0
  113. package/docs/adr/0002-zero-runtime-dependencies.md +90 -0
  114. package/docs/adr/0003-max-six-unit-types.md +85 -0
  115. package/docs/adr/0004-atomic-commit-per-unit.md +102 -0
  116. package/docs/adr/0005-three-orthogonal-file-trees.md +98 -0
  117. package/docs/adr/0006-yaml-dependency-amendment.md +60 -0
  118. package/docs/adr/README.md +27 -0
  119. package/docs/agent-frontmatter-schema.md +84 -0
  120. package/docs/phase-artifact-schemas.md +292 -0
  121. package/docs/phase-directory-layout.md +82 -0
  122. package/lib/__tests__/README.md +1 -0
  123. package/lib/agents.cjs +98 -0
  124. package/lib/agents.test.cjs +286 -0
  125. package/lib/askuser.cjs +36 -0
  126. package/lib/askuser.test.cjs +310 -0
  127. package/lib/checkpoint.cjs +135 -0
  128. package/lib/checkpoint.test.cjs +184 -0
  129. package/lib/core.cjs +165 -0
  130. package/lib/core.test.cjs +405 -0
  131. package/lib/fixtures/README.md +1 -0
  132. package/lib/fixtures/phase-tree/README.md +1 -0
  133. package/lib/fixtures/plans/cycle/PLAN.md +16 -0
  134. package/lib/fixtures/plans/cycle/tasks/T-01.md +20 -0
  135. package/lib/fixtures/plans/cycle/tasks/T-02.md +20 -0
  136. package/lib/fixtures/plans/cycle/tasks/T-03.md +20 -0
  137. package/lib/fixtures/plans/linear/PLAN.md +16 -0
  138. package/lib/fixtures/plans/linear/tasks/T-01.md +20 -0
  139. package/lib/fixtures/plans/linear/tasks/T-02.md +20 -0
  140. package/lib/fixtures/plans/linear/tasks/T-03.md +20 -0
  141. package/lib/fixtures/plans/parallel/PLAN.md +16 -0
  142. package/lib/fixtures/plans/parallel/tasks/T-01.md +20 -0
  143. package/lib/fixtures/plans/parallel/tasks/T-02.md +20 -0
  144. package/lib/fixtures/plans/parallel/tasks/T-03.md +20 -0
  145. package/lib/fixtures/plans/wave-conflict/PLAN.md +16 -0
  146. package/lib/fixtures/plans/wave-conflict/tasks/T-01.md +20 -0
  147. package/lib/fixtures/plans/wave-conflict/tasks/T-02.md +20 -0
  148. package/lib/fixtures/roadmap/ROADMAP-malformed.md +3 -0
  149. package/lib/fixtures/roadmap/ROADMAP-minimal.md +51 -0
  150. package/lib/fixtures/roadmap/roadmap-malformed.yaml +7 -0
  151. package/lib/fixtures/roadmap/roadmap-minimal.yaml +40 -0
  152. package/lib/fixtures/roadmap/roadmap-ten-phases.yaml +101 -0
  153. package/lib/fixtures/templates/phase-context.md +6 -0
  154. package/lib/fixtures/templates/plan-skeleton.md +6 -0
  155. package/lib/frontmatter.cjs +251 -0
  156. package/lib/frontmatter.test.cjs +177 -0
  157. package/lib/gaps.cjs +197 -0
  158. package/lib/gaps.test.cjs +200 -0
  159. package/lib/git.cjs +207 -0
  160. package/lib/git.test.cjs +305 -0
  161. package/lib/install/agents-md.cjs +77 -0
  162. package/lib/install/backup.cjs +70 -0
  163. package/lib/install/codex-toml.cjs +440 -0
  164. package/lib/install/managed-block.cjs +30 -0
  165. package/lib/install/manifest.cjs +148 -0
  166. package/lib/install/mcp-writer.cjs +127 -0
  167. package/lib/install/runtime-detect.cjs +44 -0
  168. package/lib/install/staging.cjs +149 -0
  169. package/lib/metrics-aggregate.cjs +229 -0
  170. package/lib/metrics-aggregate.test.cjs +192 -0
  171. package/lib/metrics.cjs +120 -0
  172. package/lib/metrics.test.cjs +182 -0
  173. package/lib/model-aliases.regression.test.cjs +16 -0
  174. package/lib/model-profiles.cjs +42 -0
  175. package/lib/model-profiles.test.cjs +61 -0
  176. package/lib/next.cjs +236 -0
  177. package/lib/next.test.cjs +194 -0
  178. package/lib/phase.cjs +95 -0
  179. package/lib/phase.test.cjs +189 -0
  180. package/lib/plan-checker-contract.test.cjs +72 -0
  181. package/lib/plan-diff.cjs +173 -0
  182. package/lib/plan-diff.test.cjs +217 -0
  183. package/lib/plan.cjs +85 -0
  184. package/lib/plan.test.cjs +263 -0
  185. package/lib/progress.cjs +95 -0
  186. package/lib/progress.test.cjs +116 -0
  187. package/lib/researcher-contract.test.cjs +61 -0
  188. package/lib/roadmap-render.cjs +206 -0
  189. package/lib/roadmap-render.test.cjs +121 -0
  190. package/lib/roadmap.cjs +416 -0
  191. package/lib/roadmap.test.cjs +371 -0
  192. package/lib/runtime/_contract.test.cjs +61 -0
  193. package/lib/runtime/_readline.cjs +119 -0
  194. package/lib/runtime/_readline.test.cjs +126 -0
  195. package/lib/runtime/claude.cjs +48 -0
  196. package/lib/runtime/claude.test.cjs +101 -0
  197. package/lib/runtime/codex.cjs +35 -0
  198. package/lib/runtime/codex.test.cjs +114 -0
  199. package/lib/runtime/gemini.cjs +35 -0
  200. package/lib/runtime/gemini.test.cjs +109 -0
  201. package/lib/runtime/index.cjs +49 -0
  202. package/lib/runtime/index.test.cjs +181 -0
  203. package/lib/runtime/opencode.cjs +35 -0
  204. package/lib/runtime/opencode.test.cjs +124 -0
  205. package/lib/state.cjs +205 -0
  206. package/lib/state.test.cjs +264 -0
  207. package/lib/surface-audit.test.cjs +46 -0
  208. package/lib/tasks.cjs +327 -0
  209. package/lib/tasks.test.cjs +389 -0
  210. package/lib/template.cjs +66 -0
  211. package/lib/template.test.cjs +159 -0
  212. package/lib/undo.cjs +179 -0
  213. package/lib/undo.test.cjs +261 -0
  214. package/lib/verify.cjs +116 -0
  215. package/lib/verify.test.cjs +187 -0
  216. package/np-tools.cjs +303 -0
  217. package/package.json +39 -0
  218. package/templates/AI-SPEC.md +90 -0
  219. package/templates/CONTEXT.md +32 -0
  220. package/templates/PLAN.md +69 -0
  221. package/templates/PROJECT.md +60 -0
  222. package/templates/REQUIREMENTS.md +38 -0
  223. package/templates/SECURITY.md +61 -0
  224. package/templates/UI-SPEC.md +64 -0
  225. package/templates/VALIDATION.md +76 -0
  226. package/templates/claude/payload/README.md +11 -0
  227. package/templates/opencode/opencode.json +6 -0
  228. package/templates/opencode/payload/AGENTS.md +9 -0
  229. package/workflows/add-backlog.md +212 -0
  230. package/workflows/add-tests.md +69 -0
  231. package/workflows/add-todo.md +222 -0
  232. package/workflows/ai-integration-phase.md +230 -0
  233. package/workflows/autonomous.md +94 -0
  234. package/workflows/cleanup.md +325 -0
  235. package/workflows/code-review-fix.md +435 -0
  236. package/workflows/code-review.md +447 -0
  237. package/workflows/discuss-phase-assumptions.md +269 -0
  238. package/workflows/discuss-phase-power.md +139 -0
  239. package/workflows/discuss-phase.md +386 -0
  240. package/workflows/dispatch.md +9 -0
  241. package/workflows/doctor.md +10 -0
  242. package/workflows/eval-review.md +243 -0
  243. package/workflows/execute-phase.md +142 -0
  244. package/workflows/execute-plan.md +82 -0
  245. package/workflows/help.md +8 -0
  246. package/workflows/new-milestone.md +166 -0
  247. package/workflows/new-project.md +213 -0
  248. package/workflows/next.md +8 -0
  249. package/workflows/note.md +244 -0
  250. package/workflows/park.md +29 -0
  251. package/workflows/pause-work.md +34 -0
  252. package/workflows/plan-milestone-gaps.md +233 -0
  253. package/workflows/plan-phase.md +351 -0
  254. package/workflows/progress.md +8 -0
  255. package/workflows/queue.md +9 -0
  256. package/workflows/research-phase.md +327 -0
  257. package/workflows/reset-slice.md +39 -0
  258. package/workflows/resume-work.md +79 -0
  259. package/workflows/review.md +489 -0
  260. package/workflows/secure-phase.md +209 -0
  261. package/workflows/session-report.md +243 -0
  262. package/workflows/skip.md +29 -0
  263. package/workflows/state.md +7 -0
  264. package/workflows/stats.md +170 -0
  265. package/workflows/thread.md +214 -0
  266. package/workflows/triage.md +9 -0
  267. package/workflows/ui-phase.md +246 -0
  268. package/workflows/ui-review.md +222 -0
  269. package/workflows/undo-task.md +42 -0
  270. package/workflows/undo.md +55 -0
  271. package/workflows/unpark.md +29 -0
  272. package/workflows/validate-phase.md +231 -0
  273. package/workflows/verify-work.md +83 -0
@@ -0,0 +1,189 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const os = require('node:os');
6
+ const crypto = require('node:crypto');
7
+
8
+ const phase = require('./phase.cjs');
9
+ const { NubosPilotError } = require('./core.cjs');
10
+
11
+ function makeSandbox() {
12
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-phase-'));
13
+ fs.mkdirSync(path.join(dir, '.nubos-pilot', 'phases'), { recursive: true });
14
+ return dir;
15
+ }
16
+
17
+ function rmSandbox(dir) {
18
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
19
+ }
20
+
21
+ function withSandbox(fn) {
22
+ const dir = makeSandbox();
23
+ try { return fn(dir); } finally { rmSandbox(dir); }
24
+ }
25
+
26
+ test('PH-1 paddedPhase(3) === "03"', () => {
27
+ assert.equal(phase.paddedPhase(3), '03');
28
+ });
29
+
30
+ test('PH-2 paddedPhase("3") === "03"', () => {
31
+ assert.equal(phase.paddedPhase('3'), '03');
32
+ });
33
+
34
+ test('PH-3 paddedPhase(10) === "10"', () => {
35
+ assert.equal(phase.paddedPhase(10), '10');
36
+ });
37
+
38
+ test('PH-4 paddedPhase("2.1") === "02.1"', () => {
39
+ assert.equal(phase.paddedPhase('2.1'), '02.1');
40
+ });
41
+
42
+ test('PH-5 paddedPhase("12.3") === "12.3"', () => {
43
+ assert.equal(phase.paddedPhase('12.3'), '12.3');
44
+ });
45
+
46
+ test('PH-6 paddedPhase("abc") throws NubosPilotError(phase-not-found)', () => {
47
+ try {
48
+ phase.paddedPhase('abc');
49
+ assert.fail('expected throw');
50
+ } catch (err) {
51
+ assert.ok(err instanceof NubosPilotError, 'expected NubosPilotError');
52
+ assert.equal(err.code, 'phase-not-found');
53
+ assert.deepEqual(err.details, { got: 'abc' });
54
+ }
55
+ });
56
+
57
+ test('PH-7 paddedPhase(0) === "00"', () => {
58
+ assert.equal(phase.paddedPhase(0), '00');
59
+ });
60
+
61
+ test('PH-8 phaseSlug("Core Lib — Parsers & Dispatcher") === "core-lib-parsers-dispatcher"', () => {
62
+ assert.equal(phase.phaseSlug('Core Lib — Parsers & Dispatcher'), 'core-lib-parsers-dispatcher');
63
+ });
64
+
65
+ test('PH-9 phaseSlug("AI/ML") === "ai-ml"', () => {
66
+ assert.equal(phase.phaseSlug('AI/ML'), 'ai-ml');
67
+ });
68
+
69
+ test('PH-10 phaseSlug(" spaced ") === "spaced"', () => {
70
+ assert.equal(phase.phaseSlug(' spaced '), 'spaced');
71
+ });
72
+
73
+ test('PH-11 phaseSlug("123-NUMBERS") === "123-numbers"', () => {
74
+ assert.equal(phase.phaseSlug('123-NUMBERS'), '123-numbers');
75
+ });
76
+
77
+ test('PH-12 findPhaseDir(3, sandbox) returns absolute path ending in phases/03-core-lib', () => {
78
+ withSandbox((sandbox) => {
79
+ fs.mkdirSync(path.join(sandbox, '.nubos-pilot', 'phases', '03-core-lib'), { recursive: true });
80
+ const result = phase.findPhaseDir(3, sandbox);
81
+ assert.ok(result, 'expected non-null');
82
+ assert.ok(path.isAbsolute(result), 'expected absolute path');
83
+ assert.ok(result.endsWith(path.join('phases', '03-core-lib')), `expected suffix phases/03-core-lib, got ${result}`);
84
+ });
85
+ });
86
+
87
+ test('PH-13 findPhaseDir(999, sandbox) returns null (no throw)', () => {
88
+ withSandbox((sandbox) => {
89
+ assert.equal(phase.findPhaseDir(999, sandbox), null);
90
+ });
91
+ });
92
+
93
+ test('PH-14 findPhaseDir when phases/ dir absent returns null (not throw)', () => {
94
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-phase-nop-'));
95
+ fs.mkdirSync(path.join(dir, '.nubos-pilot'), { recursive: true });
96
+ try {
97
+ assert.equal(phase.findPhaseDir(3, dir), null);
98
+ } finally {
99
+ rmSandbox(dir);
100
+ }
101
+ });
102
+
103
+ test('PH-15 findPhaseDir("2.1") finds 02.1-hotfix', () => {
104
+ withSandbox((sandbox) => {
105
+ fs.mkdirSync(path.join(sandbox, '.nubos-pilot', 'phases', '02.1-hotfix'), { recursive: true });
106
+ const result = phase.findPhaseDir('2.1', sandbox);
107
+ assert.ok(result);
108
+ assert.ok(result.endsWith(path.join('phases', '02.1-hotfix')), `got ${result}`);
109
+ });
110
+ });
111
+
112
+ test('PH-16 findPhaseDir prefers longer slug-bearing match over bare padded', () => {
113
+ withSandbox((sandbox) => {
114
+ const phases = path.join(sandbox, '.nubos-pilot', 'phases');
115
+ fs.mkdirSync(path.join(phases, '03'), { recursive: true });
116
+ fs.mkdirSync(path.join(phases, '03-full-slug'), { recursive: true });
117
+ const result = phase.findPhaseDir(3, sandbox);
118
+ assert.ok(result);
119
+ assert.ok(
120
+ result.endsWith(path.join('phases', '03-full-slug')),
121
+ `expected longer-slug winner, got ${result}`
122
+ );
123
+ });
124
+ });
125
+
126
+ test('PH-17 createPhaseDir creates phases/04-base-workflows/', () => {
127
+ withSandbox((sandbox) => {
128
+ const result = phase.createPhaseDir(4, 'base-workflows', sandbox);
129
+ assert.ok(path.isAbsolute(result));
130
+ assert.ok(result.endsWith(path.join('phases', '04-base-workflows')), `got ${result}`);
131
+ assert.ok(fs.statSync(result).isDirectory());
132
+ });
133
+ });
134
+
135
+ test('PH-18 createPhaseDir is idempotent for same (n, slug)', () => {
136
+ withSandbox((sandbox) => {
137
+ const a = phase.createPhaseDir(4, 'base-workflows', sandbox);
138
+ const b = phase.createPhaseDir(4, 'base-workflows', sandbox);
139
+ assert.equal(a, b);
140
+ });
141
+ });
142
+
143
+ test('PH-19 createPhaseDir throws phase-slug-mismatch on conflict', () => {
144
+ withSandbox((sandbox) => {
145
+ fs.mkdirSync(
146
+ path.join(sandbox, '.nubos-pilot', 'phases', '04-something-else'),
147
+ { recursive: true }
148
+ );
149
+ try {
150
+ phase.createPhaseDir(4, 'base-workflows', sandbox);
151
+ assert.fail('expected throw');
152
+ } catch (err) {
153
+ assert.ok(err instanceof NubosPilotError, 'expected NubosPilotError');
154
+ assert.equal(err.code, 'phase-slug-mismatch');
155
+ assert.equal(err.details.existing_slug, 'something-else');
156
+ assert.equal(err.details.expected_slug, 'base-workflows');
157
+ }
158
+ });
159
+ });
160
+
161
+ test('PH-20 createPhaseDir leaves no sidecar lock file behind', () => {
162
+ withSandbox((sandbox) => {
163
+ phase.createPhaseDir(4, 'base-workflows', sandbox);
164
+ const entries = fs.readdirSync(path.join(sandbox, '.nubos-pilot', 'phases'));
165
+ const locks = entries.filter((n) => n.includes('.lock') || n.startsWith('.phase-create'));
166
+ assert.deepEqual(locks, [], `stale lock artefacts: ${locks.join(',')}`);
167
+ });
168
+ });
169
+
170
+ test('PH-21 integration: repo-style phase tree resolves 03-core-lib-parsers-dispatcher-capability-layer', () => {
171
+
172
+
173
+
174
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-phase-integ-'));
175
+ try {
176
+ fs.mkdirSync(
177
+ path.join(dir, '.nubos-pilot', 'phases', '03-core-lib-parsers-dispatcher-capability-layer'),
178
+ { recursive: true }
179
+ );
180
+ const result = phase.findPhaseDir(3, dir);
181
+ assert.ok(result);
182
+ assert.ok(
183
+ result.endsWith(path.join('phases', '03-core-lib-parsers-dispatcher-capability-layer')),
184
+ `got ${result}`
185
+ );
186
+ } finally {
187
+ rmSandbox(dir);
188
+ }
189
+ });
@@ -0,0 +1,72 @@
1
+ const fs = require('node:fs');
2
+ const os = require('node:os');
3
+ const path = require('node:path');
4
+ const { test } = require('node:test');
5
+ const assert = require('node:assert/strict');
6
+
7
+ const { loadAgent, FORBIDDEN } = require('./agents.cjs');
8
+
9
+ const AGENT_PATH = path.join(__dirname, '..', 'agents', 'np-plan-checker.md');
10
+ const BODY = fs.readFileSync(AGENT_PATH, 'utf-8');
11
+
12
+ function makeSandbox() {
13
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'np-pc-contract-'));
14
+ fs.mkdirSync(path.join(root, '.nubos-pilot'), { recursive: true });
15
+ fs.mkdirSync(path.join(root, 'agents'), { recursive: true });
16
+ fs.writeFileSync(path.join(root, 'agents', 'np-plan-checker.md'), BODY, 'utf-8');
17
+ return root;
18
+ }
19
+
20
+ const CATEGORIES = [
21
+ 'missing-success-criterion',
22
+ 'non-atomic-task',
23
+ 'unbounded-scope',
24
+ 'broken-dependency',
25
+ 'cyclic-dependency',
26
+ 'fake-promotion-trigger',
27
+ 'missing-coverage-annotation',
28
+ 'bare-askuser-call',
29
+ 'hook-field-present',
30
+ 'forbidden-agent-field',
31
+ ];
32
+
33
+ const REQUIRED_H2 = [
34
+ '## Role',
35
+ '## Inputs',
36
+ '## Review Dimensions',
37
+ '## Verdict Format',
38
+ '## Severity Rubric',
39
+ '## Forbidden Outputs',
40
+ '## Semantic Blocks',
41
+ ];
42
+
43
+ test('PC-1: loadAgent(plan-checker) returns tier=opus and name=plan-checker', () => {
44
+ const sb = makeSandbox();
45
+ try {
46
+ const fm = loadAgent('np-plan-checker', sb);
47
+ assert.equal(fm.tier, 'opus');
48
+ assert.equal(fm.name, 'np-plan-checker');
49
+ } finally {
50
+ fs.rmSync(sb, { recursive: true, force: true });
51
+ }
52
+ });
53
+
54
+ test('PC-2: body contains all 10 canonical finding-category identifiers', () => {
55
+ for (const c of CATEGORIES) {
56
+ assert.ok(BODY.includes(c), 'missing canonical category: ' + c);
57
+ }
58
+ });
59
+
60
+ test('PC-4: body contains all 7 required H2 section headers verbatim', () => {
61
+ for (const h of REQUIRED_H2) {
62
+ assert.ok(BODY.includes(h), 'missing required H2 header: ' + h);
63
+ }
64
+ });
65
+
66
+ test('PC-5: frontmatter contains no FORBIDDEN key (model/model_profile/hooks)', () => {
67
+ const fmBlock = BODY.split(/^---$/m)[1] || '';
68
+ for (const f of FORBIDDEN) {
69
+ const re = new RegExp('^' + f + ':', 'm');
70
+ assert.equal(re.test(fmBlock), false, 'frontmatter contains forbidden field: ' + f);
71
+ }
72
+ });
@@ -0,0 +1,173 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const { NubosPilotError, findProjectRoot, projectStateDir, atomicWriteFileSync } = require('./core.cjs');
4
+ const { TASK_ID_RE } = require('./tasks.cjs');
5
+ const { gitShowSafe, gitDiffNoColor, checkoutFromHead } = require('./git.cjs');
6
+
7
+ const SEMANTIC_HEADER = '── Semantic diff (task-level) ──────────────────────────────';
8
+ const RAW_HEADER = '── Raw git diff (full text) ────────────────────────────────';
9
+
10
+ const PHASE_RE = /^\d+(\.\d+)?$/;
11
+ const PLAN_ID_RE = /^\d{2}(\.\d+)?-\d{2}$/;
12
+
13
+ function _scanTasks(body) {
14
+ const out = new Map();
15
+ const re = /<task\b[^>]*?tier=["']([^"']+)["'][^>]*?id=["']([^"']+)["']|<task\b[^>]*?id=["']([^"']+)["'][^>]*?tier=["']([^"']+)["']/g;
16
+ for (const m of String(body).matchAll(re)) {
17
+ const id = m[2] || m[3];
18
+ const tier = m[1] || m[4];
19
+ if (id && TASK_ID_RE.test(id)) {
20
+ out.set(id, { id, tier: tier || 'unknown' });
21
+ }
22
+ }
23
+ return out;
24
+ }
25
+
26
+ function semanticTaskDiff(priorBody, currentBody) {
27
+ const prior = _scanTasks(priorBody || '');
28
+ const cur = _scanTasks(currentBody || '');
29
+ const added = [];
30
+ const removed = [];
31
+ const changed = [];
32
+ for (const [id, rec] of cur) {
33
+ if (!prior.has(id)) {
34
+ added.push(rec);
35
+ } else {
36
+ const p = prior.get(id);
37
+ if (p.tier !== rec.tier && p.tier !== 'unknown' && rec.tier !== 'unknown') {
38
+ changed.push({ id, field: 'tier', from: p.tier, to: rec.tier });
39
+ }
40
+ }
41
+ }
42
+ for (const [id, rec] of prior) {
43
+ if (!cur.has(id)) removed.push(rec);
44
+ }
45
+ return { added, removed, changed };
46
+ }
47
+
48
+ function _renderSemantic(diff) {
49
+ const lines = [];
50
+ for (const a of diff.added) lines.push('+ ' + a.id + ': tier=' + a.tier);
51
+ for (const c of diff.changed) lines.push('~ ' + c.id + ': ' + c.field + '=' + c.from + '→' + c.to);
52
+ for (const r of diff.removed) lines.push('- ' + r.id);
53
+ return lines.length ? lines.join('\n') : '(no task-level changes detected)';
54
+ }
55
+
56
+ function _validatePhaseArg(phase) {
57
+ if (typeof phase !== 'string' || !PHASE_RE.test(phase)) {
58
+ throw new NubosPilotError(
59
+ 'plan-diff-invalid-phase',
60
+ 'Invalid phase argument: ' + String(phase),
61
+ { phase: String(phase) },
62
+ );
63
+ }
64
+ }
65
+
66
+ function _validatePlanId(planId) {
67
+ if (typeof planId !== 'string' || !PLAN_ID_RE.test(planId)) {
68
+ throw new NubosPilotError(
69
+ 'plan-diff-invalid-plan-id',
70
+ 'Invalid plan-id argument: ' + String(planId),
71
+ { planId: String(planId) },
72
+ );
73
+ }
74
+ }
75
+
76
+ function _planPathForIds(phase, planId, cwd) {
77
+ _validatePhaseArg(phase);
78
+ _validatePlanId(planId);
79
+ let root;
80
+ try {
81
+ root = findProjectRoot(cwd || process.cwd());
82
+ } catch {
83
+ root = path.resolve(cwd || process.cwd());
84
+ }
85
+ const phasesRoot = path.join(projectStateDir(root), 'phases');
86
+ const padded = String(phase).padStart(2, '0');
87
+ let phaseDirName = null;
88
+ try {
89
+ const entries = fs.readdirSync(phasesRoot, { withFileTypes: true });
90
+ const matches = entries
91
+ .filter((e) => e.isDirectory())
92
+ .filter((e) => e.name === padded || e.name.startsWith(padded + '-'))
93
+ .map((e) => e.name)
94
+ .sort((a, b) => b.length - a.length);
95
+ if (matches.length > 0) phaseDirName = matches[0];
96
+ } catch {
97
+ phaseDirName = null;
98
+ }
99
+ if (!phaseDirName) {
100
+ throw new NubosPilotError(
101
+ 'plan-diff-phase-not-found',
102
+ 'No phase directory starting with ' + padded + ' under ' + phasesRoot,
103
+ { phase, phasesRoot },
104
+ );
105
+ }
106
+ const phaseDir = path.join(phasesRoot, phaseDirName);
107
+ const planFile = planId + '-PLAN.md';
108
+ const relative = path.relative(root, path.join(phaseDir, planFile));
109
+ return {
110
+ absolute: path.join(phaseDir, planFile),
111
+ relative,
112
+ phaseDir,
113
+ phaseDirName,
114
+ root,
115
+ padded,
116
+ };
117
+ }
118
+
119
+ function renderTwoPartDiff({ phase, planId, cwd }) {
120
+ const resolved = _planPathForIds(phase, planId, cwd);
121
+ const prev = process.cwd();
122
+ process.chdir(resolved.root);
123
+ try {
124
+ const prior = gitShowSafe('HEAD', resolved.relative);
125
+ if (prior === null) return { hasPrior: false };
126
+ const current = fs.existsSync(resolved.absolute)
127
+ ? fs.readFileSync(resolved.absolute, 'utf-8')
128
+ : '';
129
+ const diff = semanticTaskDiff(prior, current);
130
+ const semantic = _renderSemantic(diff);
131
+ const raw = gitDiffNoColor('HEAD', resolved.relative);
132
+ const combined = [SEMANTIC_HEADER, '', semantic, '', RAW_HEADER, '', raw].join('\n');
133
+ return { hasPrior: true, semantic, raw, combined };
134
+ } finally {
135
+ process.chdir(prev);
136
+ }
137
+ }
138
+
139
+ function restoreFromHead({ phase, planId, cwd }) {
140
+ const resolved = _planPathForIds(phase, planId, cwd);
141
+ checkoutFromHead([resolved.relative], { cwd: resolved.root });
142
+ }
143
+
144
+ function archiveRejected({ phase, planId, reason, cwd }) {
145
+ const resolved = _planPathForIds(phase, planId, cwd);
146
+ const body = fs.existsSync(resolved.absolute)
147
+ ? fs.readFileSync(resolved.absolute, 'utf-8')
148
+ : '';
149
+ const iso = new Date().toISOString();
150
+ const safeIso = iso.replace(/:/g, '-');
151
+ const archiveName = resolved.padded + '-' + planId + '-PLAN-DIFF-' + safeIso + '.md';
152
+ const archivePath = path.join(resolved.phaseDir, archiveName);
153
+ const content = [
154
+ '---',
155
+ 'rejected_at: ' + iso,
156
+ 'reason: ' + JSON.stringify(reason == null ? '' : String(reason)),
157
+ '---',
158
+ '',
159
+ body,
160
+ ].join('\n');
161
+ atomicWriteFileSync(archivePath, content);
162
+ restoreFromHead({ phase, planId, cwd });
163
+ return archivePath;
164
+ }
165
+
166
+ module.exports = {
167
+ semanticTaskDiff,
168
+ renderTwoPartDiff,
169
+ archiveRejected,
170
+ restoreFromHead,
171
+ SEMANTIC_HEADER,
172
+ RAW_HEADER,
173
+ };
@@ -0,0 +1,217 @@
1
+ const { test, after } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const os = require('node:os');
6
+ const { execFileSync } = require('node:child_process');
7
+
8
+ const pd = require('./plan-diff.cjs');
9
+
10
+ const _repos = [];
11
+
12
+ function makeRepo() {
13
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'np-plan-diff-'));
14
+ execFileSync('git', ['init', '-q', '-b', 'main', root], { stdio: 'pipe' });
15
+ execFileSync('git', ['-C', root, 'config', 'user.email', 'test@nubos-pilot.local']);
16
+ execFileSync('git', ['-C', root, 'config', 'user.name', 'nubos-test']);
17
+ fs.mkdirSync(path.join(root, '.nubos-pilot'), { recursive: true });
18
+ execFileSync('git', ['-C', root, 'commit', '--allow-empty', '-q', '-m', 'chore: init'], { stdio: 'pipe' });
19
+ _repos.push(root);
20
+ return root;
21
+ }
22
+
23
+ function seedPlanCommit(root, phase, phaseSlug, planId, body) {
24
+ const padded = String(phase).padStart(2, '0');
25
+ const phaseDir = path.join(root, '.nubos-pilot', 'phases', padded + '-' + phaseSlug);
26
+ fs.mkdirSync(phaseDir, { recursive: true });
27
+ const rel = path.join('.nubos-pilot', 'phases', padded + '-' + phaseSlug, planId + '-PLAN.md');
28
+ const abs = path.join(root, rel);
29
+ fs.writeFileSync(abs, body, 'utf-8');
30
+ execFileSync('git', ['-C', root, 'add', '--', rel], { stdio: 'pipe' });
31
+ execFileSync('git', ['-C', root, 'commit', '-q', '-m', 'docs(' + planId + '): seed PLAN.md'], { stdio: 'pipe' });
32
+ return { abs, rel, phaseDir };
33
+ }
34
+
35
+ function planBody(tasks) {
36
+ const lines = ['---', 'plan: 1', 'phase: 9', 'requirements: [R-05]', '---', '', '## Tasks', ''];
37
+ for (const t of tasks) {
38
+ lines.push('<task type="auto" tier="' + t.tier + '" id="' + t.id + '">');
39
+ lines.push('body');
40
+ lines.push('</task>');
41
+ lines.push('');
42
+ }
43
+ return lines.join('\n');
44
+ }
45
+
46
+ function inRepo(root, fn) {
47
+ const prev = process.cwd();
48
+ process.chdir(root);
49
+ try { return fn(); } finally { process.chdir(prev); }
50
+ }
51
+
52
+ after(() => {
53
+ while (_repos.length) {
54
+ const r = _repos.pop();
55
+ try { fs.rmSync(r, { recursive: true, force: true }); } catch {}
56
+ }
57
+ });
58
+
59
+ test('PD-1: added task (new id in current, not in prior) is reported', () => {
60
+ const prior = planBody([{ id: '09-01-T01', tier: 'opus' }]);
61
+ const current = planBody([
62
+ { id: '09-01-T01', tier: 'opus' },
63
+ { id: '09-01-T04', tier: 'haiku' },
64
+ ]);
65
+ const diff = pd.semanticTaskDiff(prior, current);
66
+ assert.deepEqual(diff.added, [{ id: '09-01-T04', tier: 'haiku' }]);
67
+ assert.deepEqual(diff.removed, []);
68
+ assert.deepEqual(diff.changed, []);
69
+ });
70
+
71
+ test('PD-2: removed task (id in prior but not current) is reported', () => {
72
+ const prior = planBody([
73
+ { id: '09-01-T01', tier: 'opus' },
74
+ { id: '09-01-T03', tier: 'haiku' },
75
+ ]);
76
+ const current = planBody([{ id: '09-01-T01', tier: 'opus' }]);
77
+ const diff = pd.semanticTaskDiff(prior, current);
78
+ assert.deepEqual(diff.added, []);
79
+ assert.equal(diff.removed.length, 1);
80
+ assert.equal(diff.removed[0].id, '09-01-T03');
81
+ assert.deepEqual(diff.changed, []);
82
+ });
83
+
84
+ test('PD-3: tier change on same id is reported as changed', () => {
85
+ const prior = planBody([{ id: '09-01-T01', tier: 'opus' }]);
86
+ const current = planBody([{ id: '09-01-T01', tier: 'sonnet' }]);
87
+ const diff = pd.semanticTaskDiff(prior, current);
88
+ assert.deepEqual(diff.added, []);
89
+ assert.deepEqual(diff.removed, []);
90
+ assert.deepEqual(diff.changed, [{ id: '09-01-T01', field: 'tier', from: 'opus', to: 'sonnet' }]);
91
+ });
92
+
93
+ test('PD-4: non-canonical task-id formats are ignored (Pitfall 4)', () => {
94
+ const prior = '<task tier="opus" id="T-09-01-04">x</task>\n<task tier="opus" id="T01">y</task>\n';
95
+ const current = '<task tier="opus" id="T-09-01-04">x</task>\n<task tier="haiku" id="09-01-T05">z</task>\n';
96
+ const diff = pd.semanticTaskDiff(prior, current);
97
+ assert.deepEqual(diff.added, [{ id: '09-01-T05', tier: 'haiku' }]);
98
+ assert.deepEqual(diff.removed, []);
99
+ assert.deepEqual(diff.changed, []);
100
+ });
101
+
102
+ test('PD-5: renderTwoPartDiff returns hasPrior=true with combined Semantic + Raw sections', () => {
103
+ const root = makeRepo();
104
+ const { abs } = seedPlanCommit(root, '09', 'feature-set', '09-01', planBody([
105
+ { id: '09-01-T01', tier: 'opus' },
106
+ ]));
107
+ const modified = planBody([
108
+ { id: '09-01-T01', tier: 'opus' },
109
+ { id: '09-01-T02', tier: 'sonnet' },
110
+ ]);
111
+ fs.writeFileSync(abs, modified, 'utf-8');
112
+ const r = pd.renderTwoPartDiff({ phase: '09', planId: '09-01', cwd: root });
113
+ assert.equal(r.hasPrior, true);
114
+ assert.ok(r.semantic.includes('09-01-T02'));
115
+ assert.ok(r.raw.startsWith('diff --git'));
116
+ assert.ok(r.combined.includes('Semantic diff'));
117
+ assert.ok(r.combined.includes('Raw git diff'));
118
+ });
119
+
120
+ test('PD-6: renderTwoPartDiff returns {hasPrior:false} when HEAD has no prior PLAN.md', () => {
121
+ const root = makeRepo();
122
+ const padded = '09';
123
+ const phaseDir = path.join(root, '.nubos-pilot', 'phases', padded + '-feature-set');
124
+ fs.mkdirSync(phaseDir, { recursive: true });
125
+ fs.writeFileSync(path.join(phaseDir, '09-01-PLAN.md'), planBody([{ id: '09-01-T01', tier: 'opus' }]), 'utf-8');
126
+ const r = pd.renderTwoPartDiff({ phase: '09', planId: '09-01', cwd: root });
127
+ assert.equal(r.hasPrior, false);
128
+ assert.equal(r.semantic, undefined);
129
+ });
130
+
131
+ test('PD-7: archiveRejected writes PLAN-DIFF-{ISO}.md with frontmatter + restores HEAD', () => {
132
+ const root = makeRepo();
133
+ const priorBody = planBody([{ id: '09-01-T01', tier: 'opus' }]);
134
+ const { abs, phaseDir } = seedPlanCommit(root, '09', 'feature-set', '09-01', priorBody);
135
+ const newBody = planBody([
136
+ { id: '09-01-T01', tier: 'opus' },
137
+ { id: '09-01-T02', tier: 'sonnet' },
138
+ ]);
139
+ fs.writeFileSync(abs, newBody, 'utf-8');
140
+ const archivePath = pd.archiveRejected({ phase: '09', planId: '09-01', reason: 'design flaw', cwd: root });
141
+ assert.ok(fs.existsSync(archivePath));
142
+ const name = path.basename(archivePath);
143
+ assert.match(name, /^09-09-01-PLAN-DIFF-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.\d{3}Z\.md$/);
144
+ const archived = fs.readFileSync(archivePath, 'utf-8');
145
+ assert.match(archived, /^---\n/);
146
+ assert.ok(archived.includes('rejected_at:'));
147
+ assert.ok(archived.includes('reason: "design flaw"'));
148
+ assert.ok(archived.includes('09-01-T02'), 'archive must include rejected body');
149
+ const restored = fs.readFileSync(abs, 'utf-8');
150
+ assert.equal(restored, priorBody, 'working-tree PLAN.md must be reset to HEAD body');
151
+ assert.ok(phaseDir);
152
+ });
153
+
154
+ test('PD-8: back-to-back archiveRejected produces distinct filenames (millisecond ISO, Pitfall 9)', () => {
155
+ const root = makeRepo();
156
+ const priorBody = planBody([{ id: '09-01-T01', tier: 'opus' }]);
157
+ const { abs } = seedPlanCommit(root, '09', 'feature-set', '09-01', priorBody);
158
+ fs.writeFileSync(abs, planBody([{ id: '09-01-T02', tier: 'haiku' }]), 'utf-8');
159
+ const a1 = pd.archiveRejected({ phase: '09', planId: '09-01', reason: 'first', cwd: root });
160
+ fs.writeFileSync(abs, planBody([{ id: '09-01-T03', tier: 'haiku' }]), 'utf-8');
161
+ const a2 = pd.archiveRejected({ phase: '09', planId: '09-01', reason: 'second', cwd: root });
162
+ assert.notEqual(a1, a2);
163
+ assert.ok(fs.existsSync(a1));
164
+ assert.ok(fs.existsSync(a2));
165
+ });
166
+
167
+ test('PD-9: archiveRejected with empty reason → frontmatter reason: ""', () => {
168
+ const root = makeRepo();
169
+ const priorBody = planBody([{ id: '09-01-T01', tier: 'opus' }]);
170
+ const { abs } = seedPlanCommit(root, '09', 'feature-set', '09-01', priorBody);
171
+ fs.writeFileSync(abs, planBody([{ id: '09-01-T02', tier: 'haiku' }]), 'utf-8');
172
+ const archivePath = pd.archiveRejected({ phase: '09', planId: '09-01', reason: '', cwd: root });
173
+ const body = fs.readFileSync(archivePath, 'utf-8');
174
+ assert.ok(body.includes('reason: ""'));
175
+ });
176
+
177
+ test('PD-10: restoreFromHead is idempotent', () => {
178
+ const root = makeRepo();
179
+ const priorBody = planBody([{ id: '09-01-T01', tier: 'opus' }]);
180
+ const { abs } = seedPlanCommit(root, '09', 'feature-set', '09-01', priorBody);
181
+ fs.writeFileSync(abs, 'mutated', 'utf-8');
182
+ pd.restoreFromHead({ phase: '09', planId: '09-01', cwd: root });
183
+ assert.equal(fs.readFileSync(abs, 'utf-8'), priorBody);
184
+ pd.restoreFromHead({ phase: '09', planId: '09-01', cwd: root });
185
+ assert.equal(fs.readFileSync(abs, 'utf-8'), priorBody);
186
+ });
187
+
188
+ test('PD-11: semantic diff line format uses +/~/- prefixes with expected fields', () => {
189
+ const root = makeRepo();
190
+ const { abs } = seedPlanCommit(root, '09', 'feature-set', '09-01', planBody([
191
+ { id: '09-01-T01', tier: 'opus' },
192
+ { id: '09-01-T03', tier: 'haiku' },
193
+ ]));
194
+ fs.writeFileSync(abs, planBody([
195
+ { id: '09-01-T01', tier: 'sonnet' },
196
+ { id: '09-01-T04', tier: 'haiku' },
197
+ ]), 'utf-8');
198
+ const r = pd.renderTwoPartDiff({ phase: '09', planId: '09-01', cwd: root });
199
+ assert.ok(r.combined.includes('+ 09-01-T04: tier=haiku'));
200
+ assert.ok(r.combined.includes('~ 09-01-T01: tier=opus→sonnet'));
201
+ assert.ok(r.combined.includes('- 09-01-T03'));
202
+ });
203
+
204
+ test('PD-12: rendered combined output contains no ANSI escape bytes (Pitfall 6)', () => {
205
+ const root = makeRepo();
206
+ const { abs } = seedPlanCommit(root, '09', 'feature-set', '09-01', planBody([
207
+ { id: '09-01-T01', tier: 'opus' },
208
+ ]));
209
+ execFileSync('git', ['-C', root, 'config', '--local', 'color.ui', 'always'], { stdio: 'pipe' });
210
+ fs.writeFileSync(abs, planBody([
211
+ { id: '09-01-T01', tier: 'opus' },
212
+ { id: '09-01-T02', tier: 'sonnet' },
213
+ ]), 'utf-8');
214
+ const r = pd.renderTwoPartDiff({ phase: '09', planId: '09-01', cwd: root });
215
+ assert.equal(r.combined.indexOf('\x1b'), -1);
216
+ inRepo(root, () => { });
217
+ });