ultra-builder-pro-cli 5.0.1 → 6.0.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 (159) hide show
  1. package/CHANGELOG.md +103 -3
  2. package/README.md +67 -478
  3. package/README.zh-CN.md +49 -422
  4. package/adapters/_shared/frontmatter.cjs +1 -18
  5. package/adapters/_shared/plugin-core.cjs +17 -83
  6. package/adapters/_shared/runtime-assets.cjs +6 -120
  7. package/adapters/claude.js +8 -24
  8. package/adapters/codex.js +13 -32
  9. package/bin/install.js +35 -57
  10. package/docs/PLUGIN-ISOLATION-CONTRACT.md +13 -101
  11. package/docs/examples/drive/claude-reviewer.sh +53 -0
  12. package/docs/examples/drive/claude-writer.sh +43 -0
  13. package/engine/FORMATS.md +1051 -0
  14. package/engine/checks.cjs +139 -0
  15. package/engine/cli.cjs +148 -0
  16. package/engine/completion.cjs +48 -0
  17. package/engine/context.cjs +94 -0
  18. package/engine/drive.cjs +220 -0
  19. package/engine/expect-worker.cjs +5 -0
  20. package/engine/expect.cjs +33 -0
  21. package/engine/formats.cjs +189 -0
  22. package/engine/git-state.cjs +112 -0
  23. package/engine/ledger.cjs +205 -0
  24. package/engine/loading.cjs +28 -0
  25. package/engine/merge.cjs +273 -0
  26. package/engine/milestone.cjs +89 -0
  27. package/engine/plan.cjs +232 -0
  28. package/engine/project.cjs +5 -0
  29. package/engine/signals.cjs +3 -0
  30. package/engine/states.cjs +175 -0
  31. package/hooks/session-start.cjs +54 -0
  32. package/package.json +17 -52
  33. package/skills/ultra/SKILL.md +395 -0
  34. package/.ultra-template/changes/abandoned/.gitkeep +0 -0
  35. package/.ultra-template/changes/active/.gitkeep +0 -1
  36. package/.ultra-template/changes/archive/.gitkeep +0 -1
  37. package/.ultra-template/contexts/TEMPLATE.md +0 -135
  38. package/.ultra-template/decisions/.gitkeep +0 -1
  39. package/.ultra-template/evidence/.gitkeep +0 -1
  40. package/.ultra-template/north-star.md +0 -52
  41. package/.ultra-template/project-brief.md +0 -28
  42. package/.ultra-template/research/.gitkeep +0 -1
  43. package/.ultra-template/specs/architecture.md +0 -81
  44. package/.ultra-template/specs/discovery.md +0 -74
  45. package/.ultra-template/specs/product.md +0 -80
  46. package/.ultra-template/specs/research-distillate.md +0 -57
  47. package/.ultra-template/tasks.json +0 -4
  48. package/.ultra-template/test-report.json +0 -44
  49. package/adapters/_shared/host-profile.cjs +0 -5
  50. package/adapters/grok.js +0 -242
  51. package/adapters/kimi.js +0 -232
  52. package/adapters/opencode.js +0 -408
  53. package/adapters/zcode.js +0 -375
  54. package/bin/delegate-worker.cjs +0 -5
  55. package/bin/delegate.cjs +0 -5
  56. package/docs/ARCHITECTURE.md +0 -274
  57. package/docs/ARTIFACT-AUTHORITY.md +0 -305
  58. package/docs/DECISIONS.md +0 -229
  59. package/docs/PHILOSOPHY.md +0 -261
  60. package/docs/RUNTIME-COMPAT-MATRIX.md +0 -316
  61. package/docs/SKILL-AUTHORING.md +0 -113
  62. package/docs/WORKFLOW-LIFECYCLE.md +0 -350
  63. package/docs/evals/adversarial-review-2026-08-14.md +0 -84
  64. package/docs/evals/ultra-5-0-journey-results.md +0 -62
  65. package/docs/evals/ultra-5-0-live-acp-conformance.md +0 -93
  66. package/docs/evals/ultra-5-0-route-journeys.md +0 -145
  67. package/docs/evals/zcode-automation-2026-08-14.md +0 -103
  68. package/hooks/README.md +0 -52
  69. package/hooks/_common.py +0 -1779
  70. package/hooks/adapters/codex.py +0 -295
  71. package/hooks/adapters/grok.py +0 -134
  72. package/hooks/adapters/kimi.py +0 -156
  73. package/hooks/adapters/zcode.py +0 -227
  74. package/hooks/block_dangerous_commands.py +0 -201
  75. package/hooks/compact_context.py +0 -224
  76. package/hooks/mid_workflow_recall.py +0 -47
  77. package/hooks/post_edit_guard.py +0 -215
  78. package/hooks/session_context.py +0 -741
  79. package/skills/ultra-change/SKILL.md +0 -165
  80. package/skills/ultra-change/references/change-contract.md +0 -199
  81. package/skills/ultra-change/references/execution-grant.md +0 -109
  82. package/skills/ultra-change/references/primary-transfer.md +0 -273
  83. package/skills/ultra-change/scripts/validate_primary_transfer.cjs +0 -1928
  84. package/skills/ultra-delegate/SKILL.md +0 -195
  85. package/skills/ultra-delegate/patches/zcode-acp-0.1.0-zcode-0.16.5.patch +0 -184
  86. package/skills/ultra-delegate/references/delegation-contract.md +0 -138
  87. package/skills/ultra-delegate/scripts/acp-conformance.cjs +0 -287
  88. package/skills/ultra-delegate/scripts/acp-driver.cjs +0 -762
  89. package/skills/ultra-delegate/scripts/agent-execution-driver.cjs +0 -320
  90. package/skills/ultra-delegate/scripts/cli-driver.cjs +0 -61
  91. package/skills/ultra-delegate/scripts/delegate-profiles/kimi-read-only.md +0 -15
  92. package/skills/ultra-delegate/scripts/delegate-profiles/kimi-write.md +0 -17
  93. package/skills/ultra-delegate/scripts/delegate-worker.cjs +0 -328
  94. package/skills/ultra-delegate/scripts/delegate.cjs +0 -690
  95. package/skills/ultra-delegate/scripts/delegate_wait.py +0 -86
  96. package/skills/ultra-delegate/scripts/host-profile.cjs +0 -267
  97. package/skills/ultra-deliver/SKILL.md +0 -194
  98. package/skills/ultra-deliver/references/baseline-reconciliation.md +0 -41
  99. package/skills/ultra-dev/SKILL.md +0 -265
  100. package/skills/ultra-dev/references/debugging.md +0 -22
  101. package/skills/ultra-domain-modeling/SKILL.md +0 -79
  102. package/skills/ultra-grilling/SKILL.md +0 -121
  103. package/skills/ultra-grilling/references/reframing.md +0 -76
  104. package/skills/ultra-init/SKILL.md +0 -143
  105. package/skills/ultra-init/references/brownfield-adoption.md +0 -32
  106. package/skills/ultra-init/scripts/init_project.cjs +0 -1031
  107. package/skills/ultra-plan/SKILL.md +0 -160
  108. package/skills/ultra-plan/references/task-evidence-v2.md +0 -300
  109. package/skills/ultra-plan/scripts/validate_task_evidence.cjs +0 -859
  110. package/skills/ultra-research/SKILL.md +0 -167
  111. package/skills/ultra-research/references/00-problem-validation.md +0 -36
  112. package/skills/ultra-research/references/01-opportunity-discovery.md +0 -33
  113. package/skills/ultra-research/references/02-market-assessment.md +0 -31
  114. package/skills/ultra-research/references/03-alternatives.md +0 -31
  115. package/skills/ultra-research/references/04-product-strategy.md +0 -41
  116. package/skills/ultra-research/references/05-assumptions-validation.md +0 -31
  117. package/skills/ultra-research/references/10-user-personas.md +0 -30
  118. package/skills/ultra-research/references/11-user-scenarios.md +0 -28
  119. package/skills/ultra-research/references/20-user-stories.md +0 -31
  120. package/skills/ultra-research/references/21-features-scope.md +0 -38
  121. package/skills/ultra-research/references/22-success-metrics.md +0 -33
  122. package/skills/ultra-research/references/30-architecture-context.md +0 -30
  123. package/skills/ultra-research/references/31-solution-strategy.md +0 -28
  124. package/skills/ultra-research/references/32-building-blocks.md +0 -28
  125. package/skills/ultra-research/references/40-deployment.md +0 -29
  126. package/skills/ultra-research/references/41-quality-risks.md +0 -28
  127. package/skills/ultra-research/references/99-synthesis.md +0 -59
  128. package/skills/ultra-research/references/north-star-v2.md +0 -168
  129. package/skills/ultra-research/references/wayfinding.md +0 -50
  130. package/skills/ultra-research/scripts/validate_north_star.cjs +0 -1174
  131. package/skills/ultra-review/SKILL.md +0 -275
  132. package/skills/ultra-review/references/adversarial-evaluation.md +0 -31
  133. package/skills/ultra-review/references/code.md +0 -17
  134. package/skills/ultra-review/references/comments.md +0 -12
  135. package/skills/ultra-review/references/design.md +0 -12
  136. package/skills/ultra-review/references/errors.md +0 -13
  137. package/skills/ultra-review/references/review-modes.md +0 -29
  138. package/skills/ultra-review/references/spec.md +0 -18
  139. package/skills/ultra-review/references/tests.md +0 -12
  140. package/skills/ultra-review/references/unified-schema.md +0 -284
  141. package/skills/ultra-review/references/worker-packet.md +0 -183
  142. package/skills/ultra-review/scripts/review_wait.py +0 -2943
  143. package/skills/ultra-status/SKILL.md +0 -209
  144. package/skills/ultra-status/references/route-contract.md +0 -100
  145. package/skills/ultra-tdd/SKILL.md +0 -91
  146. package/skills/ultra-tdd/references/templates/README.md +0 -20
  147. package/skills/ultra-tdd/references/templates/feature-flag-default-audit.sh +0 -79
  148. package/skills/ultra-tdd/references/templates/persistence-real.ts +0 -78
  149. package/skills/ultra-tdd/references/templates/testcontainer-postgres.py +0 -78
  150. package/skills/ultra-tdd/references/templates/testcontainer-postgres.ts +0 -73
  151. package/skills/ultra-tdd/references/templates/vertical-slice.ts +0 -66
  152. package/skills/ultra-tdd/references/test-execution.md +0 -23
  153. package/skills/ultra-test/SKILL.md +0 -244
  154. package/skills/ultra-test/references/export-syntax.md +0 -18
  155. package/skills/ultra-test/scripts/validate_review_transport.cjs +0 -549
  156. package/skills/ultra-test/scripts/worktree_digest.cjs +0 -576
  157. package/skills/ultra-think/SKILL.md +0 -56
  158. package/skills/ultra-think/references/autonomy-boundary.md +0 -48
  159. package/skills/ultra-think/references/interaction-boundary.md +0 -42
@@ -0,0 +1,273 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { randomUUID } = require('node:crypto');
6
+ const { gitAsync, bindGit, mergeOptions } = require('./git-state.cjs');
7
+ const { problem } = require('./formats.cjs');
8
+ const { status } = require('./states.cjs');
9
+ const { execute } = require('./checks.cjs');
10
+ const { acquireLock } = require('./ledger.cjs');
11
+ const { load } = require('./loading.cjs');
12
+ const { planProblems } = require('./plan.cjs');
13
+ const INTERRUPTS = require('./signals.cjs');
14
+
15
+ const mergeGit = (root, args, options) => gitAsync(root, [...mergeOptions, ...args], options);
16
+ const passing = (record) => record.outcome === 'pass' && !record.dirty;
17
+ const text = (value) => value.toString('utf8').trim();
18
+ const paths = (value) => value.toString('utf8').split('\0').filter(Boolean);
19
+ const firstError = (error) => String(error.stderr || error.message).split('\n')
20
+ .find((line) => line.trim()) ?? 'Git failed';
21
+
22
+ async function resolveCommit(run, root, ref) {
23
+ try { return text(await run(root, ['rev-parse', '--verify', `${ref}^{commit}`])); }
24
+ catch (error) {
25
+ if (error.exitCode) throw error;
26
+ throw new Error(`Cannot resolve commit ${ref}. Use a valid Git commit or branch.`);
27
+ }
28
+ }
29
+
30
+ async function ancestor(run, root, before, after) {
31
+ try { await run(root, ['merge-base', '--is-ancestor', before, after]); return true; }
32
+ catch (error) { if (error.code === 1) return false; throw error; }
33
+ }
34
+
35
+ async function mergeInto(run, root, commit, branch) {
36
+ try {
37
+ await run(root, ['merge', '--no-ff', '--no-edit', '-m',
38
+ `Merge ${branch} (ubp merge gate)`, commit]);
39
+ return [];
40
+ } catch (error) {
41
+ if (error.exitCode) throw error;
42
+ const conflicts = paths(await run(root, ['diff', '--name-only', '--diff-filter=U', '-z']));
43
+ if (!conflicts.length) throw error;
44
+ return conflicts;
45
+ }
46
+ }
47
+
48
+ function makeWritable(directory) {
49
+ const entry = fs.lstatSync(directory, { throwIfNoEntry: false });
50
+ if (!entry?.isDirectory()) return;
51
+ fs.chmodSync(directory, entry.mode | 0o700);
52
+ for (const child of fs.readdirSync(directory, { withFileTypes: true })) {
53
+ if (child.isDirectory()) makeWritable(path.join(directory, child.name));
54
+ }
55
+ }
56
+
57
+ async function removeTree(root, directory, warnings) {
58
+ try { await mergeGit(root, ['worktree', 'remove', '--force', directory]); return; }
59
+ catch { /* A check may have made directories read-only, or left an unregistered tree. */ }
60
+ try {
61
+ makeWritable(directory);
62
+ fs.rmSync(directory, { recursive: true, force: true });
63
+ } catch (error) { warnings.push(`Could not remove ${directory}: ${error.message}`); }
64
+ try {
65
+ const registered = paths(await mergeGit(root, ['worktree', 'list', '--porcelain', '-z']))
66
+ .includes(`worktree ${directory}`);
67
+ if (registered) await mergeGit(root, ['worktree', 'remove', '--force', directory]);
68
+ } catch (error) { warnings.push(`Could not unregister ${directory}: ${firstError(error)}`); }
69
+ }
70
+
71
+ async function dirtyUltra(run, root) {
72
+ const changed = paths(await run(root, ['diff', '--name-only', '-z', '--', '.ultra']));
73
+ const staged = paths(await run(root,
74
+ ['diff', '--cached', '--name-only', '-z', 'HEAD', '--', '.ultra']));
75
+ const untracked = paths(await run(root,
76
+ ['ls-files', '--others', '--exclude-standard', '-z', '--', '.ultra']));
77
+ return [...new Set([...changed, ...staged, ...untracked])].filter((file) =>
78
+ file !== '.ultra/.runtime' && !file.startsWith('.ultra/.runtime/'));
79
+ }
80
+
81
+ async function firstBad(run, probe, root, head, branch, commits, check, greenAtHead) {
82
+ const reset = async () => {
83
+ await run(root, ['reset', '--hard', head]);
84
+ await run(root, ['clean', '-fdx']);
85
+ };
86
+ if (!greenAtHead) return 'not green at HEAD before the merge';
87
+ let left = 0;
88
+ let right = commits.length - 1;
89
+ while (left < right) {
90
+ const middle = Math.floor((left + right) / 2);
91
+ await reset();
92
+ const conflicts = await mergeInto(run, root, commits[middle], branch);
93
+ const green = !conflicts.length && passing(await probe(check));
94
+ if (green) left = middle + 1;
95
+ else right = middle;
96
+ }
97
+ const sha = commits[left];
98
+ const subject = text(await run(root, ['show', '-s', '--format=%s', sha]));
99
+ return `first bad commit ${sha} ${subject}`;
100
+ }
101
+
102
+ async function merge(project, branch, expected) {
103
+ if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/iu.test(expected)) {
104
+ throw new Error('--expect-head requires a full commit id (40 or 64 hex digits).');
105
+ }
106
+ const root = project.root;
107
+ const warnings = [];
108
+ const cleanupWarnings = [];
109
+ const refused = (code, message, hint) => ({ ok: false, merged: null,
110
+ problems: [problem(code, message, hint)], warnings });
111
+ const moved = () => refused('head-moved', 'HEAD differs from --expect-head',
112
+ 'Review the current HEAD and retry with its full commit id.');
113
+ const release = acquireLock(root, 'merge');
114
+ if (!release) return refused('merge-busy', 'Another merge holds merge.lock',
115
+ 'Retry after the running merge finishes.');
116
+ let temporary;
117
+ let unbind;
118
+ let interrupted = 0;
119
+ let forwarding = false;
120
+ const controller = new AbortController();
121
+ const ensureRunning = () => {
122
+ if (interrupted) {
123
+ throw Object.assign(new Error('Merge interrupted.'), { exitCode: interrupted });
124
+ }
125
+ };
126
+ const handlers = Object.entries(INTERRUPTS).map(([signal, exitCode]) => [signal, () => {
127
+ if (!forwarding) { interrupted = exitCode; controller.abort(); }
128
+ }]);
129
+ const run = async (directory, args) => {
130
+ ensureRunning();
131
+ try {
132
+ const result = await mergeGit(directory, args, forwarding
133
+ ? { detached: true } : { signal: controller.signal });
134
+ ensureRunning();
135
+ return result;
136
+ } catch (error) {
137
+ interrupted ||= INTERRUPTS[error.signal] ?? 0;
138
+ ensureRunning();
139
+ throw error;
140
+ }
141
+ };
142
+ for (const [signal, handler] of handlers) process.on(signal, handler);
143
+ try {
144
+ const tip = await resolveCommit(run, root, branch);
145
+ const head = await resolveCommit(run, root, expected);
146
+ const directory = path.join(root, '.ultra', '.runtime', 'merge');
147
+ fs.mkdirSync(directory, { recursive: true });
148
+ const registered = paths(await run(root, ['worktree', 'list', '--porcelain', '-z']))
149
+ .filter((entry) => entry.startsWith('worktree ')).map((entry) => entry.slice(9))
150
+ .filter((entry) => entry.startsWith(`${directory}${path.sep}`));
151
+ const leftovers = new Set([...registered,
152
+ ...fs.readdirSync(directory).map((entry) => path.join(directory, entry))]);
153
+ for (const entry of leftovers) {
154
+ await removeTree(root, entry, cleanupWarnings);
155
+ ensureRunning();
156
+ }
157
+ const state = status(project);
158
+ if (await resolveCommit(run, root, 'HEAD') !== head) return moved();
159
+ const dirty = await dirtyUltra(run, root);
160
+ if (state.dirty || dirty.length) return refused('dirty-tree',
161
+ dirty.length ? `Uncommitted code or .ultra files: ${dirty.join(', ')}` : 'Uncommitted code',
162
+ 'Commit code and .ultra/ first, then retry.');
163
+ if (state.gate !== 'pass') return refused('plan-gate', `Plan gate is ${state.gate}`,
164
+ 'Fix plan problems and rerun gate.');
165
+ if (await ancestor(run, root, tip, head)) return refused('nothing-to-merge',
166
+ `${branch} is already in HEAD`, 'Choose a branch with commits not yet merged.');
167
+ const base = text(await run(root, ['merge-base', head, tip]));
168
+ temporary = path.join(directory, randomUUID());
169
+ await run(root, ['worktree', 'add', '--detach', temporary, head]);
170
+ unbind = bindGit(temporary);
171
+ const conflicts = await mergeInto(run, temporary, tip, branch);
172
+ if (conflicts.length) return refused('merge-conflict',
173
+ `Conflicting paths: ${conflicts.join(', ')}`,
174
+ conflicts.some((file) => file.startsWith('.ultra/ledger/'))
175
+ ? 'Add .ultra/ledger/*.jsonl merge=union to .gitattributes on the main line.'
176
+ : 'Resolve conflicts on the branch and retry.');
177
+ const merged = await resolveCommit(run, temporary, 'HEAD');
178
+ const changes = paths(await run(root,
179
+ ['diff-tree', '-r', '--no-renames', '-z', head, merged, '--', ':(icase).ultra']));
180
+ const problems = [];
181
+ for (let i = 0; i < changes.length; i += 2) {
182
+ const [, mode, , , kind] = changes[i].split(' ');
183
+ const file = changes[i + 1];
184
+ const allowed = ['.ultra/ledger/evidence.jsonl', '.ultra/ledger/nodes.jsonl',
185
+ '.ultra/rulings.md'].includes(file) || file.startsWith('.ultra/records/');
186
+ if (!allowed || !['A', 'M'].includes(kind) || !['100644', '100755'].includes(mode)) {
187
+ problems.push(problem('truth-changed', `${branch} changes ${file}`,
188
+ 'Have the planner or controller change protected files on the main line.'));
189
+ }
190
+ }
191
+ if (problems.length) return { ok: false, merged: null, problems, warnings };
192
+ const mergedProject = load(temporary);
193
+ const mergedProblems = planProblems(mergedProject);
194
+ if (mergedProblems.length) return refused('merged-gate',
195
+ `Merged plan problems: ${mergedProblems.map((p) => p.code).join(', ')}`,
196
+ 'Repair plan problems on the branch and retry.');
197
+ const closed = project.milestones
198
+ .filter((m) => project.closures.some((c) => c.milestone === m.id))
199
+ .flatMap((m) => m.requirements);
200
+ const current = project.milestones.find((m) => m.id === state.milestone)?.requirements ?? [];
201
+ const finished = project.nodes
202
+ .filter((n) => mergedProject.completions.some((c) => c.node === n.id));
203
+ const probe = async (check) => {
204
+ ensureRunning();
205
+ const record = await execute({ ...project, root: temporary }, check);
206
+ ensureRunning();
207
+ return record;
208
+ };
209
+ const failing = [];
210
+ for (const check of project.checks) {
211
+ const record = await probe(check);
212
+ if (!passing(record)) failing.push({ check, record });
213
+ }
214
+ if (failing.length) {
215
+ const history = await run(root,
216
+ ['rev-list', '--first-parent', '--reverse', `${base}..${tip}`]);
217
+ const commits = text(history).split('\n').filter(Boolean);
218
+ for (const { check, record } of failing) {
219
+ const needed = finished.filter((n) => n.covers.some((r) => check.covers.includes(r)));
220
+ const fixed = Object.hasOwn(check, 'fix') || check.covers.some((r) => closed.includes(r))
221
+ || needed.some((n) => project.completions.some((c) => c.node === n.id));
222
+ const frontier = check.covers.some((r) => current.includes(r));
223
+ if (!fixed && !needed.length && !frontier) continue;
224
+ await run(temporary, ['reset', '--hard', head]);
225
+ await run(temporary, ['clean', '-fdx']);
226
+ const green = passing(await probe(check));
227
+ if (!fixed && !needed.length && !green) {
228
+ warnings.push(`${check.id} is not green after the merge and was not green before it; `
229
+ + 'no finished work needs it');
230
+ continue;
231
+ }
232
+ const bad = !fixed && needed.length && (!frontier || !green)
233
+ ? `the branch marks ${needed[0].id} done`
234
+ : await firstBad(run, probe, temporary, head, branch, commits, check, green);
235
+ const reason = record.dirty && record.outcome === 'pass'
236
+ ? 'dirty-run' : record.error_line || record.reason;
237
+ problems.push(problem('check-not-green',
238
+ `${check.id} is not green: ${reason}; ${bad}`,
239
+ check.fix ?? 'Repair the failing check on the branch and retry.'));
240
+ }
241
+ }
242
+ ensureRunning();
243
+ if (problems.length) return { ok: false, merged: null, problems, warnings };
244
+ if (await resolveCommit(run, root, 'HEAD') !== head) return moved();
245
+ forwarding = true;
246
+ try { await run(root, ['merge', '--ff-only', merged]); }
247
+ catch (error) {
248
+ if (await resolveCommit(run, root, 'HEAD') !== head) return moved();
249
+ return refused('fast-forward-failed', firstError(error),
250
+ 'Restore the main tree and commit .ultra/ before retrying.');
251
+ }
252
+ return { ok: true, merged, problems: [], warnings };
253
+ } catch (error) {
254
+ interrupted ||= INTERRUPTS[error.signal] ?? 0;
255
+ ensureRunning();
256
+ throw error;
257
+ } finally {
258
+ try { if (temporary) await removeTree(root, temporary, cleanupWarnings); }
259
+ finally {
260
+ warnings.push(...cleanupWarnings);
261
+ unbind?.();
262
+ release();
263
+ const detach = () => {
264
+ for (const [signal, handler] of handlers) process.off(signal, handler);
265
+ };
266
+ // A completed fast-forward stays protected while the CLI flushes its result.
267
+ if (forwarding) process.once('exit', detach);
268
+ else detach();
269
+ }
270
+ }
271
+ }
272
+
273
+ module.exports = { merge };
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+
3
+ const path = require('node:path');
4
+ const { hash, problem, unique, markdown, readRecord } = require('./formats.cjs');
5
+ const { withLock, append } = require('./ledger.cjs');
6
+ const { load } = require('./loading.cjs');
7
+ const { status, invariantProblems } = require('./states.cjs');
8
+
9
+ function reviewPath(root, requested) {
10
+ const relative = path.relative(root, path.resolve(root, requested));
11
+ if (!relative || path.isAbsolute(requested) || relative === '..'
12
+ || relative.startsWith(`..${path.sep}`)) {
13
+ throw new Error('--review must be a relative path inside the project root.');
14
+ }
15
+ return relative.split(path.sep).join('/');
16
+ }
17
+
18
+ function reviewVerdict(bytes, requireReviewer = false) {
19
+ const text = bytes?.toString('utf8').replace(/^\uFEFF/u, '');
20
+ if (!text || (requireReviewer && !text.startsWith('Reviewer: '))) return null;
21
+ const verdicts = [];
22
+ markdown(text, (line, number, outside) => {
23
+ if (outside && line.startsWith('Verdict:')) verdicts.push(line.trimEnd());
24
+ });
25
+ return verdicts.length === 1 && /^Verdict: (accept|revise)$/u.test(verdicts[0])
26
+ ? verdicts[0].slice(9) : null;
27
+ }
28
+
29
+ function readReview(root, relative, add) {
30
+ const { bytes } = readRecord(root, relative);
31
+ if (!bytes) {
32
+ add('review-missing', `${relative} is not a regular file`,
33
+ 'Write the milestone review as a regular file at this path.');
34
+ return null;
35
+ }
36
+ if (reviewVerdict(bytes) !== 'accept') {
37
+ add('review-not-accepted', `${relative} does not hold exactly one accepting verdict`,
38
+ 'Have the reviewer write exactly one Verdict: accept line after accepting the review.');
39
+ }
40
+ return { path: relative, sha256: hash(bytes) };
41
+ }
42
+
43
+ function recordClosure(project, id, opts) {
44
+ const milestone = project.milestones.find((m) => m.id === id);
45
+ if (!milestone) throw new Error(`Unknown milestone ${id}. Use an id from plan.json.`);
46
+ const relative = reviewPath(project.root, opts.review);
47
+ const state = status(project);
48
+ const problems = [];
49
+ const add = (code, message, hint) => problems.push(problem(code, message, hint));
50
+ if (state.gate !== 'pass') add('plan-gate', `Plan gate is ${state.gate}`,
51
+ 'Fix plan problems and rerun gate.');
52
+ if (state.dirty) add('dirty-tree', 'Code tree is dirty',
53
+ 'Commit or restore code changes before closing the milestone.');
54
+ const closed = new Set(project.closures.map((c) => c.milestone));
55
+ if (closed.has(id) || project.milestones.find((m) => !closed.has(m.id))?.id !== id) {
56
+ add('not-current', `${id} is already closed or is not the first open milestone`,
57
+ 'Close milestones in plan order, once each.');
58
+ }
59
+ for (const node of state.nodes.filter((n) => n.milestone === id)) {
60
+ if (node.state !== 'done') add('node-not-done', `${node.id} is not done`,
61
+ 'Complete the milestone node first.');
62
+ }
63
+ const required = unique(project.milestones.filter((m) => m.id === id || closed.has(m.id))
64
+ .flatMap((m) => m.requirements));
65
+ problems.push(...required.filter((req) => !state.requirements.some((r) =>
66
+ r.id === req && r.state === 'green'))
67
+ .map((req) => problem('requirement-not-green', `${req} is not green`,
68
+ 'Run its checks on clean, current code.')));
69
+ if (milestone.journey === undefined) add('no-journey', `${id} names no journey check`,
70
+ 'Set journey to the registered end-to-end check for this milestone.');
71
+ const journey = state.checks.find((c) => c.id === milestone.journey);
72
+ if (journey && journey.state !== 'green') {
73
+ add('journey-not-green', `${journey.id} is not green`,
74
+ 'Run the journey check on clean, current code.');
75
+ }
76
+ problems.push(...invariantProblems(state));
77
+ const review = readReview(project.root, relative, add);
78
+ if (!problems.length) {
79
+ append(project.root, 'milestones', { v: 1, milestone: id, code: project.code,
80
+ reqs: Object.fromEntries(milestone.requirements.map((r) => [r, project.reqs.get(r)])),
81
+ journey: { check: milestone.journey, run: journey.last_run },
82
+ review, owner: { words: opts.owner, proxy: opts.proxy } }, true);
83
+ }
84
+ return { ok: !problems.length, milestone: id, problems };
85
+ }
86
+
87
+ const close = ({ root }, id, opts) => withLock(root, () => recordClosure(load(root), id, opts));
88
+
89
+ module.exports = { close, reviewVerdict };
@@ -0,0 +1,232 @@
1
+ 'use strict';
2
+
3
+ const { unique, problem, rulingProblems } = require('./formats.cjs');
4
+ const { latest, withLock, append, approvalItem } = require('./ledger.cjs');
5
+ const { load } = require('./loading.cjs');
6
+
7
+ function requirementProblems(project, add) {
8
+ const { reqs, checks, milestones, nodes } = project;
9
+ for (const [entries, field] of [[milestones, 'requirements'], [nodes, 'covers'],
10
+ [checks, 'covers']]) {
11
+ for (const entry of entries) {
12
+ for (const id of entry[field]) {
13
+ if (!reqs.has(id)) add('unknown-requirement', `${entry.id} names unknown requirement ${id}`,
14
+ 'Add the requirement to spec.md or correct the reference.');
15
+ }
16
+ }
17
+ }
18
+ for (const id of reqs.keys()) {
19
+ const owners = milestones.filter((m) => m.requirements.includes(id));
20
+ if (!owners.length) add('unplanned-requirement', `${id} has no milestone`,
21
+ 'Assign the requirement to a milestone.');
22
+ if (owners.length > 1) add('requirement-in-multiple-milestones',
23
+ `${id} belongs to multiple milestones`, 'Keep the requirement in exactly one milestone.');
24
+ }
25
+ if (!milestones.length) add('empty-plan', 'The plan has no milestones', 'Add a milestone.');
26
+ for (const m of milestones) {
27
+ const ownNodes = nodes.filter((n) => n.milestone === m.id);
28
+ if (!m.requirements.length || !ownNodes.length) add('empty-milestone',
29
+ `${m.id} has no requirements or nodes`, 'Add requirements and nodes or remove the milestone');
30
+ for (const id of unique(m.requirements)) {
31
+ if (!checks.some((c) => c.covers.includes(id))) add('uncovered-requirement',
32
+ `${id} has no check`, 'Register a check covering this requirement in checks.json.');
33
+ if (!ownNodes.some((n) => n.covers.includes(id))) add('requirement-without-node',
34
+ `${id} has no node in ${m.id}`, 'Add a node covering this requirement to its milestone.');
35
+ }
36
+ }
37
+ }
38
+
39
+ function nodeProblems(project, add) {
40
+ const ms = new Map(project.milestones.map((m, i) => [m.id, { ...m, order: i }]));
41
+ const ns = new Map(project.nodes.map((n) => [n.id, n]));
42
+ for (const n of project.nodes) {
43
+ const m = ms.get(n.milestone);
44
+ if (!m) add('unknown-milestone', `${n.id} names unknown milestone ${n.milestone}`,
45
+ 'Correct the milestone reference or add the milestone.');
46
+ if (!n.covers.length && n.kind !== 'cleanup') add('empty-node', `${n.id} covers no requirement`,
47
+ 'Link the node to a requirement or remove it.');
48
+ for (const id of n.covers) {
49
+ if (m && project.reqs.has(id) && !m.requirements.includes(id)) {
50
+ add('node-outside-milestone', `${n.id} covers ${id} outside ${m.id}`,
51
+ 'Move the node or correct its requirement links.');
52
+ }
53
+ }
54
+ for (const dep of n.depends) {
55
+ const dependency = ns.get(dep);
56
+ if (!dependency) add('unknown-dependency', `${n.id} depends on unknown node ${dep}`,
57
+ 'Add the dependency node or correct the reference.');
58
+ else if (m && ms.has(dependency.milestone) && ms.get(dependency.milestone).order > m.order) {
59
+ add('dependency-on-later-milestone', `${n.id} depends on later milestone node ${dep}`,
60
+ 'Move the dependency to this or an earlier milestone.');
61
+ }
62
+ }
63
+ }
64
+ const visited = new Set();
65
+ const active = new Set();
66
+ const visit = (id) => {
67
+ if (active.has(id)) {
68
+ add('dependency-cycle', `Dependency cycle at ${id}`,
69
+ 'Remove a dependency edge to break the cycle.');
70
+ return;
71
+ }
72
+ if (visited.has(id) || !ns.has(id)) return;
73
+ visited.add(id);
74
+ active.add(id);
75
+ ns.get(id).depends.forEach(visit);
76
+ active.delete(id);
77
+ };
78
+ project.nodes.forEach((n) => visit(n.id));
79
+ }
80
+
81
+ function planProblems(project) {
82
+ const problems = [];
83
+ const add = (code, message, hint) => problems.push(problem(code, message, hint));
84
+ requirementProblems(project, add);
85
+ nodeProblems(project, add);
86
+ for (const m of project.milestones) {
87
+ if (m.journey === undefined) continue;
88
+ const check = project.checks.find((c) => c.id === m.journey);
89
+ if (!check) add('unknown-journey', `${m.id} names unknown journey ${m.journey}`,
90
+ 'Register the journey check in checks.json or correct its id.');
91
+ else if (!check.covers.some((id) => m.requirements.includes(id))) {
92
+ add('journey-outside-milestone', `${m.journey} covers no requirement of ${m.id}`,
93
+ 'Choose a journey check covering a requirement of this milestone.');
94
+ }
95
+ }
96
+ if (project.cleanup_every !== undefined) {
97
+ for (const m of project.milestones) {
98
+ const nodes = project.nodes.filter((n) => n.milestone === m.id);
99
+ let consecutive = 0;
100
+ const tooMany = nodes.some((n) => {
101
+ consecutive = n.kind === 'cleanup' ? 0 : consecutive + 1;
102
+ return consecutive > project.cleanup_every;
103
+ });
104
+ if (tooMany || nodes.at(-1)?.kind !== 'cleanup') {
105
+ add('cleanup-missing', `${m.id} needs cleanup every ${project.cleanup_every} nodes`,
106
+ 'End the milestone with cleanup and insert cleanup nodes at the required cadence.');
107
+ }
108
+ }
109
+ }
110
+ reductionProblems(project, add);
111
+ return problems.concat(rulingProblems(project.rulings));
112
+ }
113
+
114
+ function snapshot(project, milestone) {
115
+ const requirements = unique(milestone.requirements).filter((id) => project.reqs.has(id)).sort();
116
+ return {
117
+ shared: project.shared,
118
+ requirements: Object.fromEntries(requirements.map((id) => [id, project.reqs.get(id)])),
119
+ nodes: project.nodes.filter((n) => n.milestone === milestone.id).map((n) => n.id).sort(),
120
+ checks: Object.fromEntries(project.checks.filter((check) =>
121
+ check.covers.some((id) => requirements.includes(id))).map((check) =>
122
+ [check.id, { fp: check.fp, covers: unique(check.covers.filter((id) =>
123
+ requirements.includes(id))).sort() }])),
124
+ };
125
+ }
126
+
127
+ function sameSnapshot(current, previous) {
128
+ if (!previous || previous.type === 'removed') return false;
129
+ return Object.values(snapshotDiff(current, previous))
130
+ .every((value) => Array.isArray(value) && value.length === 0);
131
+ }
132
+
133
+ const has = (object, id) => Object.hasOwn(object, id);
134
+
135
+ function snapshotDiff(now, before) {
136
+ return {
137
+ added: Object.keys(now.requirements).filter((id) => !has(before.requirements, id)).sort(),
138
+ removed: Object.keys(before.requirements).filter((id) => !has(now.requirements, id)).sort(),
139
+ modified: Object.keys(now.requirements).filter((id) => has(before.requirements, id)
140
+ && now.requirements[id] !== before.requirements[id]).sort(),
141
+ nodes_added: now.nodes.filter((id) => !before.nodes.includes(id)).sort(),
142
+ nodes_removed: before.nodes.filter((id) => !now.nodes.includes(id)).sort(),
143
+ checks_added: Object.keys(now.checks).filter((id) => !has(before.checks, id)).sort(),
144
+ checks_removed: Object.keys(before.checks).filter((id) => !has(now.checks, id)).sort(),
145
+ checks_modified: Object.keys(now.checks).filter((id) => has(before.checks, id)
146
+ && (now.checks[id].fp !== before.checks[id].fp
147
+ || JSON.stringify(now.checks[id].covers) !== JSON.stringify(before.checks[id].covers)))
148
+ .sort(),
149
+ ...(now.shared !== before.shared ? { shared: true } : {}),
150
+ };
151
+ }
152
+
153
+ function emptySnapshot(project) {
154
+ return { shared: project.shared, requirements: {}, nodes: [], checks: {} };
155
+ }
156
+
157
+ function reductionProblems(project, add) {
158
+ for (const id of unique(project.plans.map((p) => p.milestone))) {
159
+ const before = latest(project.plans, 'milestone', id);
160
+ if (before.type === 'removed') continue;
161
+ const milestone = project.milestones.find((m) => m.id === id);
162
+ const now = milestone ? snapshot(project, milestone) : emptySnapshot(project);
163
+ const removed = Object.keys(before.requirements)
164
+ .filter((req) => !Object.hasOwn(now.requirements, req)).sort();
165
+ const checks = Object.keys(before.checks).filter((check) =>
166
+ !Object.hasOwn(now.checks, check) || before.checks[check].covers.some((req) =>
167
+ Object.hasOwn(now.requirements, req) && !now.checks[check].covers.includes(req))).sort();
168
+ // An approval answers the plan as it stood: one given before a later plan change or a
169
+ // closure of the milestone no longer counts.
170
+ const closed = latest(project.closures, 'milestone', id);
171
+ const floor = closed && closed.at > before.at ? closed.at : before.at;
172
+ for (const item of unique([...removed, ...checks]).map((entry) => `${id}/${entry}`)) {
173
+ if (!project.approvals.some((a) => a.at > floor && a.items.includes(item))) {
174
+ add('reduction-unapproved', `Reduction ${item} needs the Owner's approval`,
175
+ `Record approval with ubp approve ${item} --owner "<words>".`);
176
+ }
177
+ }
178
+ }
179
+ }
180
+
181
+ function pendingPlans(project) {
182
+ const pending = [];
183
+ const add = (id, now, before, type) => {
184
+ const record = { v: 1, type, milestone: id, at: new Date().toISOString(), ...now };
185
+ if (before) record.diff = snapshotDiff(now, before);
186
+ pending.push(record);
187
+ };
188
+ for (const m of project.milestones) {
189
+ const now = snapshot(project, m);
190
+ const before = latest(project.plans, 'milestone', m.id);
191
+ if (!sameSnapshot(now, before)) add(m.id, now, before, before ? 'change' : 'baseline');
192
+ }
193
+ for (const id of unique(project.plans.map((p) => p.milestone))) {
194
+ const before = latest(project.plans, 'milestone', id);
195
+ if (before.type !== 'removed' && !project.milestones.some((m) => m.id === id)) {
196
+ add(id, emptySnapshot(project), before, 'removed');
197
+ }
198
+ }
199
+ return pending;
200
+ }
201
+
202
+ function gate(project) {
203
+ return withLock(project.root, () => {
204
+ const current = load(project.root);
205
+ const problems = planProblems(current);
206
+ const records = problems.length ? [] : pendingPlans(current);
207
+ for (const record of records) append(current.root, 'plan-log', record, true);
208
+ return { ok: !problems.length, problems,
209
+ recorded: records.map(({ milestone, type }) => ({ milestone, type })) };
210
+ });
211
+ }
212
+
213
+ function approve(project, items, opts) {
214
+ return withLock(project.root, () => {
215
+ const current = load(project.root);
216
+ const sorted = unique(items).sort();
217
+ for (const item of sorted) {
218
+ const [milestone, id] = item.split('/');
219
+ const before = latest(current.plans, 'milestone', milestone);
220
+ if (!approvalItem(item) || !before || before.type === 'removed'
221
+ || !(Object.hasOwn(before.requirements, id) || Object.hasOwn(before.checks, id))) {
222
+ throw new Error(`Invalid approval item ${item}. Use <milestone>/<requirement or check>`
223
+ + ' from the milestone\'s latest plan-log snapshot.');
224
+ }
225
+ }
226
+ append(current.root, 'approvals', { v: 1, items: sorted,
227
+ owner: { words: opts.owner, proxy: opts.proxy } }, true);
228
+ return { ok: true, items: sorted };
229
+ });
230
+ }
231
+
232
+ module.exports = { planProblems, pendingPlans, gate, approve };
@@ -0,0 +1,5 @@
1
+ 'use strict';
2
+ module.exports = {
3
+ load: require('./loading.cjs').load, status: require('./states.cjs').status,
4
+ gate: require('./plan.cjs').gate, complete: require('./completion.cjs').complete,
5
+ };
@@ -0,0 +1,3 @@
1
+ 'use strict';
2
+
3
+ module.exports = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 };