@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.
@@ -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 { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
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-8/12: known adapter shorthands. A `profiles` id may not collide
29
- // with one of these, and these are the adapters with readiness predicates.
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: resolve a scalar `captain` / `players.<role>` value as a profile
239
- // id or adapter shorthand, or a full agent block whose optional `profile`
240
- // key names a `profiles` entry whose settings are the base under the block's
241
- // own explicit fields. The composed block carries no `profile` key.
242
- export function resolveAgent(value, profiles, path) {
243
- if (typeof value === 'string') {
244
- if (hasOwn(profiles, value)) return { ...profiles[value] };
245
- return { adapter: value };
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
- if (isObject(value)) {
248
- const { profile, ...rest } = value;
249
- let base = {};
250
- if (profile !== undefined) {
251
- if (typeof profile !== 'string' || !hasOwn(profiles, profile)) {
252
- throw new Error(`${path}.profile must name a profiles entry`);
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
- base = { ...profiles[profile] };
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
- return { ...base, ...rest };
385
+ changed = true;
257
386
  }
258
- throw new Error(
259
- `${path} must be a profile id, an adapter shorthand, or an agent block`,
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 `profiles` / `playbooks` config into
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
- const profiles = isObject(top.profiles) ? top.profiles : {};
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, profiles, '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
- captain.options = { playbooks: optionsPlaybooks };
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
- ' - reuse agent settings under top-level profiles',
508
- ' - point each playbooks.<id>.captain / players.<role> at a profile id',
509
- ' or an adapter shorthand (claude, codex)',
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
- const result = await captainAgent.run(prompt, {
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
- allowedTools: [],
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 @specs/{user,dev,test}/.',
63
- reviewBossCommitCode: 'CODE-6: Reviewer reviews a Boss-intent commit whose changes are only outside @specs/{user,dev,test}/.',
64
- reviewBossCommitMixed: 'CODE-7: Reviewer reviews a Boss-intent commit whose changes span both @specs/{user,dev,test}/ and other files.',
65
- reviewIrTaskCommitSpecs: 'CODE-8: Reviewer reviews an IR-task commit whose changes are only in @specs/{user,dev,test}/.',
66
- reviewIrTaskCommitCode: 'CODE-9: Reviewer reviews an IR-task commit whose changes are only outside @specs/{user,dev,test}/.',
67
- reviewIrTaskCommitMixed: 'CODE-10: Reviewer reviews an IR-task commit whose changes span both @specs/{user,dev,test}/ and other files.',
68
- reviewChangesSpecs: 'CODE-11: Reviewer reviews uncommitted Coder changes that touch only @specs/{user,dev,test}/ with no accompanying rebuttals.',
69
- reviewChangesCode: 'CODE-12: Reviewer reviews uncommitted Coder changes that touch only files outside @specs/{user,dev,test}/ with no accompanying rebuttals.',
70
- reviewChangesMixed: 'CODE-13: Reviewer reviews uncommitted Coder changes that touch both @specs/{user,dev,test}/ and other files with no accompanying rebuttals.',
71
- reviewChangesAndChallengesSpecs: 'CODE-15: Reviewer reviews uncommitted Coder changes that touch only @specs/{user,dev,test}/ and adjudicates accompanying rebuttals in one round.',
72
- reviewChangesAndChallengesCode: 'CODE-16: Reviewer reviews uncommitted Coder changes that touch only files outside @specs/{user,dev,test}/ and adjudicates accompanying rebuttals in one round.',
73
- reviewChangesAndChallengesMixed: 'CODE-17: Reviewer reviews uncommitted Coder changes that touch both @specs/{user,dev,test}/ and other files and adjudicates accompanying rebuttals in one round.',
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 user requirements in @specs/user,',
192
- '- the system behavior in @specs/dev, and',
193
- '- the integration/system test cases in @specs/test.',
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 iteration.',
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}/ only, without raising any rebuttals.',
400
- changesMadeCode: 'Coder accepted items and produced unstaged/untracked edits outside @specs/{user,dev,test}/ only, without raising any rebuttals.',
401
- changesMadeMixed: 'Coder accepted items and produced unstaged/untracked edits spanning both @specs/{user,dev,test}/ and other files, without raising any rebuttals.',
402
- changesMadeSpecsAndChallenged: 'Coder produced unstaged/untracked edits in @specs/{user,dev,test}/ only AND challenged one or more review items. Output shall include `challenges: <numbered rebuttals, one per challenged item>`.',
403
- changesMadeCodeAndChallenged: 'Coder produced unstaged/untracked edits outside @specs/{user,dev,test}/ only 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 @specs/{user,dev,test}/ and other files AND challenged one or more review items. Output shall include `challenges: <numbered rebuttals, one per challenged item>`.',
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: user requirements (in @specs/user) or system behavior (in @specs/dev), not implementation specifics; integration/system testing (in @specs/test), not unit testing.',
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: user requirements (in @specs/user) or system behavior (in @specs/dev), not implementation specifics; integration/system testing (in @specs/test), not unit testing.',
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: user requirements (in @specs/user) or system behavior (in @specs/dev), not implementation specifics; integration/system testing (in @specs/test), not unit testing.',
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: user requirements (in @specs/user) or system behavior (in @specs/dev), not implementation specifics; integration/system testing (in @specs/test), not unit testing.',
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: user requirements (in @specs/user) or system behavior (in @specs/dev), not implementation specifics; integration/system testing (in @specs/test), not unit testing.',
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: user requirements (in @specs/user) or system behavior (in @specs/dev), not implementation specifics; integration/system testing (in @specs/test), not unit testing.',
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: user requirements (in @specs/user) or system behavior (in @specs/dev), not implementation specifics; integration/system testing (in @specs/test), not unit testing.',
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: user requirements (in @specs/user) or system behavior (in @specs/dev), not implementation specifics; integration/system testing (in @specs/test), not unit testing.',
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/dev/git.md (reread if necessary).',
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 outside @specs/{user,dev,test}/.',
1317
- committedMixed: 'Committed changes that span both @specs/{user,dev,test}/ and other files.',
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/dev/git.md (reread if necessary).',
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`).',