@sublang/playbook 17.3.0 → 17.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +5 -2
  2. package/docs/configuration.md +45 -4
  3. package/docs/embedding.md +94 -0
  4. package/package.json +34 -5
  5. package/reference/sdlc/captain.md +6 -4
  6. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +5 -0
  7. package/reference/sdlc/captain.playbook/captain.fsm.js +4 -3
  8. package/reference/sdlc/captain.playbook/captain.fsm.ts +9 -4
  9. package/reference/sdlc/captain.playbook/captain.gears.md +6 -3
  10. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +2 -0
  11. package/reference/sdlc/captain.playbook/captain.playbook.js +7 -3
  12. package/reference/sdlc/captain.playbook/captain.playbook.ts +8 -4
  13. package/reference/sdlc/code.md +24 -4
  14. package/reference/sdlc/code.playbook/asset-types.d.ts +32 -0
  15. package/reference/sdlc/code.playbook/bin/launch-config.js +2 -0
  16. package/reference/sdlc/code.playbook/bin/portable-codec.js +12 -5
  17. package/reference/sdlc/code.playbook/bin/run.js +68 -4
  18. package/reference/sdlc/code.playbook/bin/session-host.js +16 -4
  19. package/reference/sdlc/code.playbook/bin/session-media.js +46 -0
  20. package/reference/sdlc/code.playbook/bin/session-store.js +145 -13
  21. package/reference/sdlc/code.playbook/code.fsm.d.ts +12 -0
  22. package/reference/sdlc/code.playbook/code.fsm.js +40 -6
  23. package/reference/sdlc/code.playbook/code.fsm.ts +53 -6
  24. package/reference/sdlc/code.playbook/code.gears.md +32 -4
  25. package/reference/sdlc/code.playbook/playbook-captain.d.ts +17 -1
  26. package/reference/sdlc/code.playbook/playbook-captain.js +201 -19
  27. package/reference/sdlc/code.playbook/playbook-captain.ts +224 -24
  28. package/reference/sdlc/code.playbook/playbook.config.template.yaml +16 -3
  29. package/reference/sdlc/code.playbook/session-assets.d.ts +53 -0
  30. package/reference/sdlc/code.playbook/session-assets.js +303 -0
  31. package/reference/sdlc/code.playbook/session-host.d.ts +9 -1
  32. package/reference/sdlc/code.playbook/session-store.d.ts +26 -2
  33. package/reference/sdlc/code.playbook/worker-evidence.d.ts +8 -0
  34. package/reference/sdlc/code.playbook/worker-evidence.js +51 -0
  35. package/reference/sdlc/inspect.md +40 -0
  36. package/reference/sdlc/inspect.playbook/inspect.fsm.d.ts +168 -0
  37. package/reference/sdlc/inspect.playbook/inspect.fsm.js +354 -0
  38. package/reference/sdlc/inspect.playbook/inspect.fsm.ts +455 -0
  39. package/reference/sdlc/inspect.playbook/inspect.gears.md +36 -0
  40. package/reference/sdlc/inspect.playbook/inspect.playbook.d.ts +17 -0
  41. package/reference/sdlc/inspect.playbook/inspect.playbook.js +142 -0
  42. package/reference/sdlc/inspect.playbook/inspect.playbook.ts +184 -0
  43. package/reference/sdlc/inspect.playbook/inspect.registry.d.ts +41 -0
  44. package/reference/sdlc/inspect.playbook/inspect.registry.js +51 -0
  45. package/reference/sdlc/inspect.playbook/inspect.registry.ts +110 -0
  46. package/slc/gears2fsm.md +17 -3
  47. package/slc/text2gears.md +7 -0
  48. package/slc/workflow-contracts.json +43 -1
  49. package/src/xstate-playbook-runtime.js +5 -0
  50. package/src/xstate-playbook-runtime.ts +7 -0
@@ -7,6 +7,7 @@
7
7
  // complete retained-generation map under guarded source-first publication.
8
8
 
9
9
  import { createHash, randomUUID } from 'node:crypto';
10
+ import { createAssetStore, parseAssetUri, validateAssetRef, validateAssetRefs } from '../session-assets.js';
10
11
  import {
11
12
  SESSION_MANIFEST_VERSION, EMPTY_REPLAY_SHA256, sha256,
12
13
  validateSessionManifest, recoveryFromManifest, manifestFromRecovery,
@@ -637,6 +638,12 @@ export function createCaptainSessionStore(options = {}) {
637
638
  const replayReadCursors = new Map();
638
639
  const replayReadQueues = new Map();
639
640
  const portableWriters = new Map();
641
+ const assetStores = new Map();
642
+ const assetStoreFor = (sessionId) => {
643
+ assertSessionId(sessionId);
644
+ if (!assetStores.has(sessionId)) assetStores.set(sessionId, createAssetStore({ directory: join(sessionsDir, `${sessionId}.assets`), ...(options.maxAssetBytes === undefined ? {} : { maxAssetBytes: options.maxAssetBytes }) }));
645
+ return assetStores.get(sessionId);
646
+ };
640
647
 
641
648
  if (!isAbsolute(sessionsDir)) {
642
649
  throw new Error('Captain session store path must be absolute');
@@ -758,7 +765,11 @@ export function createCaptainSessionStore(options = {}) {
758
765
  if (value.sessionId !== sessionId) throw new Error('session manifest identity does not match its filename');
759
766
  return value.schemaVersion === 7 ? validateSessionManifest(value) : value;
760
767
  };
761
- const prepare = () => prepareSessionPermissions(sessionsDir, fs);
768
+ const prepare = async () => {
769
+ await prepareSessionPermissions(sessionsDir, fs);
770
+ let names; try { names = await fs.readdir(sessionsDir); } catch (cause) { if (cause?.code === 'ENOENT') return; throw cause; }
771
+ for (const name of names) if (name.endsWith('.assets') && SESSION_ID_PATTERN.test(name.slice(0, -7))) await assetStoreFor(name.slice(0, -7)).prepare();
772
+ };
762
773
  const readHistory = async (sessionId, options = {}) => {
763
774
  assertSessionId(sessionId);
764
775
  const history = await readSessionHistory({ sessionsDir, path: recordsPathFor(sessionId), fs, afterSeq: options.afterSeq ?? 0 });
@@ -771,6 +782,35 @@ export function createCaptainSessionStore(options = {}) {
771
782
  } catch { return history; }
772
783
  return legacyJournalHistory(legacy, options.afterSeq ?? 0, history);
773
784
  };
785
+ // Only our typed reference locations participate; opaque tool/text data is history.
786
+ const assertAssetInventory = (manifest, entries) => {
787
+ const declared = new Map((manifest.assets?.entries ?? []).map((reference) => [reference.assetId, reference]));
788
+ const check = (reference) => {
789
+ const valid = validateAssetRef(reference);
790
+ if (declared.get(valid.assetId)?.byteLength !== valid.byteLength) throw new Error(`session asset reference is absent from its checkpoint inventory: ${valid.assetId}`);
791
+ };
792
+ const checkRefs = (references) => { for (const reference of references ?? []) check(reference); };
793
+ const snapshot = (value) => {
794
+ if (!value) return;
795
+ checkRefs(value.pendingAttachments);
796
+ for (const frame of value.frames ?? []) checkRefs(frame.attachments);
797
+ for (const item of value.journal ?? []) if (item.kind === 'boss' && typeof item.payload === 'object' && item.payload !== null) checkRefs(item.payload.attachments);
798
+ };
799
+ snapshot(manifest.snapshot); snapshot(manifest.uncertain?.progress?.snapshot);
800
+ checkRefs(manifest.uncertain?.attachments);
801
+ for (const generation of Object.values(manifest.retainedGenerations ?? {})) for (const frame of generation.frames ?? []) checkRefs(frame.attachments);
802
+ for (const { record } of entries) {
803
+ if (record.type === 'turn_started') checkRefs(record.turn?.attachments);
804
+ else if (record.type === 'playbook_evidence') check(record.asset);
805
+ else if (record.type === 'player_event' || record.type === 'captain_event') {
806
+ const event = record.event;
807
+ if (event?.type === 'media' && event.payload?.source?.type === 'uri') {
808
+ const id = parseAssetUri(event.payload.source.uri);
809
+ if (id && !declared.has(id)) throw new Error(`session media asset is absent from its checkpoint inventory: ${id}`);
810
+ } else if (event?.type === 'tool_result' && event.payload?.output?.type === 'asset_reference') check(event.payload.output.asset);
811
+ }
812
+ }
813
+ };
774
814
  const validate = async (sessionId, context = {}) => {
775
815
  const manifest = await readManifest(sessionId);
776
816
  const history = await readHistory(sessionId);
@@ -790,6 +830,12 @@ export function createCaptainSessionStore(options = {}) {
790
830
  integrityValid = false;
791
831
  reasons.push('session replay contains provider continuation fields');
792
832
  }
833
+ try { assertAssetInventory(manifest, history.entries.filter(({ seq }) => seq <= manifest.replay.seq)); }
834
+ catch (cause) { integrityValid = false; reasons.push(errorMessage(cause)); }
835
+ for (const reference of manifest.assets?.entries ?? []) {
836
+ try { const reader = await assetStoreFor(sessionId).openAsset(reference); await reader.close(); }
837
+ catch (cause) { integrityValid = false; reasons.push(`session asset is unavailable: ${errorMessage(cause)}`); }
838
+ }
793
839
  if (manifest.state === 'history-only') reasons.push(manifest.reason);
794
840
  if (manifest.replay.incomplete || history.incomplete) reasons.push('session replay is incomplete');
795
841
  if (history.digests[manifest.replay.seq] !== manifest.replay.sha256) { integrityValid = false; reasons.push('session replay checkpoint digest does not match'); }
@@ -1111,6 +1157,24 @@ export function createCaptainSessionStore(options = {}) {
1111
1157
  };
1112
1158
 
1113
1159
  const deleteRecord = async (sessionId) => {
1160
+ const assetsDirectory = join(sessionsDir, `${sessionId}.assets`);
1161
+ try {
1162
+ await assertPrivateDirectory(assetsDirectory, fs);
1163
+ const names = await fs.readdir(assetsDirectory);
1164
+ const owned = names.filter((name) => /^(?:[0-9a-f]{64}(?:\.json)?|\.import-[0-9a-f-]+\.tmp)$/.test(name));
1165
+ for (const name of owned) await assertPrivateRegularPath(join(assetsDirectory, name), 0o600, fs, 'session asset');
1166
+ for (const name of owned) await fs.unlink(join(assetsDirectory, name));
1167
+ // Foreign regular files (Finder or editor files) go with the session; a
1168
+ // remaining subdirectory or link keeps the directory, not the session.
1169
+ for (const name of names.filter((entry) => !owned.includes(entry))) {
1170
+ const path = join(assetsDirectory, name);
1171
+ try { const stat = await fs.lstat(path); if (stat.isFile() && !stat.isSymbolicLink()) await fs.unlink(path); }
1172
+ catch (cause) { if (cause?.code !== 'ENOENT') throw cause; }
1173
+ }
1174
+ try { await fs.rmdir(assetsDirectory); }
1175
+ catch (cause) { if (cause?.code !== 'ENOTEMPTY' && cause?.code !== 'EEXIST') throw cause; }
1176
+ await syncDirectory(sessionsDir, fs);
1177
+ } catch (cause) { if (cause?.code !== 'ENOENT') throw cause; }
1114
1178
  for (const suffix of ['.records.jsonl', '.hints.json', '.spex.json', '.json']) {
1115
1179
  const path = join(sessionsDir, `${sessionId}${suffix}`);
1116
1180
  try { await assertPrivateRegularPath(path, 0o600, fs, 'session file'); await fs.unlink(path); await syncDirectory(sessionsDir, fs); }
@@ -1396,6 +1460,7 @@ export function createCaptainSessionStore(options = {}) {
1396
1460
  const acquire = async (sessionId, management = false) => {
1397
1461
  assertSessionId(sessionId);
1398
1462
  await prepareSessionPermissions(sessionsDir, fs, sessionId);
1463
+ await assetStoreFor(sessionId).prepare();
1399
1464
  let stage;
1400
1465
  let stagePublished = false;
1401
1466
  try {
@@ -1451,6 +1516,7 @@ export function createCaptainSessionStore(options = {}) {
1451
1516
  readHistory,
1452
1517
  validateSession: validate,
1453
1518
  portableWriters,
1519
+ assetStore: assetStoreFor(sessionId),
1454
1520
  writeRecord,
1455
1521
  syncRecordDirectory: () => syncDirectory(sessionsDir, fs),
1456
1522
  deleteRecord,
@@ -1684,8 +1750,39 @@ export function createCaptainSessionStore(options = {}) {
1684
1750
  finally { await lease.release(); }
1685
1751
  };
1686
1752
 
1753
+ const exportBundle = async (sessionId) => {
1754
+ assertSessionId(sessionId);
1755
+ for (let attempt = 0; attempt < 3; attempt++) {
1756
+ const manifestText = await readPrivateRegularFile(recordPathFor(sessionId), 0o600, fs, 'record');
1757
+ const manifest = validateSessionManifest(JSON.parse(manifestText));
1758
+ const replayBytes = Buffer.from(await readPrivateRegularFile(recordsPathFor(sessionId), 0o600, fs, 'replay stream'), 'utf8');
1759
+ let end = 0;
1760
+ for (let seq = 0; seq < manifest.replay.seq; seq++) {
1761
+ const next = replayBytes.indexOf(10, end);
1762
+ if (next === -1) throw new Error('session bundle is missing its checkpoint replay prefix');
1763
+ end = next + 1;
1764
+ }
1765
+ const replay = replayBytes.subarray(0, end);
1766
+ if (sha256(replay) !== manifest.replay.sha256 || manifest.replay.incomplete) throw new Error('session bundle replay checkpoint is incomplete or corrupt');
1767
+ assertAssetInventory(manifest, parseSessionHistory(replay).entries);
1768
+ const assets = [];
1769
+ for (const reference of manifest.assets?.entries ?? []) {
1770
+ const reader = await assetStoreFor(sessionId).openAsset(reference); await reader.close();
1771
+ const hash = reference.assetId.slice(7);
1772
+ assets.push(Object.freeze({ assetId: reference.assetId, path: hash, metadataPath: `${hash}.json`, byteLength: reference.byteLength }));
1773
+ }
1774
+ if (manifestText !== await readPrivateRegularFile(recordPathFor(sessionId), 0o600, fs, 'record')) continue;
1775
+ return Object.freeze({ sessionId, manifest: Buffer.from(manifestText, 'utf8'), replay, assets: Object.freeze(assets) });
1776
+ }
1777
+ throw new Error('session changed while exporting its bundle; retry');
1778
+ };
1779
+
1687
1780
  return Object.freeze({
1688
1781
  sessionsDir,
1782
+ readAsset: (sessionId, reference, options) => assetStoreFor(sessionId).readAsset(reference, options),
1783
+ openAsset: (sessionId, reference, options) => assetStoreFor(sessionId).openAsset(reference, options),
1784
+ describeAsset: (sessionId, assetId) => assetStoreFor(sessionId).describeAsset(assetId),
1785
+ exportBundle,
1689
1786
  prepare,
1690
1787
  migrate,
1691
1788
  migrateLegacyDefault,
@@ -2540,6 +2637,7 @@ async function createLease({
2540
2637
  readHistory,
2541
2638
  validateSession,
2542
2639
  portableWriters,
2640
+ assetStore,
2543
2641
  writeRecord,
2544
2642
  syncRecordDirectory,
2545
2643
  deleteRecord,
@@ -2636,7 +2734,9 @@ async function createLease({
2636
2734
  const seq = history.digests[durableSeq] === undefined ? previousManifest?.replay?.seq ?? 0 : durableSeq;
2637
2735
  const digest = history.digests[seq] ?? previousManifest?.replay?.sha256 ?? EMPTY_REPLAY_SHA256;
2638
2736
  const replay = { seq, sha256: digest, incomplete: previousManifest?.replay?.incomplete === true || status.incomplete || history.incomplete || history.digests[seq] === undefined };
2639
- const manifest = manifestFromRecovery(recovery, replay, contextSeq);
2737
+ const entries = await assetStore.listAssets();
2738
+ const assets = entries.length === 0 ? undefined : { version: 1, entries: await Promise.all(entries.map(({ assetId }) => assetStore.describeAsset(assetId))) };
2739
+ const manifest = manifestFromRecovery(recovery, replay, contextSeq, assets);
2640
2740
  return manifest;
2641
2741
  };
2642
2742
  portableWriters.set(sessionId, { checkpoint, sync: replayWriter.checkpoint, published: (manifest) => { previousManifest = manifest; } });
@@ -2928,6 +3028,8 @@ async function createLease({
2928
3028
  source.effectLedger,
2929
3029
  );
2930
3030
 
3031
+ const inheritedAssets = new Map(Object.values(retainedGenerations).flatMap((generation) => generation.frames.flatMap((frame) => frame.attachments ?? [])).map((reference) => [reference.assetId, reference]));
3032
+ for (const reference of inheritedAssets.values()) await assetStore.copyAsset(sourceLease, reference);
2931
3033
  await sourceLease.assertOwner();
2932
3034
  await assertOwnerUnchecked();
2933
3035
  let sourcePublished = false;
@@ -3026,11 +3128,12 @@ async function createLease({
3026
3128
 
3027
3129
  const beginTurn = ({
3028
3130
  input,
3131
+ attachments,
3029
3132
  attemptId,
3030
3133
  attemptedExecutionProjection,
3031
3134
  } = {}) =>
3032
3135
  runExclusive(async () => {
3033
- assertAcceptedInput(input);
3136
+ assertAcceptedInput(input, attachments);
3034
3137
  assertUuid(attemptId, 'Captain session attempt id');
3035
3138
  const attempted = validateCaptainSessionExecutionProjection(
3036
3139
  attemptedExecutionProjection,
@@ -3065,6 +3168,7 @@ async function createLease({
3065
3168
  uncertain: {
3066
3169
  baseUpdatedAt: prior.updatedAt,
3067
3170
  input,
3171
+ ...(attachments?.length ? { attachments: validateAssetRefs(attachments) } : {}),
3068
3172
  attemptId,
3069
3173
  attemptNumber: 1,
3070
3174
  markedAt: timestamp,
@@ -3119,6 +3223,7 @@ async function createLease({
3119
3223
  baseUpdatedAt: prior.uncertain.baseUpdatedAt,
3120
3224
  ...(prior.uncertain.progress === undefined ? {} : { progress: prior.uncertain.progress }),
3121
3225
  input: prior.uncertain.input,
3226
+ ...(prior.uncertain.attachments === undefined ? {} : { attachments: prior.uncertain.attachments }),
3122
3227
  attemptId: nextAttemptId,
3123
3228
  attemptNumber: prior.uncertain.attemptNumber + 1,
3124
3229
  markedAt: timestamp,
@@ -3637,11 +3742,11 @@ async function createLease({
3637
3742
  if (Object.hasOwn(step, 'result') && !['completion', 'answer'].includes(step.kind)) throw new Error('A step result requires its saved start');
3638
3743
  progress.steps.push(step);
3639
3744
  } else {
3640
- const { result, ...start } = step;
3745
+ const { result, workerEvidence, ...start } = step;
3641
3746
  const saved = progress.steps[index];
3642
- const { result: priorResult, ...priorStart } = saved;
3747
+ const { result: priorResult, workerEvidence: priorEvidence, ...priorStart } = saved;
3643
3748
  if (!isDeepStrictEqual(start, priorStart) ||
3644
- (priorResult !== undefined && !isDeepStrictEqual(priorResult, result))) {
3749
+ (priorResult !== undefined && (!isDeepStrictEqual(priorResult, result) || !isDeepStrictEqual(priorEvidence, workerEvidence)))) {
3645
3750
  throw new Error('A saved step cannot be replaced');
3646
3751
  }
3647
3752
  progress.steps[index] = step;
@@ -3728,6 +3833,11 @@ async function createLease({
3728
3833
  sessionId,
3729
3834
  ownerToken: owner.ownerToken,
3730
3835
  append,
3836
+ importAsset: (input) => runExclusive(async () => { await assertOwnerUnchecked(); return assetStore.importAsset(input); }),
3837
+ resolveAttachments: (references, options) => runExclusive(async () => { await assertOwnerUnchecked(); if (!Array.isArray(references)) throw new TypeError('attachments must be an array'); return Promise.all(references.map((reference) => assetStore.resolveAttachment(reference, options))); }),
3838
+ readAsset: (reference, options) => assetStore.readAsset(reference, options),
3839
+ openAsset: (reference, options) => assetStore.openAsset(reference, options),
3840
+ describeAsset: (assetId) => assetStore.describeAsset(assetId),
3731
3841
  recordContext,
3732
3842
  consumeHints,
3733
3843
  acknowledgeHint,
@@ -4089,7 +4199,7 @@ function validateCanonicalCaptainSessionRecord(
4089
4199
  exactOptionalKeys(
4090
4200
  uncertain,
4091
4201
  UNCERTAIN_KEYS,
4092
- ['abandonment', 'progress'],
4202
+ ['abandonment', 'progress', 'attachments'],
4093
4203
  'Captain session record uncertain',
4094
4204
  );
4095
4205
  if (uncertain.baseUpdatedAt !== null) {
@@ -4110,7 +4220,7 @@ function validateCanonicalCaptainSessionRecord(
4110
4220
  );
4111
4221
  }
4112
4222
  }
4113
- assertAcceptedInput(uncertain.input);
4223
+ assertAcceptedInput(uncertain.input, uncertain.attachments);
4114
4224
  assertUuid(uncertain.attemptId, 'Captain session attempt id');
4115
4225
  if (
4116
4226
  !Number.isSafeInteger(uncertain.attemptNumber) ||
@@ -4172,8 +4282,9 @@ function validateCanonicalCaptainSessionRecord(
4172
4282
  exactOptionalKeys(progress, ['snapshot', 'steps'], ['positionStepId'], 'Captain progress');
4173
4283
  if (!Array.isArray(progress.steps)) throw new Error('Captain steps must be an array');
4174
4284
  const ids = new Set();
4285
+ const workerBoundaryIds = new Set();
4175
4286
  for (const step of progress.steps) {
4176
- exactOptionalKeys(requireRecord(step, 'Captain step'), ['id', 'kind', 'stateId', 'runtimeSessionId', 'playbookId'], ['result'], 'Captain step');
4287
+ exactOptionalKeys(requireRecord(step, 'Captain step'), ['id', 'kind', 'stateId', 'runtimeSessionId', 'playbookId'], ['result', 'workerEvidence'], 'Captain step');
4177
4288
  assertUuid(step.id, 'Captain step id');
4178
4289
  assertUuid(step.runtimeSessionId, 'Captain step runtime');
4179
4290
  if (ids.has(step.id)) throw new Error('Captain step ids must be unique');
@@ -4209,6 +4320,22 @@ function validateCanonicalCaptainSessionRecord(
4209
4320
  }
4210
4321
  requireCanonicalNonblank(step.stateId, 'Captain step state');
4211
4322
  if (!Object.hasOwn(structural.catalog, step.playbookId)) throw new Error('Captain step names an unknown playbook');
4323
+ if (step.workerEvidence !== undefined) {
4324
+ const evidence = requireRecord(step.workerEvidence, 'Worker evidence');
4325
+ exactOptionalKeys(evidence, ['boundaryId', 'playerId'], [], 'Worker evidence');
4326
+ assertUuid(evidence.boundaryId, 'Worker evidence boundary');
4327
+ requireCanonicalNonblank(evidence.playerId, 'Worker evidence player');
4328
+ const boundary = record.effectLedger.boundaries.find((entry) => entry.boundaryId === evidence.boundaryId);
4329
+ if (step.kind !== 'player' || !step.result || typeof step.result !== 'object' || Array.isArray(step.result) ||
4330
+ !boundary || boundary.playbookId !== step.playbookId || boundary.sourceStateId !== step.stateId ||
4331
+ workerBoundaryIds.has(evidence.boundaryId) || isDeepStrictEqual(boundary, record.snapshot.effectLedger.boundaries[boundary.sequence - 1]) ||
4332
+ !boundary.physicalReceipt || !boundary.finalText?.trim() ||
4333
+ boundary.semanticCandidate?.guard !== step.result.guard || typeof step.result.guard !== 'string' || step.result.guard === 'needsBossReply' ||
4334
+ structural.catalog[step.playbookId]?.roles?.[boundary.roleId]?.playerId !== evidence.playerId) {
4335
+ throw new Error('Worker evidence must name its accepted player boundary');
4336
+ }
4337
+ workerBoundaryIds.add(evidence.boundaryId);
4338
+ }
4212
4339
  }
4213
4340
  if (progress.positionStepId !== undefined && progress.positionStepId !== null && !ids.has(progress.positionStepId)) throw new Error('Captain progress positionStepId must name a recorded step');
4214
4341
  if (progress.snapshot === null && progress.positionStepId != null) throw new Error('Captain progress positionStepId requires a saved position');
@@ -5312,7 +5439,7 @@ function assertReleasedSchema2CaptainSessionRecord(record) {
5312
5439
  );
5313
5440
  }
5314
5441
  }
5315
- assertAcceptedInput(uncertain.input);
5442
+ assertAcceptedInput(uncertain.input, uncertain.attachments);
5316
5443
  assertUuid(uncertain.attemptId, 'Captain session attempt id');
5317
5444
  if (
5318
5445
  !Number.isSafeInteger(uncertain.attemptNumber) ||
@@ -5408,6 +5535,7 @@ function validateCaptainSessionProjection(
5408
5535
  'fastMode',
5409
5536
  'subagentModel',
5410
5537
  'subagentEffort',
5538
+ 'browser',
5411
5539
  ],
5412
5540
  playerPath,
5413
5541
  );
@@ -5578,6 +5706,7 @@ function validateProjectedAgent(value, path, { structural, hasId = false }) {
5578
5706
  const agent = requireRecord(value, path);
5579
5707
  if (!structural) {
5580
5708
  validateProjectedFastMode(agent, `${path}.fastMode`);
5709
+ if (agent.browser !== undefined && typeof agent.browser !== 'boolean') throw new Error(`${path}.browser must be a boolean`);
5581
5710
  validateProjectedSubagentTuning(agent, path);
5582
5711
  }
5583
5712
  if (!hasId) {
@@ -5592,6 +5721,7 @@ function validateProjectedAgent(value, path, { structural, hasId = false }) {
5592
5721
  'fastMode',
5593
5722
  'subagentModel',
5594
5723
  'subagentEffort',
5724
+ 'browser',
5595
5725
  ],
5596
5726
  path,
5597
5727
  );
@@ -6144,9 +6274,10 @@ function validateRetainedGenerationFrame(
6144
6274
  'roleBindings',
6145
6275
  'runtime',
6146
6276
  ],
6147
- ['parentSessionId', 'parentCallId', 'request', 'inputs'],
6277
+ ['parentSessionId', 'parentCallId', 'request', 'inputs', 'attachments'],
6148
6278
  path,
6149
6279
  );
6280
+ if (frame.attachments !== undefined) validateAssetRefs(frame.attachments);
6150
6281
  if (frame.request !== undefined) {
6151
6282
  if (index !== 0) throw new Error(`${path}.request belongs only to the root`);
6152
6283
  requireNonblank(frame.request, `${path}.request`);
@@ -6369,8 +6500,9 @@ async function requireUncertainRecord(record, attemptId) {
6369
6500
  return record;
6370
6501
  }
6371
6502
 
6372
- function assertAcceptedInput(value) {
6373
- if (typeof value !== 'string' || value.trim().length === 0) {
6503
+ function assertAcceptedInput(value, attachments) {
6504
+ if (attachments !== undefined) validateAssetRefs(attachments);
6505
+ if (typeof value !== 'string' || (value.trim().length === 0 && !attachments?.length)) {
6374
6506
  throw new Error('Captain session input must be a non-empty string');
6375
6507
  }
6376
6508
  }
@@ -51,6 +51,8 @@ export interface IrTaskPhasePlayerInput extends PlayerInputBase {
51
51
  readonly sourceItem: 'CODE-3';
52
52
  /** `<ir-number>`: the IR whose next unfinished task this phase implements. */
53
53
  readonly irNumber?: string;
54
+ /** Source-owned relay; historical canonical evidence, not present approval. */
55
+ readonly previousPhaseReview: string;
54
56
  }
55
57
  export type PlayerInput = FirstPhasePlayerInput | IrTaskPhasePlayerInput;
56
58
  export type PlayerOutput = {
@@ -125,6 +127,14 @@ export interface CodeInput {
125
127
  /** Optional seed for `<run-results>`; `START_CODE` may supply its own. */
126
128
  readonly runResults?: string;
127
129
  }
130
+ export interface PreviousPhaseReview {
131
+ readonly phaseKind: 'direct' | 'new-intent' | 'ir-task';
132
+ readonly phaseOutcome: PhaseOutcome;
133
+ readonly scopeCommit: string;
134
+ readonly evaluatedRevision: string;
135
+ readonly irNumber?: string;
136
+ readonly irTask?: string;
137
+ }
128
138
  export interface CodeContext {
129
139
  /** `<run-results>`: relevant run results relayed to every phase. */
130
140
  readonly runResults: string;
@@ -142,6 +152,8 @@ export interface CodeContext {
142
152
  readonly phaseOutcome?: PhaseOutcome;
143
153
  /** Revision evaluated by the latest passing review. */
144
154
  readonly evaluatedRevision?: string;
155
+ /** Frozen only by an accepted canonical clean child REVIEW. */
156
+ readonly previousPhaseReview?: PreviousPhaseReview;
145
157
  /** Compact failure CODE reports when review did not pass a phase. */
146
158
  readonly reviewError?: CompactError;
147
159
  /** Sanitized evidence of the review result that ended CODE. */
@@ -25,13 +25,19 @@ const FIRST_PHASE_PROMPT = [
25
25
  'For an existing IR, read the identified IR and implement exactly its next unfinished task, including corresponding tests or specs if any.',
26
26
  'Do not implement a later task in this phase.',
27
27
  "Mark the IR's progress and deliverables when relevant.",
28
+ "After implementing an IR task, identify it and state whether any IR task still has unfinished Coder work; if so identify the next unfinished task, otherwise state this was the final IR task's Coder work.",
29
+ "Report pending independent REVIEW, owner acceptance and later delivery/workshop steps separately; they imply an unfinished IR task only when the IR assigns that work to a remaining task.",
30
+ "Report only what the IR and completed work establish, in ordinary prose without a required marker.",
28
31
  'If the IR will be finished after this phase, double-check that all acceptance criteria are met.',
29
32
  '',
30
33
  'Consult @specs/map.md for relevant context and @specs/meta.md for spec requirements, if needed.',
31
34
  '',
32
35
  'Keep to the original intent and follow what it asks.',
33
36
  'Do not re-run tests or builds whose inputs have not changed since any previous reported run.',
34
- "Make the phase's minimal changes and then one new commit, following @specs/packages/git.md; never amend an existing commit.",
37
+ "If a required Boss answer prevents completion of the current phase or IR task, ask that question and return without committing.",
38
+ "Preserve permitted uncommitted work for the deferred continuation; do not create a partial or blocker-only commit to meet the one-commit requirement.",
39
+ "After the answer allows completion, make exactly one phase-owned commit before REVIEW.",
40
+ "When the phase is complete, make its minimal changes and then one new commit, following @specs/packages/git.md; never amend an existing commit.",
35
41
  'Make the commit message explain concisely what changed and why, including relevant verification.',
36
42
  'Identify every new commit you make.',
37
43
  'Credit every AI that contributed to this commit: Coder <coder-llm>.',
@@ -43,11 +49,20 @@ const IR_TASK_PHASE_PROMPT = [
43
49
  'Read the identified IR and implement exactly its next unfinished task, including corresponding tests or specs if any.',
44
50
  'Do not implement a later task in this phase.',
45
51
  "Mark the IR's progress and deliverables when relevant.",
52
+ "After implementing an IR task, identify it and state whether any IR task still has unfinished Coder work; if so identify the next unfinished task, otherwise state this was the final IR task's Coder work.",
53
+ "Report pending independent REVIEW, owner acceptance and later delivery/workshop steps separately; they imply an unfinished IR task only when the IR assigns that work to a remaining task.",
54
+ "Report only what the IR and completed work establish, in ordinary prose without a required marker.",
46
55
  'If the IR will be finished after this phase, double-check that all acceptance criteria are met.',
56
+ 'Use the quoted previous-phase review only for its exact recorded scope; committed pending-review text may predate that canonical result.',
57
+ 'Before treating it as current review evidence, verify clean current HEAD equals its evaluatedRevision; a mismatch is not approval.',
58
+ 'It does not replace independent review of this task or any new owner or release decision.',
47
59
  '',
48
60
  'Keep to the original intent and follow what it asks.',
49
61
  'Do not re-run tests or builds whose inputs have not changed since any previous reported run.',
50
- "Make the phase's minimal changes and then one new commit, following @specs/packages/git.md; never amend an existing commit.",
62
+ "If a required Boss answer prevents completion of the current phase or IR task, ask that question and return without committing.",
63
+ "Preserve permitted uncommitted work for the deferred continuation; do not create a partial or blocker-only commit to meet the one-commit requirement.",
64
+ "After the answer allows completion, make exactly one phase-owned commit before REVIEW.",
65
+ "When the phase is complete, make its minimal changes and then one new commit, following @specs/packages/git.md; never amend an existing commit.",
51
66
  'Make the commit message explain concisely what changed and why, including relevant verification.',
52
67
  'Identify every new commit you make.',
53
68
  'Credit every AI that contributed to this commit: Coder <coder-llm>.',
@@ -55,6 +70,7 @@ const IR_TASK_PHASE_PROMPT = [
55
70
  '> Original request: <caller-input>',
56
71
  '> IR number: <ir-number>',
57
72
  '> Run results: <run-results>',
73
+ '> Previous phase review: <previous-phase-review>',
58
74
  ].join('\n');
59
75
  const REVIEW_NEW_INTENT_PHASE_TEMPLATE = [
60
76
  '> Original intent: <caller-input>',
@@ -68,7 +84,7 @@ const REVIEW_IR_TASK_PHASE_TEMPLATE = [
68
84
  '',
69
85
  '> Current IR task: <ir-task>',
70
86
  ].join('\n');
71
- const NEEDS_BOSS_REPLY_DESCRIPTION = "The acting agent's prose surfaces a clarifying question for Boss that the agent cannot answer alone. Output shall include `question: <verbatim question text from the acting agent's prose>`.";
87
+ const NEEDS_BOSS_REPLY_DESCRIPTION = "At the end of this call, Coder has an unanswered clarifying question for Boss whose answer is required to complete the current coding phase. Earlier interim questions resolved or superseded by later affirmative current-phase completion, and prerequisites only for later review, acceptance or release stages, do not establish this outcome. Output shall include `question: <verbatim question text from the acting agent's prose>`.";
72
88
  const FIRST_PHASE_RESULT = {
73
89
  directCommit: "Coder completed the new coding intent as one direct implementation phase and made its one new commit; Coder's result affirmatively supports this outcome, and no fixed presentation format of the reply is required. Output shall include `coderOutput: <verbatim final text>` and `latestCommit: <commit identity>`.",
74
90
  irCommit: "Coder decomposed the new coding intent into a new IR, implemented no IR task, and made its one new commit; Coder's result affirmatively supports this outcome, and no fixed presentation format of the reply is required. Output shall include `coderOutput: <verbatim final text>`, `latestCommit: <commit identity>`, and `irNumber` identifying the created IR.",
@@ -467,6 +483,7 @@ export const codeMachine = setup({
467
483
  irTask: undefined,
468
484
  phaseOutcome: undefined,
469
485
  evaluatedRevision: undefined,
486
+ previousPhaseReview: undefined,
470
487
  reviewError: undefined,
471
488
  reviewEvidence: undefined,
472
489
  lastError: undefined,
@@ -477,6 +494,7 @@ export const codeMachine = setup({
477
494
  resetForInterrupt: assign({
478
495
  phaseOutcome: undefined,
479
496
  evaluatedRevision: undefined,
497
+ previousPhaseReview: undefined,
480
498
  reviewError: undefined,
481
499
  reviewEvidence: undefined,
482
500
  lastError: undefined,
@@ -516,11 +534,24 @@ export const codeMachine = setup({
516
534
  pendingBossQuestion: undefined,
517
535
  bossReply: undefined,
518
536
  }),
519
- rememberReviewPass: assign(({ event }) => {
537
+ rememberReviewPass: assign(({ context, event }) => {
520
538
  const pass = reviewPassOf(event);
521
- return pass === undefined
539
+ return pass === undefined || context.codeCommit === undefined || context.phaseOutcome === undefined
522
540
  ? {}
523
- : { evaluatedRevision: pass.evaluatedRevision, lastError: undefined };
541
+ : {
542
+ evaluatedRevision: pass.evaluatedRevision,
543
+ previousPhaseReview: {
544
+ phaseKind: context.phaseOutcome === 'directCommit'
545
+ ? 'direct' : context.phaseOutcome === 'irCommit'
546
+ ? 'new-intent' : 'ir-task',
547
+ phaseOutcome: context.phaseOutcome,
548
+ scopeCommit: context.codeCommit,
549
+ evaluatedRevision: pass.evaluatedRevision,
550
+ ...(context.irNumber === undefined ? {} : { irNumber: context.irNumber }),
551
+ ...(context.irTask === undefined ? {} : { irTask: context.irTask }),
552
+ },
553
+ lastError: undefined,
554
+ };
524
555
  }),
525
556
  rememberReviewNotPassed: assign(({ event }) => {
526
557
  const output = doneOutputOf(event);
@@ -703,6 +734,9 @@ export const codeMachine = setup({
703
734
  ? { irNumber: context.irNumber }
704
735
  : {}),
705
736
  runResults: context.runResults,
737
+ previousPhaseReview: context.previousPhaseReview === undefined
738
+ ? 'No accepted prior-phase review is available.'
739
+ : JSON.stringify(context.previousPhaseReview),
706
740
  ...bossReplyFields(context),
707
741
  }),
708
742
  onDone: [
@@ -95,6 +95,8 @@ export interface IrTaskPhasePlayerInput extends PlayerInputBase {
95
95
  readonly sourceItem: 'CODE-3';
96
96
  /** `<ir-number>`: the IR whose next unfinished task this phase implements. */
97
97
  readonly irNumber?: string;
98
+ /** Source-owned relay; historical canonical evidence, not present approval. */
99
+ readonly previousPhaseReview: string;
98
100
  }
99
101
 
100
102
  export type PlayerInput = FirstPhasePlayerInput | IrTaskPhasePlayerInput;
@@ -194,6 +196,15 @@ export interface CodeInput {
194
196
  readonly runResults?: string;
195
197
  }
196
198
 
199
+ export interface PreviousPhaseReview {
200
+ readonly phaseKind: 'direct' | 'new-intent' | 'ir-task';
201
+ readonly phaseOutcome: PhaseOutcome;
202
+ readonly scopeCommit: string;
203
+ readonly evaluatedRevision: string;
204
+ readonly irNumber?: string;
205
+ readonly irTask?: string;
206
+ }
207
+
197
208
  export interface CodeContext {
198
209
  /** `<run-results>`: relevant run results relayed to every phase. */
199
210
  readonly runResults: string;
@@ -211,6 +222,8 @@ export interface CodeContext {
211
222
  readonly phaseOutcome?: PhaseOutcome;
212
223
  /** Revision evaluated by the latest passing review. */
213
224
  readonly evaluatedRevision?: string;
225
+ /** Frozen only by an accepted canonical clean child REVIEW. */
226
+ readonly previousPhaseReview?: PreviousPhaseReview;
214
227
  /** Compact failure CODE reports when review did not pass a phase. */
215
228
  readonly reviewError?: CompactError;
216
229
  /** Sanitized evidence of the review result that ended CODE. */
@@ -249,13 +262,19 @@ const FIRST_PHASE_PROMPT = [
249
262
  'For an existing IR, read the identified IR and implement exactly its next unfinished task, including corresponding tests or specs if any.',
250
263
  'Do not implement a later task in this phase.',
251
264
  "Mark the IR's progress and deliverables when relevant.",
265
+ "After implementing an IR task, identify it and state whether any IR task still has unfinished Coder work; if so identify the next unfinished task, otherwise state this was the final IR task's Coder work.",
266
+ "Report pending independent REVIEW, owner acceptance and later delivery/workshop steps separately; they imply an unfinished IR task only when the IR assigns that work to a remaining task.",
267
+ "Report only what the IR and completed work establish, in ordinary prose without a required marker.",
252
268
  'If the IR will be finished after this phase, double-check that all acceptance criteria are met.',
253
269
  '',
254
270
  'Consult @specs/map.md for relevant context and @specs/meta.md for spec requirements, if needed.',
255
271
  '',
256
272
  'Keep to the original intent and follow what it asks.',
257
273
  'Do not re-run tests or builds whose inputs have not changed since any previous reported run.',
258
- "Make the phase's minimal changes and then one new commit, following @specs/packages/git.md; never amend an existing commit.",
274
+ "If a required Boss answer prevents completion of the current phase or IR task, ask that question and return without committing.",
275
+ "Preserve permitted uncommitted work for the deferred continuation; do not create a partial or blocker-only commit to meet the one-commit requirement.",
276
+ "After the answer allows completion, make exactly one phase-owned commit before REVIEW.",
277
+ "When the phase is complete, make its minimal changes and then one new commit, following @specs/packages/git.md; never amend an existing commit.",
259
278
  'Make the commit message explain concisely what changed and why, including relevant verification.',
260
279
  'Identify every new commit you make.',
261
280
  'Credit every AI that contributed to this commit: Coder <coder-llm>.',
@@ -268,11 +287,20 @@ const IR_TASK_PHASE_PROMPT = [
268
287
  'Read the identified IR and implement exactly its next unfinished task, including corresponding tests or specs if any.',
269
288
  'Do not implement a later task in this phase.',
270
289
  "Mark the IR's progress and deliverables when relevant.",
290
+ "After implementing an IR task, identify it and state whether any IR task still has unfinished Coder work; if so identify the next unfinished task, otherwise state this was the final IR task's Coder work.",
291
+ "Report pending independent REVIEW, owner acceptance and later delivery/workshop steps separately; they imply an unfinished IR task only when the IR assigns that work to a remaining task.",
292
+ "Report only what the IR and completed work establish, in ordinary prose without a required marker.",
271
293
  'If the IR will be finished after this phase, double-check that all acceptance criteria are met.',
294
+ 'Use the quoted previous-phase review only for its exact recorded scope; committed pending-review text may predate that canonical result.',
295
+ 'Before treating it as current review evidence, verify clean current HEAD equals its evaluatedRevision; a mismatch is not approval.',
296
+ 'It does not replace independent review of this task or any new owner or release decision.',
272
297
  '',
273
298
  'Keep to the original intent and follow what it asks.',
274
299
  'Do not re-run tests or builds whose inputs have not changed since any previous reported run.',
275
- "Make the phase's minimal changes and then one new commit, following @specs/packages/git.md; never amend an existing commit.",
300
+ "If a required Boss answer prevents completion of the current phase or IR task, ask that question and return without committing.",
301
+ "Preserve permitted uncommitted work for the deferred continuation; do not create a partial or blocker-only commit to meet the one-commit requirement.",
302
+ "After the answer allows completion, make exactly one phase-owned commit before REVIEW.",
303
+ "When the phase is complete, make its minimal changes and then one new commit, following @specs/packages/git.md; never amend an existing commit.",
276
304
  'Make the commit message explain concisely what changed and why, including relevant verification.',
277
305
  'Identify every new commit you make.',
278
306
  'Credit every AI that contributed to this commit: Coder <coder-llm>.',
@@ -280,6 +308,7 @@ const IR_TASK_PHASE_PROMPT = [
280
308
  '> Original request: <caller-input>',
281
309
  '> IR number: <ir-number>',
282
310
  '> Run results: <run-results>',
311
+ '> Previous phase review: <previous-phase-review>',
283
312
  ].join('\n');
284
313
 
285
314
  const REVIEW_NEW_INTENT_PHASE_TEMPLATE = [
@@ -297,7 +326,7 @@ const REVIEW_IR_TASK_PHASE_TEMPLATE = [
297
326
  ].join('\n');
298
327
 
299
328
  const NEEDS_BOSS_REPLY_DESCRIPTION =
300
- "The acting agent's prose surfaces a clarifying question for Boss that the agent cannot answer alone. Output shall include `question: <verbatim question text from the acting agent's prose>`.";
329
+ "At the end of this call, Coder has an unanswered clarifying question for Boss whose answer is required to complete the current coding phase. Earlier interim questions resolved or superseded by later affirmative current-phase completion, and prerequisites only for later review, acceptance or release stages, do not establish this outcome. Output shall include `question: <verbatim question text from the acting agent's prose>`.";
301
330
 
302
331
  const FIRST_PHASE_RESULT = {
303
332
  directCommit:
@@ -823,6 +852,7 @@ export const codeMachine = setup({
823
852
  irTask: undefined,
824
853
  phaseOutcome: undefined,
825
854
  evaluatedRevision: undefined,
855
+ previousPhaseReview: undefined,
826
856
  reviewError: undefined,
827
857
  reviewEvidence: undefined,
828
858
  lastError: undefined,
@@ -834,6 +864,7 @@ export const codeMachine = setup({
834
864
  resetForInterrupt: assign({
835
865
  phaseOutcome: undefined,
836
866
  evaluatedRevision: undefined,
867
+ previousPhaseReview: undefined,
837
868
  reviewError: undefined,
838
869
  reviewEvidence: undefined,
839
870
  lastError: undefined,
@@ -876,11 +907,24 @@ export const codeMachine = setup({
876
907
  pendingBossQuestion: undefined,
877
908
  bossReply: undefined,
878
909
  }),
879
- rememberReviewPass: assign(({ event }) => {
910
+ rememberReviewPass: assign(({ context, event }) => {
880
911
  const pass = reviewPassOf(event);
881
- return pass === undefined
912
+ return pass === undefined || context.codeCommit === undefined || context.phaseOutcome === undefined
882
913
  ? {}
883
- : { evaluatedRevision: pass.evaluatedRevision, lastError: undefined };
914
+ : {
915
+ evaluatedRevision: pass.evaluatedRevision,
916
+ previousPhaseReview: {
917
+ phaseKind: context.phaseOutcome === 'directCommit'
918
+ ? 'direct' as const : context.phaseOutcome === 'irCommit'
919
+ ? 'new-intent' as const : 'ir-task' as const,
920
+ phaseOutcome: context.phaseOutcome,
921
+ scopeCommit: context.codeCommit,
922
+ evaluatedRevision: pass.evaluatedRevision,
923
+ ...(context.irNumber === undefined ? {} : { irNumber: context.irNumber }),
924
+ ...(context.irTask === undefined ? {} : { irTask: context.irTask }),
925
+ },
926
+ lastError: undefined,
927
+ };
884
928
  }),
885
929
  rememberReviewNotPassed: assign(({ event }) => {
886
930
  const output = doneOutputOf(event);
@@ -1086,6 +1130,9 @@ export const codeMachine = setup({
1086
1130
  ? { irNumber: context.irNumber }
1087
1131
  : {}),
1088
1132
  runResults: context.runResults,
1133
+ previousPhaseReview: context.previousPhaseReview === undefined
1134
+ ? 'No accepted prior-phase review is available.'
1135
+ : JSON.stringify(context.previousPhaseReview),
1089
1136
  ...bossReplyFields(context),
1090
1137
  }),
1091
1138
  onDone: [