@sublang/playbook 3.0.0 → 4.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/README.md +28 -5
- package/docs/cli.md +82 -1
- package/package.json +6 -4
- package/reference/sdlc/captain.playbook/captain.playbook.js +21 -13
- package/reference/sdlc/captain.playbook/captain.playbook.ts +20 -12
- package/reference/sdlc/code.playbook/bin/adapter-sdk.js +247 -0
- package/reference/sdlc/code.playbook/bin/playbook.js +55 -9
- package/reference/sdlc/code.playbook/bin/provision.js +228 -0
- package/reference/sdlc/code.playbook/bin/run.js +146 -1
- package/reference/sdlc/code.playbook/playbook-captain.js +23 -2
- package/reference/sdlc/code.playbook/playbook-captain.ts +29 -3
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +7 -0
- package/slc/link.md +14 -0
- package/src/xstate-playbook-runtime.d.ts +27 -0
- package/src/xstate-playbook-runtime.js +52 -2
- package/src/xstate-playbook-runtime.ts +87 -7
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
|
+
|
|
4
|
+
// PBCLI-36/37 (DR-024): probe-first engine provisioning for filesystem
|
|
5
|
+
// registry modules. A compiled thin artifact imports `xstate` and
|
|
6
|
+
// `@sublang/playbook/xstate-runtime`, which Node resolves by walking up
|
|
7
|
+
// from the artifact's own directory; a globally installed host therefore
|
|
8
|
+
// fails at artifact load in a bare directory. Before importing such a
|
|
9
|
+
// module, `playbook run` probes both specifiers with the module's path as
|
|
10
|
+
// resolution parent and, only when a probe fails, symlinks the running
|
|
11
|
+
// host's own installed package roots beside the module. It never shells
|
|
12
|
+
// out to `npm link` and never installs from the registry.
|
|
13
|
+
|
|
14
|
+
import { existsSync, lstatSync, readFileSync, readlinkSync } from 'node:fs';
|
|
15
|
+
import { mkdir, symlink, unlink } from 'node:fs/promises';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
20
|
+
// PBCLI-37: probe specifier → the package name provisioning may link.
|
|
21
|
+
const ENGINE_LINKS = new Map([
|
|
22
|
+
['xstate', 'xstate'],
|
|
23
|
+
['@sublang/playbook/xstate-runtime', '@sublang/playbook'],
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const DEPENDENCY_FIELDS = [
|
|
27
|
+
'dependencies',
|
|
28
|
+
'devDependencies',
|
|
29
|
+
'peerDependencies',
|
|
30
|
+
'optionalDependencies',
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
// PBCLI-37: package names whose probes fail with the module as resolution
|
|
34
|
+
// parent. `createRequire` follows the same walk-up Node uses for the
|
|
35
|
+
// module's own imports, so an empty result means a project-local (or
|
|
36
|
+
// already provisioned) engine wins and provisioning must touch nothing.
|
|
37
|
+
function missingEngineLinks(modulePath) {
|
|
38
|
+
const req = createRequire(modulePath);
|
|
39
|
+
const missing = [];
|
|
40
|
+
for (const [specifier, name] of ENGINE_LINKS) {
|
|
41
|
+
try {
|
|
42
|
+
req.resolve(specifier);
|
|
43
|
+
} catch {
|
|
44
|
+
missing.push(name);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return missing;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// PBCLI-36: a manifest at or above the module declaring @sublang/playbook
|
|
51
|
+
// means a project chose a dependency and its install is broken or absent;
|
|
52
|
+
// shadow-provisioning would mask the real fix.
|
|
53
|
+
function declaringManifest(startDir) {
|
|
54
|
+
for (let dir = startDir; ; ) {
|
|
55
|
+
const manifestPath = join(dir, 'package.json');
|
|
56
|
+
if (existsSync(manifestPath)) {
|
|
57
|
+
try {
|
|
58
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
59
|
+
for (const field of DEPENDENCY_FIELDS) {
|
|
60
|
+
const block = manifest?.[field];
|
|
61
|
+
if (
|
|
62
|
+
block !== null &&
|
|
63
|
+
typeof block === 'object' &&
|
|
64
|
+
Object.prototype.hasOwnProperty.call(block, '@sublang/playbook')
|
|
65
|
+
) {
|
|
66
|
+
return manifestPath;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
// An unreadable manifest cannot declare the dependency.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const parent = dirname(dir);
|
|
74
|
+
if (parent === dir) return undefined;
|
|
75
|
+
dir = parent;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function packageRootUpward(startDir, name) {
|
|
80
|
+
for (let dir = startDir; ; ) {
|
|
81
|
+
const manifestPath = join(dir, 'package.json');
|
|
82
|
+
if (existsSync(manifestPath)) {
|
|
83
|
+
try {
|
|
84
|
+
if (JSON.parse(readFileSync(manifestPath, 'utf8')).name === name) {
|
|
85
|
+
return dir;
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
// Keep walking past an unreadable manifest.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const parent = dirname(dir);
|
|
92
|
+
if (parent === dir) return undefined;
|
|
93
|
+
dir = parent;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// PBCLI-37: the running host's own installed package roots, resolved from
|
|
98
|
+
// the host's module scope — this file lives inside @sublang/playbook, and
|
|
99
|
+
// xstate resolves from that root's own dependency tree.
|
|
100
|
+
function defaultHostRoots() {
|
|
101
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
102
|
+
const playbookRoot = packageRootUpward(here, '@sublang/playbook');
|
|
103
|
+
if (playbookRoot === undefined) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
'cannot locate the running @sublang/playbook package root for provisioning',
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
const req = createRequire(join(playbookRoot, 'package.json'));
|
|
109
|
+
let xstateRoot;
|
|
110
|
+
try {
|
|
111
|
+
xstateRoot = packageRootUpward(dirname(req.resolve('xstate')), 'xstate');
|
|
112
|
+
} catch {
|
|
113
|
+
xstateRoot = undefined;
|
|
114
|
+
}
|
|
115
|
+
if (xstateRoot === undefined) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
"cannot locate the running host's own xstate package for provisioning",
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return { xstate: xstateRoot, '@sublang/playbook': playbookRoot };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 'absent' | 'dangling' | 'live' (a symlink with an existing target) |
|
|
124
|
+
// 'occupied' (a real file or directory, never removed).
|
|
125
|
+
function linkState(linkPath) {
|
|
126
|
+
let stat;
|
|
127
|
+
try {
|
|
128
|
+
stat = lstatSync(linkPath);
|
|
129
|
+
} catch {
|
|
130
|
+
return 'absent';
|
|
131
|
+
}
|
|
132
|
+
if (!stat.isSymbolicLink()) return 'occupied';
|
|
133
|
+
return existsSync(linkPath) ? 'live' : 'dangling';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// PBCLI-36/37: probe, then provision the missing engine links beside a
|
|
137
|
+
// filesystem registry module. Returns {} when the run may proceed (either
|
|
138
|
+
// nothing was needed or links were created and logged) or { code: 1 }
|
|
139
|
+
// after writing one `playbook run: <message>` diagnostic to stderr.
|
|
140
|
+
export async function provisionEngine({
|
|
141
|
+
modulePath,
|
|
142
|
+
stderr,
|
|
143
|
+
enabled = true,
|
|
144
|
+
hostRoots,
|
|
145
|
+
}) {
|
|
146
|
+
const missing = missingEngineLinks(modulePath);
|
|
147
|
+
if (missing.length === 0) return {};
|
|
148
|
+
|
|
149
|
+
const moduleDir = dirname(modulePath);
|
|
150
|
+
if (!enabled) {
|
|
151
|
+
// PBCLI-36: --no-provision still owes the dangling-link diagnostic —
|
|
152
|
+
// a stale link we (or a prior host) created must not surface as a raw
|
|
153
|
+
// module-not-found error.
|
|
154
|
+
for (const name of missing) {
|
|
155
|
+
const linkPath = join(moduleDir, 'node_modules', name);
|
|
156
|
+
if (linkState(linkPath) === 'dangling') {
|
|
157
|
+
stderr.write(
|
|
158
|
+
`playbook run: ${linkPath} is a stale engine link to missing ` +
|
|
159
|
+
`${readlinkSync(linkPath)}; rerun without --no-provision to relink\n`,
|
|
160
|
+
);
|
|
161
|
+
return { code: 1 };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return {};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const manifestPath = declaringManifest(moduleDir);
|
|
168
|
+
if (manifestPath !== undefined) {
|
|
169
|
+
stderr.write(
|
|
170
|
+
`playbook run: ${manifestPath} declares @sublang/playbook; ` +
|
|
171
|
+
'provisioning would shadow the project install — run the ' +
|
|
172
|
+
"project's dependency install (e.g. npm install) instead\n",
|
|
173
|
+
);
|
|
174
|
+
return { code: 1 };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let roots;
|
|
178
|
+
try {
|
|
179
|
+
roots = hostRoots ?? defaultHostRoots();
|
|
180
|
+
} catch (error) {
|
|
181
|
+
stderr.write(
|
|
182
|
+
`playbook run: ${error instanceof Error ? error.message : String(error)}\n`,
|
|
183
|
+
);
|
|
184
|
+
return { code: 1 };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// PBCLI-37: validate every destination before mutating any, so an
|
|
188
|
+
// occupied-path refusal leaves the module directory unchanged rather
|
|
189
|
+
// than half-provisioned.
|
|
190
|
+
const plans = [];
|
|
191
|
+
for (const name of missing) {
|
|
192
|
+
const linkPath = join(moduleDir, 'node_modules', name);
|
|
193
|
+
const state = linkState(linkPath);
|
|
194
|
+
if (state === 'occupied' || state === 'live') {
|
|
195
|
+
// A live-but-unresolvable link is as foreign as a real directory:
|
|
196
|
+
// neither is a link this host may replace.
|
|
197
|
+
stderr.write(
|
|
198
|
+
`playbook run: cannot provision ${linkPath}: the path is already ` +
|
|
199
|
+
`occupied${state === 'live' ? ' by a foreign symbolic link' : ''}\n`,
|
|
200
|
+
);
|
|
201
|
+
return { code: 1 };
|
|
202
|
+
}
|
|
203
|
+
plans.push({
|
|
204
|
+
linkPath,
|
|
205
|
+
target: roots[name],
|
|
206
|
+
dangling: state === 'dangling',
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const created = [];
|
|
211
|
+
try {
|
|
212
|
+
for (const { linkPath, target, dangling } of plans) {
|
|
213
|
+
if (dangling) await unlink(linkPath);
|
|
214
|
+
await mkdir(dirname(linkPath), { recursive: true });
|
|
215
|
+
await symlink(target, linkPath, 'dir');
|
|
216
|
+
created.push(`${linkPath} -> ${target}`);
|
|
217
|
+
}
|
|
218
|
+
} catch (error) {
|
|
219
|
+
// PBCLI-37: a filesystem failure is a load fault, not a raw crash.
|
|
220
|
+
stderr.write(
|
|
221
|
+
'playbook run: cannot provision engine links: ' +
|
|
222
|
+
`${error instanceof Error ? error.message : String(error)}\n`,
|
|
223
|
+
);
|
|
224
|
+
return { code: 1 };
|
|
225
|
+
}
|
|
226
|
+
stderr.write(`playbook run: provisioned ${created.join(', ')}\n`);
|
|
227
|
+
return {};
|
|
228
|
+
}
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
} from 'node:fs/promises';
|
|
22
22
|
import { homedir } from 'node:os';
|
|
23
23
|
import { isAbsolute, join, resolve } from 'node:path';
|
|
24
|
-
import { pathToFileURL } from 'node:url';
|
|
24
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
25
25
|
import {
|
|
26
26
|
Cligent,
|
|
27
27
|
isEffortSupported,
|
|
@@ -29,6 +29,13 @@ import {
|
|
|
29
29
|
} from '@sublang/cligent';
|
|
30
30
|
import { parse as parseYaml } from 'yaml';
|
|
31
31
|
import { hiddenControlEnvelope } from '../../../../src/xstate-runtime.js';
|
|
32
|
+
import {
|
|
33
|
+
adapterSdkFailureLines,
|
|
34
|
+
checkAdapterSdks,
|
|
35
|
+
mappedSdksFor,
|
|
36
|
+
probeAdapterSdk,
|
|
37
|
+
} from './adapter-sdk.js';
|
|
38
|
+
import { provisionEngine } from './provision.js';
|
|
32
39
|
|
|
33
40
|
// PBCLI-19: adapter shorthands the run host can construct.
|
|
34
41
|
const ADAPTER_LOADERS = {
|
|
@@ -76,6 +83,18 @@ export async function runPlaybookRun(options = {}) {
|
|
|
76
83
|
readStdin,
|
|
77
84
|
sessionsDir,
|
|
78
85
|
userConfigPath,
|
|
86
|
+
// PBCLI-39: the adapter SDK probe and the runtime classifier, injectable
|
|
87
|
+
// like createAgent so tests can drive an unavailable or below-floor
|
|
88
|
+
// runtime without uninstalling or downgrading one.
|
|
89
|
+
probeAdapterSdk: options.probeAdapterSdk ?? probeAdapterSdk,
|
|
90
|
+
classifyRuntime: options.classifyRuntime,
|
|
91
|
+
// PBCLI-40: the original invocation, preserved on the ephemeral re-run;
|
|
92
|
+
// this module receives argv with the leading `run` already consumed.
|
|
93
|
+
rawArgv: ['run', ...argv],
|
|
94
|
+
ephemeralNpx: options.ephemeralNpx,
|
|
95
|
+
// PBCLI-37: injected host package roots let tests provision against
|
|
96
|
+
// synthetic trees, like the injected session store.
|
|
97
|
+
hostRoots: options.hostRoots,
|
|
79
98
|
};
|
|
80
99
|
|
|
81
100
|
let args;
|
|
@@ -101,6 +120,11 @@ async function runFirst(args, ctx) {
|
|
|
101
120
|
return { code: EXIT.arg };
|
|
102
121
|
}
|
|
103
122
|
|
|
123
|
+
// PBCLI-36/37 (DR-024): provision engine links for a filesystem module
|
|
124
|
+
// before importing it; a resolvable engine is never touched.
|
|
125
|
+
const provisioned = await maybeProvision(args.from, args, ctx);
|
|
126
|
+
if (provisioned.code !== undefined) return provisioned;
|
|
127
|
+
|
|
104
128
|
const loaded = await loadRegistryEntry(args.from, ctx);
|
|
105
129
|
if (loaded.code !== undefined) return loaded;
|
|
106
130
|
const { entry } = loaded;
|
|
@@ -150,6 +174,18 @@ async function runFirst(args, ctx) {
|
|
|
150
174
|
return { code: EXIT.arg };
|
|
151
175
|
}
|
|
152
176
|
|
|
177
|
+
// PBCLI-39/40: an optional-peer SDK that is not installed fails here,
|
|
178
|
+
// before the runtime exists and before any agent call — never mid-turn.
|
|
179
|
+
const sdkError = await adapterSdksDiagnostic(
|
|
180
|
+
[...roleSpecs.values(), captainSpec],
|
|
181
|
+
ctx,
|
|
182
|
+
stdinReplayArgs(args, task),
|
|
183
|
+
);
|
|
184
|
+
if (sdkError !== undefined) {
|
|
185
|
+
stderr.write(sdkError);
|
|
186
|
+
return { code: EXIT.arg };
|
|
187
|
+
}
|
|
188
|
+
|
|
153
189
|
let runtime;
|
|
154
190
|
try {
|
|
155
191
|
runtime = entry.createRuntime({
|
|
@@ -246,6 +282,11 @@ async function runResume(args, ctx) {
|
|
|
246
282
|
return { code: EXIT.arg };
|
|
247
283
|
}
|
|
248
284
|
|
|
285
|
+
// PBCLI-37: a stored filesystem `from` (a file: URL) is probed and
|
|
286
|
+
// provisioned on resume exactly as on a first run.
|
|
287
|
+
const provisioned = await maybeProvision(record.from, args, ctx);
|
|
288
|
+
if (provisioned.code !== undefined) return provisioned;
|
|
289
|
+
|
|
249
290
|
const loaded = await loadRegistryEntry(record.from, ctx);
|
|
250
291
|
if (loaded.code !== undefined) return loaded;
|
|
251
292
|
const { entry } = loaded;
|
|
@@ -276,6 +317,18 @@ async function runResume(args, ctx) {
|
|
|
276
317
|
return { code: EXIT.arg };
|
|
277
318
|
}
|
|
278
319
|
|
|
320
|
+
// PBCLI-39: a resume rebuilds the stored lineup, so it needs the same
|
|
321
|
+
// SDKs — an install that lost one must not resume into a mid-turn error.
|
|
322
|
+
const sdkError = await adapterSdksDiagnostic(
|
|
323
|
+
[...roleSpecs.values(), record.captain],
|
|
324
|
+
ctx,
|
|
325
|
+
stdinReplayArgs(args, reply),
|
|
326
|
+
);
|
|
327
|
+
if (sdkError !== undefined) {
|
|
328
|
+
stderr.write(sdkError);
|
|
329
|
+
return { code: EXIT.arg };
|
|
330
|
+
}
|
|
331
|
+
|
|
279
332
|
let runtime;
|
|
280
333
|
try {
|
|
281
334
|
runtime = entry.createRuntime({
|
|
@@ -625,6 +678,50 @@ function specsDiagnostic(specs) {
|
|
|
625
678
|
return undefined;
|
|
626
679
|
}
|
|
627
680
|
|
|
681
|
+
// PBCLI-40: the replay tail for input the command consumed from stdin —
|
|
682
|
+
// the pipe that carried it will not exist when the printed command runs.
|
|
683
|
+
// The value rides behind a `--` end-of-options terminator, because quoting
|
|
684
|
+
// alone cannot keep a flag-shaped value (`--json`, `--last`, a `-`-leading
|
|
685
|
+
// bullet) from being read as an option; where the original invocation
|
|
686
|
+
// already activated a terminator of its own, that one is reused — a second
|
|
687
|
+
// `--` after the first would itself be positional data on the replay,
|
|
688
|
+
// turning a `--json` task into `-- --json`.
|
|
689
|
+
function stdinReplayArgs(args, resolved) {
|
|
690
|
+
if (args.task !== undefined) return [];
|
|
691
|
+
return args.terminated ? [resolved] : ['--', resolved];
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// PBCLI-39/40: returns the ready-to-write stderr block naming every bound
|
|
695
|
+
// adapter whose optional-peer SDK is not installed, or undefined when every
|
|
696
|
+
// one of them loads. Runs only after specsDiagnostic has accepted the
|
|
697
|
+
// adapter names, so every spec here carries a known adapter.
|
|
698
|
+
async function adapterSdksDiagnostic(specs, ctx, stdinArgs = []) {
|
|
699
|
+
const adapters = specs.map((spec) => spec.adapter);
|
|
700
|
+
const { unusableAdapters } = await checkAdapterSdks(
|
|
701
|
+
adapters,
|
|
702
|
+
ctx.probeAdapterSdk,
|
|
703
|
+
...(ctx.classifyRuntime ? [ctx.classifyRuntime] : []),
|
|
704
|
+
);
|
|
705
|
+
if (unusableAdapters.length === 0) return undefined;
|
|
706
|
+
const [header, ...commands] = adapterSdkFailureLines(unusableAdapters, {
|
|
707
|
+
// PBCLI-40: the ephemeral re-run carries the lineup's full mapped SDK
|
|
708
|
+
// set and the original arguments, so it completes in one hop and runs
|
|
709
|
+
// exactly as printed. stdinArgs arrive terminator-ready from
|
|
710
|
+
// stdinReplayArgs — appended verbatim here, because whether a `--` is
|
|
711
|
+
// needed depends on the original invocation's own parse state.
|
|
712
|
+
requiredSdks: mappedSdksFor(adapters),
|
|
713
|
+
invocation: [...ctx.rawArgv, ...stdinArgs],
|
|
714
|
+
...(ctx.ephemeralNpx !== undefined
|
|
715
|
+
? { ephemeralNpx: ctx.ephemeralNpx }
|
|
716
|
+
: {}),
|
|
717
|
+
}).filter((line) => line !== '');
|
|
718
|
+
// Only the header takes the command prefix; the install lines stay
|
|
719
|
+
// copy-pasteable.
|
|
720
|
+
return [`playbook run: ${header}`, ...commands]
|
|
721
|
+
.map((line) => `${line}\n`)
|
|
722
|
+
.join('');
|
|
723
|
+
}
|
|
724
|
+
|
|
628
725
|
function isAgentSpec(spec) {
|
|
629
726
|
return (
|
|
630
727
|
typeof spec === 'object' &&
|
|
@@ -876,14 +973,29 @@ export function parseRunArgs(argv) {
|
|
|
876
973
|
cwd: undefined,
|
|
877
974
|
json: false,
|
|
878
975
|
verbose: false,
|
|
976
|
+
noProvision: false,
|
|
879
977
|
help: false,
|
|
978
|
+
terminated: false,
|
|
880
979
|
};
|
|
881
980
|
const positionals = [];
|
|
882
981
|
for (let i = 0; i < argv.length; i += 1) {
|
|
883
982
|
const arg = argv[i];
|
|
983
|
+
// PBCLI-40: end-of-options — everything after `--` is positional, so a
|
|
984
|
+
// flag-shaped task or reply (a stdin-derived `--json`, a `- bullet`
|
|
985
|
+
// line) survives the ephemeral re-run round trip instead of being
|
|
986
|
+
// reinterpreted as an option. `terminated` records that this branch
|
|
987
|
+
// fired — only a `--` the walk itself treats as the terminator counts,
|
|
988
|
+
// never one consumed as an option's value (`--cwd --`) — so the re-run
|
|
989
|
+
// builder can reuse an active terminator instead of doubling it.
|
|
990
|
+
if (arg === '--') {
|
|
991
|
+
args.terminated = true;
|
|
992
|
+
positionals.push(...argv.slice(i + 1));
|
|
993
|
+
break;
|
|
994
|
+
}
|
|
884
995
|
if (arg === '--help' || arg === '-h') args.help = true;
|
|
885
996
|
else if (arg === '--json') args.json = true;
|
|
886
997
|
else if (arg === '--verbose') args.verbose = true;
|
|
998
|
+
else if (arg === '--no-provision') args.noProvision = true;
|
|
887
999
|
else if (arg === '--last') args.last = true;
|
|
888
1000
|
else if (arg === '--cwd') args.cwd = takeValue(argv, (i += 1), '--cwd');
|
|
889
1001
|
else if (arg === '--captain')
|
|
@@ -961,6 +1073,36 @@ function registryImportSpecifier(specifier, cwd) {
|
|
|
961
1073
|
return specifier;
|
|
962
1074
|
}
|
|
963
1075
|
|
|
1076
|
+
// PBCLI-37: the absolute file path of a filesystem `<from>` (path or
|
|
1077
|
+
// file: URL), or undefined for a bare package specifier — those resolve
|
|
1078
|
+
// from the host's own module tree and are neither probed nor provisioned.
|
|
1079
|
+
function moduleFilePath(specifier, cwd) {
|
|
1080
|
+
if (specifier.startsWith('file:')) return fileURLToPath(specifier);
|
|
1081
|
+
if (
|
|
1082
|
+
isAbsolute(specifier) ||
|
|
1083
|
+
specifier.startsWith('./') ||
|
|
1084
|
+
specifier.startsWith('../') ||
|
|
1085
|
+
specifier.startsWith('.\\') ||
|
|
1086
|
+
specifier.startsWith('..\\')
|
|
1087
|
+
) {
|
|
1088
|
+
return resolve(cwd, specifier);
|
|
1089
|
+
}
|
|
1090
|
+
return undefined;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// PBCLI-36/37: probe-and-provision for a filesystem registry module.
|
|
1094
|
+
// Returns {} to proceed or { code } after a reported provisioning fault.
|
|
1095
|
+
async function maybeProvision(specifier, args, ctx) {
|
|
1096
|
+
const modulePath = moduleFilePath(specifier, ctx.cwdDefault);
|
|
1097
|
+
if (modulePath === undefined) return {};
|
|
1098
|
+
return provisionEngine({
|
|
1099
|
+
modulePath,
|
|
1100
|
+
stderr: ctx.stderr,
|
|
1101
|
+
enabled: !args.noProvision,
|
|
1102
|
+
hostRoots: ctx.hostRoots,
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
|
|
964
1106
|
function isValidRegistryEntry(value) {
|
|
965
1107
|
return (
|
|
966
1108
|
typeof value === 'object' &&
|
|
@@ -990,6 +1132,7 @@ function runHelpText() {
|
|
|
990
1132
|
' <from> registry module specifier (package subpath, path, or file: URL)',
|
|
991
1133
|
' [task] Boss intent; read from stdin when omitted',
|
|
992
1134
|
' [reply] Boss reply to a parked session; read from stdin when omitted',
|
|
1135
|
+
' -- end of options; use before a task or reply that starts with -',
|
|
993
1136
|
'',
|
|
994
1137
|
'Options:',
|
|
995
1138
|
' --player <role>=<agent> bind a required role (repeatable)',
|
|
@@ -1000,6 +1143,8 @@ function runHelpText() {
|
|
|
1000
1143
|
' output or questions) instead of plain text',
|
|
1001
1144
|
' --last resume the most recently parked session',
|
|
1002
1145
|
' --verbose forward telemetry topics to stderr',
|
|
1146
|
+
' --no-provision never create engine links beside a',
|
|
1147
|
+
' filesystem <from> module',
|
|
1003
1148
|
' -h, --help print this help',
|
|
1004
1149
|
'',
|
|
1005
1150
|
' <agent> is <adapter>[:<model>][@<effort>] over the shorthands',
|
|
@@ -919,9 +919,30 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
919
919
|
if (visibilityControlError !== undefined)
|
|
920
920
|
throw visibilityControlError;
|
|
921
921
|
}
|
|
922
|
+
// CAPTAIN-34/35: a parentless internal Captain frame holds no recoverable
|
|
923
|
+
// work, so any rejected boundary call disposes the stack instead of
|
|
924
|
+
// stranding a frame that would refuse every later registered command. A
|
|
925
|
+
// parentless external root keeps its frame for Boss recovery.
|
|
926
|
+
async function failParentlessBoundary(frame, error) {
|
|
927
|
+
if (!frame.internal || !frames.includes(frame))
|
|
928
|
+
throw error;
|
|
929
|
+
if (!disposing) {
|
|
930
|
+
try {
|
|
931
|
+
await disposeStack('failure');
|
|
932
|
+
}
|
|
933
|
+
catch {
|
|
934
|
+
// The boundary failure wins; disposal detail stays on telemetry.
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
const commands = [...enablementById.values()]
|
|
938
|
+
.map((enablement) => `/${enablement.command} <task>`)
|
|
939
|
+
.join(' or ');
|
|
940
|
+
throw new Error('Captain could not finish that turn and the engagement was reset. ' +
|
|
941
|
+
`Send the request again${commands ? `, or start a playbook directly with ${commands}` : ''}.`, { cause: error });
|
|
942
|
+
}
|
|
922
943
|
async function returnBoundaryFailure(frame, error, context) {
|
|
923
944
|
if (!frame.parent)
|
|
924
|
-
|
|
945
|
+
await failParentlessBoundary(frame, error);
|
|
925
946
|
await resumeParent(frame, {
|
|
926
947
|
status: context.signal.aborted ? 'aborted' : 'error',
|
|
927
948
|
playbookId: frame.entry.id,
|
|
@@ -1126,7 +1147,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1126
1147
|
completed = true;
|
|
1127
1148
|
}
|
|
1128
1149
|
else {
|
|
1129
|
-
|
|
1150
|
+
await failParentlessBoundary(frame, error);
|
|
1130
1151
|
}
|
|
1131
1152
|
}
|
|
1132
1153
|
finally {
|
|
@@ -103,7 +103,7 @@ class VisibilityControlError extends Error {
|
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
type DisposalReason = 'dismiss' | 'final' | 'dispose';
|
|
106
|
+
type DisposalReason = 'dismiss' | 'final' | 'dispose' | 'failure';
|
|
107
107
|
|
|
108
108
|
interface ControlLedger {
|
|
109
109
|
activePlaybookId?: string;
|
|
@@ -1275,12 +1275,38 @@ export function createPlaybookCaptainShell(
|
|
|
1275
1275
|
if (visibilityControlError !== undefined) throw visibilityControlError;
|
|
1276
1276
|
}
|
|
1277
1277
|
|
|
1278
|
+
// CAPTAIN-34/35: a parentless internal Captain frame holds no recoverable
|
|
1279
|
+
// work, so any rejected boundary call disposes the stack instead of
|
|
1280
|
+
// stranding a frame that would refuse every later registered command. A
|
|
1281
|
+
// parentless external root keeps its frame for Boss recovery.
|
|
1282
|
+
async function failParentlessBoundary(
|
|
1283
|
+
frame: EngagementFrame,
|
|
1284
|
+
error: unknown,
|
|
1285
|
+
): Promise<never> {
|
|
1286
|
+
if (!frame.internal || !frames.includes(frame)) throw error;
|
|
1287
|
+
if (!disposing) {
|
|
1288
|
+
try {
|
|
1289
|
+
await disposeStack('failure');
|
|
1290
|
+
} catch {
|
|
1291
|
+
// The boundary failure wins; disposal detail stays on telemetry.
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
const commands = [...enablementById.values()]
|
|
1295
|
+
.map((enablement) => `/${enablement.command} <task>`)
|
|
1296
|
+
.join(' or ');
|
|
1297
|
+
throw new Error(
|
|
1298
|
+
'Captain could not finish that turn and the engagement was reset. ' +
|
|
1299
|
+
`Send the request again${commands ? `, or start a playbook directly with ${commands}` : ''}.`,
|
|
1300
|
+
{ cause: error },
|
|
1301
|
+
);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1278
1304
|
async function returnBoundaryFailure(
|
|
1279
1305
|
frame: EngagementFrame,
|
|
1280
1306
|
error: unknown,
|
|
1281
1307
|
context: CaptainContext,
|
|
1282
1308
|
): Promise<void> {
|
|
1283
|
-
if (!frame.parent)
|
|
1309
|
+
if (!frame.parent) await failParentlessBoundary(frame, error);
|
|
1284
1310
|
await resumeParent(
|
|
1285
1311
|
frame,
|
|
1286
1312
|
{
|
|
@@ -1524,7 +1550,7 @@ export function createPlaybookCaptainShell(
|
|
|
1524
1550
|
await returnBoundaryFailure(frame, error, context);
|
|
1525
1551
|
completed = true;
|
|
1526
1552
|
} else {
|
|
1527
|
-
|
|
1553
|
+
await failParentlessBoundary(frame, error);
|
|
1528
1554
|
}
|
|
1529
1555
|
} finally {
|
|
1530
1556
|
activeTurnSummary = undefined;
|
|
@@ -10,6 +10,13 @@
|
|
|
10
10
|
# settings inline: an adapter shorthand (claude, codex) or a block with
|
|
11
11
|
# adapter/model/effort/permissions. Retuning one agent never changes
|
|
12
12
|
# another.
|
|
13
|
+
|
|
14
|
+
# Each adapter needs its vendor SDK installed as its own top-level
|
|
15
|
+
# install root — they are optional peer dependencies, so you pay only
|
|
16
|
+
# for the vendors named below:
|
|
17
|
+
# claude -> npm install -g @anthropic-ai/claude-agent-sdk
|
|
18
|
+
# codex -> npm install -g @openai/codex-sdk
|
|
19
|
+
# Drop an adapter from this file and you can skip its SDK entirely.
|
|
13
20
|
# Every seeded agent runs in cligent's protected auto mode
|
|
14
21
|
# (permissions.mode: auto): claude maps it to permissionMode auto, codex to
|
|
15
22
|
# on-request + auto_review. Codex roles also grant writablePaths: ['.git']
|
package/slc/link.md
CHANGED
|
@@ -1335,6 +1335,20 @@ The emitted module:
|
|
|
1335
1335
|
type; supplying one is a construction error, so a linker that judges a
|
|
1336
1336
|
runtime-owned arm to have lost payload detail under erasure shall report
|
|
1337
1337
|
that gap rather than emit the entry.
|
|
1338
|
+
- Supplies `spec.compat` with the compatibility values current at link time:
|
|
1339
|
+
`{ artifactSchema, runtimeAbi }`, where `artifactSchema` is `1` — the
|
|
1340
|
+
schema number of the thin-module format this §Output defines — and
|
|
1341
|
+
`runtimeAbi` is the installed shared engine's `RUNTIME_ABI` self-report.
|
|
1342
|
+
The linker shall verify that the installed engine lists the emitted
|
|
1343
|
+
schema in `SUPPORTED_ARTIFACT_SCHEMAS` and treat its absence as a
|
|
1344
|
+
link-time error; it shall not stamp a different member (such as the
|
|
1345
|
+
highest) merely because that engine also supports a newer artifact
|
|
1346
|
+
format — the declaration names the format of the emitted module, not
|
|
1347
|
+
the capability of the emitting engine. The factory checks the
|
|
1348
|
+
declaration against the engine instance that actually loads the emitted
|
|
1349
|
+
module and fails construction on a mismatch, so an artifact linked under
|
|
1350
|
+
one engine cannot run silently skewed under another. Modules emitted
|
|
1351
|
+
before this contract carry no `compat` member and remain loadable.
|
|
1338
1352
|
- Default-exports the factory call as `createPlaybookRuntime`, typed
|
|
1339
1353
|
`PlaybookRuntimeFactory<PlaybookRuntimeOptions>`.
|
|
1340
1354
|
- Exposes, under an `_internal` export, the pure helpers verification
|
|
@@ -70,9 +70,26 @@ export declare const BOSS_REPLY_ERRORS: {
|
|
|
70
70
|
readonly missingQuestion: "needsBossReply outcome missing 'question' field";
|
|
71
71
|
readonly unregisteredState: (stateId: string) => string;
|
|
72
72
|
};
|
|
73
|
+
/** The runtime ABI this engine implements (DR-022). */
|
|
74
|
+
export declare const RUNTIME_ABI = 1;
|
|
75
|
+
/** The linked-artifact schema versions this engine accepts (DR-022). */
|
|
76
|
+
export declare const SUPPORTED_ARTIFACT_SCHEMAS: readonly number[];
|
|
77
|
+
/** A linked artifact's declared link-time compatibility values (DR-022). */
|
|
78
|
+
export interface XStatePlaybookRuntimeCompat {
|
|
79
|
+
/** The artifact schema version the linker emitted. */
|
|
80
|
+
artifactSchema: number;
|
|
81
|
+
/** The engine ABI the artifact was linked against. */
|
|
82
|
+
runtimeAbi: number;
|
|
83
|
+
}
|
|
73
84
|
export interface XStatePlaybookRuntimeSpec<TOptions> {
|
|
74
85
|
/** Diagnostic label used in internal invariant errors. Default 'playbook'. */
|
|
75
86
|
label?: string;
|
|
87
|
+
/**
|
|
88
|
+
* Link-time compatibility declaration checked at construction against the
|
|
89
|
+
* loaded engine's self-report (DR-022). Absent: a legacy artifact emitted
|
|
90
|
+
* before the contract — constructed with no compatibility check.
|
|
91
|
+
*/
|
|
92
|
+
compat?: XStatePlaybookRuntimeCompat;
|
|
76
93
|
/** Validate and JSON-snapshot the caller's per-run options. */
|
|
77
94
|
snapshotOptions: (value: unknown) => TOptions;
|
|
78
95
|
/** Derive the FSM machine input from validated options. Default: identity. */
|
|
@@ -186,6 +203,16 @@ export interface PlayerBridgeSpec {
|
|
|
186
203
|
resumableStateIds: ReadonlySet<string>;
|
|
187
204
|
}
|
|
188
205
|
export declare function createPlayerBridge(spec: PlayerBridgeSpec, ports: PlaybookPorts, getActiveSignal?: () => AbortSignal | undefined, boundary?: RuntimeBoundaryCalls, onControlPlaneError?: (error: unknown) => void): PromiseActorLogic<PlaybookActorOutput, PlaybookPlayerInput>;
|
|
206
|
+
/**
|
|
207
|
+
* Default direct-Captain adjudicator prompt (DR-025). The single statement of
|
|
208
|
+
* the `{ guard, …structuralPayloadFields }` reply contract, shared with the
|
|
209
|
+
* compiled default Captain artifact so the wording cannot drift.
|
|
210
|
+
*/
|
|
211
|
+
export declare function defaultBuildCaptainJudgePrompt(input: {
|
|
212
|
+
readonly stateId: string;
|
|
213
|
+
readonly sourceItem: string;
|
|
214
|
+
readonly result: Readonly<Record<string, string>>;
|
|
215
|
+
}, finalText: string): string;
|
|
189
216
|
/** Targets of the FSM's `awaitBossReply` BOSS_REPLY transitions. */
|
|
190
217
|
export declare function resumableStateIdsFromMachine(machine: AnyStateMachine): ReadonlySet<string>;
|
|
191
218
|
/**
|