ultra-builder-pro-cli 0.7.0 → 0.8.1
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 +29 -1
- package/README.md +15 -3
- package/adapters/_shared/handbook.cjs +1 -0
- package/adapters/_shared/provenance.cjs +286 -0
- package/adapters/_shared/tests/provenance.test.cjs +133 -0
- package/adapters/claude.js +33 -0
- package/adapters/codex.js +87 -0
- package/adapters/opencode.js +66 -0
- package/adapters/tests/codex.test.cjs +37 -0
- package/agents/debugger.md +12 -0
- package/bin/install.js +63 -8
- package/commands/ultra-change.md +3 -2
- package/docs/ARCHITECTURE.md +37 -3
- package/docs/PLAN.zh-CN.md +6 -4
- package/docs/RUNTIME-COMPAT-MATRIX.md +8 -1
- package/hooks/README.md +6 -2
- package/hooks/workflow_checkpoint.py +15 -1
- package/hooks/workflow_resume.py +96 -7
- package/mcp-server/lib/change-workflow.cjs +69 -0
- package/mcp-server/lib/change-workflow.test.cjs +87 -0
- package/package.json +1 -1
- package/skills/ultra-change/SKILL.md +12 -3
- package/skills/ultra-deliver/SKILL.md +3 -1
- package/skills/ultra-doctor/SKILL.md +10 -3
- package/spec/mcp-tools.yaml +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.8.1] — 2026-07-17
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Removed a retired-surface name accidentally reintroduced in the D53 decision
|
|
15
|
+
note. The unchanged retirement contract caught it before `v0.8.0` reached npm;
|
|
16
|
+
this immutable patch release carries the complete harness closure forward.
|
|
17
|
+
|
|
18
|
+
## [0.8.0] — 2026-07-17
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- Added a first-class incident debug lane: incident changes now create a
|
|
23
|
+
registered `diagnosis.md` artifact whose reproduction, hypotheses, root
|
|
24
|
+
cause, regression-test, and recovery sections are required by convergence.
|
|
25
|
+
- Added normalized install-provenance manifests for Claude Code, OpenCode, and
|
|
26
|
+
Codex plus read-only `ubp --doctor [--json]` checks for asset hashes and
|
|
27
|
+
host-specific plugin, hook, MCP, launcher, and runtime entry points.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- Turned workflow compaction checkpoints into a real recovery consumer:
|
|
32
|
+
resume now rejects malformed or terminal checkpoints, selects the newest
|
|
33
|
+
valid live/checkpoint state, and atomically restores missing or older live
|
|
34
|
+
workflow state before re-injecting context.
|
|
35
|
+
|
|
10
36
|
## [0.7.0] — 2026-07-17
|
|
11
37
|
|
|
12
38
|
### Added
|
|
@@ -306,7 +332,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
306
332
|
skill manifest, CLI protocol + mapping table; 5 spec validators.
|
|
307
333
|
- **Phase 0 — skeleton**: multi-runtime installer scaffolding.
|
|
308
334
|
|
|
309
|
-
[Unreleased]: https://github.com/rocky2431/ultra-builder-pro-cli/compare/v0.
|
|
335
|
+
[Unreleased]: https://github.com/rocky2431/ultra-builder-pro-cli/compare/v0.8.1...HEAD
|
|
336
|
+
[0.8.1]: https://github.com/rocky2431/ultra-builder-pro-cli/compare/v0.8.0...v0.8.1
|
|
337
|
+
[0.8.0]: https://github.com/rocky2431/ultra-builder-pro-cli/compare/v0.7.0...v0.8.0
|
|
310
338
|
[0.7.0]: https://github.com/rocky2431/ultra-builder-pro-cli/compare/v0.6.0...v0.7.0
|
|
311
339
|
[0.6.0]: https://github.com/rocky2431/ultra-builder-pro-cli/compare/v0.5.3...v0.6.0
|
|
312
340
|
[0.5.3]: https://github.com/rocky2431/ultra-builder-pro-cli/compare/v0.5.2...v0.5.3
|
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ authoritative `.ultra/state.db`.
|
|
|
8
8
|
|
|
9
9
|
<div align="center">
|
|
10
10
|
|
|
11
|
-
[](./CHANGELOG.md)
|
|
12
12
|
[](#verification)
|
|
13
13
|
[](./LICENSE)
|
|
14
14
|
[](./package.json)
|
|
@@ -23,7 +23,8 @@ authoritative `.ultra/state.db`.
|
|
|
23
23
|
|
|
24
24
|
- **Builds host-native plugins from one allowlist.** Claude Code, OpenCode, and
|
|
25
25
|
Codex receive their own command/skill, agent, hook, and MCP representation.
|
|
26
|
-
Install and uninstall are symmetric on every supported host
|
|
26
|
+
Install and uninstall are symmetric on every supported host; `ubp --doctor`
|
|
27
|
+
verifies the installed asset hashes and host entry points without mutation.
|
|
27
28
|
- **Shares state across runtimes.** `.ultra/state.db` (SQLite + WAL) is the
|
|
28
29
|
authoritative source for changes, tasks, sessions, events, incidents,
|
|
29
30
|
projection jobs, and telemetry. `tasks.json` and context markdown are
|
|
@@ -37,6 +38,8 @@ authoritative `.ultra/state.db`.
|
|
|
37
38
|
context manifest; `/ultra-deliver` requires docs/test/review evidence and
|
|
38
39
|
baseline reconciliation before archive. `/ultra-doctor` reports incidents,
|
|
39
40
|
projection lag, orphan sessions, and backup-first mechanical recovery.
|
|
41
|
+
Incident changes additionally require a five-section `diagnosis.md` covering
|
|
42
|
+
reproduction, hypotheses, root cause, regression test, and recovery.
|
|
40
43
|
- **Keeps external context external.** Memory and code-graph providers retain
|
|
41
44
|
their own content; Ultra stores only provider/project/revision/status metadata
|
|
42
45
|
references in a compiled change context.
|
|
@@ -57,6 +60,10 @@ npx ultra-builder-pro-cli --claude --global
|
|
|
57
60
|
|
|
58
61
|
# Uninstall (symmetric)
|
|
59
62
|
npx ultra-builder-pro-cli --all --local --uninstall
|
|
63
|
+
|
|
64
|
+
# Read-only installation provenance and drift diagnosis
|
|
65
|
+
npx ultra-builder-pro-cli --all --local --doctor
|
|
66
|
+
npx ultra-builder-pro-cli --all --local --doctor --json
|
|
60
67
|
```
|
|
61
68
|
|
|
62
69
|
After install, start a new host session/task and point it at the project.
|
|
@@ -131,7 +138,7 @@ native command form.
|
|
|
131
138
|
|
|
132
139
|
| Binary | Purpose |
|
|
133
140
|
|--------|---------|
|
|
134
|
-
| `ultra-builder-pro-cli` / `ubp` | Installer — `--claude / --opencode / --codex / --all`, `--local / --global`, `--uninstall` |
|
|
141
|
+
| `ultra-builder-pro-cli` / `ubp` | Installer — `--claude / --opencode / --codex / --all`, `--local / --global`, `--uninstall`; read-only install checks via `--doctor [--json]` |
|
|
135
142
|
| `ubp-orchestrator` | Session dispatch daemon — `run`, `start`, `stop`, `status` |
|
|
136
143
|
| `ultra-tools` | State-layer CLI — `task`, `session`, `status`, `db`, `migrate`; explicit `legacy-memory` archive/prune migration |
|
|
137
144
|
| `ubp-handbook` | Preview/apply the managed Ultra contract in `CLAUDE.md` / `AGENTS.md`, with backup |
|
|
@@ -165,6 +172,11 @@ Individual suites: `test:state`, `test:orch`, `test:spec`, `test:rest`.
|
|
|
165
172
|
or `./.opencode` etc.; `--global` writes to the user-level dir). The
|
|
166
173
|
install log prints the exact target path. Restart Claude Code/OpenCode or
|
|
167
174
|
start a new Codex task after changing an installed plugin.
|
|
175
|
+
- **Installed hook/MCP path is missing or stale**: run
|
|
176
|
+
`npx ultra-builder-pro-cli --<runtime> --local --doctor` first. It compares
|
|
177
|
+
the recorded install provenance with current asset hashes and validates the
|
|
178
|
+
host-specific hook, MCP, plugin, and runtime-manifest entry points. Reinstall
|
|
179
|
+
only after preserving the degraded report as evidence.
|
|
168
180
|
- **Legacy Ultra memory data remains on disk**: inspect first with
|
|
169
181
|
`ultra-tools legacy-memory inspect`, archive with
|
|
170
182
|
`ultra-tools legacy-memory archive`, then prune only with the explicit
|
|
@@ -55,6 +55,7 @@ function renderHandbook(runtime) {
|
|
|
55
55
|
'- Authority: `.ultra/state.db` is the only durable Ultra authority for changes, tasks, sessions, events, incidents, projection state, telemetry, and review evidence. Generated JSON and Markdown are projections or workflow artifacts.',
|
|
56
56
|
'- Memory and graph boundary: Ultra Builder Pro does not capture prompts, transcripts, observations, summaries, cross-session memory, or code-graph content. Separately installed providers own that data; Ultra may store only their metadata references in a change context manifest.',
|
|
57
57
|
'- Hook boundary: Ultra hooks observe workflow/change lifecycle only. Health/context may run when `.ultra/state.db` exists; projection protection is always authoritative, while edit/compact/stop/subagent enforcement remains active-workflow scoped. Generic command blocking and post-edit policy stay in user or repository governance.',
|
|
58
|
+
'- Installation boundary: `ubp --doctor` is the read-only authority for installed asset provenance, content hashes, and host entry-point wiring; `system.doctor` remains scoped to project state and workflow recovery.',
|
|
58
59
|
'- Agent boundary: the bundled review and debugging agents are bounded workers. They use the current checkout and parent-supplied context, do not own private persistent state, and never replace the primary agent.',
|
|
59
60
|
'- Package boundary: only the twelve Ultra workflows, four internal review-rule skills, and host-specific collaboration companions belong to this plugin. General browser, deployment, discovery, and framework skills must be installed from their owners.',
|
|
60
61
|
END_MARKER,
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { spawnSync } = require('node:child_process');
|
|
7
|
+
|
|
8
|
+
const { listRelative, writeAtomic } = require('./file-ops.cjs');
|
|
9
|
+
|
|
10
|
+
const SCHEMA_VERSION = 1;
|
|
11
|
+
const DIGEST_ALGORITHM = 'sha256';
|
|
12
|
+
|
|
13
|
+
function issue(code, details = {}) {
|
|
14
|
+
return { code, ...details };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function safeRelative(value) {
|
|
18
|
+
if (typeof value !== 'string' || !value.trim() || path.isAbsolute(value)) {
|
|
19
|
+
throw new Error(`provenance asset path must be relative: ${String(value)}`);
|
|
20
|
+
}
|
|
21
|
+
const normalized = path.normalize(value);
|
|
22
|
+
if (normalized === '..' || normalized.startsWith(`..${path.sep}`)) {
|
|
23
|
+
throw new Error(`provenance asset path escapes its root: ${value}`);
|
|
24
|
+
}
|
|
25
|
+
return normalized;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeRoots(roots) {
|
|
29
|
+
if (!roots || typeof roots !== 'object' || Array.isArray(roots)) {
|
|
30
|
+
throw new Error('provenance roots must be an object');
|
|
31
|
+
}
|
|
32
|
+
const normalized = {};
|
|
33
|
+
for (const [name, root] of Object.entries(roots)) {
|
|
34
|
+
if (!/^[a-z][a-z0-9_-]*$/.test(name) || typeof root !== 'string' || !root.trim()) {
|
|
35
|
+
throw new Error(`invalid provenance root: ${name}`);
|
|
36
|
+
}
|
|
37
|
+
normalized[name] = path.resolve(root);
|
|
38
|
+
}
|
|
39
|
+
if (Object.keys(normalized).length === 0) throw new Error('at least one provenance root is required');
|
|
40
|
+
return normalized;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeRef(ref, roots) {
|
|
44
|
+
if (!ref || typeof ref !== 'object' || Array.isArray(ref) || !roots[ref.root]) {
|
|
45
|
+
throw new Error(`invalid provenance asset root: ${ref && ref.root}`);
|
|
46
|
+
}
|
|
47
|
+
return { root: ref.root, path: safeRelative(ref.path) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function resolveRef(roots, ref) {
|
|
51
|
+
const normalized = normalizeRef(ref, roots);
|
|
52
|
+
const root = roots[normalized.root];
|
|
53
|
+
const target = path.resolve(root, normalized.path);
|
|
54
|
+
if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
|
|
55
|
+
throw new Error(`provenance asset escapes root ${normalized.root}: ${normalized.path}`);
|
|
56
|
+
}
|
|
57
|
+
return { ...normalized, target };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function hashFile(file) {
|
|
61
|
+
return crypto.createHash(DIGEST_ALGORITHM).update(fs.readFileSync(file)).digest('hex');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizeAssetRefs(assets, roots) {
|
|
65
|
+
if (!Array.isArray(assets) || assets.length === 0) {
|
|
66
|
+
throw new Error('at least one provenance asset is required');
|
|
67
|
+
}
|
|
68
|
+
const unique = new Map();
|
|
69
|
+
for (const input of assets) {
|
|
70
|
+
const ref = normalizeRef(input, roots);
|
|
71
|
+
unique.set(`${ref.root}\0${ref.path}`, ref);
|
|
72
|
+
}
|
|
73
|
+
return [...unique.values()].sort((a, b) => (
|
|
74
|
+
a.root.localeCompare(b.root) || a.path.localeCompare(b.path)
|
|
75
|
+
));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function digestEntries(entries) {
|
|
79
|
+
const hash = crypto.createHash(DIGEST_ALGORITHM);
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
hash.update(entry.root);
|
|
82
|
+
hash.update('\0');
|
|
83
|
+
hash.update(entry.path);
|
|
84
|
+
hash.update('\0');
|
|
85
|
+
hash.update(entry.sha256);
|
|
86
|
+
hash.update('\0');
|
|
87
|
+
hash.update(String(entry.size_bytes));
|
|
88
|
+
hash.update('\0');
|
|
89
|
+
}
|
|
90
|
+
return hash.digest('hex');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function sourceCommit(repoRoot) {
|
|
94
|
+
if (!fs.existsSync(path.join(repoRoot, '.git'))) return null;
|
|
95
|
+
const result = spawnSync('git', ['rev-parse', 'HEAD'], {
|
|
96
|
+
cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
97
|
+
});
|
|
98
|
+
const value = result.status === 0 ? result.stdout.trim() : '';
|
|
99
|
+
return /^[0-9a-f]{40,64}$/i.test(value) ? value : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function repositoryUrl(pkg) {
|
|
103
|
+
if (typeof pkg.repository === 'string') return pkg.repository;
|
|
104
|
+
if (pkg.repository && typeof pkg.repository.url === 'string') return pkg.repository.url;
|
|
105
|
+
return typeof pkg.homepage === 'string' ? pkg.homepage : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function packageSource(repoRoot) {
|
|
109
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
|
|
110
|
+
return {
|
|
111
|
+
packageInfo: { name: pkg.name, version: pkg.version },
|
|
112
|
+
repository: repositoryUrl(pkg),
|
|
113
|
+
sourceCommit: sourceCommit(repoRoot),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function assetRefsForTree(rootName, root, { exclude = [] } = {}) {
|
|
118
|
+
const excluded = new Set(exclude.map((entry) => path.normalize(entry)));
|
|
119
|
+
return listRelative(root)
|
|
120
|
+
.filter((entry) => !excluded.has(path.normalize(entry)))
|
|
121
|
+
.map((entry) => ({ root: rootName, path: entry }));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function writeProvenance({
|
|
125
|
+
file, adapter, packageInfo, repository, sourceCommit: commit = null,
|
|
126
|
+
roots: inputRoots, assets: inputAssets, contracts: inputContracts = {},
|
|
127
|
+
}) {
|
|
128
|
+
if (!file || !adapter || !packageInfo?.name || !packageInfo?.version || !repository) {
|
|
129
|
+
throw new Error('provenance file, adapter, package, version, and repository are required');
|
|
130
|
+
}
|
|
131
|
+
const roots = normalizeRoots(inputRoots);
|
|
132
|
+
const refs = normalizeAssetRefs(inputAssets, roots);
|
|
133
|
+
const assets = refs.map((ref) => {
|
|
134
|
+
const { target } = resolveRef(roots, ref);
|
|
135
|
+
const stat = fs.statSync(target);
|
|
136
|
+
if (!stat.isFile()) throw new Error(`provenance asset is not a file: ${target}`);
|
|
137
|
+
return { ...ref, size_bytes: stat.size, sha256: hashFile(target) };
|
|
138
|
+
});
|
|
139
|
+
const contracts = {};
|
|
140
|
+
for (const [name, ref] of Object.entries(inputContracts || {})) {
|
|
141
|
+
if (!/^[a-z][a-z0-9_-]*$/.test(name)) throw new Error(`invalid provenance contract: ${name}`);
|
|
142
|
+
contracts[name] = normalizeRef(ref, roots);
|
|
143
|
+
}
|
|
144
|
+
const manifest = {
|
|
145
|
+
schema_version: SCHEMA_VERSION,
|
|
146
|
+
adapter,
|
|
147
|
+
package: { name: packageInfo.name, version: packageInfo.version },
|
|
148
|
+
source: { repository, commit: commit || null },
|
|
149
|
+
installed_at: new Date().toISOString(),
|
|
150
|
+
roots,
|
|
151
|
+
content: { algorithm: DIGEST_ALGORITHM, digest: digestEntries(assets) },
|
|
152
|
+
assets,
|
|
153
|
+
contracts,
|
|
154
|
+
};
|
|
155
|
+
writeAtomic(path.resolve(file), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
156
|
+
return manifest;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function invalidReport(file, status, problem, adapter = null) {
|
|
160
|
+
return {
|
|
161
|
+
adapter,
|
|
162
|
+
status,
|
|
163
|
+
manifest_path: path.resolve(file),
|
|
164
|
+
package: null,
|
|
165
|
+
source: null,
|
|
166
|
+
content_digest: null,
|
|
167
|
+
checks: {
|
|
168
|
+
manifest: { status: 'fail' },
|
|
169
|
+
assets: { status: 'unknown', checked: 0, failed: 0 },
|
|
170
|
+
contracts: { status: 'unknown', checked: 0, failed: 0 },
|
|
171
|
+
},
|
|
172
|
+
issues: [problem],
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function inspectProvenance({ file, expectedAdapter = null, expectedPackageVersion = null }) {
|
|
177
|
+
const manifestPath = path.resolve(file);
|
|
178
|
+
if (!fs.existsSync(manifestPath)) {
|
|
179
|
+
return invalidReport(
|
|
180
|
+
manifestPath, 'missing', issue('PROVENANCE_MISSING', { path: manifestPath }), expectedAdapter,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
let manifest;
|
|
184
|
+
try {
|
|
185
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
186
|
+
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) throw new Error('manifest must be an object');
|
|
187
|
+
if (manifest.schema_version !== SCHEMA_VERSION) throw new Error(`unsupported schema ${manifest.schema_version}`);
|
|
188
|
+
if (!manifest.package?.name || !manifest.package?.version || !manifest.source?.repository) {
|
|
189
|
+
throw new Error('package and source metadata required');
|
|
190
|
+
}
|
|
191
|
+
} catch (error) {
|
|
192
|
+
return invalidReport(
|
|
193
|
+
manifestPath, 'degraded', issue('PROVENANCE_INVALID', { message: error.message }), expectedAdapter,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const issues = [];
|
|
198
|
+
let roots;
|
|
199
|
+
try {
|
|
200
|
+
roots = normalizeRoots(manifest.roots);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
return invalidReport(
|
|
203
|
+
manifestPath, 'degraded', issue('PROVENANCE_INVALID', { message: error.message }), expectedAdapter,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
if (expectedAdapter && manifest.adapter !== expectedAdapter) {
|
|
207
|
+
issues.push(issue('ADAPTER_MISMATCH', { expected: expectedAdapter, actual: manifest.adapter }));
|
|
208
|
+
}
|
|
209
|
+
if (expectedPackageVersion && manifest.package.version !== expectedPackageVersion) {
|
|
210
|
+
issues.push(issue('PACKAGE_VERSION_MISMATCH', {
|
|
211
|
+
expected: expectedPackageVersion, actual: manifest.package.version,
|
|
212
|
+
}));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const assets = Array.isArray(manifest.assets) ? manifest.assets : [];
|
|
216
|
+
if (assets.length === 0) issues.push(issue('PROVENANCE_INVALID', { message: 'assets required' }));
|
|
217
|
+
let assetFailures = 0;
|
|
218
|
+
for (const asset of assets) {
|
|
219
|
+
try {
|
|
220
|
+
const { target, root, path: assetPath } = resolveRef(roots, asset);
|
|
221
|
+
if (!fs.existsSync(target) || !fs.statSync(target).isFile()) {
|
|
222
|
+
assetFailures += 1;
|
|
223
|
+
issues.push(issue('ASSET_MISSING', { root, path: assetPath }));
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const actual = hashFile(target);
|
|
227
|
+
if (actual !== asset.sha256) {
|
|
228
|
+
assetFailures += 1;
|
|
229
|
+
issues.push(issue('ASSET_HASH_MISMATCH', {
|
|
230
|
+
root, path: assetPath, expected: asset.sha256, actual,
|
|
231
|
+
}));
|
|
232
|
+
}
|
|
233
|
+
} catch (error) {
|
|
234
|
+
assetFailures += 1;
|
|
235
|
+
issues.push(issue('ASSET_INVALID', { message: error.message }));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (manifest.content?.algorithm !== DIGEST_ALGORITHM
|
|
239
|
+
|| manifest.content?.digest !== digestEntries(assets)) {
|
|
240
|
+
issues.push(issue('MANIFEST_DIGEST_MISMATCH'));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const contracts = manifest.contracts && typeof manifest.contracts === 'object'
|
|
244
|
+
&& !Array.isArray(manifest.contracts) ? manifest.contracts : {};
|
|
245
|
+
let contractFailures = 0;
|
|
246
|
+
for (const [name, ref] of Object.entries(contracts)) {
|
|
247
|
+
try {
|
|
248
|
+
const { target, root, path: contractPath } = resolveRef(roots, ref);
|
|
249
|
+
if (!fs.existsSync(target) || !fs.statSync(target).isFile()) {
|
|
250
|
+
contractFailures += 1;
|
|
251
|
+
issues.push(issue('CONTRACT_TARGET_MISSING', { contract: name, root, path: contractPath }));
|
|
252
|
+
}
|
|
253
|
+
} catch (error) {
|
|
254
|
+
contractFailures += 1;
|
|
255
|
+
issues.push(issue('CONTRACT_INVALID', { contract: name, message: error.message }));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
adapter: manifest.adapter,
|
|
261
|
+
status: issues.length === 0 ? 'healthy' : 'degraded',
|
|
262
|
+
manifest_path: manifestPath,
|
|
263
|
+
package: manifest.package,
|
|
264
|
+
source: manifest.source,
|
|
265
|
+
content_digest: manifest.content?.digest || null,
|
|
266
|
+
checks: {
|
|
267
|
+
manifest: { status: 'pass' },
|
|
268
|
+
assets: { status: assetFailures === 0 ? 'pass' : 'fail', checked: assets.length, failed: assetFailures },
|
|
269
|
+
contracts: {
|
|
270
|
+
status: contractFailures === 0 ? 'pass' : 'fail',
|
|
271
|
+
checked: Object.keys(contracts).length,
|
|
272
|
+
failed: contractFailures,
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
issues,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
module.exports = {
|
|
280
|
+
SCHEMA_VERSION,
|
|
281
|
+
DIGEST_ALGORITHM,
|
|
282
|
+
assetRefsForTree,
|
|
283
|
+
inspectProvenance,
|
|
284
|
+
packageSource,
|
|
285
|
+
writeProvenance,
|
|
286
|
+
};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const { execFileSync } = require('node:child_process');
|
|
9
|
+
|
|
10
|
+
const provenance = require('../provenance.cjs');
|
|
11
|
+
|
|
12
|
+
function fixture() {
|
|
13
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-provenance-'));
|
|
14
|
+
fs.mkdirSync(path.join(root, 'runtime'), { recursive: true });
|
|
15
|
+
fs.mkdirSync(path.join(root, 'hooks'), { recursive: true });
|
|
16
|
+
fs.writeFileSync(path.join(root, 'runtime', 'launch.cjs'), 'module.exports = true;\n');
|
|
17
|
+
fs.writeFileSync(path.join(root, 'hooks', 'hooks.json'), '{}\n');
|
|
18
|
+
const file = path.join(root, 'provenance.json');
|
|
19
|
+
provenance.writeProvenance({
|
|
20
|
+
file,
|
|
21
|
+
adapter: 'claude',
|
|
22
|
+
packageInfo: { name: 'ultra-builder-pro-cli', version: '1.2.3' },
|
|
23
|
+
repository: 'https://example.test/ultra-builder-pro',
|
|
24
|
+
sourceCommit: '0123456789abcdef0123456789abcdef01234567',
|
|
25
|
+
roots: { plugin: root },
|
|
26
|
+
assets: [
|
|
27
|
+
{ root: 'plugin', path: 'hooks/hooks.json' },
|
|
28
|
+
{ root: 'plugin', path: 'runtime/launch.cjs' },
|
|
29
|
+
],
|
|
30
|
+
contracts: {
|
|
31
|
+
hooks_manifest: { root: 'plugin', path: 'hooks/hooks.json' },
|
|
32
|
+
mcp_launcher: { root: 'plugin', path: 'runtime/launch.cjs' },
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
return { root, file };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test('writeProvenance + inspectProvenance produce a normalized healthy report', () => {
|
|
39
|
+
const fx = fixture();
|
|
40
|
+
try {
|
|
41
|
+
const manifest = JSON.parse(fs.readFileSync(fx.file, 'utf8'));
|
|
42
|
+
assert.equal(manifest.schema_version, 1);
|
|
43
|
+
assert.equal(manifest.adapter, 'claude');
|
|
44
|
+
assert.equal(manifest.package.version, '1.2.3');
|
|
45
|
+
assert.equal(manifest.source.repository, 'https://example.test/ultra-builder-pro');
|
|
46
|
+
assert.match(manifest.content.digest, /^[0-9a-f]{64}$/);
|
|
47
|
+
assert.deepEqual(manifest.assets.map((asset) => asset.path), [
|
|
48
|
+
'hooks/hooks.json', 'runtime/launch.cjs',
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
const report = provenance.inspectProvenance({
|
|
52
|
+
file: fx.file,
|
|
53
|
+
expectedAdapter: 'claude',
|
|
54
|
+
expectedPackageVersion: '1.2.3',
|
|
55
|
+
});
|
|
56
|
+
assert.equal(report.status, 'healthy');
|
|
57
|
+
assert.deepEqual(report.issues, []);
|
|
58
|
+
assert.equal(report.checks.assets.checked, 2);
|
|
59
|
+
assert.equal(report.checks.contracts.checked, 2);
|
|
60
|
+
} finally {
|
|
61
|
+
fs.rmSync(fx.root, { recursive: true, force: true });
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('inspectProvenance detects content drift and missing contract targets', () => {
|
|
66
|
+
const fx = fixture();
|
|
67
|
+
try {
|
|
68
|
+
fs.writeFileSync(path.join(fx.root, 'runtime', 'launch.cjs'), 'tampered\n');
|
|
69
|
+
fs.unlinkSync(path.join(fx.root, 'hooks', 'hooks.json'));
|
|
70
|
+
const report = provenance.inspectProvenance({
|
|
71
|
+
file: fx.file,
|
|
72
|
+
expectedAdapter: 'claude',
|
|
73
|
+
expectedPackageVersion: '1.2.3',
|
|
74
|
+
});
|
|
75
|
+
assert.equal(report.status, 'degraded');
|
|
76
|
+
assert.ok(report.issues.some((issue) => issue.code === 'ASSET_HASH_MISMATCH'));
|
|
77
|
+
assert.ok(report.issues.some(
|
|
78
|
+
(issue) => issue.code === 'CONTRACT_TARGET_MISSING' && issue.contract === 'hooks_manifest',
|
|
79
|
+
));
|
|
80
|
+
} finally {
|
|
81
|
+
fs.rmSync(fx.root, { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('inspectProvenance fails closed for missing, corrupt, or mismatched manifests', () => {
|
|
86
|
+
const fx = fixture();
|
|
87
|
+
try {
|
|
88
|
+
const mismatch = provenance.inspectProvenance({
|
|
89
|
+
file: fx.file,
|
|
90
|
+
expectedAdapter: 'codex',
|
|
91
|
+
expectedPackageVersion: '9.9.9',
|
|
92
|
+
});
|
|
93
|
+
assert.equal(mismatch.status, 'degraded');
|
|
94
|
+
assert.ok(mismatch.issues.some((issue) => issue.code === 'ADAPTER_MISMATCH'));
|
|
95
|
+
assert.ok(mismatch.issues.some((issue) => issue.code === 'PACKAGE_VERSION_MISMATCH'));
|
|
96
|
+
|
|
97
|
+
fs.writeFileSync(fx.file, 'not-json');
|
|
98
|
+
const corrupt = provenance.inspectProvenance({ file: fx.file });
|
|
99
|
+
assert.equal(corrupt.status, 'degraded');
|
|
100
|
+
assert.equal(corrupt.issues[0].code, 'PROVENANCE_INVALID');
|
|
101
|
+
|
|
102
|
+
fs.unlinkSync(fx.file);
|
|
103
|
+
const missing = provenance.inspectProvenance({ file: fx.file });
|
|
104
|
+
assert.equal(missing.status, 'missing');
|
|
105
|
+
assert.equal(missing.issues[0].code, 'PROVENANCE_MISSING');
|
|
106
|
+
} finally {
|
|
107
|
+
fs.rmSync(fx.root, { recursive: true, force: true });
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('packageSource never attributes an enclosing consumer repository commit to the package', () => {
|
|
112
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-provenance-source-'));
|
|
113
|
+
try {
|
|
114
|
+
execFileSync('git', ['init', '-q'], { cwd: root });
|
|
115
|
+
execFileSync('git', ['config', 'user.email', 'test@ubp.dev'], { cwd: root });
|
|
116
|
+
execFileSync('git', ['config', 'user.name', 'ubp-test'], { cwd: root });
|
|
117
|
+
fs.writeFileSync(path.join(root, 'README.md'), '# consumer\n');
|
|
118
|
+
execFileSync('git', ['add', 'README.md'], { cwd: root });
|
|
119
|
+
execFileSync('git', ['commit', '-q', '-m', 'seed'], { cwd: root });
|
|
120
|
+
const packageRoot = path.join(root, 'node_modules', 'ultra-builder-pro-cli');
|
|
121
|
+
fs.mkdirSync(packageRoot, { recursive: true });
|
|
122
|
+
fs.writeFileSync(path.join(packageRoot, 'package.json'), JSON.stringify({
|
|
123
|
+
name: 'ultra-builder-pro-cli',
|
|
124
|
+
version: '1.2.3',
|
|
125
|
+
repository: { url: 'https://example.test/ultra-builder-pro' },
|
|
126
|
+
}));
|
|
127
|
+
|
|
128
|
+
const source = provenance.packageSource(packageRoot);
|
|
129
|
+
assert.equal(source.sourceCommit, null);
|
|
130
|
+
} finally {
|
|
131
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
132
|
+
}
|
|
133
|
+
});
|
package/adapters/claude.js
CHANGED
|
@@ -15,6 +15,7 @@ const {
|
|
|
15
15
|
writeAtomic,
|
|
16
16
|
} = require('./_shared/file-ops.cjs');
|
|
17
17
|
const { buildMcpRuntime } = require('./_shared/codex-assets.cjs');
|
|
18
|
+
const provenance = require('./_shared/provenance.cjs');
|
|
18
19
|
const {
|
|
19
20
|
CORE_PUBLIC_SKILLS,
|
|
20
21
|
WORKFLOW_HOOK_FILES,
|
|
@@ -26,6 +27,7 @@ const MCP_SERVER_NAME = PLUGIN_NAME;
|
|
|
26
27
|
const SOURCE_TAG = 'ubp';
|
|
27
28
|
const SENTINEL_KEY = '_ubp_manifest';
|
|
28
29
|
const COMMAND_NAMES = CORE_PUBLIC_SKILLS.filter((name) => name !== 'ultra-review');
|
|
30
|
+
const PROVENANCE_FILE = 'provenance.json';
|
|
29
31
|
|
|
30
32
|
function resolveTarget(ctx = {}) {
|
|
31
33
|
if (ctx.configDir) return ctx.configDir;
|
|
@@ -155,9 +157,39 @@ function install(ctx = {}) {
|
|
|
155
157
|
license: pkg.license || 'MIT',
|
|
156
158
|
}, null, 2) + '\n');
|
|
157
159
|
markManaged(target, { adapter: 'claude', plugin: PLUGIN_NAME });
|
|
160
|
+
const source = provenance.packageSource(repoRoot);
|
|
161
|
+
const provenanceFile = path.join(target, PROVENANCE_FILE);
|
|
162
|
+
report.provenance = provenance.writeProvenance({
|
|
163
|
+
file: provenanceFile,
|
|
164
|
+
adapter: 'claude',
|
|
165
|
+
...source,
|
|
166
|
+
roots: { plugin: target },
|
|
167
|
+
assets: provenance.assetRefsForTree('plugin', target, {
|
|
168
|
+
exclude: ['.ubp-managed', PROVENANCE_FILE],
|
|
169
|
+
}),
|
|
170
|
+
contracts: {
|
|
171
|
+
plugin_manifest: { root: 'plugin', path: '.claude-plugin/plugin.json' },
|
|
172
|
+
mcp_registration: { root: 'plugin', path: '.mcp.json' },
|
|
173
|
+
mcp_launcher: { root: 'plugin', path: 'runtime/launch.cjs' },
|
|
174
|
+
hooks_manifest: { root: 'plugin', path: 'hooks/hooks.json' },
|
|
175
|
+
checkpoint_hook: { root: 'plugin', path: 'hooks/workflow_checkpoint.py' },
|
|
176
|
+
resume_hook: { root: 'plugin', path: 'hooks/workflow_resume.py' },
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
report.provenance.file = provenanceFile;
|
|
158
180
|
return report;
|
|
159
181
|
}
|
|
160
182
|
|
|
183
|
+
function doctor(ctx = {}) {
|
|
184
|
+
const repoRoot = resolveRepoRoot(ctx);
|
|
185
|
+
const source = provenance.packageSource(repoRoot);
|
|
186
|
+
return provenance.inspectProvenance({
|
|
187
|
+
file: path.join(resolvePluginRoot(ctx), PROVENANCE_FILE),
|
|
188
|
+
expectedAdapter: 'claude',
|
|
189
|
+
expectedPackageVersion: source.packageInfo.version,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
161
193
|
function uninstall(ctx = {}) {
|
|
162
194
|
const target = resolvePluginRoot(ctx);
|
|
163
195
|
const report = { target, removed: {}, config: { updated: false } };
|
|
@@ -179,4 +211,5 @@ module.exports = {
|
|
|
179
211
|
resolvePluginRoot,
|
|
180
212
|
install,
|
|
181
213
|
uninstall,
|
|
214
|
+
doctor,
|
|
182
215
|
};
|