@sublang/playbook 13.3.0 → 14.1.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 +5 -5
- package/docs/cli.md +14 -14
- package/docs/configuration.md +10 -2
- package/docs/embedding.md +20 -1
- package/package.json +6 -1
- package/reference/sdlc/branch.playbook/branch.fsm.d.ts +1 -1
- package/reference/sdlc/code.playbook/bin/launch-config.js +88 -5
- package/reference/sdlc/code.playbook/bin/playbook.js +4 -0
- package/reference/sdlc/code.playbook/bin/repository-effects.js +198 -17
- package/reference/sdlc/code.playbook/bin/run.js +4 -0
- package/reference/sdlc/code.playbook/bin/session-store.js +2 -2
- package/reference/sdlc/code.playbook/code.fsm.d.ts +1 -1
- package/reference/sdlc/code.playbook/host-capabilities.d.ts +6 -0
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +3 -1
- package/reference/sdlc/code.playbook/playbook-captain.js +374 -20
- package/reference/sdlc/code.playbook/playbook-captain.ts +478 -38
- package/reference/sdlc/code.playbook/session-host.d.ts +6 -3
- package/reference/sdlc/decide.playbook/decide.fsm.d.ts +1 -1
- package/reference/sdlc/decide.playbook/decide.playbook.js +25 -1
- package/reference/sdlc/decide.playbook/decide.playbook.ts +35 -2
- package/reference/sdlc/dev.md +15 -8
- package/reference/sdlc/dev.playbook/dev.fsm.d.ts +1 -1
- package/reference/sdlc/dev.playbook/dev.fsm.js +18 -12
- package/reference/sdlc/dev.playbook/dev.fsm.ts +18 -13
- package/reference/sdlc/dev.playbook/dev.gears.md +19 -12
- package/reference/sdlc/dev.playbook/dev.playbook.d.ts +3 -10
- package/reference/sdlc/dev.playbook/dev.playbook.js +6 -6
- package/reference/sdlc/dev.playbook/dev.playbook.ts +8 -9
- package/reference/sdlc/dev.playbook/dev.registry.js +1 -1
- package/reference/sdlc/dev.playbook/dev.registry.ts +1 -1
- package/reference/sdlc/pr.md +17 -4
- package/reference/sdlc/pr.playbook/pr.fsm.d.ts +18 -4
- package/reference/sdlc/pr.playbook/pr.fsm.js +49 -18
- package/reference/sdlc/pr.playbook/pr.fsm.ts +63 -20
- package/reference/sdlc/pr.playbook/pr.gears.md +20 -7
- package/reference/sdlc/pr.playbook/pr.playbook.js +2 -1
- package/reference/sdlc/pr.playbook/pr.playbook.ts +2 -1
- package/reference/sdlc/review.playbook/review.fsm.d.ts +1 -1
- package/slc/experiments/fsm-scaffold-guidance.md +31 -0
- package/slc/gears2fsm.md +145 -58
- package/slc/link.md +190 -7
- package/slc/materialize-link.mjs +460 -0
- package/slc/optimize.md +46 -6
- package/slc/scaffold-fsm.mjs +225 -0
- package/slc/slc.pin-inputs.json +13 -0
- package/slc/text2gears.md +38 -3
- package/slc/workflow-contracts.json +371 -0
- package/src/runtime.d.ts +70 -0
- package/src/runtime.js +232 -1
- package/src/runtime.ts +358 -1
- package/src/xstate-playbook-runtime.d.ts +9 -10
- package/src/xstate-playbook-runtime.js +407 -21
- package/src/xstate-playbook-runtime.ts +514 -20
- package/src/xstate-runtime.d.ts +9 -1
- package/src/xstate-runtime.js +299 -19
- package/src/xstate-runtime.ts +362 -21
|
@@ -19,6 +19,7 @@ import type { Effort, PermissionPolicy } from '@sublang/cligent';
|
|
|
19
19
|
import type {
|
|
20
20
|
JsonValue,
|
|
21
21
|
NormalizedError,
|
|
22
|
+
PlaybookControlStanding,
|
|
22
23
|
PlaybookEffectLedger,
|
|
23
24
|
PlaybookEffectLedgerCommandBatch,
|
|
24
25
|
PlaybookCallRequest,
|
|
@@ -26,6 +27,8 @@ import type {
|
|
|
26
27
|
PlaybookCallStart,
|
|
27
28
|
PlaybookControlAction,
|
|
28
29
|
PlaybookControlView,
|
|
30
|
+
PlaybookFailureCause,
|
|
31
|
+
PlaybookFailureCode,
|
|
29
32
|
PlaybookPorts,
|
|
30
33
|
PlaybookRunResult,
|
|
31
34
|
PlaybookRuntime,
|
|
@@ -34,6 +37,7 @@ import type {
|
|
|
34
37
|
PlayerSessionStore,
|
|
35
38
|
PlaybookState,
|
|
36
39
|
} from '@sublang/playbook/runtime';
|
|
40
|
+
import { assertPlaybookFailureCause } from '@sublang/playbook/runtime';
|
|
37
41
|
import {
|
|
38
42
|
assertPlaybookRuntimeSnapshot,
|
|
39
43
|
assertPlaybookEffectLedger,
|
|
@@ -273,7 +277,12 @@ type PlaybookCaptainShellSnapshotValue =
|
|
|
273
277
|
readonly frames: readonly PlaybookCaptainFrameSnapshot[];
|
|
274
278
|
readonly retainedEffectReconciliation?: PlaybookCaptainRetainedEffectReconciliation;
|
|
275
279
|
readonly pendingBossQuestions?: JsonValue;
|
|
276
|
-
|
|
280
|
+
/** DR-063 §2: the parked failure's cause travels with its error. */
|
|
281
|
+
readonly lastError?: {
|
|
282
|
+
readonly name: string;
|
|
283
|
+
readonly message: string;
|
|
284
|
+
readonly cause?: PlaybookFailureCause;
|
|
285
|
+
};
|
|
277
286
|
}
|
|
278
287
|
);
|
|
279
288
|
|
|
@@ -517,7 +526,7 @@ interface ControlLedger {
|
|
|
517
526
|
latestSubRuntimeStateId?: string;
|
|
518
527
|
latestSubRuntimeState?: PlaybookState;
|
|
519
528
|
pendingBossQuestions?: unknown;
|
|
520
|
-
lastError?: { name: string; message: string };
|
|
529
|
+
lastError?: { name: string; message: string; cause?: PlaybookFailureCause };
|
|
521
530
|
// CAPTAIN-5/CAPTAIN-6: the session Captain's own identity plus the durable
|
|
522
531
|
// conversation and journal by presence only — never the pinned token value.
|
|
523
532
|
captainSessionId?: string;
|
|
@@ -708,6 +717,17 @@ interface ActiveTurn {
|
|
|
708
717
|
outcomeRecorded: boolean;
|
|
709
718
|
/** Canonical host evidence frozen before the controller result phase. */
|
|
710
719
|
unresolvedEffects?: readonly PlaybookCaptainUnresolvedEffect[];
|
|
720
|
+
/**
|
|
721
|
+
* DR-062 §5: the effect ledger's boundary count when this turn was
|
|
722
|
+
* admitted. A boundary beyond it completed during this turn; `undefined`
|
|
723
|
+
* where the count could not be read, which reports nothing rather than
|
|
724
|
+
* report a boundary an earlier turn already reported.
|
|
725
|
+
*/
|
|
726
|
+
readonly effectBoundaryPrefixCount?: number;
|
|
727
|
+
/** Set once this turn's carried pre-existing changes have been reported. */
|
|
728
|
+
carriedPreExistingReported?: boolean;
|
|
729
|
+
/** DR-063 §4: set once this turn's failure report has been appended. */
|
|
730
|
+
failureReported?: boolean;
|
|
711
731
|
}
|
|
712
732
|
|
|
713
733
|
function parseRegisteredCommand(
|
|
@@ -1203,6 +1223,199 @@ function unresolvedEffectBossReport(
|
|
|
1203
1223
|
].join('\n');
|
|
1204
1224
|
}
|
|
1205
1225
|
|
|
1226
|
+
// DR-062 §5: a settled turn whose accepted commit absorbed or altered the
|
|
1227
|
+
// Boss's own uncommitted changes says so, bounded to paths.
|
|
1228
|
+
const CARRIED_CHANGE_PATH_LIMIT = 24;
|
|
1229
|
+
|
|
1230
|
+
interface CarriedPreExistingChanges {
|
|
1231
|
+
readonly commitOid: string;
|
|
1232
|
+
readonly absorbed: readonly string[];
|
|
1233
|
+
readonly altered: readonly string[];
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
function boundedPathList(paths: readonly string[]): string {
|
|
1237
|
+
const printed = paths.slice(0, CARRIED_CHANGE_PATH_LIMIT);
|
|
1238
|
+
const omitted = paths.length - printed.length;
|
|
1239
|
+
return omitted > 0
|
|
1240
|
+
? `${printed.join(', ')}, … and ${omitted} more`
|
|
1241
|
+
: printed.join(', ');
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/**
|
|
1245
|
+
* The carried pre-existing changes of the boundaries completed after
|
|
1246
|
+
* `boundaryPrefixCount`, one entry per commit OID in boundary sequence order.
|
|
1247
|
+
*/
|
|
1248
|
+
function carriedPreExistingChanges(
|
|
1249
|
+
ledger: PlaybookEffectLedger,
|
|
1250
|
+
boundaryPrefixCount: number,
|
|
1251
|
+
): readonly CarriedPreExistingChanges[] {
|
|
1252
|
+
const byCommit = new Map<string, { absorbed: string[]; altered: string[] }>();
|
|
1253
|
+
for (const boundary of ledger.boundaries) {
|
|
1254
|
+
if (boundary.sequence <= boundaryPrefixCount) continue;
|
|
1255
|
+
const receipt = boundary.physicalReceipt;
|
|
1256
|
+
if (
|
|
1257
|
+
receipt?.classification !== 'one-descendant-commit' ||
|
|
1258
|
+
receipt.commitOid === undefined ||
|
|
1259
|
+
receipt.preExisting === undefined
|
|
1260
|
+
) {
|
|
1261
|
+
continue;
|
|
1262
|
+
}
|
|
1263
|
+
const { absorbed, altered } = receipt.preExisting;
|
|
1264
|
+
if (absorbed.length === 0 && altered.length === 0) continue;
|
|
1265
|
+
const carried = byCommit.get(receipt.commitOid) ?? {
|
|
1266
|
+
absorbed: [],
|
|
1267
|
+
altered: [],
|
|
1268
|
+
};
|
|
1269
|
+
carried.absorbed.push(...absorbed);
|
|
1270
|
+
carried.altered.push(...altered);
|
|
1271
|
+
byCommit.set(receipt.commitOid, carried);
|
|
1272
|
+
}
|
|
1273
|
+
return [...byCommit].map(([commitOid, { absorbed, altered }]) => ({
|
|
1274
|
+
commitOid,
|
|
1275
|
+
absorbed: [...new Set(absorbed)].sort(),
|
|
1276
|
+
altered: [...new Set(altered)].sort(),
|
|
1277
|
+
}));
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
function carriedPreExistingReport(
|
|
1281
|
+
carried: readonly CarriedPreExistingChanges[],
|
|
1282
|
+
): string | undefined {
|
|
1283
|
+
if (carried.length === 0) return undefined;
|
|
1284
|
+
return carried
|
|
1285
|
+
.map(({ commitOid, absorbed, altered }) =>
|
|
1286
|
+
[
|
|
1287
|
+
`Pre-existing changes carried by commit ${commitOid}:`,
|
|
1288
|
+
...(absorbed.length === 0
|
|
1289
|
+
? []
|
|
1290
|
+
: [`- absorbed as found: ${boundedPathList(absorbed)}`]),
|
|
1291
|
+
...(altered.length === 0
|
|
1292
|
+
? []
|
|
1293
|
+
: [`- altered before committing: ${boundedPathList(altered)}`]),
|
|
1294
|
+
'These changes were uncommitted before the step.',
|
|
1295
|
+
].join('\n'),
|
|
1296
|
+
)
|
|
1297
|
+
.join('\n\n');
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
function carriedPreExistingFacts(
|
|
1301
|
+
carried: readonly CarriedPreExistingChanges[],
|
|
1302
|
+
): readonly string[] {
|
|
1303
|
+
return carried.map(({ commitOid, absorbed, altered }) => {
|
|
1304
|
+
const paths = [...new Set([...absorbed, ...altered])].sort();
|
|
1305
|
+
return `The commit ${commitOid} carries ${paths.length} changes that were uncommitted before the step: ${boundedPathList(paths)}.`;
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// DR-063 §4: the Boss-visible failure report. Every host sees the same
|
|
1310
|
+
// sentences because the shell composes them from the structured cause instead
|
|
1311
|
+
// of asking a model to describe facts it may drop. The report exposes no file
|
|
1312
|
+
// content, player prose, or internal identity: paths, revisions, dispositions,
|
|
1313
|
+
// and the reported error message are what a cause carries.
|
|
1314
|
+
|
|
1315
|
+
/** The Boss-facing phrase for each closed control-action reason (DR-063 §3). */
|
|
1316
|
+
const CONTROL_REASON_PHRASES: Readonly<Record<string, string>> = {
|
|
1317
|
+
'receipt-complete': 'nothing has changed since it failed',
|
|
1318
|
+
};
|
|
1319
|
+
|
|
1320
|
+
function controlReasonPhrase(reason: string | undefined): string {
|
|
1321
|
+
if (reason === undefined) return 'no reason was published';
|
|
1322
|
+
return CONTROL_REASON_PHRASES[reason] ?? reason;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
function causePathList(
|
|
1326
|
+
paths: readonly string[] | undefined,
|
|
1327
|
+
truncated: number | undefined,
|
|
1328
|
+
): string {
|
|
1329
|
+
const list = paths ?? [];
|
|
1330
|
+
const printed = list.slice(0, CARRIED_CHANGE_PATH_LIMIT);
|
|
1331
|
+
const omitted = list.length - printed.length + (truncated ?? 0);
|
|
1332
|
+
return omitted > 0
|
|
1333
|
+
? `${printed.join(', ')}, … and ${omitted} more`
|
|
1334
|
+
: printed.join(', ');
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
/** One sentence per code, with that code's bounded evidence (DR-063 §4). */
|
|
1338
|
+
function failureCauseSentence(
|
|
1339
|
+
cause: PlaybookFailureCause,
|
|
1340
|
+
describePlaybook: (playbookId: string) => string,
|
|
1341
|
+
): string {
|
|
1342
|
+
const evidence = cause.evidence;
|
|
1343
|
+
const paths = evidence.paths;
|
|
1344
|
+
const code: PlaybookFailureCode = cause.code;
|
|
1345
|
+
switch (code) {
|
|
1346
|
+
case 'commit-missing':
|
|
1347
|
+
return (paths?.uncommitted ?? []).length === 0
|
|
1348
|
+
? 'the step had to commit its work and committed nothing.'
|
|
1349
|
+
: `the step had to commit its work and committed nothing; these changes are uncommitted: ${causePathList(paths?.uncommitted, paths?.truncated)}.`;
|
|
1350
|
+
case 'commit-residual': {
|
|
1351
|
+
const subject =
|
|
1352
|
+
evidence.commitOid === undefined
|
|
1353
|
+
? "the step's commit"
|
|
1354
|
+
: `the commit ${evidence.commitOid}`;
|
|
1355
|
+
if ((paths?.uncommitted ?? []).length > 0) {
|
|
1356
|
+
return `${subject} left changes uncommitted: ${causePathList(paths?.uncommitted, paths?.truncated)}.`;
|
|
1357
|
+
}
|
|
1358
|
+
if ((paths?.altered ?? []).length > 0) {
|
|
1359
|
+
return `${subject} left altered changes uncommitted: ${causePathList(paths?.altered, paths?.truncated)}.`;
|
|
1360
|
+
}
|
|
1361
|
+
return `${subject} left changes uncommitted beside it.`;
|
|
1362
|
+
}
|
|
1363
|
+
case 'pre-existing-lost':
|
|
1364
|
+
return `uncommitted changes you had were lost: ${causePathList(paths?.lost, paths?.truncated)}.`;
|
|
1365
|
+
case 'commits-more-than-one':
|
|
1366
|
+
return `the step made more than one commit; HEAD moved from ${evidence.baselineHead} to ${evidence.afterHead}.`;
|
|
1367
|
+
case 'history-rewritten':
|
|
1368
|
+
return `the repository history was rewritten; HEAD moved from ${evidence.baselineHead} to ${evidence.afterHead}.`;
|
|
1369
|
+
case 'foreign-change':
|
|
1370
|
+
return `the repository changed outside this step: ${causePathList(paths?.changed, paths?.truncated)}.`;
|
|
1371
|
+
case 'observation-unstable':
|
|
1372
|
+
return `the repository could not be observed after the step; it stood at ${evidence.baselineHead} before it.`;
|
|
1373
|
+
case 'attribution-ambiguous':
|
|
1374
|
+
return `the repository change could not be attributed to this step; it required ${evidence.required} and observed ${evidence.observed}.`;
|
|
1375
|
+
case 'receipt-missing':
|
|
1376
|
+
return `a step left no complete repository receipt; the repository stood at ${evidence.baselineHead} before it.`;
|
|
1377
|
+
case 'judge-failed':
|
|
1378
|
+
return evidence.error === undefined
|
|
1379
|
+
? `the hidden adjudication failed: ${evidence.reason}.`
|
|
1380
|
+
: `the hidden adjudication failed: ${evidence.reason} (${evidence.error.name}: ${compactEvidence(evidence.error.message)}).`;
|
|
1381
|
+
case 'player-failed':
|
|
1382
|
+
return `the ${evidence.roleId} call failed: ${compactEvidence(evidence.error?.message ?? 'no detail was reported')}.`;
|
|
1383
|
+
case 'aborted':
|
|
1384
|
+
return 'the turn was aborted before the work settled.';
|
|
1385
|
+
case 'child-failed': {
|
|
1386
|
+
const nested = describePlaybook(evidence.playbookId ?? 'nested playbook');
|
|
1387
|
+
return evidence.cause === undefined
|
|
1388
|
+
? `the nested ${nested} run failed.`
|
|
1389
|
+
: `the nested ${nested} run failed: ${failureCauseSentence(evidence.cause, describePlaybook)}`;
|
|
1390
|
+
}
|
|
1391
|
+
case 'runtime-defect':
|
|
1392
|
+
return `the runtime could not settle the step: ${compactEvidence(evidence.reason ?? 'no reason was recorded')}.`;
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
function failureControlLine(action: {
|
|
1397
|
+
readonly label: string;
|
|
1398
|
+
readonly standing?: PlaybookControlStanding;
|
|
1399
|
+
readonly reason?: string;
|
|
1400
|
+
}): string {
|
|
1401
|
+
// DR-063 §3: an action that publishes no standing is `ready`.
|
|
1402
|
+
const standing = action.standing ?? 'ready';
|
|
1403
|
+
return standing === 'ready'
|
|
1404
|
+
? `- ${action.label} (ready)`
|
|
1405
|
+
: `- ${action.label} (${standing}: ${controlReasonPhrase(action.reason)})`;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
function failureReportText(
|
|
1409
|
+
sentence: string,
|
|
1410
|
+
controls: readonly string[],
|
|
1411
|
+
): string {
|
|
1412
|
+
return [
|
|
1413
|
+
`Failure: ${sentence}`,
|
|
1414
|
+
'Controls:',
|
|
1415
|
+
...(controls.length === 0 ? ['- none'] : controls),
|
|
1416
|
+
].join('\n');
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1206
1419
|
function appendMandatoryPresentationSuffix(
|
|
1207
1420
|
turn: ActiveTurn,
|
|
1208
1421
|
suffix: string,
|
|
@@ -2374,16 +2587,23 @@ export function assertPlaybookCaptainShellSnapshot(
|
|
|
2374
2587
|
});
|
|
2375
2588
|
}
|
|
2376
2589
|
let normalizedLastError:
|
|
2377
|
-
| {
|
|
2590
|
+
| {
|
|
2591
|
+
readonly name: string;
|
|
2592
|
+
readonly message: string;
|
|
2593
|
+
readonly cause?: PlaybookFailureCause;
|
|
2594
|
+
}
|
|
2378
2595
|
| undefined;
|
|
2379
2596
|
if (snapshot.lastError !== undefined) {
|
|
2380
2597
|
const error = snapshotRecord(
|
|
2381
2598
|
snapshot.lastError,
|
|
2382
2599
|
'Captain shell snapshot.lastError',
|
|
2383
2600
|
);
|
|
2601
|
+
// DR-063 §2: the cause enters the stored record inside `lastError`, so a
|
|
2602
|
+
// parked failure explains itself after a restart. An unrecognized shape is
|
|
2603
|
+
// not a cause and the closed validator refuses the snapshot.
|
|
2384
2604
|
rejectSnapshotKeys(
|
|
2385
2605
|
error,
|
|
2386
|
-
['name', 'message'],
|
|
2606
|
+
['name', 'message', 'cause'],
|
|
2387
2607
|
'Captain shell snapshot.lastError',
|
|
2388
2608
|
);
|
|
2389
2609
|
normalizedLastError = {
|
|
@@ -2397,6 +2617,9 @@ export function assertPlaybookCaptainShellSnapshot(
|
|
|
2397
2617
|
'Captain shell snapshot.lastError.message',
|
|
2398
2618
|
true,
|
|
2399
2619
|
),
|
|
2620
|
+
...(error.cause === undefined
|
|
2621
|
+
? {}
|
|
2622
|
+
: { cause: assertPlaybookFailureCause(error.cause) }),
|
|
2400
2623
|
};
|
|
2401
2624
|
}
|
|
2402
2625
|
const activePlaybooks = new Set<string>();
|
|
@@ -3083,7 +3306,9 @@ export function createPlaybookCaptainShell(
|
|
|
3083
3306
|
| PlaybookCaptainRetainedEffectReconciliation
|
|
3084
3307
|
| undefined;
|
|
3085
3308
|
let pendingBossQuestions: unknown;
|
|
3086
|
-
let lastError:
|
|
3309
|
+
let lastError:
|
|
3310
|
+
| { name: string; message: string; cause?: PlaybookFailureCause }
|
|
3311
|
+
| undefined;
|
|
3087
3312
|
let activeTurnSummary: ActiveTurnSummary | undefined;
|
|
3088
3313
|
let activeTurnHostCalls: Set<Promise<unknown>> | undefined;
|
|
3089
3314
|
const issuedSessionIds = new Set<string>();
|
|
@@ -3351,6 +3576,15 @@ export function createPlaybookCaptainShell(
|
|
|
3351
3576
|
original.projection,
|
|
3352
3577
|
after.projection,
|
|
3353
3578
|
);
|
|
3579
|
+
// DR-062 §3: a pure projection never downgrades a classification the
|
|
3580
|
+
// physical receipt proved. The latest receipt already accounted for every
|
|
3581
|
+
// pre-existing entry of its own baseline, so when that baseline is the
|
|
3582
|
+
// original one its accounting is the operation's accounting, and the
|
|
3583
|
+
// projection may not demand a byte-equal after projection on top of it.
|
|
3584
|
+
const receiptAccountsFromOriginal = isDeepStrictEqual(
|
|
3585
|
+
receipt.baseline.projection,
|
|
3586
|
+
original.projection,
|
|
3587
|
+
);
|
|
3354
3588
|
if (after.head === original.head) {
|
|
3355
3589
|
if (
|
|
3356
3590
|
receipt.classification !== 'unchanged' &&
|
|
@@ -3369,9 +3603,12 @@ export function createPlaybookCaptainShell(
|
|
|
3369
3603
|
isDeepStrictEqual(entry, after.projection[path]),
|
|
3370
3604
|
);
|
|
3371
3605
|
return {
|
|
3372
|
-
classification:
|
|
3373
|
-
|
|
3374
|
-
|
|
3606
|
+
classification:
|
|
3607
|
+
preservesOriginal ||
|
|
3608
|
+
(receipt.classification === 'worktree-only-change' &&
|
|
3609
|
+
receiptAccountsFromOriginal)
|
|
3610
|
+
? 'worktree-only-change'
|
|
3611
|
+
: 'observation-ambiguous',
|
|
3375
3612
|
baselineHead: original.head,
|
|
3376
3613
|
afterHead: after.head,
|
|
3377
3614
|
};
|
|
@@ -3379,7 +3616,7 @@ export function createPlaybookCaptainShell(
|
|
|
3379
3616
|
|
|
3380
3617
|
if (
|
|
3381
3618
|
receipt.classification === 'one-descendant-commit' &&
|
|
3382
|
-
sameProjection
|
|
3619
|
+
(sameProjection || receiptAccountsFromOriginal)
|
|
3383
3620
|
) {
|
|
3384
3621
|
return {
|
|
3385
3622
|
classification: 'one-descendant-commit',
|
|
@@ -3523,6 +3760,44 @@ export function createPlaybookCaptainShell(
|
|
|
3523
3760
|
return frozen;
|
|
3524
3761
|
};
|
|
3525
3762
|
|
|
3763
|
+
/**
|
|
3764
|
+
* DR-062 §5: one deterministic Boss-visible report, once per turn, for the
|
|
3765
|
+
* pre-existing changes the commits completed during this turn carried, and
|
|
3766
|
+
* the same information as settlement facts the closing-reply prompt reads.
|
|
3767
|
+
* It supplements the unresolved-effect report and enters no run result.
|
|
3768
|
+
*/
|
|
3769
|
+
const reportCarriedPreExistingChanges = (turn: ActiveTurn): void => {
|
|
3770
|
+
if (turn.carriedPreExistingReported) return;
|
|
3771
|
+
if (turn.effectBoundaryPrefixCount === undefined) return;
|
|
3772
|
+
let carried: readonly CarriedPreExistingChanges[];
|
|
3773
|
+
try {
|
|
3774
|
+
carried = carriedPreExistingChanges(
|
|
3775
|
+
assertPlaybookEffectLedger(currentEffectLedger()),
|
|
3776
|
+
turn.effectBoundaryPrefixCount,
|
|
3777
|
+
);
|
|
3778
|
+
} catch {
|
|
3779
|
+
return;
|
|
3780
|
+
}
|
|
3781
|
+
const report = carriedPreExistingReport(carried);
|
|
3782
|
+
if (report === undefined) return;
|
|
3783
|
+
turn.carriedPreExistingReported = true;
|
|
3784
|
+
appendMandatoryPresentationSuffix(turn, report);
|
|
3785
|
+
const facts = carriedPreExistingFacts(carried);
|
|
3786
|
+
turn.settlementFacts.push(...facts);
|
|
3787
|
+
// The action's own report was assembled from the facts as they stood when
|
|
3788
|
+
// it settled; the result-phase prompt reads this report, so the same facts
|
|
3789
|
+
// join it here rather than only the turn's running list.
|
|
3790
|
+
if (turn.report !== undefined) {
|
|
3791
|
+
turn.report = {
|
|
3792
|
+
...turn.report,
|
|
3793
|
+
facts: [...turn.report.facts, ...facts],
|
|
3794
|
+
...(turn.report.bossFacts === undefined
|
|
3795
|
+
? {}
|
|
3796
|
+
: { bossFacts: [...turn.report.bossFacts, ...facts] }),
|
|
3797
|
+
};
|
|
3798
|
+
}
|
|
3799
|
+
};
|
|
3800
|
+
|
|
3526
3801
|
const normalizeInstalledRetainedGenerations = (
|
|
3527
3802
|
value: Readonly<Record<string, PlaybookCaptainRetainedGeneration>>,
|
|
3528
3803
|
): Map<string, PlaybookCaptainRetainedGeneration> => {
|
|
@@ -5229,6 +5504,7 @@ export function createPlaybookCaptainShell(
|
|
|
5229
5504
|
const settleUnresolvedEffectAbandonment = async (
|
|
5230
5505
|
unresolvedLeaf: EngagementFrame,
|
|
5231
5506
|
): Promise<void> => {
|
|
5507
|
+
abandonmentSettlementUnsafe = true;
|
|
5232
5508
|
if (leafFrame() !== unresolvedLeaf) {
|
|
5233
5509
|
throw new Error(
|
|
5234
5510
|
'unresolved-effect abandonment requires the active leaf',
|
|
@@ -5263,6 +5539,7 @@ export function createPlaybookCaptainShell(
|
|
|
5263
5539
|
rootPlaybookId,
|
|
5264
5540
|
});
|
|
5265
5541
|
await runEffect(() => unresolvedEffectSettlement.complete(settlement));
|
|
5542
|
+
abandonmentSettlementUnsafe = false;
|
|
5266
5543
|
};
|
|
5267
5544
|
|
|
5268
5545
|
const callResultFor = (
|
|
@@ -5764,6 +6041,30 @@ export function createPlaybookCaptainShell(
|
|
|
5764
6041
|
];
|
|
5765
6042
|
};
|
|
5766
6043
|
|
|
6044
|
+
// DR-063 §3: every advertised action names its standing in the digest, so a
|
|
6045
|
+
// model is never invited to select an action that changes nothing.
|
|
6046
|
+
const advertisedActionDigest = (
|
|
6047
|
+
actions: readonly PlaybookControlAction[],
|
|
6048
|
+
): string => {
|
|
6049
|
+
if (actions.length === 0) return 'Advertised actions: none.';
|
|
6050
|
+
const lines = [
|
|
6051
|
+
'Advertised actions:',
|
|
6052
|
+
...actions.map((action) =>
|
|
6053
|
+
(action.standing ?? 'ready') === 'ready'
|
|
6054
|
+
? digestLine`- ${action.id}: ${action.label}`
|
|
6055
|
+
: digestLine`- ${action.id}: ${action.label} (${action.standing!}: ${
|
|
6056
|
+
action.reason ?? 'no reason was published'
|
|
6057
|
+
})`,
|
|
6058
|
+
),
|
|
6059
|
+
];
|
|
6060
|
+
if (actions.some(({ standing }) => standing !== undefined && standing !== 'ready')) {
|
|
6061
|
+
lines.push(
|
|
6062
|
+
'An action marked no-op runs and changes nothing; a blocked action cannot run at all.',
|
|
6063
|
+
);
|
|
6064
|
+
}
|
|
6065
|
+
return lines.join('\n');
|
|
6066
|
+
};
|
|
6067
|
+
|
|
5767
6068
|
const retainedResumptionDigest = (): string => {
|
|
5768
6069
|
if (rootFrame() !== undefined) {
|
|
5769
6070
|
return 'Retained resumptions: unavailable while a playbook is engaged.';
|
|
@@ -5796,10 +6097,7 @@ export function createPlaybookCaptainShell(
|
|
|
5796
6097
|
}
|
|
5797
6098
|
refreshRetainedEffectFence();
|
|
5798
6099
|
if (retainedEffectReconciliation !== undefined) {
|
|
5799
|
-
let reconciliationActions: readonly
|
|
5800
|
-
readonly id: string;
|
|
5801
|
-
readonly label: string;
|
|
5802
|
-
}[] = [];
|
|
6100
|
+
let reconciliationActions: readonly PlaybookControlAction[] = [];
|
|
5803
6101
|
if (
|
|
5804
6102
|
typeof leaf.runtime.describe === 'function' &&
|
|
5805
6103
|
typeof leaf.runtime.apply === 'function'
|
|
@@ -5823,16 +6121,7 @@ export function createPlaybookCaptainShell(
|
|
|
5823
6121
|
`Leaf ${frameLabel(leaf)} is parked for repository-effect reconciliation.`,
|
|
5824
6122
|
);
|
|
5825
6123
|
lines.push('Pending Boss questions: withheld until reconciliation.');
|
|
5826
|
-
lines.push(
|
|
5827
|
-
reconciliationActions.length === 0
|
|
5828
|
-
? 'Advertised actions: none.'
|
|
5829
|
-
: [
|
|
5830
|
-
'Advertised actions:',
|
|
5831
|
-
...reconciliationActions.map(
|
|
5832
|
-
(action) => digestLine`- ${action.id}: ${action.label}`,
|
|
5833
|
-
),
|
|
5834
|
-
].join('\n'),
|
|
5835
|
-
);
|
|
6124
|
+
lines.push(advertisedActionDigest(reconciliationActions));
|
|
5836
6125
|
lines.push(
|
|
5837
6126
|
'Ordinary delivery, switching, dismissal, and runtime actions are unavailable while retained effect evidence is unresolved. Only the advertised unresolved-effect controls may run. Conversation is unaffected: `respond` stays valid for any turn.',
|
|
5838
6127
|
);
|
|
@@ -5937,16 +6226,7 @@ export function createPlaybookCaptainShell(
|
|
|
5937
6226
|
})}`,
|
|
5938
6227
|
);
|
|
5939
6228
|
}
|
|
5940
|
-
lines.push(
|
|
5941
|
-
view.actions.length === 0
|
|
5942
|
-
? 'Advertised actions: none.'
|
|
5943
|
-
: [
|
|
5944
|
-
'Advertised actions:',
|
|
5945
|
-
...view.actions.map(
|
|
5946
|
-
(action) => digestLine`- ${action.id}: ${action.label}`,
|
|
5947
|
-
),
|
|
5948
|
-
].join('\n'),
|
|
5949
|
-
);
|
|
6229
|
+
lines.push(advertisedActionDigest(view.actions));
|
|
5950
6230
|
lines.push(retainedResumptionDigest());
|
|
5951
6231
|
return lines.join('\n');
|
|
5952
6232
|
};
|
|
@@ -5987,7 +6267,17 @@ export function createPlaybookCaptainShell(
|
|
|
5987
6267
|
action.id === UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID ||
|
|
5988
6268
|
action.id === UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID,
|
|
5989
6269
|
)
|
|
5990
|
-
|
|
6270
|
+
// DR-063 §3: the leaf's own standing is published with the pair, so a
|
|
6271
|
+
// host drawing the control says what running it would do. A leaf that
|
|
6272
|
+
// publishes none advertises `ready`, which is what it always meant.
|
|
6273
|
+
.map((action) =>
|
|
6274
|
+
Object.freeze({
|
|
6275
|
+
id: action.id,
|
|
6276
|
+
label: action.label,
|
|
6277
|
+
standing: action.standing ?? 'ready',
|
|
6278
|
+
...(action.reason === undefined ? {} : { reason: action.reason }),
|
|
6279
|
+
}),
|
|
6280
|
+
),
|
|
5991
6281
|
);
|
|
5992
6282
|
};
|
|
5993
6283
|
|
|
@@ -6014,6 +6304,9 @@ export function createPlaybookCaptainShell(
|
|
|
6014
6304
|
// way the Boss started it rather than the frame that happens to be
|
|
6015
6305
|
// parked inside it.
|
|
6016
6306
|
label: `Stop ${frameLabel(root)}`,
|
|
6307
|
+
// DR-063 §3: giving up always ends the engagement, whatever the leaf
|
|
6308
|
+
// is parked on.
|
|
6309
|
+
standing: 'ready' as const,
|
|
6017
6310
|
}),
|
|
6018
6311
|
]);
|
|
6019
6312
|
};
|
|
@@ -6913,6 +7206,14 @@ export function createPlaybookCaptainShell(
|
|
|
6913
7206
|
const root = rootFrame();
|
|
6914
7207
|
if (!root) return false;
|
|
6915
7208
|
const label = frameLabel(root);
|
|
7209
|
+
if (
|
|
7210
|
+
activeTurn?.hostGiveUp === true &&
|
|
7211
|
+
freezeTurnUnresolvedEffects().length > 0
|
|
7212
|
+
) {
|
|
7213
|
+
await settleUnresolvedEffectAbandonment(leafFrame()!);
|
|
7214
|
+
facts.push(`Dismissed the ${label} engagement with unresolved repository effects reported.`);
|
|
7215
|
+
return false;
|
|
7216
|
+
}
|
|
6916
7217
|
// Dismissal leaves the procedure unfinished. Persist the latest safe
|
|
6917
7218
|
// generation captured for this turn before disposal erases the frames —
|
|
6918
7219
|
// unless the Boss gave up on it (CAPTAIN-44), in which case retaining it
|
|
@@ -7176,6 +7477,123 @@ export function createPlaybookCaptainShell(
|
|
|
7176
7477
|
);
|
|
7177
7478
|
};
|
|
7178
7479
|
|
|
7480
|
+
// DR-063 §4: the leaf's failure as the shell reads it — the cause the runtime
|
|
7481
|
+
// decided, or, behind the retained-effect fence where the leaf itself holds
|
|
7482
|
+
// no error, the incomplete receipt the frozen evidence names.
|
|
7483
|
+
const leafFailureCause = (
|
|
7484
|
+
view: PlaybookControlView | undefined,
|
|
7485
|
+
fenced: boolean,
|
|
7486
|
+
unresolvedEffects: readonly PlaybookCaptainUnresolvedEffect[],
|
|
7487
|
+
): PlaybookFailureCause | undefined => {
|
|
7488
|
+
const recorded = view?.lastError ?? lastError;
|
|
7489
|
+
if (recorded?.cause !== undefined) {
|
|
7490
|
+
try {
|
|
7491
|
+
return assertPlaybookFailureCause(recorded.cause);
|
|
7492
|
+
} catch {
|
|
7493
|
+
// A cause the closed validator refuses is not a cause.
|
|
7494
|
+
}
|
|
7495
|
+
}
|
|
7496
|
+
if (fenced) {
|
|
7497
|
+
const incomplete = unresolvedEffects.find(
|
|
7498
|
+
({ classification }) => classification === 'incomplete',
|
|
7499
|
+
);
|
|
7500
|
+
if (incomplete !== undefined) {
|
|
7501
|
+
return assertPlaybookFailureCause({
|
|
7502
|
+
code: 'receipt-missing',
|
|
7503
|
+
evidence: { baselineHead: incomplete.baselineHead },
|
|
7504
|
+
});
|
|
7505
|
+
}
|
|
7506
|
+
}
|
|
7507
|
+
// There is no failure without a cause: a parked failure whose error
|
|
7508
|
+
// carries none is a runtime defect named by that error.
|
|
7509
|
+
if (view?.state.stateId === 'failed' && recorded !== undefined) {
|
|
7510
|
+
return assertPlaybookFailureCause({
|
|
7511
|
+
code: 'runtime-defect',
|
|
7512
|
+
evidence: {
|
|
7513
|
+
reason:
|
|
7514
|
+
recorded.message.trim().length > 0
|
|
7515
|
+
? recorded.message
|
|
7516
|
+
: recorded.name,
|
|
7517
|
+
},
|
|
7518
|
+
});
|
|
7519
|
+
}
|
|
7520
|
+
return undefined;
|
|
7521
|
+
};
|
|
7522
|
+
|
|
7523
|
+
const failureControlLines = (
|
|
7524
|
+
view: PlaybookControlView | undefined,
|
|
7525
|
+
fenced: boolean,
|
|
7526
|
+
): readonly string[] => {
|
|
7527
|
+
const runtimeActions = (view?.actions ?? []).filter(
|
|
7528
|
+
(action) =>
|
|
7529
|
+
!fenced ||
|
|
7530
|
+
action.id === UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID ||
|
|
7531
|
+
action.id === UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID,
|
|
7532
|
+
);
|
|
7533
|
+
const root = rootFrame();
|
|
7534
|
+
return [
|
|
7535
|
+
...runtimeActions.map((action) => failureControlLine(action)),
|
|
7536
|
+
// The shell's own control is published beside the leaf's, so the report
|
|
7537
|
+
// names every door out of this failure (CAPTAIN-62).
|
|
7538
|
+
...(root === undefined
|
|
7539
|
+
? []
|
|
7540
|
+
: [
|
|
7541
|
+
failureControlLine({
|
|
7542
|
+
label: `Stop ${frameLabel(root)}`,
|
|
7543
|
+
standing: 'ready',
|
|
7544
|
+
}),
|
|
7545
|
+
]),
|
|
7546
|
+
];
|
|
7547
|
+
};
|
|
7548
|
+
|
|
7549
|
+
const describeNestedPlaybook = (playbookId: string): string => {
|
|
7550
|
+
const enablement = enablementById.get(playbookId);
|
|
7551
|
+
return enablement === undefined ? playbookId : `/${enablement.command}`;
|
|
7552
|
+
};
|
|
7553
|
+
|
|
7554
|
+
// DR-063 §4: appended once, beside the unresolved-effect and pre-existing
|
|
7555
|
+
// change reports, whenever the turn settles with the leaf parked in its
|
|
7556
|
+
// failure state or behind the retained-effect fence.
|
|
7557
|
+
const reportLeafFailure = (
|
|
7558
|
+
turn: ActiveTurn,
|
|
7559
|
+
unresolvedEffects: readonly PlaybookCaptainUnresolvedEffect[],
|
|
7560
|
+
): void => {
|
|
7561
|
+
if (turn.failureReported) return;
|
|
7562
|
+
const leaf = leafFrame();
|
|
7563
|
+
if (!leaf) return;
|
|
7564
|
+
const fenced = retainedEffectReconciliation !== undefined;
|
|
7565
|
+
let view: PlaybookControlView | undefined;
|
|
7566
|
+
if (typeof leaf.runtime.describe === 'function') {
|
|
7567
|
+
try {
|
|
7568
|
+
view = leaf.runtime.describe();
|
|
7569
|
+
} catch {
|
|
7570
|
+
// An unreadable control view leaves the mirrored error as evidence.
|
|
7571
|
+
}
|
|
7572
|
+
}
|
|
7573
|
+
if (!fenced && view?.state.stateId !== 'failed') return;
|
|
7574
|
+
const cause = leafFailureCause(view, fenced, unresolvedEffects);
|
|
7575
|
+
if (cause === undefined) return;
|
|
7576
|
+
turn.failureReported = true;
|
|
7577
|
+
const sentence = failureCauseSentence(cause, describeNestedPlaybook);
|
|
7578
|
+
appendMandatoryPresentationSuffix(
|
|
7579
|
+
turn,
|
|
7580
|
+
failureReportText(sentence, failureControlLines(view, fenced)),
|
|
7581
|
+
);
|
|
7582
|
+
// The result-phase prompt reads the turn's report, so the same fact joins
|
|
7583
|
+
// it here rather than only the turn's running list.
|
|
7584
|
+
const fact = `The workflow failed: ${sentence}`;
|
|
7585
|
+
turn.settlementFacts.push(fact);
|
|
7586
|
+
if (turn.report !== undefined) {
|
|
7587
|
+
turn.report = {
|
|
7588
|
+
...turn.report,
|
|
7589
|
+
facts: [...turn.report.facts, fact],
|
|
7590
|
+
...(turn.report.bossFacts === undefined
|
|
7591
|
+
? {}
|
|
7592
|
+
: { bossFacts: [...turn.report.bossFacts, fact] }),
|
|
7593
|
+
};
|
|
7594
|
+
}
|
|
7595
|
+
};
|
|
7596
|
+
|
|
7179
7597
|
const settleSelection = async (
|
|
7180
7598
|
selection: CaptainControllerSelection,
|
|
7181
7599
|
signal: AbortSignal,
|
|
@@ -7192,6 +7610,10 @@ export function createPlaybookCaptainShell(
|
|
|
7192
7610
|
if (turn !== undefined && report !== undefined) {
|
|
7193
7611
|
appendMandatoryPresentationSuffix(turn, report);
|
|
7194
7612
|
}
|
|
7613
|
+
if (turn !== undefined) reportCarriedPreExistingChanges(turn);
|
|
7614
|
+
if (turn !== undefined) {
|
|
7615
|
+
reportLeafFailure(turn, frozenUnresolvedEffects);
|
|
7616
|
+
}
|
|
7195
7617
|
return frozenUnresolvedEffects;
|
|
7196
7618
|
};
|
|
7197
7619
|
const finalizeSettlement = (
|
|
@@ -7368,7 +7790,8 @@ export function createPlaybookCaptainShell(
|
|
|
7368
7790
|
fencedLeaf !== undefined;
|
|
7369
7791
|
if (
|
|
7370
7792
|
retainedEffectReconciliation !== undefined &&
|
|
7371
|
-
!routesRetainedReconciliation
|
|
7793
|
+
!routesRetainedReconciliation &&
|
|
7794
|
+
!(selection.action === 'dismiss' && turn.hostGiveUp === true)
|
|
7372
7795
|
) {
|
|
7373
7796
|
return rejectSelection(
|
|
7374
7797
|
selection,
|
|
@@ -7726,7 +8149,6 @@ export function createPlaybookCaptainShell(
|
|
|
7726
8149
|
try {
|
|
7727
8150
|
await settleUnresolvedEffectAbandonment(leaf);
|
|
7728
8151
|
} catch (error) {
|
|
7729
|
-
abandonmentSettlementUnsafe = true;
|
|
7730
8152
|
const normalized = normalizeErrorCompact(error) ?? {
|
|
7731
8153
|
name: 'Error',
|
|
7732
8154
|
message: String(error),
|
|
@@ -7852,8 +8274,13 @@ export function createPlaybookCaptainShell(
|
|
|
7852
8274
|
const giveUpReplyText = (): string => {
|
|
7853
8275
|
const report = activeTurn?.report;
|
|
7854
8276
|
const facts = report?.bossFacts ?? report?.facts ?? [];
|
|
7855
|
-
const
|
|
7856
|
-
const
|
|
8277
|
+
const stopped = report?.status === 'ok';
|
|
8278
|
+
const opening = stopped
|
|
8279
|
+
? 'Stopped that workflow. I will not resume it.'
|
|
8280
|
+
: 'I could not confirm that the workflow was stopped.';
|
|
8281
|
+
const closing = stopped
|
|
8282
|
+
? 'Send the command again to start fresh work.'
|
|
8283
|
+
: 'The stop did not settle successfully; recover the session before continuing.';
|
|
7857
8284
|
const composed = [
|
|
7858
8285
|
opening,
|
|
7859
8286
|
...(facts.length === 0
|
|
@@ -8997,12 +9424,25 @@ export function createPlaybookCaptainShell(
|
|
|
8997
9424
|
const hostGiveUp = decided?.kind === 'give-up' ? true : undefined;
|
|
8998
9425
|
const parsed =
|
|
8999
9426
|
decided === undefined ? resolveCommandTurn(turn.prompt) : undefined;
|
|
9427
|
+
// DR-062 §5: the boundary count this turn starts from. Every boundary
|
|
9428
|
+
// beyond it completed during this turn.
|
|
9429
|
+
let effectBoundaryPrefixCount: number | undefined;
|
|
9430
|
+
try {
|
|
9431
|
+
effectBoundaryPrefixCount = assertPlaybookEffectLedger(
|
|
9432
|
+
currentEffectLedger(),
|
|
9433
|
+
).boundaries.length;
|
|
9434
|
+
} catch {
|
|
9435
|
+
effectBoundaryPrefixCount = undefined;
|
|
9436
|
+
}
|
|
9000
9437
|
activeTurn = {
|
|
9001
9438
|
id: ++turnSequence,
|
|
9002
9439
|
captainSyncedJournalSeq: journalSeq,
|
|
9003
9440
|
bossText: turn.prompt,
|
|
9004
9441
|
authoritativeText: parsed?.authoritativeText ?? turn.prompt,
|
|
9005
9442
|
...(parsed ? { resolution: parsed.resolution } : {}),
|
|
9443
|
+
...(effectBoundaryPrefixCount === undefined
|
|
9444
|
+
? {}
|
|
9445
|
+
: { effectBoundaryPrefixCount }),
|
|
9006
9446
|
...(hostRuntimeActionId === undefined ? {} : { hostRuntimeActionId }),
|
|
9007
9447
|
...(hostGiveUp === undefined ? {} : { hostGiveUp }),
|
|
9008
9448
|
settled: false,
|