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
package/lib/plan.cjs ADDED
@@ -0,0 +1,85 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const { extractFrontmatter } = require('./frontmatter.cjs');
4
+ const { NubosPilotError } = require('./core.cjs');
5
+
6
+ const PLAN_FILENAME_RE = /^\d{2}(\.\d+)?-\d{2}-PLAN\.md$/;
7
+
8
+ function parsePlan(planPath) {
9
+ let raw;
10
+ try {
11
+ raw = fs.readFileSync(planPath, 'utf-8');
12
+ } catch (err) {
13
+ if (err && err.code === 'ENOENT') {
14
+ throw new NubosPilotError(
15
+ 'plan-not-found',
16
+ `PLAN.md not found at ${planPath}`,
17
+ { path: planPath, cause: err.code },
18
+ );
19
+ }
20
+ throw err;
21
+ }
22
+ const { frontmatter, body } = extractFrontmatter(raw);
23
+ return { frontmatter, body, path: planPath };
24
+ }
25
+
26
+ function listPlans(phaseDir) {
27
+ let entries;
28
+ try {
29
+ entries = fs.readdirSync(phaseDir);
30
+ } catch (err) {
31
+ if (err && err.code === 'ENOENT') return [];
32
+ throw err;
33
+ }
34
+ const matches = entries.filter((name) => name === 'PLAN.md' || PLAN_FILENAME_RE.test(name));
35
+ matches.sort();
36
+ return matches.map((name) => path.join(phaseDir, name));
37
+ }
38
+
39
+ function enumerateTasks(planPath) {
40
+ const tasksDir = path.join(path.dirname(planPath), 'tasks');
41
+ let entries;
42
+ try {
43
+ entries = fs.readdirSync(tasksDir);
44
+ } catch (err) {
45
+ if (err && err.code === 'ENOENT') return [];
46
+ throw err;
47
+ }
48
+ const md = entries.filter((name) => name.endsWith('.md'));
49
+ md.sort();
50
+ return md.map((name) => path.join(tasksDir, name));
51
+ }
52
+
53
+ const { computeWaves } = require('./tasks.cjs');
54
+
55
+ function shouldPromoteToTasks(plan) {
56
+ const tasks = (plan && plan.tasks) || [];
57
+ const triggers = [];
58
+ if (tasks.length === 0) return { promote: false, triggers };
59
+
60
+ const computeInput = tasks.map((t) => ({
61
+ id: t.id,
62
+ depends_on: (t.frontmatter && t.frontmatter.depends_on) || [],
63
+ wave: t.frontmatter && t.frontmatter.wave,
64
+ }));
65
+ const { waves } = computeWaves(computeInput);
66
+ if (waves.length > 1) {
67
+ const maxWaveSize = Math.max(...waves.map((w) => w.length));
68
+ if (maxWaveSize >= 2) triggers.push('parallelism');
69
+ }
70
+
71
+ const tierSet = new Set(tasks.map((t) => t.frontmatter && t.frontmatter.tier));
72
+ if (tierSet.size >= 2) triggers.push('mixed-tiers');
73
+
74
+ if (
75
+ tasks.some(
76
+ (t) => ((t.frontmatter && t.frontmatter.depends_on) || []).length >= 2,
77
+ )
78
+ ) {
79
+ triggers.push('non-linear-deps');
80
+ }
81
+
82
+ return { promote: triggers.length > 0, triggers };
83
+ }
84
+
85
+ module.exports = { parsePlan, listPlans, enumerateTasks, shouldPromoteToTasks };
@@ -0,0 +1,263 @@
1
+ const { test, describe, it } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+
7
+ const plan = require('./plan.cjs');
8
+ const { shouldPromoteToTasks } = require('./plan.cjs');
9
+
10
+ const FIXTURES = path.join(__dirname, 'fixtures', 'plans');
11
+ const sandboxes = [];
12
+
13
+ function makeSandbox() {
14
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'np-plan-'));
15
+ sandboxes.push(root);
16
+ return root;
17
+ }
18
+
19
+ process.on('exit', () => {
20
+ for (const s of sandboxes) {
21
+ try { fs.rmSync(s, { recursive: true, force: true }); } catch {}
22
+ }
23
+ });
24
+
25
+ test('PL-1: parsePlan returns frontmatter + body + path for valid PLAN.md', () => {
26
+ const p = path.join(FIXTURES, 'linear', 'PLAN.md');
27
+ const result = plan.parsePlan(p);
28
+ assert.equal(result.path, p);
29
+ assert.equal(result.frontmatter.phase, 99);
30
+ assert.equal(typeof result.body, 'string');
31
+ assert.ok(result.body.length > 0);
32
+ });
33
+
34
+ test('PL-2: parsePlan on missing path throws plan-not-found', () => {
35
+ const p = path.join(FIXTURES, 'does-not-exist', 'PLAN.md');
36
+ assert.throws(
37
+ () => plan.parsePlan(p),
38
+ (err) => err.name === 'NubosPilotError' && err.code === 'plan-not-found',
39
+ );
40
+ });
41
+
42
+ test('PL-3: parsePlan on malformed frontmatter propagates frontmatter-parse-error', () => {
43
+ const root = makeSandbox();
44
+ const p = path.join(root, 'BAD.md');
45
+ fs.writeFileSync(p, '---\n\tbad: tab\n---\n\nbody\n');
46
+ assert.throws(
47
+ () => plan.parsePlan(p),
48
+ (err) => err.name === 'NubosPilotError' && err.code === 'frontmatter-parse-error',
49
+ );
50
+ });
51
+
52
+ test('PL-4: listPlans returns sorted absolute paths of PLAN.md variants', () => {
53
+ const root = makeSandbox();
54
+ fs.writeFileSync(path.join(root, '01-02-PLAN.md'), '---\nphase: 1\n---\n');
55
+ fs.writeFileSync(path.join(root, '01-01-PLAN.md'), '---\nphase: 1\n---\n');
56
+ const result = plan.listPlans(root);
57
+ assert.equal(result.length, 2);
58
+ assert.equal(result[0], path.join(root, '01-01-PLAN.md'));
59
+ assert.equal(result[1], path.join(root, '01-02-PLAN.md'));
60
+ assert.ok(path.isAbsolute(result[0]));
61
+ });
62
+
63
+ test('PL-5: enumerateTasks returns sorted absolute paths of task files', () => {
64
+ const planPath = path.join(FIXTURES, 'linear', 'PLAN.md');
65
+ const result = plan.enumerateTasks(planPath);
66
+ assert.equal(result.length, 3);
67
+ assert.equal(path.basename(result[0]), 'T-01.md');
68
+ assert.equal(path.basename(result[1]), 'T-02.md');
69
+ assert.equal(path.basename(result[2]), 'T-03.md');
70
+ assert.ok(path.isAbsolute(result[0]));
71
+ });
72
+
73
+ test('PL-6: enumerateTasks on plan with no tasks/ dir returns empty array', () => {
74
+ const root = makeSandbox();
75
+ const planPath = path.join(root, 'PLAN.md');
76
+ fs.writeFileSync(planPath, '---\nphase: 1\n---\n');
77
+ const result = plan.enumerateTasks(planPath);
78
+ assert.deepEqual(result, []);
79
+ });
80
+
81
+ test('PL-7: parsePlan integration on real 02-01-PLAN.md preserves requirements array', () => {
82
+ const realPlan = path.resolve(
83
+ __dirname,
84
+ '..',
85
+ '.planning',
86
+ 'phases',
87
+ '02-core-lib-atomic-state-primitives',
88
+ '02-01-PLAN.md',
89
+ );
90
+ if (!fs.existsSync(realPlan)) {
91
+
92
+ return;
93
+ }
94
+ const result = plan.parsePlan(realPlan);
95
+ assert.ok(Array.isArray(result.frontmatter.requirements), 'requirements must be an array');
96
+ assert.deepEqual(result.frontmatter.requirements, ['LIB-01']);
97
+ });
98
+
99
+ function mkTask(id, { tier = 'sonnet', deps = [] } = {}) {
100
+ return {
101
+ id,
102
+ frontmatter: { id, status: 'pending', tier, owner: 'claude', depends_on: deps },
103
+ body: '',
104
+ };
105
+ }
106
+
107
+ function mkPlan(tasks) {
108
+ return { frontmatter: {}, tasks };
109
+ }
110
+
111
+ function mulberry32(seed) {
112
+ return function () {
113
+ let t = (seed += 0x6d2b79f5);
114
+ t = Math.imul(t ^ (t >>> 15), t | 1);
115
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
116
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
117
+ };
118
+ }
119
+
120
+ describe('shouldPromoteToTasks (D-18 heuristic)', () => {
121
+ it('returns {promote:false, triggers:[]} for empty tasks', () => {
122
+ assert.deepStrictEqual(shouldPromoteToTasks(mkPlan([])), {
123
+ promote: false,
124
+ triggers: [],
125
+ });
126
+ });
127
+
128
+ it('returns {promote:false, triggers:[]} for a single task with no deps', () => {
129
+ const p = mkPlan([mkTask('T-01')]);
130
+ assert.deepStrictEqual(shouldPromoteToTasks(p), { promote: false, triggers: [] });
131
+ });
132
+
133
+ it('three tasks in one wave (all same tier, no deps) does NOT promote', () => {
134
+
135
+ const p = mkPlan([mkTask('T-01'), mkTask('T-02'), mkTask('T-03')]);
136
+ const r = shouldPromoteToTasks(p);
137
+ assert.deepStrictEqual(r, { promote: false, triggers: [] });
138
+ });
139
+
140
+ it('parallelism trigger fires: 2 parallel roots + 2 dependents (waves.length=2, maxSize=2)', () => {
141
+ const p = mkPlan([
142
+ mkTask('T-01'),
143
+ mkTask('T-02'),
144
+ mkTask('T-03', { deps: ['T-01'] }),
145
+ mkTask('T-04', { deps: ['T-02'] }),
146
+ ]);
147
+ const r = shouldPromoteToTasks(p);
148
+ assert.equal(r.promote, true);
149
+ assert.ok(r.triggers.includes('parallelism'));
150
+ });
151
+
152
+ it('parallelism NOT fired for strictly sequential chain (maxSize < 2)', () => {
153
+ const p = mkPlan([
154
+ mkTask('T-01'),
155
+ mkTask('T-02', { deps: ['T-01'] }),
156
+ mkTask('T-03', { deps: ['T-02'] }),
157
+ ]);
158
+ const r = shouldPromoteToTasks(p);
159
+ assert.ok(!r.triggers.includes('parallelism'));
160
+ });
161
+
162
+ it('mixed-tiers trigger fires when tier cardinality >= 2', () => {
163
+ const p = mkPlan([
164
+ mkTask('T-01', { tier: 'haiku' }),
165
+ mkTask('T-02', { tier: 'opus' }),
166
+ ]);
167
+ const r = shouldPromoteToTasks(p);
168
+ assert.equal(r.promote, true);
169
+ assert.ok(r.triggers.includes('mixed-tiers'));
170
+ });
171
+
172
+ it('mixed-tiers NOT fired when all tasks share a tier', () => {
173
+ const p = mkPlan([
174
+ mkTask('T-01', { tier: 'sonnet' }),
175
+ mkTask('T-02', { tier: 'sonnet' }),
176
+ mkTask('T-03', { tier: 'sonnet' }),
177
+ ]);
178
+ const r = shouldPromoteToTasks(p);
179
+ assert.ok(!r.triggers.includes('mixed-tiers'));
180
+ });
181
+
182
+ it('non-linear-deps trigger fires when any task has depends_on.length >= 2', () => {
183
+ const p = mkPlan([
184
+ mkTask('T-01'),
185
+ mkTask('T-02'),
186
+ mkTask('T-03', { deps: ['T-01', 'T-02'] }),
187
+ ]);
188
+ const r = shouldPromoteToTasks(p);
189
+ assert.equal(r.promote, true);
190
+ assert.ok(r.triggers.includes('non-linear-deps'));
191
+ });
192
+
193
+ it('non-linear-deps NOT fired when every task has depends_on.length <= 1', () => {
194
+ const p = mkPlan([
195
+ mkTask('T-01'),
196
+ mkTask('T-02', { deps: ['T-01'] }),
197
+ mkTask('T-03', { deps: ['T-02'] }),
198
+ ]);
199
+ const r = shouldPromoteToTasks(p);
200
+ assert.ok(!r.triggers.includes('non-linear-deps'));
201
+ });
202
+
203
+ it('combined: all three triggers fire together', () => {
204
+
205
+
206
+
207
+ const p = mkPlan([
208
+ mkTask('T-01', { tier: 'haiku' }),
209
+ mkTask('T-02', { tier: 'opus' }),
210
+ mkTask('T-03', { tier: 'haiku', deps: ['T-01'] }),
211
+ mkTask('T-04', { tier: 'opus', deps: ['T-01', 'T-02'] }),
212
+ ]);
213
+ const r = shouldPromoteToTasks(p);
214
+ assert.equal(r.promote, true);
215
+ assert.deepStrictEqual(
216
+ [...r.triggers].sort(),
217
+ ['mixed-tiers', 'non-linear-deps', 'parallelism'],
218
+ );
219
+ });
220
+
221
+ it('purity property: 100 random deterministic plans produce byte-identical output on repeat', () => {
222
+ const rand = mulberry32(12345);
223
+ const TIERS = ['haiku', 'sonnet', 'opus'];
224
+ for (let trial = 0; trial < 100; trial++) {
225
+ const n = Math.floor(rand() * 11);
226
+ const tasks = [];
227
+ for (let i = 0; i < n; i++) {
228
+ const id = `T-${String(i + 1).padStart(2, '0')}`;
229
+ const tier = TIERS[Math.floor(rand() * TIERS.length)];
230
+ const deps = [];
231
+
232
+ const numDeps = Math.min(i, Math.floor(rand() * 3));
233
+ const priorIds = tasks.map((t) => t.id);
234
+
235
+ const shuffled = [...priorIds];
236
+ for (let k = shuffled.length - 1; k > 0; k--) {
237
+ const j = Math.floor(rand() * (k + 1));
238
+ [shuffled[k], shuffled[j]] = [shuffled[j], shuffled[k]];
239
+ }
240
+ for (let d = 0; d < numDeps && d < shuffled.length; d++) {
241
+ deps.push(shuffled[d]);
242
+ }
243
+ tasks.push(mkTask(id, { tier, deps }));
244
+ }
245
+ const p = mkPlan(tasks);
246
+ const a = shouldPromoteToTasks(p);
247
+ const b = shouldPromoteToTasks(p);
248
+ assert.equal(
249
+ JSON.stringify(a),
250
+ JSON.stringify(b),
251
+ `purity violated at trial ${trial} with ${n} tasks`,
252
+ );
253
+ }
254
+ });
255
+
256
+ it('edge case: tasks missing frontmatter.tier — all undefined means tierSet size=1, mixed-tiers NOT fired', () => {
257
+
258
+ const t1 = { id: 'T-01', frontmatter: { id: 'T-01', status: 'pending', owner: 'c', depends_on: [] }, body: '' };
259
+ const t2 = { id: 'T-02', frontmatter: { id: 'T-02', status: 'pending', owner: 'c', depends_on: [] }, body: '' };
260
+ const r = shouldPromoteToTasks(mkPlan([t1, t2]));
261
+ assert.ok(!r.triggers.includes('mixed-tiers'));
262
+ });
263
+ });
@@ -0,0 +1,95 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const { mutateState, readState } = require('./state.cjs');
4
+ const { parseRoadmap } = require('./roadmap.cjs');
5
+ const { findPhaseDir } = require('./phase.cjs');
6
+ const { listPlans } = require('./plan.cjs');
7
+ const { NubosPilotError } = require('./core.cjs');
8
+
9
+ function _zeroProgress() {
10
+ return { total_phases: 0, completed_phases: 0, total_plans: 0, completed_plans: 0, percent: 0 };
11
+ }
12
+
13
+ function _phaseDirFor(n, cwd) {
14
+ try { return findPhaseDir(n, cwd); }
15
+ catch (err) {
16
+ if (err && err.code === 'not-in-project') return null;
17
+ return null;
18
+ }
19
+ }
20
+
21
+ function _computeFromRoadmap(cwd) {
22
+ let roadmap;
23
+ try { roadmap = parseRoadmap(cwd); }
24
+ catch (err) {
25
+ if (err && err.code === 'roadmap-parse-error') return _zeroProgress();
26
+ throw err;
27
+ }
28
+ const phases = roadmap.phases || [];
29
+ let totalPhases = phases.length;
30
+ let completedPhases = 0;
31
+ let totalPlans = 0;
32
+ let completedPlans = 0;
33
+ for (const ph of phases) {
34
+ if (ph.complete) completedPhases += 1;
35
+
36
+ const dir = _phaseDirFor(Number(ph.number), cwd);
37
+ let plansCount = 0;
38
+ if (dir) {
39
+
40
+ let onDisk = [];
41
+ try { onDisk = listPlans(dir); } catch { onDisk = []; }
42
+ plansCount = onDisk.length;
43
+ }
44
+ if (plansCount === 0) {
45
+ plansCount = Array.isArray(ph.plans) ? ph.plans.length : 0;
46
+ }
47
+ totalPlans += plansCount;
48
+
49
+ if (Array.isArray(ph.plans)) {
50
+ for (const p of ph.plans) if (p && p.complete) completedPlans += 1;
51
+ }
52
+ }
53
+ const percent = totalPhases === 0 ? 0 : Math.round((completedPhases / totalPhases) * 100);
54
+ return {
55
+ total_phases: totalPhases,
56
+ completed_phases: completedPhases,
57
+ total_plans: totalPlans,
58
+ completed_plans: completedPlans,
59
+ percent,
60
+ };
61
+ }
62
+
63
+ function recomputeProgress(cwd = process.cwd()) {
64
+ const computed = _computeFromRoadmap(cwd);
65
+ try {
66
+ mutateState((cur) => {
67
+ return { ...cur, frontmatter: { ...cur.frontmatter, progress: computed } };
68
+ }, cwd);
69
+ } catch (err) {
70
+
71
+ if (err && err.code && String(err.code).startsWith('ENOENT')) return computed;
72
+ if (err && err.message && /ENOENT/.test(err.message)) return computed;
73
+ throw err;
74
+ }
75
+ return computed;
76
+ }
77
+
78
+ function readProgress(cwd = process.cwd()) {
79
+ try {
80
+ const s = readState(cwd);
81
+ const p = s && s.frontmatter && s.frontmatter.progress;
82
+ if (p && typeof p === 'object') return p;
83
+ return _zeroProgress();
84
+ } catch (err) {
85
+ if (err && err.code && String(err.code).startsWith('ENOENT')) return _zeroProgress();
86
+ if (err && err.message && /ENOENT/.test(err.message)) return _zeroProgress();
87
+ throw new NubosPilotError(
88
+ 'progress-read-error',
89
+ `readProgress failed: ${err && err.message}`,
90
+ { cause: err && err.code },
91
+ );
92
+ }
93
+ }
94
+
95
+ module.exports = { recomputeProgress, readProgress };
@@ -0,0 +1,116 @@
1
+ const { test, afterEach } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+
7
+ const progress = require('./progress.cjs');
8
+
9
+ const sandboxes = [];
10
+
11
+ function makeSandbox() {
12
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'np-progress-test-'));
13
+ fs.mkdirSync(path.join(root, '.nubos-pilot'));
14
+ sandboxes.push(root);
15
+ return root;
16
+ }
17
+
18
+ function seedState(root, progressBlock) {
19
+ const lines = ['---', 'schema_version: 2', 'milestone: v1.0', 'current_phase: 1',
20
+ 'current_plan: null', 'current_task: null', 'last_updated: 2026-04-15'];
21
+ if (progressBlock) {
22
+ lines.push('progress:');
23
+ for (const k of Object.keys(progressBlock)) lines.push(` ${k}: ${progressBlock[k]}`);
24
+ }
25
+ lines.push('---', '', '# State\n');
26
+ fs.writeFileSync(path.join(root, '.nubos-pilot', 'STATE.md'), lines.join('\n'));
27
+ }
28
+
29
+ function seedRoadmap(root, phases) {
30
+ const YAML = require('yaml');
31
+ fs.writeFileSync(
32
+ path.join(root, '.nubos-pilot', 'roadmap.yaml'),
33
+ YAML.stringify({ schema_version: 1, milestones: [{ id: 'v1.0', name: 'm', phases }] }, { indent: 2 }),
34
+ );
35
+ }
36
+
37
+ function seedPhaseDir(root, n, slug, planIds) {
38
+ const padded = String(n).padStart(2, '0');
39
+ const dir = path.join(root, '.nubos-pilot', 'phases', padded + '-' + slug);
40
+ fs.mkdirSync(dir, { recursive: true });
41
+ for (const id of planIds || []) {
42
+ fs.writeFileSync(path.join(dir, id + '-PLAN.md'), '---\nphase: ' + n + '\n---\nbody\n');
43
+ }
44
+ }
45
+
46
+ afterEach(() => {
47
+ while (sandboxes.length) {
48
+ const p = sandboxes.pop();
49
+ try { fs.rmSync(p, { recursive: true, force: true }); } catch {}
50
+ }
51
+ });
52
+
53
+ test('P1: recomputeProgress writes aggregated block to STATE.md', () => {
54
+ const root = makeSandbox();
55
+ seedState(root);
56
+ seedRoadmap(root, [
57
+ { number: 1, name: 'One', slug: 'one', status: 'done', plans: [{ id: '01-01', complete: true }] },
58
+ { number: 2, name: 'Two', slug: 'two', status: 'pending', plans: [{ id: '02-01', complete: false }, { id: '02-02', complete: false }] },
59
+ ]);
60
+ seedPhaseDir(root, 1, 'one', ['01-01']);
61
+ seedPhaseDir(root, 2, 'two', ['02-01', '02-02']);
62
+ const out = progress.recomputeProgress(root);
63
+ assert.equal(out.total_phases, 2);
64
+ assert.equal(out.completed_phases, 1);
65
+ assert.equal(out.total_plans, 3);
66
+ assert.equal(out.completed_plans, 1);
67
+
68
+ assert.ok(typeof out.percent === 'number');
69
+
70
+ const raw = fs.readFileSync(path.join(root, '.nubos-pilot', 'STATE.md'), 'utf-8');
71
+ assert.match(raw, /progress:/);
72
+ assert.match(raw, /total_phases: 2/);
73
+ });
74
+
75
+ test('P2: readProgress returns persisted block (O(1) read, no recomputation)', () => {
76
+ const root = makeSandbox();
77
+ seedState(root, {
78
+ total_phases: 7, completed_phases: 3, total_plans: 12, completed_plans: 5, percent: 42,
79
+ });
80
+ const out = progress.readProgress(root);
81
+ assert.equal(out.total_phases, 7);
82
+ assert.equal(out.completed_phases, 3);
83
+ assert.equal(out.total_plans, 12);
84
+ assert.equal(out.completed_plans, 5);
85
+ assert.equal(out.percent, 42);
86
+ });
87
+
88
+ test('P3: missing phase dir = zero-contribution (Pattern S-7)', () => {
89
+ const root = makeSandbox();
90
+ seedState(root);
91
+ seedRoadmap(root, [
92
+ { number: 1, name: 'One', slug: 'one', status: 'pending', plans: [{ id: '01-01', complete: false }] },
93
+
94
+ { number: 2, name: 'Two', slug: 'two', status: 'pending', plans: [] },
95
+ ]);
96
+ seedPhaseDir(root, 1, 'one', ['01-01']);
97
+ const out = progress.recomputeProgress(root);
98
+ assert.equal(out.total_phases, 2);
99
+ assert.equal(out.completed_phases, 0);
100
+
101
+ assert.equal(out.total_plans, 1);
102
+ assert.equal(out.completed_plans, 0);
103
+ });
104
+
105
+ test('P4: recomputeProgress uses mutateState (STATE.md updated atomically)', () => {
106
+ const root = makeSandbox();
107
+ seedState(root);
108
+ seedRoadmap(root, [
109
+ { number: 1, name: 'One', slug: 'one', status: 'done', plans: [{ id: '01-01', complete: true }] },
110
+ ]);
111
+ seedPhaseDir(root, 1, 'one', ['01-01']);
112
+ progress.recomputeProgress(root);
113
+ const after = progress.readProgress(root);
114
+ assert.equal(after.total_phases, 1);
115
+ assert.equal(after.completed_phases, 1);
116
+ });
@@ -0,0 +1,61 @@
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-researcher.md');
10
+ const BODY = fs.readFileSync(AGENT_PATH, 'utf-8');
11
+
12
+ function withSandbox(fn) {
13
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'np-rc-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-researcher.md'), BODY, 'utf-8');
17
+ try { return fn(root); }
18
+ finally { fs.rmSync(root, { recursive: true, force: true }); }
19
+ }
20
+
21
+ test('RC-1: loadAgent("np-researcher") returns tier=sonnet', () => {
22
+ withSandbox((sb) => {
23
+ const fm = loadAgent('np-researcher', sb);
24
+ assert.equal(fm.tier, 'sonnet');
25
+ assert.equal(fm.name, 'np-researcher');
26
+ });
27
+ });
28
+
29
+ test('RC-2: tools list contains the required web+MCP surface', () => {
30
+ withSandbox((sb) => {
31
+ const fm = loadAgent('np-researcher', sb);
32
+ assert.equal(typeof fm.tools, 'string');
33
+ for (const needle of ['WebSearch', 'WebFetch', 'mcp__context7__*', 'mcp__firecrawl__*', 'mcp__exa__*']) {
34
+ assert.ok(
35
+ fm.tools.includes(needle),
36
+ 'tools string missing "' + needle + '" — got: ' + fm.tools,
37
+ );
38
+ }
39
+ });
40
+ });
41
+
42
+ test('RC-3: body contains verbatim offline-confirm German prompt (D-21)', () => {
43
+ const verbatim = 'Kein Web-/Context7-Zugriff verfügbar — mit lokalen Quellen (Repo + Prior-Phase-CONTEXT.md) fortfahren?';
44
+ assert.ok(BODY.includes(verbatim), 'body missing verbatim D-21 prompt');
45
+ });
46
+
47
+ test('RC-4: body contains "## Research Coverage" literal heading (D-22)', () => {
48
+ assert.ok(
49
+ /^## Research Coverage$/m.test(BODY),
50
+ 'body missing "## Research Coverage" heading on its own line',
51
+ );
52
+ });
53
+
54
+ test('RC-6: frontmatter contains no FORBIDDEN field', () => {
55
+ withSandbox((sb) => {
56
+ const fm = loadAgent('np-researcher', sb);
57
+ for (const f of FORBIDDEN) {
58
+ assert.equal(fm[f], undefined, 'FORBIDDEN field present: ' + f);
59
+ }
60
+ });
61
+ });