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.
- package/CHANGELOG.md +103 -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 +13 -32
- 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 -316
- 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,33 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { Worker } = require('node:worker_threads');
|
|
5
|
+
|
|
6
|
+
const signals = require('./signals.cjs');
|
|
7
|
+
|
|
8
|
+
function expectOutput(expect, output) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
// Isolate the acceptance author's regexp so its backtracking has a 5-second limit.
|
|
11
|
+
const worker = new Worker(path.join(__dirname, 'expect-worker.cjs'),
|
|
12
|
+
{ workerData: { expect, output } });
|
|
13
|
+
let finished = false;
|
|
14
|
+
const handlers = Object.entries(signals).map(([signal, exitCode]) =>
|
|
15
|
+
[signal, () => finish(null, Object.assign(new Error('Check interrupted.'), { exitCode }))]);
|
|
16
|
+
const finish = (result, error) => {
|
|
17
|
+
if (finished) return;
|
|
18
|
+
finished = true;
|
|
19
|
+
clearTimeout(timer);
|
|
20
|
+
for (const [signal, handler] of handlers) process.off(signal, handler);
|
|
21
|
+
worker.terminate().then(() => error ? reject(error) : resolve(result), reject);
|
|
22
|
+
};
|
|
23
|
+
const timer = setTimeout(() => finish('expect-timeout'), 5000);
|
|
24
|
+
for (const [signal, handler] of handlers) process.on(signal, handler);
|
|
25
|
+
worker.once('message', (matches) => finish(matches ? 'exit-0' : 'expect-unmet'));
|
|
26
|
+
worker.once('error', (error) => finish(null, error));
|
|
27
|
+
worker.once('exit', (code) => {
|
|
28
|
+
if (!finished) finish(null, new Error(`Expect worker exited without a result (${code}).`));
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { expectOutput };
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { createHash } = require('node:crypto');
|
|
6
|
+
|
|
7
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
|
|
8
|
+
const hash = (text) => createHash('sha256').update(text).digest('hex');
|
|
9
|
+
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
10
|
+
const strings = (value) => Array.isArray(value) && value.every((v) => typeof v === 'string');
|
|
11
|
+
const unique = (items) => [...new Set(items)];
|
|
12
|
+
const problem = (code, message, hint) => ({ code, message, hint });
|
|
13
|
+
|
|
14
|
+
function assertFile(ok, file, message) {
|
|
15
|
+
if (!ok) throw new Error(`${file}: ${message}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function read(root, file, optional = false) {
|
|
19
|
+
try { return fs.readFileSync(path.join(root, '.ultra', file), 'utf8'); }
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (optional && error.code === 'ENOENT') return '';
|
|
22
|
+
throw new Error(`.ultra/${file}: ${error.message}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readRecord(root, relative) {
|
|
27
|
+
const file = path.join(root, relative);
|
|
28
|
+
const result = { exists: true, bytes: null };
|
|
29
|
+
let fd;
|
|
30
|
+
try {
|
|
31
|
+
const entry = fs.lstatSync(file, { throwIfNoEntry: false });
|
|
32
|
+
result.exists = entry !== undefined;
|
|
33
|
+
if (!entry?.isFile()) return result;
|
|
34
|
+
const parent = fs.realpathSync(path.dirname(file));
|
|
35
|
+
const location = path.relative(fs.realpathSync(root), parent);
|
|
36
|
+
if (path.isAbsolute(location) || location === '..' || location.startsWith(`..${path.sep}`)) {
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
fd = fs.openSync(path.join(parent, path.basename(file)),
|
|
40
|
+
fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
|
|
41
|
+
if (fs.fstatSync(fd).isFile()) result.bytes = fs.readFileSync(fd);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (!['ENOENT', 'ENOTDIR', 'ELOOP'].includes(error.code)) {
|
|
44
|
+
throw new Error(`${relative}: ${error.message}`);
|
|
45
|
+
}
|
|
46
|
+
} finally {
|
|
47
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parse(text, file) {
|
|
53
|
+
try { return JSON.parse(text); }
|
|
54
|
+
catch (error) { throw new Error(`${file}: ${error.message}`); }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function markdown(text, visit) {
|
|
58
|
+
let fence = null;
|
|
59
|
+
const lines = text.replace(/\r\n/gu, '\n').split('\n');
|
|
60
|
+
lines.forEach((line, index) => {
|
|
61
|
+
const marker = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/u);
|
|
62
|
+
if (fence) {
|
|
63
|
+
visit(line, index + 1, false);
|
|
64
|
+
if (marker && marker[1][0] === fence.marker[0]
|
|
65
|
+
&& marker[1].length >= fence.marker.length && !marker[2].trim()) fence = null;
|
|
66
|
+
} else if (marker && (marker[1][0] !== '`' || !marker[2].includes('`'))) {
|
|
67
|
+
fence = { marker: marker[1], line: index + 1 };
|
|
68
|
+
visit(line, index + 1, false);
|
|
69
|
+
} else visit(line, index + 1, true);
|
|
70
|
+
});
|
|
71
|
+
return fence?.line ?? null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function requirements(text) {
|
|
75
|
+
const result = new Map();
|
|
76
|
+
const requirementText = new Map();
|
|
77
|
+
const shared = [];
|
|
78
|
+
let current = null;
|
|
79
|
+
const finish = () => {
|
|
80
|
+
if (!current) return;
|
|
81
|
+
const lines = current.lines.map((line) => line.trimEnd());
|
|
82
|
+
while (lines.length && lines.at(-1) === '') lines.pop();
|
|
83
|
+
while (lines.length && lines[0] === '') lines.shift();
|
|
84
|
+
const text = lines.join('\n');
|
|
85
|
+
result.set(current.id, hash(text));
|
|
86
|
+
requirementText.set(current.id, text);
|
|
87
|
+
current = null;
|
|
88
|
+
};
|
|
89
|
+
const unclosed = markdown(text, (line, number, outside) => {
|
|
90
|
+
const heading = outside && line.match(/^ {0,3}(#{1,2})(?:[ \t]+(.*)|[ \t]*)$/u);
|
|
91
|
+
const isRequirement = heading && heading[1] === '##' && heading[2]?.startsWith('REQ-');
|
|
92
|
+
if (outside && /^ {0,3}#+\s*REQ(?:[-_]|\s+\d)/iu.test(line) && !isRequirement) {
|
|
93
|
+
assertFile(false, `.ultra/spec.md:${number}`, `malformed requirement heading: ${line}`);
|
|
94
|
+
}
|
|
95
|
+
if (heading) {
|
|
96
|
+
finish();
|
|
97
|
+
const title = heading[2] || '';
|
|
98
|
+
if (heading[1] === '##' && title.startsWith('REQ-')) {
|
|
99
|
+
const id = title.split(/\s/u)[0];
|
|
100
|
+
const file = `.ultra/spec.md:${number}`;
|
|
101
|
+
assertFile(ID.test(id.slice(4)), file, `malformed requirement heading: ${line}`);
|
|
102
|
+
assertFile(!result.has(id), file, `duplicate requirement id ${id}`);
|
|
103
|
+
current = { id, lines: [] };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (current) current.lines.push(line);
|
|
107
|
+
else if (line.trimEnd()) shared.push(line.trimEnd());
|
|
108
|
+
});
|
|
109
|
+
assertFile(!unclosed, `.ultra/spec.md:${unclosed}`, 'unclosed fence');
|
|
110
|
+
finish();
|
|
111
|
+
return { reqs: result, requirementText, shared: hash(shared.join('\n')) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function validateCheck(item, valid) {
|
|
115
|
+
const invariant = Object.hasOwn(item, 'fix');
|
|
116
|
+
valid(!invariant || (typeof item.fix === 'string' && item.fix.trim().length > 0),
|
|
117
|
+
`${item.id}: fix must be a non-empty string`);
|
|
118
|
+
valid(invariant ? item.covers.length === 0 : item.covers.length > 0,
|
|
119
|
+
`${item.id}: invariants must cover nothing; other checks must cover a requirement`);
|
|
120
|
+
valid(item.run.trim().length > 0, `${item.id}: run must be non-empty`);
|
|
121
|
+
const timeout = item.timeout_sec === undefined ? 600 : item.timeout_sec;
|
|
122
|
+
valid(Number.isInteger(timeout) && timeout > 0 && timeout <= 86400,
|
|
123
|
+
`${item.id}: invalid timeout_sec`);
|
|
124
|
+
valid(typeof item.expect === 'string' && item.expect.length > 0,
|
|
125
|
+
`${item.id}: expect must be a non-empty regular expression string`);
|
|
126
|
+
let pattern;
|
|
127
|
+
try { pattern = new RegExp(item.expect, 'mu'); }
|
|
128
|
+
catch (error) { valid(false, `${item.id}: invalid expect: ${error.message}`); }
|
|
129
|
+
valid(!pattern.test(''), `${item.id}: expect must not match the empty string`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function registry(root, file, kinds) {
|
|
133
|
+
const data = parse(read(root, file), `.ultra/${file}`);
|
|
134
|
+
const valid = (ok, message) => assertFile(ok, `.ultra/${file}`, message);
|
|
135
|
+
valid(object(data) && data.version === 1, 'expected an object with version 1');
|
|
136
|
+
if (file === 'plan.json' && Object.hasOwn(data, 'cleanup_every')) {
|
|
137
|
+
valid(Number.isInteger(data.cleanup_every) && data.cleanup_every > 0,
|
|
138
|
+
'cleanup_every must be a positive integer');
|
|
139
|
+
}
|
|
140
|
+
for (const [kind, fields] of Object.entries(kinds)) {
|
|
141
|
+
valid(Array.isArray(data[kind]), `${kind} must be an array`);
|
|
142
|
+
const ids = new Set();
|
|
143
|
+
for (const item of data[kind]) {
|
|
144
|
+
valid(object(item) && typeof item.id === 'string' && ID.test(item.id),
|
|
145
|
+
`invalid ${kind} id`);
|
|
146
|
+
valid(!ids.has(item.id), `duplicate id ${item.id}`);
|
|
147
|
+
ids.add(item.id);
|
|
148
|
+
for (const [field, type] of Object.entries(fields)) {
|
|
149
|
+
valid(type === 'array' ? strings(item[field]) : typeof item[field] === type,
|
|
150
|
+
`${item.id}: invalid or missing ${field}`);
|
|
151
|
+
}
|
|
152
|
+
if (kind === 'checks') validateCheck(item, valid);
|
|
153
|
+
if (kind === 'nodes' && item.scope !== undefined) {
|
|
154
|
+
valid(strings(item.scope), `${item.id}: scope must be an array of strings`);
|
|
155
|
+
}
|
|
156
|
+
if (kind === 'nodes' && Object.hasOwn(item, 'kind')) {
|
|
157
|
+
valid(item.kind === 'cleanup', `${item.id}: kind must be cleanup`);
|
|
158
|
+
valid(item.covers.length === 0, `${item.id}: a cleanup node must cover nothing`);
|
|
159
|
+
}
|
|
160
|
+
if (kind === 'milestones' && item.journey !== undefined) {
|
|
161
|
+
valid(typeof item.journey === 'string', `${item.id}: journey must be a string`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return data;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function rulingProblems(text) {
|
|
169
|
+
const problems = [];
|
|
170
|
+
// Each part needs a character that is neither space nor punctuation, so matching finds a
|
|
171
|
+
// split where all have content. No gap after a part: it would multiply backtracking.
|
|
172
|
+
const gap = '[\\s\\p{P}]*';
|
|
173
|
+
const part = `${gap}[^\\s\\p{P}].*?`;
|
|
174
|
+
const ruling = new RegExp(`^- ${gap}\\d{4}-\\d{2}-\\d{2}${gap}L[0-4]${gap}裁决${part}因为`
|
|
175
|
+
+ `(?!${gap}如果判断错了${gap}代价)${part}(?:如果判断错了${gap})?代价(?:是|(?!是))${part}$`, 'u');
|
|
176
|
+
const hint = 'Use: - YYYY-MM-DD L0–L4 裁决:decision — 因为 reason '
|
|
177
|
+
+ '— 如果判断错了,代价是 cost';
|
|
178
|
+
const unclosed = markdown(text, (line, number, outside) => {
|
|
179
|
+
if (outside && /^\s*[-*+] /u.test(line) && !ruling.test(line)) {
|
|
180
|
+
problems.push(problem('malformed-ruling', `.ultra/rulings.md:${number}: ${line}`, hint));
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
if (unclosed) problems.push(problem('malformed-ruling',
|
|
184
|
+
`.ultra/rulings.md:${unclosed}: unclosed fence`, 'Close the fence opened on this line.'));
|
|
185
|
+
return problems;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = { ID, hash, object, strings, unique, problem, read, readRecord, markdown,
|
|
189
|
+
requirements, registry, rulingProblems };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { createHash } = require('node:crypto');
|
|
6
|
+
const { spawn, execFileSync } = require('node:child_process');
|
|
7
|
+
const INTERRUPTS = require('./signals.cjs');
|
|
8
|
+
let localVariables;
|
|
9
|
+
const boundRoots = new Set();
|
|
10
|
+
const mergeOptions = ['-c', 'maintenance.auto=false', '-c', 'gc.auto=0'];
|
|
11
|
+
const bindGit = (root) => { boundRoots.add(root); return () => boundRoots.delete(root); };
|
|
12
|
+
|
|
13
|
+
function gitEnvironment(root) {
|
|
14
|
+
localVariables ??= execFileSync('git', ['rev-parse', '--local-env-vars'],
|
|
15
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim().split('\n');
|
|
16
|
+
const env = { ...process.env };
|
|
17
|
+
for (const name of [...localVariables, 'GIT_LITERAL_PATHSPECS', 'GIT_GLOB_PATHSPECS',
|
|
18
|
+
'GIT_NOGLOB_PATHSPECS', 'GIT_ICASE_PATHSPECS']) delete env[name];
|
|
19
|
+
if (boundRoots.has(root)) env.GIT_CEILING_DIRECTORIES = path.dirname(root);
|
|
20
|
+
return env;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function gitFailure(args, error) {
|
|
24
|
+
const reason = String(error.stderr || '').split('\n').find((line) => line.trim());
|
|
25
|
+
error.message = `Git failed: ${args.join(' ')}` + (reason ? `: ${reason}` : '');
|
|
26
|
+
if (INTERRUPTS[error.signal]) error.exitCode = INTERRUPTS[error.signal];
|
|
27
|
+
return error;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function git(root, args) {
|
|
31
|
+
if (boundRoots.has(root)) args = [...mergeOptions, ...args];
|
|
32
|
+
try {
|
|
33
|
+
return execFileSync('git', args, { cwd: root, env: gitEnvironment(root), maxBuffer: Infinity,
|
|
34
|
+
stdio: ['ignore', 'pipe', 'pipe'] });
|
|
35
|
+
} catch (error) {
|
|
36
|
+
throw gitFailure(args, error);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function gitAsync(root, args, options = {}) {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const child = spawn('git', args, { cwd: root, env: gitEnvironment(root),
|
|
43
|
+
stdio: ['ignore', 'pipe', 'pipe'], ...options });
|
|
44
|
+
const stdout = [];
|
|
45
|
+
const stderr = [];
|
|
46
|
+
child.stdout.on('data', (chunk) => stdout.push(chunk));
|
|
47
|
+
child.stderr.on('data', (chunk) => stderr.push(chunk));
|
|
48
|
+
child.on('error', (error) => reject(gitFailure(args, error)));
|
|
49
|
+
child.on('close', (code, signal) => {
|
|
50
|
+
if (code === 0) resolve(Buffer.concat(stdout));
|
|
51
|
+
else reject(gitFailure(args, Object.assign(new Error(),
|
|
52
|
+
{ code, signal, stderr: Buffer.concat(stderr).toString('utf8') })));
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const ownedFiles = new Set(['.ultra/spec.md', '.ultra/checks.json',
|
|
58
|
+
'.ultra/plan.json', '.ultra/rulings.md']);
|
|
59
|
+
const ownedDirs = ['.ultra/ledger', '.ultra/records', '.ultra/.runtime'];
|
|
60
|
+
const engineOwned = (name) => ownedFiles.has(name)
|
|
61
|
+
|| ownedDirs.some((dir) => name === dir || name.startsWith(`${dir}/`));
|
|
62
|
+
|
|
63
|
+
function fingerprintEntries(digest, tree, exclude) {
|
|
64
|
+
// ls-tree separates mode/type/object metadata from the unquoted path with a tab.
|
|
65
|
+
const PATH_SEPARATOR = 0x09;
|
|
66
|
+
let start = 0;
|
|
67
|
+
for (let end = tree.indexOf(0); end !== -1; end = tree.indexOf(0, start)) {
|
|
68
|
+
const entry = tree.subarray(start, end);
|
|
69
|
+
const name = entry.subarray(entry.indexOf(PATH_SEPARATOR) + 1).toString('utf8');
|
|
70
|
+
if (!exclude(name)) digest.update(tree.subarray(start, end + 1));
|
|
71
|
+
start = end + 1;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function codeState(root, exclude = engineOwned) {
|
|
76
|
+
const digest = createHash('sha256');
|
|
77
|
+
fingerprintEntries(digest, git(root, ['ls-tree', '--full-tree', '-z', 'HEAD']),
|
|
78
|
+
(name) => name === '.ultra');
|
|
79
|
+
fingerprintEntries(digest, git(root, ['ls-tree', '--full-tree', '-z', 'HEAD', '--',
|
|
80
|
+
'.ultra/']), engineOwned);
|
|
81
|
+
const changes = git(root, ['--no-optional-locks', 'status', '--porcelain', '-z',
|
|
82
|
+
`--untracked-files=${exclude === engineOwned ? 'normal' : 'all'}`])
|
|
83
|
+
.toString('utf8').split('\0');
|
|
84
|
+
let dirty = false;
|
|
85
|
+
for (let i = 0; i < changes.length; i++) {
|
|
86
|
+
if (!changes[i]) continue;
|
|
87
|
+
if (!exclude(changes[i].slice(3).replace(/\/$/u, ''))) dirty = true;
|
|
88
|
+
if (/[RC]/u.test(changes[i].slice(0, 2)) && !exclude(changes[++i])) dirty = true;
|
|
89
|
+
}
|
|
90
|
+
return { code: digest.digest('hex'), dirty };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function projectRoot(requested) {
|
|
94
|
+
const root = path.resolve(requested);
|
|
95
|
+
if (!fs.existsSync(root)) {
|
|
96
|
+
throw new Error(`--root ${root} does not exist. Choose an existing project directory.`);
|
|
97
|
+
}
|
|
98
|
+
let top;
|
|
99
|
+
try { top = git(root, ['rev-parse', '--show-toplevel']).toString('utf8').replace(/\n$/u, ''); }
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (error.exitCode) throw error;
|
|
102
|
+
throw new Error(`${root} is not inside a Git work tree. Run git init first.`);
|
|
103
|
+
}
|
|
104
|
+
try { git(top, ['rev-parse', '--verify', 'HEAD']); }
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (error.exitCode) throw error;
|
|
107
|
+
throw new Error(`${top} has no commit. Commit the code first.`);
|
|
108
|
+
}
|
|
109
|
+
return top;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = { git, gitAsync, gitEnvironment, bindGit, mergeOptions, codeState, projectRoot };
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { ID, object, strings, read } = require('./formats.cjs');
|
|
6
|
+
|
|
7
|
+
const hashValue = (value) => typeof value === 'string' && /^[a-f0-9]{64}$/u.test(value);
|
|
8
|
+
const hashes = (value) => object(value) && Object.values(value).every(hashValue);
|
|
9
|
+
const textFields = (record, fields) => fields.every((key) => typeof record[key] === 'string');
|
|
10
|
+
const checkSnapshots = (value) => object(value) && Object.values(value).every((check) =>
|
|
11
|
+
object(check) && hashValue(check.fp) && strings(check.covers));
|
|
12
|
+
const LEDGERS = ['evidence', 'plan-log', 'nodes', 'milestones', 'approvals'];
|
|
13
|
+
const approvalItem = (value) => typeof value === 'string' && value.split('/').length === 2
|
|
14
|
+
&& value.split('/').every((part) => ID.test(part));
|
|
15
|
+
|
|
16
|
+
function validApproval(record) {
|
|
17
|
+
return strings(record.items) && record.items.length > 0
|
|
18
|
+
&& record.items.every((item, i) => approvalItem(item) && (!i || record.items[i - 1] < item))
|
|
19
|
+
&& object(record.owner) && typeof record.owner.words === 'string'
|
|
20
|
+
&& record.owner.words.trim().length > 0 && typeof record.owner.proxy === 'boolean';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function validClosure(record) {
|
|
24
|
+
const fields = {
|
|
25
|
+
code: hashValue,
|
|
26
|
+
reqs: hashes,
|
|
27
|
+
journey: (v) => object(v) && textFields(v, ['check', 'run']),
|
|
28
|
+
review: (v) => object(v) && textFields(v, ['path']) && hashValue(v.sha256),
|
|
29
|
+
owner: (v) => object(v) && textFields(v, ['words']) && typeof v.proxy === 'boolean',
|
|
30
|
+
};
|
|
31
|
+
return typeof record.milestone === 'string'
|
|
32
|
+
&& Object.entries(fields).every(([key, valid]) => !Object.hasOwn(record, key)
|
|
33
|
+
|| valid(record[key]));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function recordTime(value) {
|
|
37
|
+
return typeof value === 'string'
|
|
38
|
+
&& /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(value)
|
|
39
|
+
&& Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function validRecord(record, name) {
|
|
43
|
+
if (!object(record) || record.v !== 1 || !recordTime(record.at)) return false;
|
|
44
|
+
if (['evidence', 'plan-log', 'nodes'].includes(name)
|
|
45
|
+
&& Object.hasOwn(record, 'shared') && !hashValue(record.shared)) return false;
|
|
46
|
+
if (name === 'evidence') {
|
|
47
|
+
return textFields(record, ['check', 'run', 'reason', 'log'])
|
|
48
|
+
&& typeof record.dirty === 'boolean' && hashValue(record.code)
|
|
49
|
+
&& hashValue(record.check_fp) && hashes(record.reqs)
|
|
50
|
+
&& ['pass', 'fail', 'unknown'].includes(record.outcome)
|
|
51
|
+
&& (record.exit_code === null || Number.isInteger(record.exit_code))
|
|
52
|
+
&& Number.isInteger(record.duration_ms) && record.duration_ms >= 0
|
|
53
|
+
&& (record.error_line === null || typeof record.error_line === 'string');
|
|
54
|
+
}
|
|
55
|
+
if (name === 'plan-log') {
|
|
56
|
+
const diff = record.diff;
|
|
57
|
+
return typeof record.milestone === 'string' && hashes(record.requirements)
|
|
58
|
+
&& strings(record.nodes) && checkSnapshots(record.checks)
|
|
59
|
+
&& (!object(diff) || !Object.hasOwn(diff, 'shared') || diff.shared === true)
|
|
60
|
+
&& ['baseline', 'change', 'removed'].includes(record.type)
|
|
61
|
+
&& (record.type !== 'removed' || (!Object.keys(record.requirements).length
|
|
62
|
+
&& !record.nodes.length && !Object.keys(record.checks).length))
|
|
63
|
+
&& (record.type === 'baseline' || (object(diff)
|
|
64
|
+
&& ['added', 'removed', 'modified', 'nodes_added', 'nodes_removed',
|
|
65
|
+
'checks_added', 'checks_removed', 'checks_modified']
|
|
66
|
+
.every((key) => strings(diff[key]))));
|
|
67
|
+
}
|
|
68
|
+
if (name === 'nodes') return textFields(record, ['node']) && hashValue(record.code)
|
|
69
|
+
&& hashes(record.reqs) && strings(record.runs);
|
|
70
|
+
if (name === 'approvals') return validApproval(record);
|
|
71
|
+
return name === 'milestones' && validClosure(record);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function ledger(root, name) {
|
|
75
|
+
const file = `ledger/${name}.jsonl`;
|
|
76
|
+
const text = read(root, file, true);
|
|
77
|
+
const lines = text.split('\n');
|
|
78
|
+
const records = [];
|
|
79
|
+
const warnings = [];
|
|
80
|
+
let repairEnd = null;
|
|
81
|
+
lines.forEach((line, index) => {
|
|
82
|
+
if (!line.trim()) return;
|
|
83
|
+
let record;
|
|
84
|
+
try { record = JSON.parse(line); } catch { /* validated below */ }
|
|
85
|
+
if (validRecord(record, name)) { records.push(record); return; }
|
|
86
|
+
const location = `.ultra/${file}:${index + 1}`;
|
|
87
|
+
if (index === lines.length - 1 && !text.endsWith('\n')) {
|
|
88
|
+
repairEnd = Buffer.byteLength(text.slice(0, text.lastIndexOf('\n') + 1));
|
|
89
|
+
warnings.push(`${location}: incomplete final ledger record ignored; repaired on next append`);
|
|
90
|
+
} else throw new Error(`${location}: malformed ledger record; remove or repair that line.`);
|
|
91
|
+
});
|
|
92
|
+
return { records, warnings, repairEnd, needsNewline: !!text && !text.endsWith('\n') };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function latest(records, field, id) {
|
|
96
|
+
let result;
|
|
97
|
+
for (const record of records) {
|
|
98
|
+
if (record[field] === id && (!result || record.at >= result.at)) result = record;
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function alive(pid) {
|
|
104
|
+
try { process.kill(pid, 0); return true; }
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (error.code === 'EPERM') return true;
|
|
107
|
+
if (error.code === 'ESRCH') return false;
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function lockPid(root, name) {
|
|
113
|
+
try {
|
|
114
|
+
const pid = Number(fs.readFileSync(path.join(root, '.ultra/.runtime', `${name}.lock`), 'utf8'));
|
|
115
|
+
return Number.isInteger(pid) && pid > 0 && alive(pid) ? pid : null;
|
|
116
|
+
} catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function removeOwnedLock(file, pid) {
|
|
120
|
+
try {
|
|
121
|
+
if (Number(fs.readFileSync(file, 'utf8')) === pid) fs.unlinkSync(file);
|
|
122
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function breakDeadLock(file, pid) {
|
|
126
|
+
const breaker = `${file}.break`;
|
|
127
|
+
let fd;
|
|
128
|
+
try { fd = fs.openSync(breaker, 'wx'); }
|
|
129
|
+
catch (error) {
|
|
130
|
+
if (error.code !== 'EEXIST') throw error;
|
|
131
|
+
try {
|
|
132
|
+
if (Date.now() - fs.statSync(breaker).mtimeMs > 10000) {
|
|
133
|
+
fs.unlinkSync(breaker);
|
|
134
|
+
return breakDeadLock(file, pid);
|
|
135
|
+
}
|
|
136
|
+
} catch (missing) { if (missing.code !== 'ENOENT') throw missing; }
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
// Only one breaker rechecks and removes a dead pid; other waiters cannot race it.
|
|
141
|
+
if (!alive(pid)) removeOwnedLock(file, pid);
|
|
142
|
+
} finally {
|
|
143
|
+
fs.closeSync(fd);
|
|
144
|
+
try { fs.unlinkSync(breaker); }
|
|
145
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function acquireLock(root, name) {
|
|
150
|
+
const file = path.join(root, '.ultra', '.runtime', `${name}.lock`);
|
|
151
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
152
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
153
|
+
let fd;
|
|
154
|
+
try { fd = fs.openSync(file, 'wx'); }
|
|
155
|
+
catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
156
|
+
if (fd !== undefined) {
|
|
157
|
+
try { fs.writeFileSync(fd, String(process.pid)); }
|
|
158
|
+
finally { fs.closeSync(fd); }
|
|
159
|
+
return () => removeOwnedLock(file, process.pid);
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
const pid = Number(fs.readFileSync(file, 'utf8'));
|
|
163
|
+
if (Number.isInteger(pid) && pid > 0 && !alive(pid)) breakDeadLock(file, pid);
|
|
164
|
+
else return null;
|
|
165
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function withLock(root, action) {
|
|
171
|
+
const deadline = Date.now() + 30000;
|
|
172
|
+
while (true) {
|
|
173
|
+
const release = acquireLock(root, 'engine');
|
|
174
|
+
if (release) {
|
|
175
|
+
try { return action(); } finally { release(); }
|
|
176
|
+
}
|
|
177
|
+
if (Date.now() >= deadline) {
|
|
178
|
+
throw new Error('.ultra/.runtime/engine.lock: lock wait exceeded 30 s; check its holder.');
|
|
179
|
+
}
|
|
180
|
+
// Sleep without polling the filesystem in a busy loop while the holder works.
|
|
181
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function append(root, name, record, locked = false) {
|
|
186
|
+
const write = () => {
|
|
187
|
+
const ledgers = new Map(LEDGERS.map((key) => [key, ledger(root, key)]));
|
|
188
|
+
const state = ledgers.get(name);
|
|
189
|
+
// Read and advance under the same lock so clock rollback cannot hide a new record.
|
|
190
|
+
const greatest = [...ledgers.values()].flatMap((s) => s.records)
|
|
191
|
+
.reduce((time, r) => Math.max(time, Date.parse(r.at)), -1);
|
|
192
|
+
record.at = new Date(Math.max(Date.now(), greatest + 1)).toISOString();
|
|
193
|
+
if (!validRecord(record, name)) {
|
|
194
|
+
throw new Error(`.ultra/ledger/${name}.jsonl: refusing to append a malformed ledger record.`);
|
|
195
|
+
}
|
|
196
|
+
const file = path.join(root, '.ultra', 'ledger', `${name}.jsonl`);
|
|
197
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
198
|
+
if (state.repairEnd !== null) fs.truncateSync(file, state.repairEnd);
|
|
199
|
+
const prefix = state.repairEnd === null && state.needsNewline ? '\n' : '';
|
|
200
|
+
fs.appendFileSync(file, `${prefix}${JSON.stringify(record)}\n`);
|
|
201
|
+
};
|
|
202
|
+
return locked ? write() : withLock(root, write);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
module.exports = { ledger, latest, withLock, acquireLock, lockPid, append, approvalItem };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { hash, read, requirements, registry } = require('./formats.cjs');
|
|
4
|
+
const { codeState, projectRoot } = require('./git-state.cjs');
|
|
5
|
+
const { ledger } = require('./ledger.cjs');
|
|
6
|
+
|
|
7
|
+
function load(requested) {
|
|
8
|
+
const root = projectRoot(requested);
|
|
9
|
+
const code = codeState(root);
|
|
10
|
+
const { reqs, requirementText, shared } = requirements(read(root, 'spec.md'));
|
|
11
|
+
const validated = registry(root, 'checks.json', { checks: { covers: 'array', run: 'string' } });
|
|
12
|
+
const checks = validated.checks.map((check) => {
|
|
13
|
+
const timeout = check.timeout_sec === undefined ? 600 : check.timeout_sec;
|
|
14
|
+
return { ...check, timeout, fp: hash(JSON.stringify([check.run, timeout, check.expect])) };
|
|
15
|
+
});
|
|
16
|
+
const { milestones, nodes, cleanup_every } = registry(root, 'plan.json', {
|
|
17
|
+
milestones: { title: 'string', requirements: 'array' },
|
|
18
|
+
nodes: { milestone: 'string', title: 'string', covers: 'array', depends: 'array' },
|
|
19
|
+
});
|
|
20
|
+
const history = ['evidence', 'plan-log', 'nodes', 'milestones', 'approvals']
|
|
21
|
+
.map((name) => ledger(root, name));
|
|
22
|
+
return { root, ...code, reqs, requirementText, shared, checks, milestones, nodes, cleanup_every,
|
|
23
|
+
rulings: read(root, 'rulings.md', true), warnings: history.flatMap((h) => h.warnings),
|
|
24
|
+
evidence: history[0].records, plans: history[1].records,
|
|
25
|
+
completions: history[2].records, closures: history[3].records, approvals: history[4].records };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = { load };
|