ultra-builder-pro-cli 3.0.2 → 5.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/.ultra-template/contexts/TEMPLATE.md +6 -0
- package/CHANGELOG.md +73 -1
- package/README.md +126 -36
- package/README.zh-CN.md +440 -0
- package/adapters/_shared/host-profile.cjs +3 -151
- package/adapters/_shared/plugin-core.cjs +57 -0
- package/adapters/_shared/runtime-assets.cjs +64 -12
- package/bin/delegate-worker.cjs +2 -345
- package/bin/delegate.cjs +3 -530
- package/docs/ARCHITECTURE.md +59 -18
- package/docs/ARTIFACT-AUTHORITY.md +21 -5
- package/docs/DECISIONS.md +31 -10
- package/docs/PLUGIN-ISOLATION-CONTRACT.md +28 -12
- package/docs/RUNTIME-COMPAT-MATRIX.md +101 -22
- package/docs/SKILL-AUTHORING.md +15 -3
- package/docs/WORKFLOW-LIFECYCLE.md +83 -29
- package/docs/evals/ultra-5-0-journey-results.md +62 -0
- package/docs/evals/ultra-5-0-live-acp-conformance.md +93 -0
- package/docs/evals/ultra-5-0-route-journeys.md +145 -0
- package/package.json +2 -1
- package/skills/ultra-change/SKILL.md +30 -4
- package/skills/ultra-delegate/SKILL.md +80 -7
- package/skills/ultra-delegate/patches/zcode-acp-0.1.0-zcode-0.16.5.patch +184 -0
- package/skills/ultra-delegate/references/delegation-contract.md +60 -1
- package/skills/ultra-delegate/scripts/acp-conformance.cjs +287 -0
- package/skills/ultra-delegate/scripts/acp-driver.cjs +762 -0
- package/skills/ultra-delegate/scripts/agent-execution-driver.cjs +320 -0
- package/skills/ultra-delegate/scripts/cli-driver.cjs +61 -0
- package/skills/ultra-delegate/scripts/delegate-worker.cjs +328 -0
- package/skills/ultra-delegate/scripts/delegate.cjs +690 -0
- package/skills/ultra-delegate/scripts/host-profile.cjs +267 -0
- package/skills/ultra-deliver/SKILL.md +13 -0
- package/skills/ultra-dev/SKILL.md +18 -2
- package/skills/ultra-init/SKILL.md +12 -0
- package/skills/ultra-plan/SKILL.md +21 -3
- package/skills/ultra-research/SKILL.md +33 -5
- package/skills/ultra-status/SKILL.md +37 -7
- package/skills/ultra-status/references/route-contract.md +100 -0
- package/skills/ultra-test/SKILL.md +16 -1
- /package/{adapters/_shared → skills/ultra-delegate/scripts}/delegate-profiles/kimi-read-only.md +0 -0
- /package/{adapters/_shared → skills/ultra-delegate/scripts}/delegate-profiles/kimi-write.md +0 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
diff --git a/Cargo.lock b/Cargo.lock
|
|
2
|
+
index bfadfbb..4b1d665 100644
|
|
3
|
+
--- a/Cargo.lock
|
|
4
|
+
+++ b/Cargo.lock
|
|
5
|
+
@@ -2492,7 +2492,7 @@ dependencies = [
|
|
6
|
+
|
|
7
|
+
[[package]]
|
|
8
|
+
name = "zcode-acp"
|
|
9
|
+
-version = "0.1.0"
|
|
10
|
+
+version = "0.1.0+ultra.zcode.0.16.5"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"aes-gcm",
|
|
13
|
+
"agent-client-protocol",
|
|
14
|
+
diff --git a/Cargo.toml b/Cargo.toml
|
|
15
|
+
index f096b91..4b4d985 100644
|
|
16
|
+
--- a/Cargo.toml
|
|
17
|
+
+++ b/Cargo.toml
|
|
18
|
+
@@ -1,6 +1,6 @@
|
|
19
|
+
[package]
|
|
20
|
+
name = "zcode-acp"
|
|
21
|
+
-version = "0.1.0"
|
|
22
|
+
+version = "0.1.0+ultra.zcode.0.16.5"
|
|
23
|
+
edition = "2024"
|
|
24
|
+
description = "ACP adapter that bridges Buzz relay events to the ZCode CLI via its stdio app-server"
|
|
25
|
+
license = "Apache-2.0"
|
|
26
|
+
diff --git a/src/zcode/mod.rs b/src/zcode/mod.rs
|
|
27
|
+
index 7963959..14725bb 100644
|
|
28
|
+
--- a/src/zcode/mod.rs
|
|
29
|
+
+++ b/src/zcode/mod.rs
|
|
30
|
+
@@ -266,6 +266,13 @@ impl DriverState {
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
Some(Incoming::ServerRequest { id, method, params }) => {
|
|
34
|
+
+ if method == methods::SESSION_REQUEST_RUNTIME_PREFERENCES {
|
|
35
|
+
+ let _ = self.write_tx.send(json!({
|
|
36
|
+
+ "id": id,
|
|
37
|
+
+ "result": default_runtime_preferences(),
|
|
38
|
+
+ }));
|
|
39
|
+
+ return;
|
|
40
|
+
+ }
|
|
41
|
+
// The ACP layer answers via `reply_tx`; a side task writes the
|
|
42
|
+
// reply back through `write_tx` echoing the wire id.
|
|
43
|
+
let (reply_tx, reply_rx) = oneshot::channel();
|
|
44
|
+
@@ -373,6 +380,32 @@ impl DriverState {
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
+fn default_runtime_preferences() -> Value {
|
|
49
|
+
+ json!({
|
|
50
|
+
+ "nativeSearchEnhancementsEnabled": true,
|
|
51
|
+
+ "memoryEnabled": false,
|
|
52
|
+
+ "askUserQuestionAutoResolutionEnabled": true,
|
|
53
|
+
+ "modelContextBudgetStrategy": "preflight-v1",
|
|
54
|
+
+ })
|
|
55
|
+
+}
|
|
56
|
+
+
|
|
57
|
+
+fn bootstrap_server_request_response(id: RequestId, method: &str) -> Value {
|
|
58
|
+
+ if method == methods::SESSION_REQUEST_RUNTIME_PREFERENCES {
|
|
59
|
+
+ json!({
|
|
60
|
+
+ "id": id,
|
|
61
|
+
+ "result": default_runtime_preferences(),
|
|
62
|
+
+ })
|
|
63
|
+
+ } else {
|
|
64
|
+
+ json!({
|
|
65
|
+
+ "id": id,
|
|
66
|
+
+ "error": {
|
|
67
|
+
+ "code": -32601,
|
|
68
|
+
+ "message": format!("Unsupported app-server request: {method}"),
|
|
69
|
+
+ },
|
|
70
|
+
+ })
|
|
71
|
+
+ }
|
|
72
|
+
+}
|
|
73
|
+
+
|
|
74
|
+
/// Run the dispatcher: read framed messages, dispatch each into shared state.
|
|
75
|
+
async fn run_dispatcher(
|
|
76
|
+
state: Arc<Mutex<DriverState>>,
|
|
77
|
+
@@ -647,6 +680,15 @@ pub async fn build_session(
|
|
78
|
+
protocol::ResponseResult::Ok(v) => break v,
|
|
79
|
+
protocol::ResponseResult::Err(e) => return Err(SessionError::CreateFailed(e)),
|
|
80
|
+
},
|
|
81
|
+
+ Some(Incoming::ServerRequest {
|
|
82
|
+
+ id: request_id,
|
|
83
|
+
+ method,
|
|
84
|
+
+ ..
|
|
85
|
+
+ }) => {
|
|
86
|
+
+ write_tx
|
|
87
|
+
+ .send(bootstrap_server_request_response(request_id, &method))
|
|
88
|
+
+ .map_err(|e| SessionError::Send(e.to_string()))?;
|
|
89
|
+
+ }
|
|
90
|
+
_ => continue,
|
|
91
|
+
},
|
|
92
|
+
None => return Err(SessionError::TransportClosed),
|
|
93
|
+
diff --git a/src/zcode/protocol.rs b/src/zcode/protocol.rs
|
|
94
|
+
index 71fa3e9..6e3e595 100644
|
|
95
|
+
--- a/src/zcode/protocol.rs
|
|
96
|
+
+++ b/src/zcode/protocol.rs
|
|
97
|
+
@@ -138,6 +138,7 @@ impl IncomingMessage {
|
|
98
|
+
|
|
99
|
+
pub mod methods {
|
|
100
|
+
pub const SESSION_CREATE: &str = "session/create";
|
|
101
|
+
+ pub const SESSION_REQUEST_RUNTIME_PREFERENCES: &str = "session/requestRuntimePreferences";
|
|
102
|
+
pub const SESSION_RESUME: &str = "session/resume";
|
|
103
|
+
pub const SESSION_LIST: &str = "session/list";
|
|
104
|
+
pub const SESSION_SUBSCRIBE: &str = "session/subscribe";
|
|
105
|
+
diff --git a/tests/acp_client.py b/tests/acp_client.py
|
|
106
|
+
index a407220..0e46772 100644
|
|
107
|
+
--- a/tests/acp_client.py
|
|
108
|
+
+++ b/tests/acp_client.py
|
|
109
|
+
@@ -22,6 +22,7 @@ from __future__ import annotations
|
|
110
|
+
|
|
111
|
+
import json
|
|
112
|
+
import os
|
|
113
|
+
+import select
|
|
114
|
+
import subprocess
|
|
115
|
+
import sys
|
|
116
|
+
from pathlib import Path
|
|
117
|
+
@@ -40,6 +41,9 @@ def send(proc: subprocess.Popen, obj: dict) -> None:
|
|
118
|
+
|
|
119
|
+
def read_message(proc: subprocess.Popen) -> dict:
|
|
120
|
+
assert proc.stdout is not None
|
|
121
|
+
+ readable, _, _ = select.select([proc.stdout], [], [], 5)
|
|
122
|
+
+ if not readable:
|
|
123
|
+
+ raise TimeoutError("agent did not emit an ACP message within 5 seconds")
|
|
124
|
+
raw = proc.stdout.readline()
|
|
125
|
+
if not raw:
|
|
126
|
+
raise EOFError("agent closed stdout")
|
|
127
|
+
@@ -74,6 +78,7 @@ def main() -> int:
|
|
128
|
+
stdin=subprocess.PIPE,
|
|
129
|
+
stdout=subprocess.PIPE,
|
|
130
|
+
stderr=sys.stderr, # surface shim logs inline for debugging
|
|
131
|
+
+ bufsize=0,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
diff --git a/tests/fake-app-server.cjs b/tests/fake-app-server.cjs
|
|
136
|
+
index f6a5991..e690fbe 100644
|
|
137
|
+
--- a/tests/fake-app-server.cjs
|
|
138
|
+
+++ b/tests/fake-app-server.cjs
|
|
139
|
+
@@ -21,6 +21,8 @@ const rl = readline.createInterface({ input: process.stdin, terminal: false });
|
|
140
|
+
let sessionIdCounter = 0;
|
|
141
|
+
let activeSession = null;
|
|
142
|
+
let seqCounter = 0;
|
|
143
|
+
+let pendingCreateId = null;
|
|
144
|
+
+let runtimePreferencesRequestId = null;
|
|
145
|
+
|
|
146
|
+
function send(obj) {
|
|
147
|
+
process.stdout.write(JSON.stringify(obj) + "\n");
|
|
148
|
+
@@ -54,10 +56,35 @@ function handle(line) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const { id, method, params } = msg;
|
|
152
|
+
+ if (!method && id === runtimePreferencesRequestId) {
|
|
153
|
+
+ const prefs = msg.result;
|
|
154
|
+
+ const valid = prefs
|
|
155
|
+
+ && prefs.nativeSearchEnhancementsEnabled === true
|
|
156
|
+
+ && prefs.memoryEnabled === false
|
|
157
|
+
+ && prefs.askUserQuestionAutoResolutionEnabled === true
|
|
158
|
+
+ && prefs.modelContextBudgetStrategy === "preflight-v1";
|
|
159
|
+
+ if (!valid) {
|
|
160
|
+
+ send({
|
|
161
|
+
+ id: pendingCreateId,
|
|
162
|
+
+ error: { code: -32602, message: "Invalid runtime preferences response" },
|
|
163
|
+
+ });
|
|
164
|
+
+ } else {
|
|
165
|
+
+ respond(pendingCreateId, { sessionId: activeSession });
|
|
166
|
+
+ }
|
|
167
|
+
+ pendingCreateId = null;
|
|
168
|
+
+ runtimePreferencesRequestId = null;
|
|
169
|
+
+ return;
|
|
170
|
+
+ }
|
|
171
|
+
switch (method) {
|
|
172
|
+
case "session/create": {
|
|
173
|
+
activeSession = `sess_fake_${++sessionIdCounter}`;
|
|
174
|
+
- respond(id, { sessionId: activeSession });
|
|
175
|
+
+ pendingCreateId = id;
|
|
176
|
+
+ runtimePreferencesRequestId = "server-runtime-preferences-1";
|
|
177
|
+
+ send({
|
|
178
|
+
+ id: runtimePreferencesRequestId,
|
|
179
|
+
+ method: "session/requestRuntimePreferences",
|
|
180
|
+
+ params: { sessionId: activeSession, scope: "runtime-materialization" },
|
|
181
|
+
+ });
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
case "session/subscribe": {
|
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Delegation artifact contract
|
|
2
2
|
|
|
3
|
+
## Runtime ownership
|
|
4
|
+
|
|
5
|
+
The loaded `ultra-delegate` Skill owns `scripts/delegate.cjs`,
|
|
6
|
+
`scripts/delegate-worker.cjs`, `scripts/host-profile.cjs`,
|
|
7
|
+
`scripts/agent-execution-driver.cjs`, `scripts/acp-driver.cjs`,
|
|
8
|
+
`scripts/cli-driver.cjs`, `scripts/acp-conformance.cjs`, and its launch-only profiles.
|
|
9
|
+
Invoke the launcher with the loaded Skill's absolute path. It has no package-root or
|
|
10
|
+
global `ubp` dependency. The Claude and Codex profiles separately invoke exact pinned
|
|
11
|
+
ACP adapter packages through `npx`; no `npx` lookup is used to locate Ultra itself.
|
|
12
|
+
The package `ubp delegate` command is a thin
|
|
13
|
+
one-release compatibility import of the same functions; it contains no launcher or
|
|
14
|
+
worker behavior of its own.
|
|
15
|
+
|
|
3
16
|
## Permission
|
|
4
17
|
|
|
5
18
|
```json
|
|
@@ -61,7 +74,7 @@ the host prompt, extracts this object from native structured output, and adds
|
|
|
61
74
|
failures also add `failure_type`. A nonzero process cannot publish `finished`.
|
|
62
75
|
|
|
63
76
|
Every receipt, worker spec, and terminal result also records the transport truth:
|
|
64
|
-
`transport_maturity` (from the
|
|
77
|
+
`transport_maturity` (from the Skill-local host profile) and the exact `transport_surface`
|
|
65
78
|
wording. The ZCode headless transport is `experimental` with no public stability
|
|
66
79
|
contract and is not the documented ZCode Desktop interactive surface; launching it
|
|
67
80
|
requires the explicit `--ack-experimental` flag, prints a stderr warning, and stamps
|
|
@@ -69,11 +82,57 @@ requires the explicit `--ack-experimental` flag, prints a stderr warning, and st
|
|
|
69
82
|
internal CLI run as a documented Desktop session — including failure, cancellation,
|
|
70
83
|
and timeout recovery results.
|
|
71
84
|
|
|
85
|
+
They also record `execution_driver`, `driver_fallback_reason`, and the probed
|
|
86
|
+
`acp_capability`, including candidate-identity validity when the profile declares
|
|
87
|
+
bounded local version probes. ACP results add negotiated `acp_protocol_version`, observed
|
|
88
|
+
`acp_stop_reason`, and `events_file`. These are mechanical observations only. In
|
|
89
|
+
particular, `stopReason: end_turn`, a completed tool call, an ACP plan, or process exit
|
|
90
|
+
cannot synthesize the exact model response above or satisfy its Git-diff validation.
|
|
91
|
+
|
|
92
|
+
## ACP v1 permission and event boundary
|
|
93
|
+
|
|
94
|
+
The stdio client advertises no client filesystem, terminal, or terminal-auth
|
|
95
|
+
capability. It creates one fresh session with the isolated worktree as absolute `cwd`,
|
|
96
|
+
an empty MCP-server list, and one digest-bound text prompt. Agent-side file tools remain
|
|
97
|
+
inside the Host process and the existing worktree/diff enforcement remains final.
|
|
98
|
+
|
|
99
|
+
ACP `session/request_permission` uses least authority:
|
|
100
|
+
|
|
101
|
+
- read, search, and think may select an offered `allow_once`;
|
|
102
|
+
- edit, delete, and move may select `allow_once` only when every non-empty absolute
|
|
103
|
+
location is already inside a granted real writable root and outside `.ultra/`;
|
|
104
|
+
- execute, fetch, mode switching, unknown operations, read-only writes, symlink escape,
|
|
105
|
+
and every other widening select an offered rejection and make the run terminally
|
|
106
|
+
denied;
|
|
107
|
+
- `allow_always` is never selected; cancellation answers any pending permission request
|
|
108
|
+
with ACP's `cancelled` outcome.
|
|
109
|
+
|
|
110
|
+
An agent may send creation-time `available_commands_update`, `current_mode_update`,
|
|
111
|
+
`config_option_update`, or `session_info_update` before the `session/new` response.
|
|
112
|
+
Ultra holds at most 32 such messages and 256 KiB transiently, persists no raw payload,
|
|
113
|
+
and replays them only after every message session ID equals the returned session ID.
|
|
114
|
+
Any other early update kind, identity mismatch, count overflow, or byte overflow is a
|
|
115
|
+
typed failure; pre-session buffering cannot contribute model result text.
|
|
116
|
+
|
|
117
|
+
`events.jsonl` is bounded disposable telemetry. It stores phase/update type, tool kind
|
|
118
|
+
and status, counts, permission decision/scope, and terminal stop reason. It drops
|
|
119
|
+
`agent_thought_chunk` entirely and never stores message text, titles, raw tool
|
|
120
|
+
input/output, credentials, or protocol wire payloads. Status re-projects the latest
|
|
121
|
+
record through the same fixed safe shape, so a malformed or replaced journal becomes
|
|
122
|
+
`event_journal_malformed` instead of being echoed. Loss of the journal does not affect
|
|
123
|
+
recovery or completion.
|
|
124
|
+
|
|
72
125
|
## Recovery
|
|
73
126
|
|
|
74
127
|
- `status` distinguishes running, cancelling, stalled, and terminal artifacts.
|
|
128
|
+
- ACP status may also expose the latest redacted derived event; it remains non-authoritative.
|
|
75
129
|
- `cancel` writes `cancel.request`; the worker terminates its host process, publishes a
|
|
76
130
|
failed result with `failure_type: cancelled`, and removes `run.lock`.
|
|
77
131
|
- `--timeout` does the same with `failure_type: timeout`.
|
|
132
|
+
- `ULTRA_DELEGATE_FORCE_CLI=1` bypasses ACP selection for one operator-controlled run
|
|
133
|
+
and records `acp_forced_cli`; it does not mutate the conformance record.
|
|
134
|
+
- `acp_auth_required` reported by `session/new` before any prompt was sent runs the
|
|
135
|
+
immutable bounded CLI fallback once and records `acp_fallback.prompt_sent: false`.
|
|
136
|
+
No other ACP failure is retried, preventing duplicate provider work.
|
|
78
137
|
- A stopped worker with no result is recoverable through `cancel`, which finalizes the
|
|
79
138
|
stale run. Retry under a new delegation id and fresh digests.
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const crypto = require('node:crypto');
|
|
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 { run: runDelegate } = require('./delegate.cjs');
|
|
11
|
+
const { hostProfile, ZCODE_ACP_SOURCE } = require('./host-profile.cjs');
|
|
12
|
+
|
|
13
|
+
const HOSTS = new Set(['claude', 'codex', 'opencode', 'kimi', 'grok', 'zcode']);
|
|
14
|
+
const RECEIPT_SCHEMA = 'ultra-acp-conformance-receipt-v1';
|
|
15
|
+
|
|
16
|
+
function parseArgs(argv) {
|
|
17
|
+
const allowed = new Set(['--host', '--output', '--timeout', '--command', '--args-json']);
|
|
18
|
+
const values = {};
|
|
19
|
+
for (let index = 0; index < argv.length; index += 2) {
|
|
20
|
+
const key = argv[index];
|
|
21
|
+
const value = argv[index + 1];
|
|
22
|
+
if (!allowed.has(key) || value === undefined || value === '' || values[key] !== undefined) {
|
|
23
|
+
throw new Error(`invalid ACP conformance argument: ${key || '<missing>'}`);
|
|
24
|
+
}
|
|
25
|
+
values[key] = value;
|
|
26
|
+
}
|
|
27
|
+
if (!HOSTS.has(values['--host'])) throw new Error('ACP conformance requires a supported --host');
|
|
28
|
+
if (!values['--output']) throw new Error('ACP conformance requires --output');
|
|
29
|
+
const timeoutSeconds = values['--timeout'] === undefined ? 240 : Number(values['--timeout']);
|
|
30
|
+
if (!Number.isFinite(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > 600) {
|
|
31
|
+
throw new Error('ACP conformance --timeout must be between 1 and 600 seconds');
|
|
32
|
+
}
|
|
33
|
+
const hasCommand = values['--command'] !== undefined;
|
|
34
|
+
const hasArgs = values['--args-json'] !== undefined;
|
|
35
|
+
if (hasCommand !== hasArgs) throw new Error('--command and --args-json must be supplied together');
|
|
36
|
+
let argsOverride = null;
|
|
37
|
+
if (hasArgs) {
|
|
38
|
+
try { argsOverride = JSON.parse(values['--args-json']); } catch {
|
|
39
|
+
throw new Error('--args-json must be a JSON string array');
|
|
40
|
+
}
|
|
41
|
+
if (!Array.isArray(argsOverride) || !argsOverride.every((item) => typeof item === 'string')) {
|
|
42
|
+
throw new Error('--args-json must be a JSON string array');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
host: values['--host'],
|
|
47
|
+
output: path.resolve(values['--output']),
|
|
48
|
+
timeoutSeconds,
|
|
49
|
+
commandOverride: values['--command'] || null,
|
|
50
|
+
argsOverride,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function git(cwd, args) {
|
|
55
|
+
return execFileSync('git', args, {
|
|
56
|
+
cwd,
|
|
57
|
+
encoding: 'utf8',
|
|
58
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
59
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
60
|
+
}).trim();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function sha256(file) {
|
|
64
|
+
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function artifactDigest(file) {
|
|
68
|
+
const stat = fs.statSync(file);
|
|
69
|
+
return { sha256: sha256(file), bytes: stat.size };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function processAlive(pid) {
|
|
73
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
74
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function waitForFile(file, timeoutMs) {
|
|
78
|
+
const deadline = Date.now() + timeoutMs;
|
|
79
|
+
while (Date.now() < deadline) {
|
|
80
|
+
if (fs.existsSync(file) && fs.statSync(file).size > 0) return;
|
|
81
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
82
|
+
}
|
|
83
|
+
throw new Error('ACP conformance worker did not publish a terminal result before timeout');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function waitForExit(pid, timeoutMs = 5000) {
|
|
87
|
+
const deadline = Date.now() + timeoutMs;
|
|
88
|
+
while (Date.now() < deadline) {
|
|
89
|
+
if (!processAlive(pid)) return true;
|
|
90
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
91
|
+
}
|
|
92
|
+
return !processAlive(pid);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function createFixture(host) {
|
|
96
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), `ubp-${host}-acp-conformance-`));
|
|
97
|
+
git(root, ['init', '-b', 'main']);
|
|
98
|
+
git(root, ['config', 'user.email', 'acp-conformance@example.invalid']);
|
|
99
|
+
git(root, ['config', 'user.name', 'Ultra ACP Conformance']);
|
|
100
|
+
fs.writeFileSync(path.join(root, 'README.md'), '# Ultra ACP conformance fixture\n');
|
|
101
|
+
fs.writeFileSync(path.join(root, '.gitignore'), '.ultra/.runtime/\n');
|
|
102
|
+
git(root, ['add', 'README.md', '.gitignore']);
|
|
103
|
+
git(root, ['commit', '-m', 'test: create ACP conformance fixture']);
|
|
104
|
+
|
|
105
|
+
const id = `${host}-${crypto.randomUUID()}`;
|
|
106
|
+
const delegation = path.join(root, '.ultra', '.runtime', 'delegations', id);
|
|
107
|
+
const worktree = path.join(root, '.ultra', '.runtime', 'worktrees', id);
|
|
108
|
+
fs.mkdirSync(path.dirname(worktree), { recursive: true });
|
|
109
|
+
git(root, ['worktree', 'add', '--detach', worktree, 'HEAD']);
|
|
110
|
+
fs.mkdirSync(delegation, { recursive: true });
|
|
111
|
+
const expectedResult = {
|
|
112
|
+
$schema: 'ultra-delegation-result-v1',
|
|
113
|
+
status: 'finished',
|
|
114
|
+
summary: 'ACP conformance completed.',
|
|
115
|
+
changed_files: [],
|
|
116
|
+
checks: [],
|
|
117
|
+
evidence: [],
|
|
118
|
+
questions: [],
|
|
119
|
+
residual_risks: [],
|
|
120
|
+
};
|
|
121
|
+
fs.writeFileSync(path.join(delegation, 'instruction.md'), [
|
|
122
|
+
'# ACP conformance',
|
|
123
|
+
'',
|
|
124
|
+
'Do not use tools or modify files.',
|
|
125
|
+
'Return exactly this one-line JSON object with no prose, fences, or field changes:',
|
|
126
|
+
JSON.stringify(expectedResult),
|
|
127
|
+
'',
|
|
128
|
+
].join('\n'));
|
|
129
|
+
fs.writeFileSync(path.join(delegation, 'permission.json'), `${JSON.stringify({
|
|
130
|
+
$schema: 'ultra-delegation-permission-v1',
|
|
131
|
+
writable_roots: [],
|
|
132
|
+
external_effects: [],
|
|
133
|
+
}, null, 2)}\n`);
|
|
134
|
+
return { root, delegation, worktree };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function writeReceipt(output, receipt) {
|
|
138
|
+
fs.mkdirSync(path.dirname(output), { recursive: true });
|
|
139
|
+
fs.writeFileSync(output, `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600 });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function runConformance(options) {
|
|
143
|
+
const profile = hostProfile(options.host);
|
|
144
|
+
if (!profile.acp || profile.acp.transport !== 'stdio'
|
|
145
|
+
|| !profile.acp.protocolVersions.includes(1)) {
|
|
146
|
+
throw new Error(`host ${options.host} has no stable ACP v1 stdio candidate`);
|
|
147
|
+
}
|
|
148
|
+
const command = options.commandOverride || profile.acp.command({
|
|
149
|
+
host: options.host,
|
|
150
|
+
cliCommand: profile.binary,
|
|
151
|
+
env: process.env,
|
|
152
|
+
});
|
|
153
|
+
const args = options.argsOverride || profile.acp.argv({
|
|
154
|
+
host: options.host,
|
|
155
|
+
cliCommand: profile.binary,
|
|
156
|
+
env: process.env,
|
|
157
|
+
});
|
|
158
|
+
const fixture = createFixture(options.host);
|
|
159
|
+
let receipt;
|
|
160
|
+
try {
|
|
161
|
+
const runArgs = [
|
|
162
|
+
'--to', options.host,
|
|
163
|
+
'--instruction', path.join(fixture.delegation, 'instruction.md'),
|
|
164
|
+
'--permission', path.join(fixture.delegation, 'permission.json'),
|
|
165
|
+
'--worktree', fixture.worktree,
|
|
166
|
+
'--timeout', String(options.timeoutSeconds),
|
|
167
|
+
];
|
|
168
|
+
if (profile.transportMaturity === 'experimental') runArgs.push('--ack-experimental');
|
|
169
|
+
const started = runDelegate(runArgs, {
|
|
170
|
+
projectRoot: fixture.root,
|
|
171
|
+
chooseExecutionDriver: () => ({
|
|
172
|
+
driver: 'acp',
|
|
173
|
+
command,
|
|
174
|
+
args,
|
|
175
|
+
protocolVersion: 1,
|
|
176
|
+
fallbackReason: null,
|
|
177
|
+
acpCapability: {
|
|
178
|
+
candidate: true,
|
|
179
|
+
surface: profile.acp.surface,
|
|
180
|
+
transport: 'stdio',
|
|
181
|
+
protocol_versions: [1],
|
|
182
|
+
conformance_status: 'probe',
|
|
183
|
+
conformance_evidence: null,
|
|
184
|
+
conformance_reason: 'Live conformance probe in progress.',
|
|
185
|
+
executable_available: true,
|
|
186
|
+
candidate_valid: true,
|
|
187
|
+
},
|
|
188
|
+
}),
|
|
189
|
+
});
|
|
190
|
+
const resultFile = path.join(fixture.delegation, 'result.json');
|
|
191
|
+
await waitForFile(resultFile, (options.timeoutSeconds * 1000) + 10000);
|
|
192
|
+
const terminal = JSON.parse(fs.readFileSync(resultFile, 'utf8'));
|
|
193
|
+
const workerExitObserved = await waitForExit(started.worker_pid);
|
|
194
|
+
const changedFiles = Array.isArray(terminal.changed_files) ? terminal.changed_files : [];
|
|
195
|
+
const gitClean = git(fixture.worktree, ['status', '--porcelain=v1', '--untracked-files=all']) === '';
|
|
196
|
+
const artifactFiles = {
|
|
197
|
+
result: resultFile,
|
|
198
|
+
stdout: path.join(fixture.delegation, 'stdout.log'),
|
|
199
|
+
stderr: path.join(fixture.delegation, 'stderr.log'),
|
|
200
|
+
events: path.join(fixture.delegation, 'events.jsonl'),
|
|
201
|
+
host_output: path.join(fixture.delegation, 'host-result.json'),
|
|
202
|
+
};
|
|
203
|
+
const artifactDigests = {};
|
|
204
|
+
for (const [name, file] of Object.entries(artifactFiles)) {
|
|
205
|
+
if (fs.existsSync(file) && fs.statSync(file).isFile()) artifactDigests[name] = artifactDigest(file);
|
|
206
|
+
}
|
|
207
|
+
const events = fs.existsSync(artifactFiles.events)
|
|
208
|
+
? fs.readFileSync(artifactFiles.events, 'utf8').trim().split('\n').filter(Boolean)
|
|
209
|
+
: [];
|
|
210
|
+
const passed = terminal.status === 'finished'
|
|
211
|
+
&& terminal.$schema === 'ultra-delegation-result-v1'
|
|
212
|
+
&& terminal.execution_driver === 'acp'
|
|
213
|
+
&& terminal.acp_protocol_version === 1
|
|
214
|
+
&& terminal.acp_stop_reason === 'end_turn'
|
|
215
|
+
&& changedFiles.length === 0
|
|
216
|
+
&& gitClean
|
|
217
|
+
&& workerExitObserved;
|
|
218
|
+
receipt = {
|
|
219
|
+
$schema: RECEIPT_SCHEMA,
|
|
220
|
+
host: options.host,
|
|
221
|
+
candidate: {
|
|
222
|
+
surface: profile.acp.surface,
|
|
223
|
+
command,
|
|
224
|
+
args,
|
|
225
|
+
protocol_versions: [1],
|
|
226
|
+
override_used: options.commandOverride !== null,
|
|
227
|
+
...(options.host === 'zcode' ? {
|
|
228
|
+
bridge_source: ZCODE_ACP_SOURCE,
|
|
229
|
+
installed_runtime: profile.binary,
|
|
230
|
+
} : {}),
|
|
231
|
+
},
|
|
232
|
+
result: {
|
|
233
|
+
status: passed ? 'passed' : 'failed',
|
|
234
|
+
execution_driver: terminal.execution_driver || null,
|
|
235
|
+
protocol_version: terminal.acp_protocol_version ?? null,
|
|
236
|
+
stop_reason: terminal.acp_stop_reason ?? null,
|
|
237
|
+
adapter_exit_code: terminal.acp_adapter_exit_code ?? null,
|
|
238
|
+
adapter_exit_signal: terminal.acp_adapter_exit_signal ?? null,
|
|
239
|
+
git_clean: gitClean,
|
|
240
|
+
changed_files_count: changedFiles.length,
|
|
241
|
+
event_count: events.length,
|
|
242
|
+
worker_exit_observed: workerExitObserved,
|
|
243
|
+
failure_type: terminal.failure_type || null,
|
|
244
|
+
validation_diagnostic: terminal.failure_type === 'invalid_result'
|
|
245
|
+
? terminal.summary
|
|
246
|
+
: null,
|
|
247
|
+
transport_diagnostic: typeof terminal.failure_type === 'string'
|
|
248
|
+
&& terminal.failure_type.startsWith('acp_')
|
|
249
|
+
? terminal.summary
|
|
250
|
+
: null,
|
|
251
|
+
},
|
|
252
|
+
artifact_digests: artifactDigests,
|
|
253
|
+
redaction: {
|
|
254
|
+
provider_content_retained: false,
|
|
255
|
+
prompt_retained: false,
|
|
256
|
+
hidden_reasoning_retained: false,
|
|
257
|
+
credentials_retained: false,
|
|
258
|
+
temporary_artifacts_removed: true,
|
|
259
|
+
},
|
|
260
|
+
observed_at: new Date().toISOString(),
|
|
261
|
+
};
|
|
262
|
+
writeReceipt(options.output, receipt);
|
|
263
|
+
if (!passed) throw new Error(`ACP conformance failed for ${options.host}: ${terminal.failure_type || 'terminal_mismatch'}`);
|
|
264
|
+
return receipt;
|
|
265
|
+
} finally {
|
|
266
|
+
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function main(argv = process.argv.slice(2)) {
|
|
271
|
+
const options = parseArgs(argv);
|
|
272
|
+
const receipt = await runConformance(options);
|
|
273
|
+
process.stdout.write(`${JSON.stringify({
|
|
274
|
+
host: receipt.host,
|
|
275
|
+
status: receipt.result.status,
|
|
276
|
+
receipt: options.output,
|
|
277
|
+
})}\n`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (require.main === module) {
|
|
281
|
+
main().catch((error) => {
|
|
282
|
+
process.stderr.write(`${error.message}\n`);
|
|
283
|
+
process.exitCode = 1;
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
module.exports = { parseArgs, runConformance };
|