@sublang/playbook 2.0.0 → 3.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 +101 -338
- package/docs/cli.md +102 -0
- package/docs/configuration.md +158 -0
- package/docs/embedding.md +161 -0
- package/package.json +6 -2
- package/reference/sdlc/code.md +105 -0
- package/reference/sdlc/code.playbook/bin/playbook.js +236 -38
- package/reference/sdlc/code.playbook/bin/run.js +27 -2
- package/reference/sdlc/code.playbook/code.fsm.js +38 -36
- package/reference/sdlc/code.playbook/code.fsm.ts +38 -36
- package/reference/sdlc/code.playbook/code.gears.md +30 -26
- package/reference/sdlc/code.playbook/code.playbook.js +4 -0
- package/reference/sdlc/code.playbook/code.playbook.ts +6 -0
- package/reference/sdlc/code.playbook/playbook-captain.js +67 -8
- package/reference/sdlc/code.playbook/playbook-captain.ts +80 -9
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +38 -32
- package/reference/sdlc/discuss.md +93 -0
- package/reference/sdlc/discuss.playbook/discuss.fsm.js +5 -4
- package/reference/sdlc/discuss.playbook/discuss.fsm.ts +5 -4
- package/reference/sdlc/discuss.playbook/discuss.gears.md +19 -12
- package/reference/sdlc/discuss.playbook/discuss.playbook.js +3 -0
- package/reference/sdlc/discuss.playbook/discuss.playbook.ts +5 -0
- package/slc/link.md +6 -3
- package/src/xstate-playbook-runtime.js +4 -0
- package/src/xstate-playbook-runtime.ts +6 -0
- package/src/xstate-runtime.d.ts +1 -0
- package/src/xstate-runtime.js +19 -0
- package/src/xstate-runtime.ts +20 -0
|
@@ -17,7 +17,11 @@ import {
|
|
|
17
17
|
import { homedir, tmpdir } from 'node:os';
|
|
18
18
|
import { dirname, join, resolve } from 'node:path';
|
|
19
19
|
import { fileURLToPath } from 'node:url';
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
parse as parseYaml,
|
|
22
|
+
parseDocument as parseYamlDocument,
|
|
23
|
+
stringify as stringifyYaml,
|
|
24
|
+
} from 'yaml';
|
|
21
25
|
|
|
22
26
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
23
27
|
const templatePath = resolve(here, '..', 'playbook.config.template.yaml');
|
|
@@ -25,8 +29,8 @@ const templatePath = resolve(here, '..', 'playbook.config.template.yaml');
|
|
|
25
29
|
// PBCLI-1/8: the launcher composes a tmux-play config whose Captain is the
|
|
26
30
|
// Playbook Captain shell adapter module.
|
|
27
31
|
export const PLAYBOOK_CAPTAIN_MODULE = '@sublang/playbook/playbook-captain';
|
|
28
|
-
// PBCLI-
|
|
29
|
-
//
|
|
32
|
+
// PBCLI-12: known adapter shorthands — the adapters with readiness
|
|
33
|
+
// predicates.
|
|
30
34
|
const ADAPTER_SHORTHANDS = ['claude', 'codex'];
|
|
31
35
|
// PBCLI-8: launcher-owned keys inside a `playbooks.<id>` block; every other
|
|
32
36
|
// key belongs to that playbook's option slice.
|
|
@@ -103,6 +107,15 @@ export async function runPlaybookCli(options = {}) {
|
|
|
103
107
|
|
|
104
108
|
seedUserConfigIfMissing(userConfigPath, stderr);
|
|
105
109
|
|
|
110
|
+
// DR-021 §3: an existing profiles-based config is rewritten in place once,
|
|
111
|
+
// with the original kept beside it, so the user launches without editing.
|
|
112
|
+
try {
|
|
113
|
+
migrateUserConfigIfRetired(userConfigPath, stderr);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
stderr.write(`playbook: ${errorMessage(error)}\n`);
|
|
116
|
+
return { code: COMPOSITION_FAILURE_EXIT_CODE };
|
|
117
|
+
}
|
|
118
|
+
|
|
106
119
|
let composed;
|
|
107
120
|
try {
|
|
108
121
|
let top = parseYaml(readFileSync(userConfigPath, 'utf8')) ?? {};
|
|
@@ -114,7 +127,7 @@ export async function runPlaybookCli(options = {}) {
|
|
|
114
127
|
for (const overlayPath of withPaths) {
|
|
115
128
|
top = mergeConfigs(top, loadOverlayFragment(overlayPath));
|
|
116
129
|
}
|
|
117
|
-
composed = await composeGenericConfig(top, loadModule);
|
|
130
|
+
composed = await composeGenericConfig(top, loadModule, userConfigPath);
|
|
118
131
|
} catch (error) {
|
|
119
132
|
stderr.write(`playbook: ${errorMessage(error)}\n`);
|
|
120
133
|
return { code: COMPOSITION_FAILURE_EXIT_CODE };
|
|
@@ -235,29 +248,213 @@ export function resolveUserConfigPath(env = process.env, home = homedir()) {
|
|
|
235
248
|
return join(resolveConfigHome(env, home), 'playbook', 'playbook.config.yaml');
|
|
236
249
|
}
|
|
237
250
|
|
|
238
|
-
// PBCLI-8:
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
export function resolveAgent(value,
|
|
243
|
-
if (typeof value === 'string') {
|
|
244
|
-
|
|
245
|
-
|
|
251
|
+
// PBCLI-8 (DR-021): a scalar `captain` / `players.<role>` value is an
|
|
252
|
+
// adapter shorthand; a full block is a self-contained tmux-play agent block
|
|
253
|
+
// carrying its own adapter/model/effort/permissions. There is no profile
|
|
254
|
+
// indirection, so retuning one agent cannot change another.
|
|
255
|
+
export function resolveAgent(value, path) {
|
|
256
|
+
if (typeof value === 'string') return { adapter: value };
|
|
257
|
+
if (isObject(value)) return { ...value };
|
|
258
|
+
throw new Error(`${path} must be an adapter shorthand or an agent block`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// DR-021 §3: migrate the user's config on disk, once, keeping the original.
|
|
262
|
+
// The backup is written before the rewrite and never overwrites an existing
|
|
263
|
+
// file, so a prior backup — or a user's own .bak — cannot be lost.
|
|
264
|
+
function migrateUserConfigIfRetired(userConfigPath, stderr) {
|
|
265
|
+
let text;
|
|
266
|
+
try {
|
|
267
|
+
text = readFileSync(userConfigPath, 'utf8');
|
|
268
|
+
} catch {
|
|
269
|
+
return;
|
|
246
270
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
271
|
+
let migrated;
|
|
272
|
+
try {
|
|
273
|
+
migrated = migrateRetiredProfiles(text);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
throw new Error(
|
|
276
|
+
`cannot migrate the retired profiles config at ${userConfigPath}: ` +
|
|
277
|
+
`${errorMessage(error)} — edit it by hand: each agent takes its own ` +
|
|
278
|
+
'adapter, model, effort, and permissions',
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
if (migrated === undefined) return;
|
|
282
|
+
const backupPath = freeBackupPath(userConfigPath);
|
|
283
|
+
writeFileSync(backupPath, text, { mode: 0o600 });
|
|
284
|
+
writeFileSync(userConfigPath, migrated);
|
|
285
|
+
stderr.write(
|
|
286
|
+
`playbook: migrated ${userConfigPath} to inline agent settings ` +
|
|
287
|
+
`(the top-level "profiles" map was removed in 3.0.0); ` +
|
|
288
|
+
`the original is at ${backupPath}\n`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function freeBackupPath(userConfigPath) {
|
|
293
|
+
const first = `${userConfigPath}.bak`;
|
|
294
|
+
if (!existsSync(first)) return first;
|
|
295
|
+
for (let n = 2; ; n += 1) {
|
|
296
|
+
const candidate = `${userConfigPath}.bak.${n}`;
|
|
297
|
+
if (!existsSync(candidate)) return candidate;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// DR-021 §3: rewrite a config written for the retired profiles model in
|
|
302
|
+
// place, inlining each agent's settings and keeping the original beside it.
|
|
303
|
+
// Edits go through the YAML Document API so the user's comments survive;
|
|
304
|
+
// only the profiles block and its own commentary are removed. Returns the
|
|
305
|
+
// migrated text, or undefined when there is nothing to migrate.
|
|
306
|
+
export function migrateRetiredProfiles(text) {
|
|
307
|
+
const doc = parseYamlDocument(text);
|
|
308
|
+
const contents = doc.contents;
|
|
309
|
+
if (!contents || !Array.isArray(contents.items)) return undefined;
|
|
310
|
+
const profiles = doc.get('profiles');
|
|
311
|
+
const agentPaths = [['captain']];
|
|
312
|
+
const playbooks = doc.get('playbooks');
|
|
313
|
+
if (playbooks && Array.isArray(playbooks.items)) {
|
|
314
|
+
for (const entry of playbooks.items) {
|
|
315
|
+
const id = String(entry.key);
|
|
316
|
+
const players = doc.getIn(['playbooks', id, 'players']);
|
|
317
|
+
if (!players || !Array.isArray(players.items)) continue;
|
|
318
|
+
for (const player of players.items) {
|
|
319
|
+
agentPaths.push(['playbooks', id, 'players', String(player.key)]);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const profileSettings = (name) =>
|
|
325
|
+
profiles && typeof profiles.get === 'function'
|
|
326
|
+
? profiles.get(name)
|
|
327
|
+
: undefined;
|
|
328
|
+
|
|
329
|
+
let changed = false;
|
|
330
|
+
for (const path of agentPaths) {
|
|
331
|
+
const node = doc.getIn(path, true);
|
|
332
|
+
if (node && typeof node.value === 'string' && !Array.isArray(node.items)) {
|
|
333
|
+
// A scalar that named a profile; a bare adapter shorthand stays.
|
|
334
|
+
const settings = profileSettings(node.value);
|
|
335
|
+
if (settings === undefined) continue;
|
|
336
|
+
const inlined = settings.clone();
|
|
337
|
+
// The scalar carried any comment on that line, and replacing the node
|
|
338
|
+
// would drop it. Re-attach it above the block that replaces it.
|
|
339
|
+
carryScalarComment(node, inlined);
|
|
340
|
+
doc.setIn(path, inlined);
|
|
341
|
+
changed = true;
|
|
342
|
+
} else if (node && Array.isArray(node.items)) {
|
|
343
|
+
const named = node.get?.('profile');
|
|
344
|
+
if (named === undefined) continue;
|
|
345
|
+
const settings = profileSettings(named);
|
|
346
|
+
if (settings === undefined) {
|
|
347
|
+
throw new Error(
|
|
348
|
+
`${path.join('.')}.profile names "${String(named)}", which no ` +
|
|
349
|
+
'profiles entry defines',
|
|
350
|
+
);
|
|
253
351
|
}
|
|
254
|
-
|
|
352
|
+
// Fill the block from its profile in place — never rebuild it — so
|
|
353
|
+
// the user's own keys, ordering, and comments survive untouched. The
|
|
354
|
+
// block's own fields stay authoritative, so only absent keys are added.
|
|
355
|
+
node.delete('profile');
|
|
356
|
+
for (const item of settings.items) {
|
|
357
|
+
if (node.has(String(item.key))) continue;
|
|
358
|
+
// Append the whole pair, not a rebuilt key/value: a comment above a
|
|
359
|
+
// setting rides on that setting's key node, so stringifying the key
|
|
360
|
+
// would drop it.
|
|
361
|
+
node.add(item.clone());
|
|
362
|
+
}
|
|
363
|
+
changed = true;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (profiles !== undefined) {
|
|
368
|
+
// The comment block above `profiles` usually carries the file's own
|
|
369
|
+
// header, which must outlive the removed section: keep every paragraph
|
|
370
|
+
// except the last, which documents profiles themselves.
|
|
371
|
+
const index = contents.items.findIndex(
|
|
372
|
+
(item) => String(item.key) === 'profiles',
|
|
373
|
+
);
|
|
374
|
+
const lead = index === -1 ? undefined : contents.items[index]?.key
|
|
375
|
+
?.commentBefore;
|
|
376
|
+
doc.delete('profiles');
|
|
377
|
+
const header = keptHeaderComment(lead);
|
|
378
|
+
const next = contents.items[0];
|
|
379
|
+
if (header !== undefined && next?.key) {
|
|
380
|
+
next.key.commentBefore =
|
|
381
|
+
next.key.commentBefore === undefined
|
|
382
|
+
? header
|
|
383
|
+
: `${header}\n\n${next.key.commentBefore}`;
|
|
255
384
|
}
|
|
256
|
-
|
|
385
|
+
changed = true;
|
|
257
386
|
}
|
|
258
|
-
|
|
259
|
-
|
|
387
|
+
if (!changed) return undefined;
|
|
388
|
+
// Say what happened at the top of the file the user will open next:
|
|
389
|
+
// some of their remaining comments describe the retired model.
|
|
390
|
+
doc.commentBefore = MIGRATION_NOTE;
|
|
391
|
+
return doc.toString();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const MIGRATION_NOTE =
|
|
395
|
+
' Migrated by playbook 3.0.0: the top-level `profiles` map was removed and\n' +
|
|
396
|
+
' each agent now carries its settings inline. The pre-migration file is\n' +
|
|
397
|
+
' kept beside this one as a .bak. Comments below may still describe the\n' +
|
|
398
|
+
' retired profiles model.';
|
|
399
|
+
|
|
400
|
+
// Move a scalar agent's own comments onto the block that replaces it, so
|
|
401
|
+
// `captain: base # the judge` keeps its note. The pair's key comments are
|
|
402
|
+
// untouched by the replacement and need no carrying.
|
|
403
|
+
function carryScalarComment(node, inlined) {
|
|
404
|
+
const parts = [node.commentBefore, node.comment].filter(
|
|
405
|
+
(part) => typeof part === 'string' && part.trim() !== '',
|
|
260
406
|
);
|
|
407
|
+
if (parts.length === 0) return;
|
|
408
|
+
const first = inlined.items?.[0]?.key;
|
|
409
|
+
if (!first) return;
|
|
410
|
+
// A flow map carrying a comment renders as a multi-line brace block; the
|
|
411
|
+
// ordinary block form is what the rest of the config looks like.
|
|
412
|
+
inlined.flow = false;
|
|
413
|
+
const carried = parts.join('\n');
|
|
414
|
+
first.commentBefore =
|
|
415
|
+
first.commentBefore === undefined
|
|
416
|
+
? carried
|
|
417
|
+
: `${carried}\n${first.commentBefore}`;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Drop the trailing paragraph — the one describing the profiles block —
|
|
421
|
+
// and keep the rest of the leading comment (SPDX header, file overview).
|
|
422
|
+
function keptHeaderComment(comment) {
|
|
423
|
+
if (typeof comment !== 'string' || comment.trim() === '') return undefined;
|
|
424
|
+
const paragraphs = comment.split('\n\n');
|
|
425
|
+
const kept = paragraphs.slice(0, -1).join('\n\n');
|
|
426
|
+
return kept.trim() === '' ? undefined : kept;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// A `profile` key that survives migration — introduced by a `--with`
|
|
430
|
+
// overlay rather than the user's own config — is still rejected.
|
|
431
|
+
function assertNoRetiredProfiles(top, configPath) {
|
|
432
|
+
const where = configPath ? ` in ${configPath}` : '';
|
|
433
|
+
if (top.profiles !== undefined) {
|
|
434
|
+
throw new Error(
|
|
435
|
+
`top-level "profiles" was removed${where}: write each agent's settings ` +
|
|
436
|
+
'inline under captain and each playbooks.<id>.players.<role> ' +
|
|
437
|
+
'(adapter, model, effort, permissions)',
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
const blocks = [['captain', top.captain]];
|
|
441
|
+
const playbooksCfg = isObject(top.playbooks) ? top.playbooks : {};
|
|
442
|
+
for (const [id, block] of Object.entries(playbooksCfg)) {
|
|
443
|
+
const playersMap = isObject(block) && isObject(block.players)
|
|
444
|
+
? block.players
|
|
445
|
+
: {};
|
|
446
|
+
for (const [role, agent] of Object.entries(playersMap)) {
|
|
447
|
+
blocks.push([`playbooks.${id}.players.${role}`, agent]);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
for (const [path, block] of blocks) {
|
|
451
|
+
if (isObject(block) && block.profile !== undefined) {
|
|
452
|
+
throw new Error(
|
|
453
|
+
`${path}.profile was removed${where}: write the agent's settings ` +
|
|
454
|
+
'inline in that block (adapter, model, effort, permissions)',
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
261
458
|
}
|
|
262
459
|
|
|
263
460
|
function isValidRegistryEntry(value) {
|
|
@@ -272,19 +469,12 @@ function isValidRegistryEntry(value) {
|
|
|
272
469
|
);
|
|
273
470
|
}
|
|
274
471
|
|
|
275
|
-
// PBCLI-8/9/10: normalize the top-level `
|
|
472
|
+
// PBCLI-8/9/10: normalize the top-level `playbooks` config into
|
|
276
473
|
// a tmux-play config (Captain = the shell adapter; `captain.options.playbooks`
|
|
277
474
|
// the normalized enablement; a launch-time namespaced `<id>-<role>` roster;
|
|
278
475
|
// launcher-owned `layout.initialVisible`).
|
|
279
|
-
export async function composeGenericConfig(top, loadModule) {
|
|
280
|
-
|
|
281
|
-
for (const id of Object.keys(profiles)) {
|
|
282
|
-
if (ADAPTER_SHORTHANDS.includes(id)) {
|
|
283
|
-
throw new Error(
|
|
284
|
-
`profiles.${id} collides with the "${id}" adapter shorthand`,
|
|
285
|
-
);
|
|
286
|
-
}
|
|
287
|
-
}
|
|
476
|
+
export async function composeGenericConfig(top, loadModule, configPath) {
|
|
477
|
+
assertNoRetiredProfiles(top, configPath);
|
|
288
478
|
|
|
289
479
|
const playbooksCfg = requireObject(top.playbooks, 'playbooks');
|
|
290
480
|
const ids = Object.keys(playbooksCfg);
|
|
@@ -294,7 +484,7 @@ export async function composeGenericConfig(top, loadModule) {
|
|
|
294
484
|
|
|
295
485
|
const captain = {
|
|
296
486
|
from: PLAYBOOK_CAPTAIN_MODULE,
|
|
297
|
-
...resolveAgent(top.captain,
|
|
487
|
+
...resolveAgent(top.captain, 'captain'),
|
|
298
488
|
};
|
|
299
489
|
if (captain.adapter === undefined) {
|
|
300
490
|
throw new Error('captain must resolve an adapter');
|
|
@@ -389,7 +579,6 @@ export async function composeGenericConfig(top, loadModule) {
|
|
|
389
579
|
for (const role of roles) {
|
|
390
580
|
const agent = resolveAgent(
|
|
391
581
|
playersMap[role],
|
|
392
|
-
profiles,
|
|
393
582
|
`playbooks.${id}.players.${role}`,
|
|
394
583
|
);
|
|
395
584
|
if (agent.adapter === undefined) {
|
|
@@ -419,7 +608,16 @@ export async function composeGenericConfig(top, loadModule) {
|
|
|
419
608
|
listing.push({ id, command, intent: entry.intent });
|
|
420
609
|
}
|
|
421
610
|
|
|
422
|
-
|
|
611
|
+
// DR-013 A1: the shell cannot see its own captain's adapter through the
|
|
612
|
+
// tmux-play CaptainContext, so the launcher — which resolved it — passes it
|
|
613
|
+
// through. The shell needs it to decide whether an explicit empty tool
|
|
614
|
+
// allowlist can be enforced or must degrade to prompt-level restriction.
|
|
615
|
+
captain.options = {
|
|
616
|
+
playbooks: optionsPlaybooks,
|
|
617
|
+
...(typeof captain.adapter === 'string' && captain.adapter.length > 0
|
|
618
|
+
? { captainAdapter: captain.adapter }
|
|
619
|
+
: {}),
|
|
620
|
+
};
|
|
423
621
|
const config = { captain, players: roster };
|
|
424
622
|
// PBCLI-10: carry the user's tmux-play layout window/weight fields through;
|
|
425
623
|
// the launcher owns `layout.initialVisible` (first enabled playbook).
|
|
@@ -504,9 +702,9 @@ function helpText({ userConfigPath, failingAdapters = [] }) {
|
|
|
504
702
|
' codex: run Codex CLI once or set OPENAI_API_KEY.',
|
|
505
703
|
'',
|
|
506
704
|
'Agent swap recipe:',
|
|
507
|
-
' -
|
|
508
|
-
'
|
|
509
|
-
'
|
|
705
|
+
' - set each agent inline: the top-level captain and every',
|
|
706
|
+
' playbooks.<id>.players.<role> takes an adapter shorthand',
|
|
707
|
+
' (claude, codex) or a block with adapter/model/effort/permissions',
|
|
510
708
|
' - the launcher injects captain.from and the namespaced <id>-<role>',
|
|
511
709
|
' host players',
|
|
512
710
|
'',
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
supportedEffortValues,
|
|
29
29
|
} from '@sublang/cligent';
|
|
30
30
|
import { parse as parseYaml } from 'yaml';
|
|
31
|
+
import { hiddenControlEnvelope } from '../../../../src/xstate-runtime.js';
|
|
31
32
|
|
|
32
33
|
// PBCLI-19: adapter shorthands the run host can construct.
|
|
33
34
|
const ADAPTER_LOADERS = {
|
|
@@ -361,9 +362,19 @@ async function driveTurn({ ctx, runtime, store, text, json, verbose, restoreFrom
|
|
|
361
362
|
};
|
|
362
363
|
},
|
|
363
364
|
async callJudge(prompt, signal) {
|
|
364
|
-
|
|
365
|
+
// CAPTAIN-9 / DR-013 A1: wrap every judge prompt in the shared
|
|
366
|
+
// hidden-control envelope. Runtime judge prompts embed raw Boss text
|
|
367
|
+
// and quoted player output, so the envelope is what makes them
|
|
368
|
+
// delimited evidence rather than instructions — and it is the
|
|
369
|
+
// prompt-level isolation that stands in for provider enforcement
|
|
370
|
+
// when the tool allowlist below has to be omitted.
|
|
371
|
+
const result = await captainAgent.run(hiddenControlEnvelope(prompt), {
|
|
365
372
|
resume: false,
|
|
366
|
-
|
|
373
|
+
// An empty allowlist means "no tools" and is distinct from omission,
|
|
374
|
+
// which grants the adapter's full tool surface. Send it only where
|
|
375
|
+
// the adapter can enforce it; codex rejects any tool list outright,
|
|
376
|
+
// so requesting one would fail every judge call.
|
|
377
|
+
...controlCallToolOptions(store.captain.adapter),
|
|
367
378
|
signal,
|
|
368
379
|
});
|
|
369
380
|
if (result.status !== 'ok' || result.finalText === undefined) {
|
|
@@ -577,6 +588,20 @@ function playersFromSpecs(roleSpecs) {
|
|
|
577
588
|
}));
|
|
578
589
|
}
|
|
579
590
|
|
|
591
|
+
// DR-013 A1: adapters with no provider-enforced tool-restriction surface.
|
|
592
|
+
// Cligent's codex adapter rejects any allowedTools value — including the
|
|
593
|
+
// empty list that expresses tool-free — so a control call that requests one
|
|
594
|
+
// fails before the model is reached. Omission is the only way such an
|
|
595
|
+
// adapter can run a control call; isolation then rests on the prompt.
|
|
596
|
+
const ADAPTERS_WITHOUT_TOOL_ENFORCEMENT = new Set(['codex']);
|
|
597
|
+
|
|
598
|
+
// Keep requesting enforcement whenever the adapter is unknown, so the
|
|
599
|
+
// DR-013 guarantee holds by default.
|
|
600
|
+
function controlCallToolOptions(captainAdapter) {
|
|
601
|
+
if (ADAPTERS_WITHOUT_TOOL_ENFORCEMENT.has(captainAdapter)) return {};
|
|
602
|
+
return { allowedTools: [] };
|
|
603
|
+
}
|
|
604
|
+
|
|
580
605
|
// PBCLI-19/26: returns a diagnostic for the first invalid spec — an
|
|
581
606
|
// unknown adapter or an effort the adapter does not support — or
|
|
582
607
|
// undefined when every spec resolves. The caller must compare against
|
|
@@ -59,18 +59,18 @@ const stateDescriptions = {
|
|
|
59
59
|
respondToReview: 'CODE-2: Coder addresses or challenges Reviewer findings.',
|
|
60
60
|
continueIr: 'CODE-3: Coder continues an IR after the previous task or IR draft passed review.',
|
|
61
61
|
summarizeSpecs: 'CODE-4: Coder summarizes a completed IR into minimal spec items.',
|
|
62
|
-
reviewBossCommitSpecs: 'CODE-5: Reviewer reviews a Boss-intent commit whose changes are only in
|
|
63
|
-
reviewBossCommitCode: 'CODE-6: Reviewer reviews a Boss-intent commit whose changes are only outside
|
|
64
|
-
reviewBossCommitMixed: 'CODE-7: Reviewer reviews a Boss-intent commit whose changes span both
|
|
65
|
-
reviewIrTaskCommitSpecs: 'CODE-8: Reviewer reviews an IR-task commit whose changes are only in
|
|
66
|
-
reviewIrTaskCommitCode: 'CODE-9: Reviewer reviews an IR-task commit whose changes are only outside
|
|
67
|
-
reviewIrTaskCommitMixed: 'CODE-10: Reviewer reviews an IR-task commit whose changes span both
|
|
68
|
-
reviewChangesSpecs: 'CODE-11: Reviewer reviews uncommitted Coder changes that touch only
|
|
69
|
-
reviewChangesCode: 'CODE-12: Reviewer reviews uncommitted Coder changes that touch only files outside
|
|
70
|
-
reviewChangesMixed: 'CODE-13: Reviewer reviews uncommitted Coder changes that touch both
|
|
71
|
-
reviewChangesAndChallengesSpecs: 'CODE-15: Reviewer reviews uncommitted Coder changes that touch only
|
|
72
|
-
reviewChangesAndChallengesCode: 'CODE-16: Reviewer reviews uncommitted Coder changes that touch only files outside
|
|
73
|
-
reviewChangesAndChallengesMixed: 'CODE-17: Reviewer reviews uncommitted Coder changes that touch both
|
|
62
|
+
reviewBossCommitSpecs: 'CODE-5: Reviewer reviews a Boss-intent commit whose changes are only in spec item files.',
|
|
63
|
+
reviewBossCommitCode: 'CODE-6: Reviewer reviews a Boss-intent commit whose changes are only outside spec item files.',
|
|
64
|
+
reviewBossCommitMixed: 'CODE-7: Reviewer reviews a Boss-intent commit whose changes span both spec item files and other files.',
|
|
65
|
+
reviewIrTaskCommitSpecs: 'CODE-8: Reviewer reviews an IR-task commit whose changes are only in spec item files.',
|
|
66
|
+
reviewIrTaskCommitCode: 'CODE-9: Reviewer reviews an IR-task commit whose changes are only outside spec item files.',
|
|
67
|
+
reviewIrTaskCommitMixed: 'CODE-10: Reviewer reviews an IR-task commit whose changes span both spec item files and other files.',
|
|
68
|
+
reviewChangesSpecs: 'CODE-11: Reviewer reviews uncommitted Coder changes that touch only spec item files with no accompanying rebuttals.',
|
|
69
|
+
reviewChangesCode: 'CODE-12: Reviewer reviews uncommitted Coder changes that touch only files outside spec item files with no accompanying rebuttals.',
|
|
70
|
+
reviewChangesMixed: 'CODE-13: Reviewer reviews uncommitted Coder changes that touch both spec item files and other files with no accompanying rebuttals.',
|
|
71
|
+
reviewChangesAndChallengesSpecs: 'CODE-15: Reviewer reviews uncommitted Coder changes that touch only spec item files and adjudicates accompanying rebuttals in one round.',
|
|
72
|
+
reviewChangesAndChallengesCode: 'CODE-16: Reviewer reviews uncommitted Coder changes that touch only files outside spec item files and adjudicates accompanying rebuttals in one round.',
|
|
73
|
+
reviewChangesAndChallengesMixed: 'CODE-17: Reviewer reviews uncommitted Coder changes that touch both spec item files and other files and adjudicates accompanying rebuttals in one round.',
|
|
74
74
|
adjudicateChallenges: 'CODE-14: Reviewer adjudicates Coder rebuttals against the prior review when Coder produced no code edits this round.',
|
|
75
75
|
commitCoderInitial: 'CODE-18: Committer commits Coder Initial Changes when Reviewer has not played since the last commit.',
|
|
76
76
|
commitJoint: 'CODE-19: Committer commits changes when both Coder and Reviewer have played since the last commit.',
|
|
@@ -151,7 +151,7 @@ const planAndImplementInput = (context) => ({
|
|
|
151
151
|
...bossReplyInputFields(context),
|
|
152
152
|
prompt: [
|
|
153
153
|
'Assess whether this can be completed in a single commit, following best practices.',
|
|
154
|
-
'If yes, implement and test, updating both code and specs; otherwise, decompose into tasks as a new IR under @specs/iterations and stop without implementing any IR task.',
|
|
154
|
+
'If yes, implement and test, updating both code and specs; otherwise, decompose into tasks as a new IR under @specs/intents (or @specs/iterations in older scaffolds) and stop without implementing any IR task.',
|
|
155
155
|
'For context discovery, @specs/map.md indexes all spec files and @specs/meta.md describes the spec format.',
|
|
156
156
|
'Ensure @specs/map.md reflects the changes.',
|
|
157
157
|
'Do not commit.',
|
|
@@ -188,9 +188,9 @@ const summarizeSpecsInput = (context) => ({
|
|
|
188
188
|
'Read IR-<#> and corresponding commits.',
|
|
189
189
|
'According to @specs/meta.md, add or update spec items to fully capture:',
|
|
190
190
|
'',
|
|
191
|
-
'- the
|
|
192
|
-
'- the system behavior
|
|
193
|
-
'- the integration/system test cases
|
|
191
|
+
'- the external behavior users rely on,',
|
|
192
|
+
'- the internal system behavior, and',
|
|
193
|
+
'- the integration/system test cases.',
|
|
194
194
|
'',
|
|
195
195
|
'The spec items should be the *minimal* set needed to reimplement code without the IR.',
|
|
196
196
|
'The set should be complete and coherent.',
|
|
@@ -200,7 +200,7 @@ const summarizeSpecsInput = (context) => ({
|
|
|
200
200
|
].join('\n'),
|
|
201
201
|
result: withNeedsBossReply({
|
|
202
202
|
specsReady: 'Coder produced uncommitted spec updates (Initial Changes).',
|
|
203
|
-
noSpecChanges: 'Existing specs already capture the
|
|
203
|
+
noSpecChanges: 'Existing specs already capture the realized intent.',
|
|
204
204
|
}),
|
|
205
205
|
});
|
|
206
206
|
const setPendingBossQuestion = (resumeStateId) => assign({
|
|
@@ -396,12 +396,12 @@ export const codingMachine = setup({
|
|
|
396
396
|
'Stage all current changes that belong in the repo before making any edits, and leave your edits unstaged/untracked.',
|
|
397
397
|
].join('\n'),
|
|
398
398
|
result: {
|
|
399
|
-
changesMadeSpecs: 'Coder accepted items and produced unstaged/untracked edits in @specs/{user,dev,test}/
|
|
400
|
-
changesMadeCode: 'Coder accepted items and produced unstaged/untracked edits outside @specs/
|
|
401
|
-
changesMadeMixed: 'Coder accepted items and produced unstaged/untracked edits spanning both
|
|
402
|
-
changesMadeSpecsAndChallenged: 'Coder produced unstaged/untracked edits in @specs/{user,dev,test}/
|
|
403
|
-
changesMadeCodeAndChallenged: 'Coder produced unstaged/untracked edits outside
|
|
404
|
-
changesMadeMixedAndChallenged: 'Coder produced unstaged/untracked edits spanning both
|
|
399
|
+
changesMadeSpecs: 'Coder accepted items and produced unstaged/untracked edits only in spec item files (@specs/packages/, @specs/compositions/, or legacy @specs/{user,dev,test}/), without raising any rebuttals.',
|
|
400
|
+
changesMadeCode: 'Coder accepted items and produced unstaged/untracked edits only outside spec item files (any other files, including @specs/ decision, intent, or legacy iteration records, @specs/map.md, and @specs/meta.md), without raising any rebuttals.',
|
|
401
|
+
changesMadeMixed: 'Coder accepted items and produced unstaged/untracked edits spanning both spec item files and other files, without raising any rebuttals.',
|
|
402
|
+
changesMadeSpecsAndChallenged: 'Coder produced unstaged/untracked edits only in spec item files (@specs/packages/, @specs/compositions/, or legacy @specs/{user,dev,test}/) AND challenged one or more review items. Output shall include `challenges: <numbered rebuttals, one per challenged item>`.',
|
|
403
|
+
changesMadeCodeAndChallenged: 'Coder produced unstaged/untracked edits only outside spec item files AND challenged one or more review items. Output shall include `challenges: <numbered rebuttals, one per challenged item>`.',
|
|
404
|
+
changesMadeMixedAndChallenged: 'Coder produced unstaged/untracked edits spanning both spec item files and other files AND challenged one or more review items. Output shall include `challenges: <numbered rebuttals, one per challenged item>`.',
|
|
405
405
|
challengesRaised: 'Coder challenged one or more review items without producing any code edits. Output shall include `challenges: <numbered rebuttals, one per challenged item>`.',
|
|
406
406
|
accepted: 'Coder accepted the review outcome without further edits.',
|
|
407
407
|
needsBossReply: needsBossReplyDescription,
|
|
@@ -558,7 +558,7 @@ export const codingMachine = setup({
|
|
|
558
558
|
'Verify any affected spec items are:',
|
|
559
559
|
'',
|
|
560
560
|
'- Complete & coherent: sufficient for you to reimplement code.',
|
|
561
|
-
'- Right level:
|
|
561
|
+
'- Right level: external behavior users rely on or internal system behavior (organized per @specs/meta.md), not implementation specifics; integration/system testing, not unit testing.',
|
|
562
562
|
'- Minimal: essential and concise; every item earns its place; also check with other items.',
|
|
563
563
|
'- Well organized: spec packages are finely scoped, with high cohesion and low coupling.',
|
|
564
564
|
'',
|
|
@@ -680,7 +680,7 @@ export const codingMachine = setup({
|
|
|
680
680
|
'Verify any affected spec items are:',
|
|
681
681
|
'',
|
|
682
682
|
'- Complete & coherent: sufficient for you to reimplement code.',
|
|
683
|
-
'- Right level:
|
|
683
|
+
'- Right level: external behavior users rely on or internal system behavior (organized per @specs/meta.md), not implementation specifics; integration/system testing, not unit testing.',
|
|
684
684
|
'- Minimal: essential and concise; every item earns its place; also check with other items.',
|
|
685
685
|
'- Well organized: spec packages are finely scoped, with high cohesion and low coupling.',
|
|
686
686
|
'',
|
|
@@ -744,7 +744,7 @@ export const codingMachine = setup({
|
|
|
744
744
|
'Verify any affected spec items are:',
|
|
745
745
|
'',
|
|
746
746
|
'- Complete & coherent: sufficient for you to reimplement code.',
|
|
747
|
-
'- Right level:
|
|
747
|
+
'- Right level: external behavior users rely on or internal system behavior (organized per @specs/meta.md), not implementation specifics; integration/system testing, not unit testing.',
|
|
748
748
|
'- Minimal: essential and concise; every item earns its place; also check with other items.',
|
|
749
749
|
'- Well organized: spec packages are finely scoped, with high cohesion and low coupling.',
|
|
750
750
|
'',
|
|
@@ -868,7 +868,7 @@ export const codingMachine = setup({
|
|
|
868
868
|
'Verify any affected spec items are:',
|
|
869
869
|
'',
|
|
870
870
|
'- Complete & coherent: sufficient for you to reimplement code.',
|
|
871
|
-
'- Right level:
|
|
871
|
+
'- Right level: external behavior users rely on or internal system behavior (organized per @specs/meta.md), not implementation specifics; integration/system testing, not unit testing.',
|
|
872
872
|
'- Minimal: essential and concise; every item earns its place; also check with other items.',
|
|
873
873
|
'- Well organized: spec packages are finely scoped, with high cohesion and low coupling.',
|
|
874
874
|
'',
|
|
@@ -930,7 +930,7 @@ export const codingMachine = setup({
|
|
|
930
930
|
'Verify any affected spec items are:',
|
|
931
931
|
'',
|
|
932
932
|
'- Complete & coherent: sufficient for you to reimplement code.',
|
|
933
|
-
'- Right level:
|
|
933
|
+
'- Right level: external behavior users rely on or internal system behavior (organized per @specs/meta.md), not implementation specifics; integration/system testing, not unit testing.',
|
|
934
934
|
'- Minimal: essential and concise; every item earns its place; also check with other items.',
|
|
935
935
|
'- Well organized: spec packages are finely scoped, with high cohesion and low coupling.',
|
|
936
936
|
'',
|
|
@@ -1030,7 +1030,7 @@ export const codingMachine = setup({
|
|
|
1030
1030
|
'Verify any affected spec items are:',
|
|
1031
1031
|
'',
|
|
1032
1032
|
'- Complete & coherent: sufficient for you to reimplement code.',
|
|
1033
|
-
'- Right level:
|
|
1033
|
+
'- Right level: external behavior users rely on or internal system behavior (organized per @specs/meta.md), not implementation specifics; integration/system testing, not unit testing.',
|
|
1034
1034
|
'- Minimal: essential and concise; every item earns its place; also check with other items.',
|
|
1035
1035
|
'- Well organized: spec packages are finely scoped, with high cohesion and low coupling.',
|
|
1036
1036
|
'',
|
|
@@ -1084,7 +1084,7 @@ export const codingMachine = setup({
|
|
|
1084
1084
|
'Verify any affected spec items are:',
|
|
1085
1085
|
'',
|
|
1086
1086
|
'- Complete & coherent: sufficient for you to reimplement code.',
|
|
1087
|
-
'- Right level:
|
|
1087
|
+
'- Right level: external behavior users rely on or internal system behavior (organized per @specs/meta.md), not implementation specifics; integration/system testing, not unit testing.',
|
|
1088
1088
|
'- Minimal: essential and concise; every item earns its place; also check with other items.',
|
|
1089
1089
|
'- Well organized: spec packages are finely scoped, with high cohesion and low coupling.',
|
|
1090
1090
|
'',
|
|
@@ -1190,7 +1190,7 @@ export const codingMachine = setup({
|
|
|
1190
1190
|
'Verify any affected spec items are:',
|
|
1191
1191
|
'',
|
|
1192
1192
|
'- Complete & coherent: sufficient for you to reimplement code.',
|
|
1193
|
-
'- Right level:
|
|
1193
|
+
'- Right level: external behavior users rely on or internal system behavior (organized per @specs/meta.md), not implementation specifics; integration/system testing, not unit testing.',
|
|
1194
1194
|
'- Minimal: essential and concise; every item earns its place; also check with other items.',
|
|
1195
1195
|
'- Well organized: spec packages are finely scoped, with high cohesion and low coupling.',
|
|
1196
1196
|
'',
|
|
@@ -1306,15 +1306,16 @@ export const codingMachine = setup({
|
|
|
1306
1306
|
coderPlayer: context.coderPlayer,
|
|
1307
1307
|
committerPlayer: context.committerPlayer,
|
|
1308
1308
|
prompt: [
|
|
1309
|
-
'Make a commit of the changes that belong in the repo, following @specs/
|
|
1309
|
+
'Make a commit of the changes that belong in the repo, following @specs/packages/git.md (reread if necessary).',
|
|
1310
|
+
"If that spec is absent, follow the legacy @specs/dev/git.md; if neither exists, follow the repository's existing commit conventions and do not search elsewhere.",
|
|
1310
1311
|
'Write the commit message concisely.',
|
|
1311
1312
|
'Coder is <coder-llm>.',
|
|
1312
1313
|
'Format the `Co-authored-by` `<model>` token as the conventional human form of the substituted id (e.g., `claude-opus-4-7` → `Claude-Opus-4.7`, `gpt-5.5` → `GPT-5.5`).',
|
|
1313
1314
|
].join('\n'),
|
|
1314
1315
|
result: {
|
|
1315
|
-
committedSpecs: 'Committed changes that touch only @specs/{user,dev,test}
|
|
1316
|
-
committedCode: 'Committed changes that touch only files
|
|
1317
|
-
committedMixed: 'Committed changes that span both
|
|
1316
|
+
committedSpecs: 'Committed changes that touch only spec item files (@specs/packages/, @specs/compositions/, or legacy @specs/{user,dev,test}/).',
|
|
1317
|
+
committedCode: 'Committed changes that touch only files that are not spec item files (any other files, including @specs/ decision, intent, or legacy iteration records, @specs/map.md, and @specs/meta.md).',
|
|
1318
|
+
committedMixed: 'Committed changes that span both spec item files and other files.',
|
|
1318
1319
|
noRelevantChanges: 'There are no relevant changes to commit.',
|
|
1319
1320
|
needsBossInput: 'Committing requires additional Boss input.',
|
|
1320
1321
|
needsBossReply: needsBossReplyDescription,
|
|
@@ -1381,7 +1382,8 @@ export const codingMachine = setup({
|
|
|
1381
1382
|
reviewerPlayer: context.reviewerPlayer,
|
|
1382
1383
|
committerPlayer: context.committerPlayer,
|
|
1383
1384
|
prompt: [
|
|
1384
|
-
'Make a commit of the changes that belong in the repo, following @specs/
|
|
1385
|
+
'Make a commit of the changes that belong in the repo, following @specs/packages/git.md (reread if necessary).',
|
|
1386
|
+
"If that spec is absent, follow the legacy @specs/dev/git.md; if neither exists, follow the repository's existing commit conventions and do not search elsewhere.",
|
|
1385
1387
|
'Write the commit message concisely.',
|
|
1386
1388
|
'Coder is <coder-llm>; Reviewer is <reviewer-llm>.',
|
|
1387
1389
|
'Format the `Co-authored-by` `<model>` token as the conventional human form of the substituted id (e.g., `claude-opus-4-7` → `Claude-Opus-4.7`, `gpt-5.5` → `GPT-5.5`).',
|