ultra-builder-pro-cli 5.0.0 → 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.
- package/CHANGELOG.md +113 -3
- package/README.md +67 -478
- package/README.zh-CN.md +49 -422
- package/adapters/_shared/frontmatter.cjs +1 -18
- package/adapters/_shared/plugin-core.cjs +17 -83
- package/adapters/_shared/runtime-assets.cjs +6 -120
- package/adapters/claude.js +8 -24
- package/adapters/codex.js +16 -38
- package/bin/install.js +35 -57
- package/docs/PLUGIN-ISOLATION-CONTRACT.md +13 -101
- package/docs/examples/drive/claude-reviewer.sh +53 -0
- package/docs/examples/drive/claude-writer.sh +43 -0
- package/engine/FORMATS.md +1051 -0
- package/engine/checks.cjs +139 -0
- package/engine/cli.cjs +148 -0
- package/engine/completion.cjs +48 -0
- package/engine/context.cjs +94 -0
- package/engine/drive.cjs +220 -0
- package/engine/expect-worker.cjs +5 -0
- package/engine/expect.cjs +33 -0
- package/engine/formats.cjs +189 -0
- package/engine/git-state.cjs +112 -0
- package/engine/ledger.cjs +205 -0
- package/engine/loading.cjs +28 -0
- package/engine/merge.cjs +273 -0
- package/engine/milestone.cjs +89 -0
- package/engine/plan.cjs +232 -0
- package/engine/project.cjs +5 -0
- package/engine/signals.cjs +3 -0
- package/engine/states.cjs +175 -0
- package/hooks/session-start.cjs +54 -0
- package/package.json +17 -52
- package/skills/ultra/SKILL.md +395 -0
- package/.ultra-template/changes/abandoned/.gitkeep +0 -0
- package/.ultra-template/changes/active/.gitkeep +0 -1
- package/.ultra-template/changes/archive/.gitkeep +0 -1
- package/.ultra-template/contexts/TEMPLATE.md +0 -135
- package/.ultra-template/decisions/.gitkeep +0 -1
- package/.ultra-template/evidence/.gitkeep +0 -1
- package/.ultra-template/north-star.md +0 -52
- package/.ultra-template/project-brief.md +0 -28
- package/.ultra-template/research/.gitkeep +0 -1
- package/.ultra-template/specs/architecture.md +0 -81
- package/.ultra-template/specs/discovery.md +0 -74
- package/.ultra-template/specs/product.md +0 -80
- package/.ultra-template/specs/research-distillate.md +0 -57
- package/.ultra-template/tasks.json +0 -4
- package/.ultra-template/test-report.json +0 -44
- package/adapters/_shared/host-profile.cjs +0 -5
- package/adapters/grok.js +0 -242
- package/adapters/kimi.js +0 -232
- package/adapters/opencode.js +0 -408
- package/adapters/zcode.js +0 -375
- package/bin/delegate-worker.cjs +0 -5
- package/bin/delegate.cjs +0 -5
- package/docs/ARCHITECTURE.md +0 -274
- package/docs/ARTIFACT-AUTHORITY.md +0 -305
- package/docs/DECISIONS.md +0 -229
- package/docs/PHILOSOPHY.md +0 -261
- package/docs/RUNTIME-COMPAT-MATRIX.md +0 -315
- package/docs/SKILL-AUTHORING.md +0 -113
- package/docs/WORKFLOW-LIFECYCLE.md +0 -350
- package/docs/evals/adversarial-review-2026-08-14.md +0 -84
- package/docs/evals/ultra-5-0-journey-results.md +0 -62
- package/docs/evals/ultra-5-0-live-acp-conformance.md +0 -93
- package/docs/evals/ultra-5-0-route-journeys.md +0 -145
- package/docs/evals/zcode-automation-2026-08-14.md +0 -103
- package/hooks/README.md +0 -52
- package/hooks/_common.py +0 -1779
- package/hooks/adapters/codex.py +0 -295
- package/hooks/adapters/grok.py +0 -134
- package/hooks/adapters/kimi.py +0 -156
- package/hooks/adapters/zcode.py +0 -227
- package/hooks/block_dangerous_commands.py +0 -201
- package/hooks/compact_context.py +0 -224
- package/hooks/mid_workflow_recall.py +0 -47
- package/hooks/post_edit_guard.py +0 -215
- package/hooks/session_context.py +0 -741
- package/skills/ultra-change/SKILL.md +0 -165
- package/skills/ultra-change/references/change-contract.md +0 -199
- package/skills/ultra-change/references/execution-grant.md +0 -109
- package/skills/ultra-change/references/primary-transfer.md +0 -273
- package/skills/ultra-change/scripts/validate_primary_transfer.cjs +0 -1928
- package/skills/ultra-delegate/SKILL.md +0 -195
- package/skills/ultra-delegate/patches/zcode-acp-0.1.0-zcode-0.16.5.patch +0 -184
- package/skills/ultra-delegate/references/delegation-contract.md +0 -138
- package/skills/ultra-delegate/scripts/acp-conformance.cjs +0 -287
- package/skills/ultra-delegate/scripts/acp-driver.cjs +0 -762
- package/skills/ultra-delegate/scripts/agent-execution-driver.cjs +0 -320
- package/skills/ultra-delegate/scripts/cli-driver.cjs +0 -61
- package/skills/ultra-delegate/scripts/delegate-profiles/kimi-read-only.md +0 -15
- package/skills/ultra-delegate/scripts/delegate-profiles/kimi-write.md +0 -17
- package/skills/ultra-delegate/scripts/delegate-worker.cjs +0 -328
- package/skills/ultra-delegate/scripts/delegate.cjs +0 -690
- package/skills/ultra-delegate/scripts/delegate_wait.py +0 -86
- package/skills/ultra-delegate/scripts/host-profile.cjs +0 -267
- package/skills/ultra-deliver/SKILL.md +0 -194
- package/skills/ultra-deliver/references/baseline-reconciliation.md +0 -41
- package/skills/ultra-dev/SKILL.md +0 -265
- package/skills/ultra-dev/references/debugging.md +0 -22
- package/skills/ultra-domain-modeling/SKILL.md +0 -79
- package/skills/ultra-grilling/SKILL.md +0 -121
- package/skills/ultra-grilling/references/reframing.md +0 -76
- package/skills/ultra-init/SKILL.md +0 -143
- package/skills/ultra-init/references/brownfield-adoption.md +0 -32
- package/skills/ultra-init/scripts/init_project.cjs +0 -1031
- package/skills/ultra-plan/SKILL.md +0 -160
- package/skills/ultra-plan/references/task-evidence-v2.md +0 -300
- package/skills/ultra-plan/scripts/validate_task_evidence.cjs +0 -859
- package/skills/ultra-research/SKILL.md +0 -167
- package/skills/ultra-research/references/00-problem-validation.md +0 -36
- package/skills/ultra-research/references/01-opportunity-discovery.md +0 -33
- package/skills/ultra-research/references/02-market-assessment.md +0 -31
- package/skills/ultra-research/references/03-alternatives.md +0 -31
- package/skills/ultra-research/references/04-product-strategy.md +0 -41
- package/skills/ultra-research/references/05-assumptions-validation.md +0 -31
- package/skills/ultra-research/references/10-user-personas.md +0 -30
- package/skills/ultra-research/references/11-user-scenarios.md +0 -28
- package/skills/ultra-research/references/20-user-stories.md +0 -31
- package/skills/ultra-research/references/21-features-scope.md +0 -38
- package/skills/ultra-research/references/22-success-metrics.md +0 -33
- package/skills/ultra-research/references/30-architecture-context.md +0 -30
- package/skills/ultra-research/references/31-solution-strategy.md +0 -28
- package/skills/ultra-research/references/32-building-blocks.md +0 -28
- package/skills/ultra-research/references/40-deployment.md +0 -29
- package/skills/ultra-research/references/41-quality-risks.md +0 -28
- package/skills/ultra-research/references/99-synthesis.md +0 -59
- package/skills/ultra-research/references/north-star-v2.md +0 -168
- package/skills/ultra-research/references/wayfinding.md +0 -50
- package/skills/ultra-research/scripts/validate_north_star.cjs +0 -1174
- package/skills/ultra-review/SKILL.md +0 -275
- package/skills/ultra-review/references/adversarial-evaluation.md +0 -31
- package/skills/ultra-review/references/code.md +0 -17
- package/skills/ultra-review/references/comments.md +0 -12
- package/skills/ultra-review/references/design.md +0 -12
- package/skills/ultra-review/references/errors.md +0 -13
- package/skills/ultra-review/references/review-modes.md +0 -29
- package/skills/ultra-review/references/spec.md +0 -18
- package/skills/ultra-review/references/tests.md +0 -12
- package/skills/ultra-review/references/unified-schema.md +0 -284
- package/skills/ultra-review/references/worker-packet.md +0 -183
- package/skills/ultra-review/scripts/review_wait.py +0 -2943
- package/skills/ultra-status/SKILL.md +0 -209
- package/skills/ultra-status/references/route-contract.md +0 -100
- package/skills/ultra-tdd/SKILL.md +0 -91
- package/skills/ultra-tdd/references/templates/README.md +0 -20
- package/skills/ultra-tdd/references/templates/feature-flag-default-audit.sh +0 -79
- package/skills/ultra-tdd/references/templates/persistence-real.ts +0 -78
- package/skills/ultra-tdd/references/templates/testcontainer-postgres.py +0 -78
- package/skills/ultra-tdd/references/templates/testcontainer-postgres.ts +0 -73
- package/skills/ultra-tdd/references/templates/vertical-slice.ts +0 -66
- package/skills/ultra-tdd/references/test-execution.md +0 -23
- package/skills/ultra-test/SKILL.md +0 -244
- package/skills/ultra-test/references/export-syntax.md +0 -18
- package/skills/ultra-test/scripts/validate_review_transport.cjs +0 -549
- package/skills/ultra-test/scripts/worktree_digest.cjs +0 -576
- package/skills/ultra-think/SKILL.md +0 -56
- package/skills/ultra-think/references/autonomy-boundary.md +0 -48
- package/skills/ultra-think/references/interaction-boundary.md +0 -42
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { randomUUID } = require('node:crypto');
|
|
5
|
+
const { spawn } = require('node:child_process');
|
|
6
|
+
const { codeState, gitEnvironment } = require('./git-state.cjs');
|
|
7
|
+
const { append } = require('./ledger.cjs');
|
|
8
|
+
const { expectOutput } = require('./expect.cjs');
|
|
9
|
+
const { load } = require('./loading.cjs');
|
|
10
|
+
const { status } = require('./states.cjs');
|
|
11
|
+
const INTERRUPTS = require('./signals.cjs');
|
|
12
|
+
// The output tail: the last 4 MiB of the log, where `expect` and `error_line` look.
|
|
13
|
+
function readTail(fd) {
|
|
14
|
+
const size = fs.fstatSync(fd).size;
|
|
15
|
+
const buffer = Buffer.alloc(Math.min(size, 4 * 1024 * 1024));
|
|
16
|
+
fs.readSync(fd, buffer, 0, buffer.length, size - buffer.length);
|
|
17
|
+
return buffer.toString('utf8');
|
|
18
|
+
}
|
|
19
|
+
function killGroup(child, signal = 'SIGKILL') {
|
|
20
|
+
if (!(child.pid > 0)) return;
|
|
21
|
+
try { process.kill(-child.pid, signal); }
|
|
22
|
+
catch (error) { if (!['ESRCH', 'EPERM'].includes(error.code)) throw error; }
|
|
23
|
+
}
|
|
24
|
+
// The shell writes straight into the log file: no pipe is read, so output size never
|
|
25
|
+
// costs memory and a descendant that escaped the group cannot keep the engine waiting.
|
|
26
|
+
function runShell(root, check, fd, extraEnv = {}, graceMs = 0) {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
const child = spawn('/bin/sh', ['-c', check.run], { cwd: root,
|
|
29
|
+
env: { ...gitEnvironment(root), ...extraEnv },
|
|
30
|
+
detached: true, stdio: ['ignore', fd, fd] });
|
|
31
|
+
const result = { code: null, signal: null, timedOut: false, spawnError: false,
|
|
32
|
+
interrupted: 0 };
|
|
33
|
+
let graceTimer, exited = false;
|
|
34
|
+
const finish = (error) => {
|
|
35
|
+
for (const t of [timer, graceTimer]) clearTimeout(t);
|
|
36
|
+
for (const [signal, handler] of handlers) process.off(signal, handler);
|
|
37
|
+
if (error) reject(error); else resolve(result);
|
|
38
|
+
};
|
|
39
|
+
const kill = (signal) => {
|
|
40
|
+
try { killGroup(child, signal); } catch (error) { finish(error); }
|
|
41
|
+
};
|
|
42
|
+
const stop = () => {
|
|
43
|
+
if (!graceMs) return kill();
|
|
44
|
+
if (graceTimer) return;
|
|
45
|
+
kill('SIGTERM');
|
|
46
|
+
graceTimer = setTimeout(() => {
|
|
47
|
+
graceTimer = null;
|
|
48
|
+
kill();
|
|
49
|
+
if (exited) finish();
|
|
50
|
+
}, graceMs);
|
|
51
|
+
};
|
|
52
|
+
const handlers = Object.entries(INTERRUPTS).map(([signal, exitCode]) =>
|
|
53
|
+
[signal, () => { result.interrupted = exitCode; stop(); }]);
|
|
54
|
+
const timer = setTimeout(() => { result.timedOut = true; stop(); }, check.timeout * 1000);
|
|
55
|
+
for (const [signal, handler] of handlers) process.on(signal, handler);
|
|
56
|
+
child.on('error', (error) => {
|
|
57
|
+
result.spawnError = true;
|
|
58
|
+
fs.writeSync(fd, `${error.message}\n`);
|
|
59
|
+
finish();
|
|
60
|
+
});
|
|
61
|
+
child.on('exit', (code, signal) => {
|
|
62
|
+
Object.assign(result, { code, signal });
|
|
63
|
+
exited = true;
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
// Let an inner ubp stop its detached checks even when its shell exits first.
|
|
66
|
+
if (graceMs && !graceTimer) {
|
|
67
|
+
try { process.kill(-child.pid, 0); stop(); }
|
|
68
|
+
catch (error) { if (!['ESRCH', 'EPERM'].includes(error.code)) return finish(error); }
|
|
69
|
+
}
|
|
70
|
+
if (!graceTimer) { kill(); finish(); }
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
async function outcomeFor(exit, check, output) {
|
|
75
|
+
if (exit.timedOut) return ['unknown', 'timeout'];
|
|
76
|
+
if (exit.spawnError) return ['unknown', 'spawn-error'];
|
|
77
|
+
if (exit.signal) return ['unknown', `signal-${exit.signal}`];
|
|
78
|
+
if (exit.code === 126 || exit.code === 127) return ['unknown', 'not-runnable'];
|
|
79
|
+
if (exit.code !== 0) return ['fail', `exit-${exit.code}`];
|
|
80
|
+
const reason = await expectOutput(check.expect, output);
|
|
81
|
+
return [reason === 'exit-0' ? 'pass' : 'unknown', reason];
|
|
82
|
+
}
|
|
83
|
+
function errorLine(output) {
|
|
84
|
+
const lines = output.split(/\r?\n/u).filter((line) => line.trim());
|
|
85
|
+
const pattern = /^\s*(?:✖|not ok\b|FAIL(?:ED)?\b)|\b(?:[A-Z][A-Za-z]*)?Error\b/u;
|
|
86
|
+
return (lines.find((line) => pattern.test(line)) ?? lines.at(-1) ?? '').slice(0, 200);
|
|
87
|
+
}
|
|
88
|
+
async function execute(project, check) {
|
|
89
|
+
const run = randomUUID();
|
|
90
|
+
const log = `.ultra/.runtime/checks/${run}.log`;
|
|
91
|
+
fs.mkdirSync(path.dirname(path.join(project.root, log)), { recursive: true });
|
|
92
|
+
const fd = fs.openSync(path.join(project.root, log), 'w+');
|
|
93
|
+
const started = performance.now();
|
|
94
|
+
const before = codeState(project.root);
|
|
95
|
+
let exit, output;
|
|
96
|
+
try {
|
|
97
|
+
exit = await runShell(project.root, check, fd);
|
|
98
|
+
output = readTail(fd);
|
|
99
|
+
} finally { fs.closeSync(fd); }
|
|
100
|
+
if (exit.interrupted) throw Object.assign(new Error('Check interrupted.'),
|
|
101
|
+
{ exitCode: exit.interrupted });
|
|
102
|
+
const [outcome, reason] = await outcomeFor(exit, check, output);
|
|
103
|
+
const after = codeState(project.root);
|
|
104
|
+
const record = { v: 1, run, check: check.id, at: new Date().toISOString(), outcome, reason,
|
|
105
|
+
exit_code: exit.code, duration_ms: Math.round(performance.now() - started), code: before.code,
|
|
106
|
+
dirty: before.dirty || after.dirty || before.code !== after.code, check_fp: check.fp,
|
|
107
|
+
reqs: Object.fromEntries(check.covers.filter((id) => project.reqs.has(id))
|
|
108
|
+
.map((id) => [id, project.reqs.get(id)])),
|
|
109
|
+
shared: project.shared, log, error_line: outcome === 'pass' ? null : errorLine(output) };
|
|
110
|
+
append(project.root, 'evidence', record);
|
|
111
|
+
return record;
|
|
112
|
+
}
|
|
113
|
+
function selectedChecks(project, opts) {
|
|
114
|
+
const { words } = opts;
|
|
115
|
+
if (words.length && opts.selector) throw new Error('Choose check ids or a check selector.');
|
|
116
|
+
const badId = words.find((id) => !project.checks.some((c) => c.id === id));
|
|
117
|
+
if (badId !== undefined) throw new Error(`Unknown check ${badId}. Use an id from checks.json.`);
|
|
118
|
+
if (opts.req !== null && !project.reqs.has(opts.req))
|
|
119
|
+
throw new Error(`Unknown requirement ${opts.req}. Use an id from spec.md.`);
|
|
120
|
+
const scope = project.checks.filter((c) => words.length ? words.includes(c.id)
|
|
121
|
+
: opts.selector !== 'req' || c.covers.includes(opts.req));
|
|
122
|
+
const stale = !words.length && (!opts.selector || opts.selector === 'stale');
|
|
123
|
+
const pending = stale ? status(project).checks
|
|
124
|
+
.filter((c) => ['stale', 'unknown'].includes(c.state)).map((c) => c.id) : null;
|
|
125
|
+
return { scope, selected: pending ? scope.filter((c) => pending.includes(c.id)) : scope };
|
|
126
|
+
}
|
|
127
|
+
async function runChecks(project, opts) {
|
|
128
|
+
const runs = [];
|
|
129
|
+
const { scope, selected } = selectedChecks(project, opts);
|
|
130
|
+
for (const check of selected) {
|
|
131
|
+
const { check: id, run, outcome, reason, dirty } = await execute(project, check);
|
|
132
|
+
runs.push({ check: id, run, outcome, reason, dirty });
|
|
133
|
+
}
|
|
134
|
+
const after = status(load(project.root));
|
|
135
|
+
const ok = after.checks.filter((c) => scope.some((check) => check.id === c.id))
|
|
136
|
+
.every((c) => c.state === 'green');
|
|
137
|
+
return { result: { dirty: after.dirty, runs }, ok, after };
|
|
138
|
+
}
|
|
139
|
+
module.exports = { execute, runShell, runChecks };
|
package/engine/cli.cjs
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { load, status, gate, complete } = require('./project.cjs');
|
|
5
|
+
const { runChecks } = require('./checks.cjs');
|
|
6
|
+
const { drive } = require('./drive.cjs');
|
|
7
|
+
const { close } = require('./milestone.cjs');
|
|
8
|
+
const { context } = require('./context.cjs');
|
|
9
|
+
const { approve } = require('./plan.cjs');
|
|
10
|
+
const { retro } = require('./states.cjs');
|
|
11
|
+
const { merge } = require('./merge.cjs');
|
|
12
|
+
|
|
13
|
+
function options(args, command) {
|
|
14
|
+
const local = { check: ['--all', '--stale', '--req'],
|
|
15
|
+
milestone: ['--owner', '--review', '--proxy'], approve: ['--owner', '--proxy'],
|
|
16
|
+
context: ['--budget'], merge: ['--expect-head'],
|
|
17
|
+
drive: ['--writer', '--reviewer', '--attempts', '--max-writers', '--timeout-sec'] };
|
|
18
|
+
const allowed = new Set(['--root', '--json', ...(local[command] || [])]);
|
|
19
|
+
const result = { root: process.cwd(), json: false, words: [], selector: null, req: null,
|
|
20
|
+
owner: null, review: null, proxy: false, budget: 32768,
|
|
21
|
+
attempts: 3, 'max-writers': 10, 'timeout-sec': 3600 };
|
|
22
|
+
const values = ['--root', '--req', '--owner', '--review', '--budget', '--expect-head',
|
|
23
|
+
...local.drive];
|
|
24
|
+
const seen = new Set();
|
|
25
|
+
for (let i = 0; i < args.length; i++) {
|
|
26
|
+
const arg = args[i];
|
|
27
|
+
if (arg.startsWith('-') && !allowed.has(arg)) {
|
|
28
|
+
throw new Error(`Option ${arg} is not valid for ${command}.`);
|
|
29
|
+
}
|
|
30
|
+
if (values.includes(arg)) {
|
|
31
|
+
if (!['--root', '--req'].includes(arg) && seen.has(arg)) {
|
|
32
|
+
throw new Error(`${arg} may only be given once.`);
|
|
33
|
+
}
|
|
34
|
+
seen.add(arg);
|
|
35
|
+
let value = args[++i];
|
|
36
|
+
if (!value || value.startsWith('--')) throw new Error(`${arg} requires a value.`);
|
|
37
|
+
if (arg === '--req') {
|
|
38
|
+
if (result.selector) throw new Error('Choose only one check selector.');
|
|
39
|
+
result.selector = 'req';
|
|
40
|
+
} else if (['--budget', '--attempts', '--max-writers', '--timeout-sec'].includes(arg)) {
|
|
41
|
+
const n = Number(value), max = arg === '--timeout-sec' ? 86400 : Number.MAX_SAFE_INTEGER;
|
|
42
|
+
if (!/^\d+$/u.test(value) || !Number.isSafeInteger(n) || n < 1 || n > max) {
|
|
43
|
+
throw new Error(`${arg} requires a positive integer no greater than ${max}.`);
|
|
44
|
+
}
|
|
45
|
+
value = n;
|
|
46
|
+
}
|
|
47
|
+
result[arg.slice(2)] = value;
|
|
48
|
+
} else if (arg === '--json') result.json = true;
|
|
49
|
+
else if (arg === '--proxy') result.proxy = true;
|
|
50
|
+
else if (arg === '--all' || arg === '--stale') {
|
|
51
|
+
if (result.selector) throw new Error('Choose only one check selector.');
|
|
52
|
+
result.selector = arg.slice(2);
|
|
53
|
+
} else result.words.push(arg);
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function printResult(command, opts, result, project, after) {
|
|
59
|
+
if (opts.json) { console.log(JSON.stringify(result)); return; }
|
|
60
|
+
for (const warning of project.warnings) console.error(warning);
|
|
61
|
+
if (command === 'status') console.log(result.line);
|
|
62
|
+
else if (command === 'drive') return;
|
|
63
|
+
else if (command === 'approve') console.log(`approved: ${result.items.join(', ')}`);
|
|
64
|
+
else if (command === 'retro') {
|
|
65
|
+
for (const c of result.classes) {
|
|
66
|
+
console.log(`${c.count} × ${c.class} · ${c.checks.join(', ')}`);
|
|
67
|
+
}
|
|
68
|
+
console.log(`重复两次及以上:${result.classes.filter((c) => c.count >= 2).length} 类`);
|
|
69
|
+
} else if (command === 'merge') {
|
|
70
|
+
const verdict = result.ok ? `pass ${result.merged.slice(0, 7)}` : 'refused';
|
|
71
|
+
console.log(`merge ${opts.words[0]}: ${verdict}`);
|
|
72
|
+
for (const p of result.problems) console.log(`${p.code}: ${p.message} — ${p.hint}`);
|
|
73
|
+
for (const warning of result.warnings) console.error(`warning: ${warning}`);
|
|
74
|
+
} else if (command === 'context') {
|
|
75
|
+
if (result.ok) process.stdout.write(result.brief);
|
|
76
|
+
else for (const p of result.problems) console.error(`${p.code}: ${p.message} — ${p.hint}`);
|
|
77
|
+
} else if (command === 'check') {
|
|
78
|
+
for (const r of result.runs) {
|
|
79
|
+
const reason = r.reason + (r.dirty ? ', dirty: not counted' : '');
|
|
80
|
+
console.log(`${r.check}: ${r.outcome} (${reason})`);
|
|
81
|
+
const check = project.checks.find((c) => c.id === r.check);
|
|
82
|
+
if (check.fix !== undefined && r.outcome !== 'pass') console.log(` fix: ${check.fix}`);
|
|
83
|
+
}
|
|
84
|
+
console.log(after.line);
|
|
85
|
+
} else {
|
|
86
|
+
const name = ['node', 'milestone'].includes(command)
|
|
87
|
+
? `${command} ${result[command]}` : 'gate';
|
|
88
|
+
console.log(`${name}: ${result.ok ? 'pass' : 'fail'}`);
|
|
89
|
+
for (const p of result.problems) console.log(`${p.code}: ${p.message} — ${p.hint}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function handle(args) {
|
|
94
|
+
try {
|
|
95
|
+
const [command, ...rest] = args;
|
|
96
|
+
if (process.env.UBP_DRIVE !== undefined
|
|
97
|
+
&& ['approve', 'milestone', 'merge', 'drive'].includes(command)) {
|
|
98
|
+
throw new Error('Not allowed inside ubp drive.');
|
|
99
|
+
}
|
|
100
|
+
const commands = ['status', 'gate', 'check', 'node', 'milestone', 'context', 'approve',
|
|
101
|
+
'retro', 'merge', 'drive'];
|
|
102
|
+
if (!commands.includes(command)) {
|
|
103
|
+
throw new Error(`Unknown command ${command ?? ''}. Use ${commands.join(', ')}.`);
|
|
104
|
+
}
|
|
105
|
+
const opts = options(rest, command);
|
|
106
|
+
const { words } = opts;
|
|
107
|
+
if ((['status', 'gate', 'retro'].includes(command) && words.length)
|
|
108
|
+
|| (command === 'node' && (words.length !== 2 || words[0] !== 'done'))
|
|
109
|
+
|| (command === 'milestone' && (words.length !== 2 || words[0] !== 'close'))
|
|
110
|
+
|| (command === 'context' && words.length !== 1)
|
|
111
|
+
|| (command === 'merge' && (words.length !== 1 || !opts['expect-head']))
|
|
112
|
+
|| (command === 'approve' && !words.length)) {
|
|
113
|
+
const usage = { node: 'node done <id>',
|
|
114
|
+
milestone: 'milestone close <id> --owner <words> --review <path>',
|
|
115
|
+
context: 'context <id>', approve: 'approve <milestone>/<id>… --owner <words>',
|
|
116
|
+
merge: 'merge <branch> --expect-head <commit>'
|
|
117
|
+
}[command] || command;
|
|
118
|
+
throw new Error(`Usage: ${usage} [--root <dir>] [--json]`);
|
|
119
|
+
}
|
|
120
|
+
if (command === 'milestone' && (!opts.owner?.trim() || !opts.review)) {
|
|
121
|
+
throw new Error('milestone close requires --owner <non-empty words> and --review <path>.');
|
|
122
|
+
}
|
|
123
|
+
if (command === 'approve' && !opts.owner?.trim()) {
|
|
124
|
+
throw new Error('approve requires --owner <non-empty words>.');
|
|
125
|
+
}
|
|
126
|
+
const project = load(opts.root);
|
|
127
|
+
const actions = { status, gate, retro,
|
|
128
|
+
node: (p) => complete(p, words[1]), milestone: (p) => close(p, words[1], opts),
|
|
129
|
+
context: (p) => context(p, words[0], opts.budget), approve: (p) => approve(p, words, opts),
|
|
130
|
+
merge: (p) => merge(p, words[0], opts['expect-head']), drive: (p) => drive(p, opts) };
|
|
131
|
+
const checked = command === 'check' ? await runChecks(project, opts) : null;
|
|
132
|
+
const result = checked ? checked.result : await actions[command](project);
|
|
133
|
+
printResult(command, opts, result, project, checked?.after);
|
|
134
|
+
if (command === 'drive') return Number(result.verdict === 'revise'
|
|
135
|
+
|| !['milestone-gate', 'all-done'].includes(result.stop));
|
|
136
|
+
if (['status', 'retro'].includes(command)) return 0;
|
|
137
|
+
return (checked ? checked.ok : result.ok) ? 0 : 1;
|
|
138
|
+
} catch (error) {
|
|
139
|
+
console.error(error.message);
|
|
140
|
+
return error.exitCode ?? 2;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (require.main === module) {
|
|
145
|
+
handle(process.argv.slice(2)).then((code) => { process.exitCode = code; });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = { handle };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { unique, problem } = require('./formats.cjs');
|
|
4
|
+
const { withLock, append } = require('./ledger.cjs');
|
|
5
|
+
const { load } = require('./loading.cjs');
|
|
6
|
+
const { status, invariantProblems, dependencies } = require('./states.cjs');
|
|
7
|
+
|
|
8
|
+
const complete = ({ root }, id) => withLock(root, () => recordCompletion(load(root), id));
|
|
9
|
+
|
|
10
|
+
function recordCompletion(project, id) {
|
|
11
|
+
const node = project.nodes.find((n) => n.id === id);
|
|
12
|
+
if (!node) throw new Error(`Unknown node ${id}. Use an id from plan.json.`);
|
|
13
|
+
const state = status(project);
|
|
14
|
+
const problems = [];
|
|
15
|
+
if (state.gate !== 'pass') problems.push(problem('plan-gate', `Plan gate is ${state.gate}`,
|
|
16
|
+
'Fix plan problems and rerun gate.'));
|
|
17
|
+
if (state.dirty) problems.push(problem('dirty-tree', 'Code tree is dirty',
|
|
18
|
+
'Commit or restore code changes before completing the node.'));
|
|
19
|
+
problems.push(...dependencies(project, node).filter((dep) =>
|
|
20
|
+
!state.nodes.some((n) => n.id === dep && n.state === 'done'))
|
|
21
|
+
.map((dep) => problem('dependency-not-done', `${dep} is not done`,
|
|
22
|
+
'Complete the dependency first.')));
|
|
23
|
+
let required = node.covers;
|
|
24
|
+
if (node.kind === 'cleanup') {
|
|
25
|
+
const closed = new Set(project.closures.map((c) => c.milestone));
|
|
26
|
+
const done = project.nodes.filter((n) => n.milestone === node.milestone
|
|
27
|
+
&& state.nodes.some((s) => s.id === n.id && s.state === 'done'));
|
|
28
|
+
const covered = new Set(done.flatMap((n) => n.covers));
|
|
29
|
+
required = unique(project.milestones.flatMap((m) => closed.has(m.id) ? m.requirements
|
|
30
|
+
: m.id === node.milestone ? m.requirements.filter((r) => covered.has(r)) : []));
|
|
31
|
+
}
|
|
32
|
+
problems.push(...required.filter((req) => !state.requirements.some((r) =>
|
|
33
|
+
r.id === req && r.state === 'green'))
|
|
34
|
+
.map((req) => problem('requirement-not-green', `${req} is not green`,
|
|
35
|
+
'Run its checks on clean, current code.')));
|
|
36
|
+
problems.push(...invariantProblems(state));
|
|
37
|
+
if (!problems.length) {
|
|
38
|
+
const ids = state.requirements.filter((r) => node.covers.includes(r.id))
|
|
39
|
+
.flatMap((r) => r.checks.map((c) => c.id));
|
|
40
|
+
append(project.root, 'nodes', { v: 1, node: id, at: new Date().toISOString(),
|
|
41
|
+
code: project.code, shared: project.shared,
|
|
42
|
+
reqs: Object.fromEntries(node.covers.map((r) => [r, project.reqs.get(r)])),
|
|
43
|
+
runs: unique(state.checks.filter((c) => ids.includes(c.id)).map((c) => c.last_run)) }, true);
|
|
44
|
+
}
|
|
45
|
+
return { ok: !problems.length, node: id, problems };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { complete };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { problem, readRecord } = require('./formats.cjs');
|
|
4
|
+
const { status } = require('./states.cjs');
|
|
5
|
+
|
|
6
|
+
function rules(id, checks) {
|
|
7
|
+
const ids = checks.map((c) => c.id).join(' ') || '--stale';
|
|
8
|
+
return [
|
|
9
|
+
'- Do not edit .ultra/spec.md, .ultra/checks.json, .ultra/plan.json, .ultra/ledger/ '
|
|
10
|
+
+ 'or the files the checks above run. If a check or a requirement looks wrong, '
|
|
11
|
+
+ 'stop and escalate.',
|
|
12
|
+
'- Change only files inside the scope, and commit your work: '
|
|
13
|
+
+ 'evidence from an uncommitted tree never counts.',
|
|
14
|
+
`- Run \`ubp check ${ids}\` until every check above is green, then \`ubp check\` `
|
|
15
|
+
+ '(every stale check: invariants and finished requirements must stay green), '
|
|
16
|
+
+ `then \`ubp node done ${id}\`.`,
|
|
17
|
+
'- Escalate instead of deciding: a change of direction (L0) and any deletion or '
|
|
18
|
+
+ 'narrowing of a requirement go to the Owner.',
|
|
19
|
+
'- Record every other decision you made without asking as one line in .ultra/rulings.md: '
|
|
20
|
+
+ '`- YYYY-MM-DD Lx 裁决:<决定> — 因为 <理由> '
|
|
21
|
+
+ '— 如果判断错了,代价是 <代价>`.',
|
|
22
|
+
`- Before \`ubp node done ${id}\`, write .ultra/records/handoffs/${id}.md: `
|
|
23
|
+
+ 'the interfaces you created, what dependent nodes must know, and what is left unfinished.',
|
|
24
|
+
'- If three attempts leave a check red, stop and report the check, '
|
|
25
|
+
+ 'its error line and what you tried.',
|
|
26
|
+
].join('\n');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function compile(project, node, state, handoffs) {
|
|
30
|
+
const milestone = project.milestones.find((m) => m.id === node.milestone);
|
|
31
|
+
const checks = project.checks.filter((c) => node.kind === 'cleanup' ? Object.hasOwn(c, 'fix')
|
|
32
|
+
: c.covers.some((r) => node.covers.includes(r)));
|
|
33
|
+
return [
|
|
34
|
+
`# Node ${node.id}: ${node.title}`,
|
|
35
|
+
`Milestone ${node.milestone}: ${milestone?.title ?? 'not in .ultra/plan.json'} · node state `
|
|
36
|
+
+ state.nodes.find((n) => n.id === node.id).state,
|
|
37
|
+
'## Requirements',
|
|
38
|
+
node.covers.map((r) => project.requirementText.get(r) ?? `${r}: not in .ultra/spec.md`)
|
|
39
|
+
.join('\n\n') || 'None.',
|
|
40
|
+
'## Checks that must turn green (read-only)',
|
|
41
|
+
checks.map((c) => `- ${c.id} · ${state.checks.find((s) => s.id === c.id).state} · `
|
|
42
|
+
+ `\`${c.run}\`` + (node.kind === 'cleanup' ? ` · fix: ${c.fix}` : ''))
|
|
43
|
+
.join('\n') || 'None.',
|
|
44
|
+
'## Scope',
|
|
45
|
+
node.scope?.length ? node.scope.map((prefix) => `- ${prefix}`).join('\n')
|
|
46
|
+
: 'Not declared: change only what the requirements need.',
|
|
47
|
+
'## Handoffs from dependencies',
|
|
48
|
+
handoffs.length ? handoffs.map((h) => `### ${h.id}\n\n${h.text}`).join('\n\n') : 'None.',
|
|
49
|
+
'## Rules',
|
|
50
|
+
rules(node.id, checks),
|
|
51
|
+
].join('\n\n') + '\n';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function handoff(root, id) {
|
|
55
|
+
const relative = `.ultra/records/handoffs/${id}.md`;
|
|
56
|
+
const { exists, bytes } = readRecord(root, relative);
|
|
57
|
+
if (!exists) return { text: 'No handoff written.', fromFile: false };
|
|
58
|
+
if (!bytes) {
|
|
59
|
+
return { text: 'Not read: not a regular file inside the project.', fromFile: false };
|
|
60
|
+
}
|
|
61
|
+
const text = bytes.toString('utf8').replace(/\r\n/gu, '\n')
|
|
62
|
+
.replace(/^(?:[^\S\n]*\n)+/u, '').trimEnd();
|
|
63
|
+
return { text: text || 'No handoff written.', fromFile: true };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function context(project, id, budget) {
|
|
67
|
+
const node = project.nodes.find((n) => n.id === id);
|
|
68
|
+
if (!node) throw new Error(`Unknown node ${id}. Use an id from plan.json.`);
|
|
69
|
+
const state = status(project);
|
|
70
|
+
const handoffs = node.depends.map((dep) => ({ id: dep,
|
|
71
|
+
...(project.nodes.some((n) => n.id === dep) ? handoff(project.root, dep)
|
|
72
|
+
: { text: 'Not a node in .ultra/plan.json.', fromFile: false }) }));
|
|
73
|
+
let brief = compile(project, node, state, handoffs);
|
|
74
|
+
let bytes = Buffer.byteLength(brief);
|
|
75
|
+
const omitted = [];
|
|
76
|
+
for (let i = handoffs.length - 1; bytes > budget && i >= 0; i--) {
|
|
77
|
+
const handoff = handoffs[i];
|
|
78
|
+
const replacement = `Omitted to fit the budget: read .ultra/records/handoffs/${handoff.id}.md`;
|
|
79
|
+
if (!handoff.fromFile || Buffer.byteLength(handoff.text) <= Buffer.byteLength(replacement)) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
handoff.text = replacement;
|
|
83
|
+
omitted.push(handoff.id);
|
|
84
|
+
brief = compile(project, node, state, handoffs);
|
|
85
|
+
bytes = Buffer.byteLength(brief);
|
|
86
|
+
}
|
|
87
|
+
const ok = bytes <= budget;
|
|
88
|
+
const problems = ok ? [] : [problem('brief-over-budget',
|
|
89
|
+
`Node ${id} brief is ${bytes} bytes, exceeding the ${budget} byte budget`,
|
|
90
|
+
'Split the node so its requirements, checks and rules fit the budget.')];
|
|
91
|
+
return { ok, node: id, bytes, omitted, brief: ok ? brief : null, problems };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { context };
|
package/engine/drive.cjs
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { randomUUID } = require('node:crypto');
|
|
5
|
+
const { load, status, gate } = require('./project.cjs');
|
|
6
|
+
const { context } = require('./context.cjs');
|
|
7
|
+
const { runChecks, runShell } = require('./checks.cjs');
|
|
8
|
+
const { git, codeState } = require('./git-state.cjs');
|
|
9
|
+
const { problem, readRecord, hash } = require('./formats.cjs');
|
|
10
|
+
const { acquireLock } = require('./ledger.cjs');
|
|
11
|
+
const { reviewVerdict } = require('./milestone.cjs');
|
|
12
|
+
const truth = ['.ultra/spec.md', '.ultra/checks.json', '.ultra/plan.json',
|
|
13
|
+
'.ultra/ledger/milestones.jsonl', '.ultra/ledger/approvals.jsonl',
|
|
14
|
+
'.ultra/ledger/plan-log.jsonl'];
|
|
15
|
+
const exempt = [...truth, '.ultra/ledger/evidence.jsonl', '.ultra/ledger/nodes.jsonl'];
|
|
16
|
+
const quote = (word) => `'${word.replace(/'/gu, "'\\''")}'`;
|
|
17
|
+
const issue = (code, subject) => problem(code, `${subject}: ${code}`,
|
|
18
|
+
'Resolve this stop before resuming drive.');
|
|
19
|
+
const paths = (root, args) => git(root, args).toString('utf8').split('\0').filter(Boolean);
|
|
20
|
+
const within = (file, prefix) => file === prefix || file.startsWith(`${prefix}/`);
|
|
21
|
+
const clean = (root, review) => !paths(root, ['ls-files', '-v', '-z'])
|
|
22
|
+
.some((entry) => /^[a-zS]/u.test(entry)) && !codeState(root, (file) => file === review
|
|
23
|
+
|| ['.ultra/.runtime', '.ultra/ledger'].some((dir) => within(file, dir))).dirty;
|
|
24
|
+
function snapshot(root) {
|
|
25
|
+
const head = git(root, ['rev-parse', 'HEAD']).toString('utf8').trim();
|
|
26
|
+
const entries = new Map(paths(root, ['ls-tree', '-r', '-z', head, '--', ...truth])
|
|
27
|
+
.map((entry) => [entry.slice(entry.indexOf('\t') + 1), entry.split(/[ \t]/u)[2]]));
|
|
28
|
+
const settings = ['config', 'config.worktree', 'info/exclude'].map((name) => {
|
|
29
|
+
const file = git(root, ['rev-parse', '--git-path', name]).toString('utf8').trim();
|
|
30
|
+
try { return hash(fs.readFileSync(path.resolve(root, file))); }
|
|
31
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
32
|
+
});
|
|
33
|
+
return { head, settings: JSON.stringify(settings), truth: truth.map((file) => {
|
|
34
|
+
const { bytes, exists } = readRecord(root, file);
|
|
35
|
+
return [bytes ? hash(bytes) : exists ? 'not-regular' : null,
|
|
36
|
+
entries.has(file) ? hash(git(root, ['cat-file', 'blob', entries.get(file)])) : null];
|
|
37
|
+
}) };
|
|
38
|
+
}
|
|
39
|
+
const touched = (before, after) => truth.filter((file, i) =>
|
|
40
|
+
after.truth[i][0] !== before.truth[i][0] || !before.truth[i].includes(after.truth[i][1]));
|
|
41
|
+
function boundary(root, before, after, node, project) {
|
|
42
|
+
const problems = touched(before, after).map((file) => issue('truth-touched', file));
|
|
43
|
+
if (problems.length) return problems;
|
|
44
|
+
try { git(root, ['merge-base', '--is-ancestor', before.head, after.head]); }
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (error.status !== 1) throw error;
|
|
47
|
+
return [issue('history-rewritten', node.id)];
|
|
48
|
+
}
|
|
49
|
+
if (before.settings !== after.settings || !clean(root))
|
|
50
|
+
return [issue('writer-left-dirty', node.id)];
|
|
51
|
+
const named = new Set(project.checks.flatMap((c) => c.run.split(/\s+/u)
|
|
52
|
+
.map((word) => word.replace(/^["']|["']$/gu, '').replace(/^\.\//u, ''))));
|
|
53
|
+
const scopes = [...node.scope, '.ultra/rulings.md', '.ultra/records/handoffs']
|
|
54
|
+
.map((prefix) => prefix.replace(/\/+$/u, ''));
|
|
55
|
+
return paths(root, ['diff', '--no-renames', '--name-only', '-z', before.head, after.head])
|
|
56
|
+
.filter((file) => !exempt.includes(file)).sort().flatMap((file) => {
|
|
57
|
+
const code = named.has(file) ? 'check-touched'
|
|
58
|
+
: scopes.some((prefix) => within(file, prefix)) ? null : 'out-of-scope';
|
|
59
|
+
return code ? [issue(code, file)] : [];
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
function additions(before, after) {
|
|
63
|
+
const previous = before.map((r) => JSON.stringify(r));
|
|
64
|
+
return after.filter((r) => {
|
|
65
|
+
const index = previous.indexOf(JSON.stringify(r));
|
|
66
|
+
if (index >= 0) previous.splice(index, 1);
|
|
67
|
+
return index < 0;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
async function verify(project, node, before, after, baseline) {
|
|
71
|
+
const problems = boundary(project.root, before, after, node, project);
|
|
72
|
+
if (problems.some((p) => !['out-of-scope', 'check-touched'].includes(p.code))) return problems;
|
|
73
|
+
const current = load(project.root);
|
|
74
|
+
const added = additions(project.completions, current.completions);
|
|
75
|
+
problems.push(...project.nodes.filter((n) => n.id !== node.id
|
|
76
|
+
&& added.some((r) => r.node === n.id)).map((n) => issue('out-of-scope', n.id)));
|
|
77
|
+
if (problems.length) return problems;
|
|
78
|
+
const state = status(current);
|
|
79
|
+
if (state.gate !== 'pass') return [issue('plan-gate', node.id), ...state.problems];
|
|
80
|
+
const evidence = additions(project.evidence, current.evidence);
|
|
81
|
+
const requiredCheck = (c) => Object.hasOwn(c, 'fix')
|
|
82
|
+
|| c.covers.some((id) => node.covers.includes(id));
|
|
83
|
+
const checks = current.checks.filter((c) => requiredCheck(c)
|
|
84
|
+
|| evidence.some((r) => r.check === c.id)
|
|
85
|
+
|| state.checks.some((s) => s.id === c.id && ['stale', 'unknown'].includes(s.state)));
|
|
86
|
+
const checked = await runChecks(current, { words: checks.map((c) => c.id), req: null });
|
|
87
|
+
const required = new Set([baseline.milestone, ...project.closures.map((c) => c.milestone)]);
|
|
88
|
+
problems.push(...baseline.requirements.filter((r) => required.has(r.milestone)
|
|
89
|
+
&& r.state === 'green' && !checked.after.requirements.some((s) => s.id === r.id
|
|
90
|
+
&& s.state === 'green')).map((r) => issue('ratchet-broken', r.id)));
|
|
91
|
+
if (problems.length) return problems;
|
|
92
|
+
if (!checked.after.nodes.some((n) => n.id === node.id && n.state === 'done'))
|
|
93
|
+
return [issue('node-not-done', node.id)];
|
|
94
|
+
return checked.after.checks.filter((s) => s.state !== 'green' && current.checks.some((c) =>
|
|
95
|
+
c.id === s.id && requiredCheck(c)))
|
|
96
|
+
.map((c) => issue('node-not-green', c.id));
|
|
97
|
+
}
|
|
98
|
+
async function drive(project, opts) {
|
|
99
|
+
if (opts.words.length || !opts.writer?.trim()
|
|
100
|
+
|| (opts.reviewer !== undefined && !opts.reviewer.trim()))
|
|
101
|
+
throw new Error('drive requires --writer <command> and an optional non-empty --reviewer.');
|
|
102
|
+
const { root } = project;
|
|
103
|
+
const result = { run: randomUUID(), stop: null, verdict: null, steps: [], problems: [] };
|
|
104
|
+
(opts.json ? console.error : console.log)(`drive: run ${result.run}`);
|
|
105
|
+
const directory = path.join(root, '.ultra/.runtime/drive', result.run);
|
|
106
|
+
const bin = path.join(directory, 'bin');
|
|
107
|
+
const base = { UBP_ROOT: root, UBP_DRIVE: '1', PATH: `${bin}:${process.env.PATH ?? ''}`,
|
|
108
|
+
UBP: [process.execPath, path.join(__dirname, 'cli.cjs')].map(quote).join(' ') };
|
|
109
|
+
const record = (action, subject, attempt, value, problems = [], exit = null) => {
|
|
110
|
+
result.steps.push({ action, subject, attempt, result: value, exit, problems });
|
|
111
|
+
if (!opts.json) console.log(`drive: ${action} ${subject}: ${value}`
|
|
112
|
+
+ (['writer', 'reviewer'].includes(action) ? ` (exit ${exit})` : ''));
|
|
113
|
+
};
|
|
114
|
+
const stop = (reason, problems = []) => {
|
|
115
|
+
if (!opts.json) {
|
|
116
|
+
for (const p of problems) console.log(`${p.code}: ${p.message} — ${p.hint}`);
|
|
117
|
+
console.log(`drive: stop ${reason}`);
|
|
118
|
+
}
|
|
119
|
+
return Object.assign(result, { stop: reason, problems });
|
|
120
|
+
};
|
|
121
|
+
const release = acquireLock(root, 'drive');
|
|
122
|
+
if (!release) return stop('drive-running', [problem('drive-running', 'A drive is running.',
|
|
123
|
+
'Wait for the running drive to exit; `ubp status` shows it.')]);
|
|
124
|
+
process.once('exit', release); // at exit, after the result is printed; only cli.cjs calls drive
|
|
125
|
+
const shell = async (command, log, env) => {
|
|
126
|
+
fs.mkdirSync(bin, { recursive: true });
|
|
127
|
+
fs.writeFileSync(path.join(bin, 'ubp'), `#!/bin/sh\nexec ${base.UBP} "$@"\n`, { mode: 0o755 });
|
|
128
|
+
const fd = fs.openSync(path.join(directory, log), 'w');
|
|
129
|
+
let exit;
|
|
130
|
+
try { exit = await runShell(root, { run: command, timeout: opts['timeout-sec'] }, fd,
|
|
131
|
+
{ ...base, ...env }, 5000); } finally { fs.closeSync(fd); }
|
|
132
|
+
if (exit.interrupted) throw Object.assign(new Error('Drive interrupted.'),
|
|
133
|
+
{ exitCode: exit.interrupted });
|
|
134
|
+
return exit.timedOut ? 'timeout' : exit.signal ? `signal-${exit.signal}` : exit.code;
|
|
135
|
+
};
|
|
136
|
+
const check = async (selector) => {
|
|
137
|
+
const checked = await runChecks(load(root), { words: [], req: null, selector });
|
|
138
|
+
const pass = checked.result.runs.every((r) => r.outcome === 'pass' && !r.dirty);
|
|
139
|
+
record('check', selector, null, pass ? 'pass' : 'fail');
|
|
140
|
+
return checked.after;
|
|
141
|
+
};
|
|
142
|
+
const tried = new Map();
|
|
143
|
+
let writers = 0;
|
|
144
|
+
for (;;) {
|
|
145
|
+
if (!clean(root)) return stop('dirty-tree', [issue('dirty-tree', root)]);
|
|
146
|
+
project = load(root);
|
|
147
|
+
let state = status(project);
|
|
148
|
+
if (state.gate !== 'pass') {
|
|
149
|
+
if (state.gate === 'fail') return stop('plan-gate', state.problems);
|
|
150
|
+
const gated = gate(project);
|
|
151
|
+
record('gate', state.milestone, null, gated.ok ? 'pass' : 'fail', gated.problems);
|
|
152
|
+
if (!gated.ok) return stop('plan-gate', gated.problems);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const node = project.nodes.find((n) => n.id === state.next);
|
|
156
|
+
if (node) {
|
|
157
|
+
if (!node.scope?.length) return stop('scope-missing', [issue('scope-missing', node.id)]);
|
|
158
|
+
const brief = context(project, node.id, 32768);
|
|
159
|
+
if (!brief.ok) {
|
|
160
|
+
record('context', node.id, null, 'brief-over-budget', brief.problems);
|
|
161
|
+
return stop('brief-over-budget', brief.problems);
|
|
162
|
+
}
|
|
163
|
+
if (writers >= opts['max-writers']) return stop('writer-budget',
|
|
164
|
+
[issue('writer-budget', node.id)]);
|
|
165
|
+
if (state.checks.some((c) => ['stale', 'unknown'].includes(c.state))) {
|
|
166
|
+
state = await check('stale');
|
|
167
|
+
project = load(root);
|
|
168
|
+
}
|
|
169
|
+
const file = path.join(root, '.ultra/.runtime/briefs', `${node.id}.md`);
|
|
170
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
171
|
+
fs.writeFileSync(file, brief.brief);
|
|
172
|
+
const before = snapshot(root);
|
|
173
|
+
const attempt = (tried.get(node.id) ?? 0) + 1;
|
|
174
|
+
tried.set(node.id, attempt);
|
|
175
|
+
writers++;
|
|
176
|
+
const exit = await shell(opts.writer, `${node.id}-${attempt}.log`,
|
|
177
|
+
{ UBP_NODE: node.id, UBP_BRIEF: file, UBP_ATTEMPT: String(attempt) });
|
|
178
|
+
const problems = await verify(project, node, before, snapshot(root), state);
|
|
179
|
+
const failure = problems[0]?.code;
|
|
180
|
+
record('writer', node.id, attempt, failure ?? 'done', problems, exit);
|
|
181
|
+
if (failure && failure !== 'node-not-done') return stop(failure, problems);
|
|
182
|
+
if (failure && attempt >= opts.attempts) return stop('attempts-exhausted', problems);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (state.next === '检查') {
|
|
186
|
+
if ((await check('stale')).next === '检查') return stop('checks-not-green',
|
|
187
|
+
[issue('checks-not-green', state.milestone)]);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (state.next === '里程碑门') {
|
|
191
|
+
if ((await check('all')).next !== '里程碑门') continue;
|
|
192
|
+
if (opts.reviewer) {
|
|
193
|
+
const relative = `.ultra/records/milestones/${state.milestone}-review.md`;
|
|
194
|
+
const file = path.join(root, relative);
|
|
195
|
+
let failure = readRecord(root, relative).exists ? 'review-exists' : null;
|
|
196
|
+
let exit = null;
|
|
197
|
+
if (!failure) {
|
|
198
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
199
|
+
const before = snapshot(root);
|
|
200
|
+
exit = await shell(opts.reviewer, 'review.log',
|
|
201
|
+
{ UBP_MILESTONE: state.milestone, UBP_REVIEW: file });
|
|
202
|
+
const after = snapshot(root);
|
|
203
|
+
if (before.head !== after.head || touched(before, after).length || !clean(root, relative)
|
|
204
|
+
|| before.settings !== after.settings || status(load(root)).next !== '里程碑门')
|
|
205
|
+
failure = 'reviewer-changed-tree';
|
|
206
|
+
else {
|
|
207
|
+
result.verdict = reviewVerdict(readRecord(root, relative).bytes, true);
|
|
208
|
+
if (!result.verdict) failure = 'review-invalid';
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const problems = failure ? [issue(failure, relative)] : [];
|
|
212
|
+
record('reviewer', state.milestone, null, failure ?? result.verdict, problems, exit);
|
|
213
|
+
if (failure) return stop(failure, problems);
|
|
214
|
+
}
|
|
215
|
+
return stop('milestone-gate');
|
|
216
|
+
}
|
|
217
|
+
return stop(state.next === '全部完成' ? 'all-done' : 'blocked');
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
module.exports = { drive };
|