@sublang/playbook 14.0.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.
Files changed (34) hide show
  1. package/docs/cli.md +14 -14
  2. package/package.json +1 -1
  3. package/reference/sdlc/branch.playbook/branch.fsm.d.ts +1 -1
  4. package/reference/sdlc/code.playbook/bin/repository-effects.js +187 -14
  5. package/reference/sdlc/code.playbook/code.fsm.d.ts +1 -1
  6. package/reference/sdlc/code.playbook/host-capabilities.d.ts +6 -0
  7. package/reference/sdlc/code.playbook/playbook-captain.d.ts +3 -1
  8. package/reference/sdlc/code.playbook/playbook-captain.js +357 -16
  9. package/reference/sdlc/code.playbook/playbook-captain.ts +459 -34
  10. package/reference/sdlc/code.playbook/session-host.d.ts +6 -3
  11. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +1 -1
  12. package/reference/sdlc/decide.playbook/decide.playbook.js +25 -1
  13. package/reference/sdlc/decide.playbook/decide.playbook.ts +35 -2
  14. package/reference/sdlc/dev.md +15 -8
  15. package/reference/sdlc/dev.playbook/dev.fsm.d.ts +1 -1
  16. package/reference/sdlc/dev.playbook/dev.fsm.js +18 -12
  17. package/reference/sdlc/dev.playbook/dev.fsm.ts +18 -13
  18. package/reference/sdlc/dev.playbook/dev.gears.md +19 -12
  19. package/reference/sdlc/dev.playbook/dev.playbook.js +1 -1
  20. package/reference/sdlc/dev.playbook/dev.playbook.ts +1 -2
  21. package/reference/sdlc/dev.playbook/dev.registry.js +1 -1
  22. package/reference/sdlc/dev.playbook/dev.registry.ts +1 -1
  23. package/reference/sdlc/pr.playbook/pr.fsm.d.ts +1 -1
  24. package/reference/sdlc/review.playbook/review.fsm.d.ts +1 -1
  25. package/slc/link.md +32 -0
  26. package/src/runtime.d.ts +70 -0
  27. package/src/runtime.js +232 -1
  28. package/src/runtime.ts +358 -1
  29. package/src/xstate-playbook-runtime.d.ts +9 -10
  30. package/src/xstate-playbook-runtime.js +372 -20
  31. package/src/xstate-playbook-runtime.ts +460 -19
  32. package/src/xstate-runtime.d.ts +9 -1
  33. package/src/xstate-runtime.js +299 -19
  34. package/src/xstate-runtime.ts +362 -21
package/docs/cli.md CHANGED
@@ -124,20 +124,20 @@ untouched
124
124
  The current CODE, REVIEW, DECIDE, DEV, BRANCH, and PR workflows take their
125
125
  deterministic initial event from the selecting Boss turn. CODE and DECIDE then
126
126
  call REVIEW as a nested playbook, while DEV — the repository-aware planner
127
- behind `/dev` — analyzes a development request and itself calls CODE, or
128
- DECIDE and then CODE, as nested playbooks. When the request names a GitHub
129
- issue or asks for pull-request delivery, DEV reads the issue and its comments
130
- while planning, calls BRANCH before that path and PR after CODE succeeds, and
131
- ends with a merged pull request and a closed issue — or with a named failure
132
- that leaves the pull request open for a later `/pr`. Both are usable on their
133
- own: `/branch <issue or request>` creates and checks out `issue-N-slug` (or a
134
- request slug) at the current commit, changing nothing else; `/pr` pushes the
135
- checked-out branch, opens or reuses its pull request against the default
136
- branch, waits for the checks, fixes red checks through one nested CODE call,
137
- merges with a merge commit, and fast-forwards the local default branch. A
138
- repository without checks merges on CODE's nested review alone. BRANCH, PR,
139
- and issue-aware DEV planning require the GitHub CLI `gh`, authenticated for
140
- the repository's GitHub remote
127
+ behind `/dev` — chooses the development path for a request and itself calls
128
+ CODE, or DECIDE and then CODE, as nested playbooks. When the request names a
129
+ GitHub issue or asks for pull-request delivery, DEV reads the issue and its
130
+ comments while planning, calls BRANCH before that path and PR after CODE
131
+ succeeds, and ends with a merged pull request and a closed issue — or with a
132
+ named failure that leaves the pull request open for a later `/pr`. Both are
133
+ usable on their own: `/branch <issue or request>` creates and checks out
134
+ `issue-N-slug` (or a request slug) at the current commit, changing nothing
135
+ else; `/pr` pushes the checked-out branch, opens or reuses its pull request
136
+ against the default branch, waits for the checks, fixes red checks through one
137
+ nested CODE call, merges with a merge commit, and fast-forwards the local
138
+ default branch. A repository without checks merges on CODE's nested review
139
+ alone. BRANCH, PR, and issue-aware DEV planning require the GitHub CLI `gh`,
140
+ authenticated for the repository's GitHub remote
141
141
  ([DR-050](https://github.com/sublang-ai/playbook/blob/main/specs/decisions/050-pull-request-delivery.md)).
142
142
  Local role names do not imply continuity: each frame
143
143
  uses the exact stable player IDs configured under its `roles` map. Equal IDs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sublang/playbook",
3
- "version": "14.0.0",
3
+ "version": "14.1.0",
4
4
  "type": "module",
5
5
  "description": "Composable XState v5 playbook runtime with compiled Captain, CODE, REVIEW, DECIDE, DEV, BRANCH, and PR workflows driven by GEARS specs.",
6
6
  "license": "Apache-2.0",
@@ -131,7 +131,7 @@ export declare const branchMachine: import("xstate").StateMachine<BranchContext,
131
131
  } | {
132
132
  type: "resumesCreateBranch";
133
133
  params: unknown;
134
- }, never, "failed" | "awaitBossReply" | "ready" | "refused" | "createBranch" | "branched", string, Readonly<Record<string, never>>, {
134
+ }, never, "failed" | "ready" | "awaitBossReply" | "refused" | "createBranch" | "branched", string, Readonly<Record<string, never>>, {
135
135
  readonly status: "branched";
136
136
  /** Exact name of the new branch now checked out. */
137
137
  readonly branch: string;
@@ -520,14 +520,169 @@ function projectionText(projection) {
520
520
  return JSON.stringify(projection);
521
521
  }
522
522
 
523
- function projectionPreservesBaseline(baseline, after) {
524
- return Object.entries(baseline).every(
525
- ([path, entry]) =>
526
- Object.prototype.hasOwnProperty.call(after, path) &&
527
- JSON.stringify(after[path]) === JSON.stringify(entry),
523
+ const MISSING_CONTENT_IDENTITY = Object.freeze({ kind: 'missing' });
524
+
525
+ function hasPath(projection, path) {
526
+ return Object.prototype.hasOwnProperty.call(projection, path);
527
+ }
528
+
529
+ // DR-062 §1: a rename, an unmerged path, a submodule, or a nested worktree is
530
+ // preserved when byte-equal and otherwise lost, because no content matching is
531
+ // attempted for them.
532
+ function nonComparableBaselineEntry(entry) {
533
+ return (
534
+ entry.kind === 'rename-or-copy' ||
535
+ entry.kind === 'unmerged' ||
536
+ (entry.submodule !== undefined && entry.submodule !== 'N...') ||
537
+ entry.worktree?.kind === 'directory'
528
538
  );
529
539
  }
530
540
 
541
+ // The entry the baseline HEAD tree held for a path: an ordinary porcelain-v2
542
+ // record carries its HEAD mode and OID, `000000` means the path did not exist
543
+ // there, an untracked path never did, and an unborn baseline HEAD holds none.
544
+ function baselineHeadEntry(baseline, entry) {
545
+ if (baseline.head === NULL_GIT_OID) return undefined;
546
+ if (entry.kind !== 'ordinary') return undefined;
547
+ if (entry.headMode === undefined || entry.headMode === '000000') {
548
+ return undefined;
549
+ }
550
+ return { mode: entry.headMode, oid: entry.headOid };
551
+ }
552
+
553
+ function sameTreeEntry(left, right) {
554
+ if (left === undefined || right === undefined) return left === right;
555
+ return left.mode === right.mode && left.oid === right.oid;
556
+ }
557
+
558
+ // The baseline recorded nothing at the path: a deleted worktree entry, or a
559
+ // staged deletion whose index holds no object.
560
+ function baselineEntryIsDeletion(entry) {
561
+ return entry.worktree === undefined
562
+ ? entry.indexMode === '000000'
563
+ : entry.worktree.kind === 'missing';
564
+ }
565
+
566
+ // The entry one commit's tree holds for an exact path, or undefined when that
567
+ // tree holds none.
568
+ async function afterTreeEntry(worktree, head, path) {
569
+ const raw = await runGitText(worktree, [
570
+ 'ls-tree',
571
+ '-z',
572
+ '--full-tree',
573
+ head,
574
+ '--',
575
+ path,
576
+ ]);
577
+ for (const record of raw.split('\0')) {
578
+ if (record.length === 0) continue;
579
+ const tab = record.indexOf('\t');
580
+ const fields = tab < 0 ? [] : record.slice(0, tab).split(' ');
581
+ if (fields.length !== 3) {
582
+ throw new RepositoryObservationAmbiguousError(
583
+ 'Git returned a malformed tree record',
584
+ );
585
+ }
586
+ if (record.slice(tab + 1) !== path) continue;
587
+ return { mode: fields[0], oid: fields[2] };
588
+ }
589
+ return undefined;
590
+ }
591
+
592
+ // A blob addressed exactly as `worktreePathIdentity` addresses the same bytes
593
+ // in the working tree, so a committed object and the Boss's own content are
594
+ // comparable by the projection's content addressing rather than by Git OID.
595
+ async function blobContentIdentity(worktree, mode, oid) {
596
+ const bytes = await runGit(worktree, ['cat-file', 'blob', oid]);
597
+ return {
598
+ kind: mode === '120000' ? 'symlink' : 'file',
599
+ mode,
600
+ content: `sha256:${sha256(bytes)}`,
601
+ };
602
+ }
603
+
604
+ function baselineContentIdentity(worktree, entry) {
605
+ return entry.worktree === undefined
606
+ ? blobContentIdentity(worktree, entry.indexMode, entry.indexOid)
607
+ : entry.worktree;
608
+ }
609
+
610
+ function sameContentIdentity(left, right) {
611
+ return (
612
+ left.kind === right.kind &&
613
+ left.mode === right.mode &&
614
+ left.content === right.content
615
+ );
616
+ }
617
+
618
+ // DR-062 §1: the fate of one baseline projection entry at the end of a
619
+ // governed call — `preserved`, `altered-uncommitted`, `altered-committed`,
620
+ // `absorbed`, or `lost`.
621
+ async function baselineEntryFate(baseline, after, path, entry) {
622
+ if (hasPath(after.projection, path)) {
623
+ return JSON.stringify(after.projection[path]) === JSON.stringify(entry)
624
+ ? 'preserved'
625
+ : 'altered-uncommitted';
626
+ }
627
+ // The entry left the working tree, so only a commit this call made can hold
628
+ // the Boss's content; a call that moved no HEAD made none.
629
+ if (baseline.head === after.head) return 'lost';
630
+ if (nonComparableBaselineEntry(entry)) return 'lost';
631
+ try {
632
+ const treeEntry = await afterTreeEntry(after.worktree, after.head, path);
633
+ if (sameTreeEntry(treeEntry, baselineHeadEntry(baseline, entry))) {
634
+ // The commit left the path exactly as the baseline HEAD held it, so it
635
+ // carried no change of the Boss's and his content is nowhere.
636
+ return 'lost';
637
+ }
638
+ if (baselineEntryIsDeletion(entry)) {
639
+ return treeEntry === undefined ? 'absorbed' : 'lost';
640
+ }
641
+ if (treeEntry?.mode === '160000') return 'lost';
642
+ const [baselineIdentity, treeIdentity] = await Promise.all([
643
+ baselineContentIdentity(after.worktree, entry),
644
+ treeEntry === undefined
645
+ ? MISSING_CONTENT_IDENTITY
646
+ : blobContentIdentity(after.worktree, treeEntry.mode, treeEntry.oid),
647
+ ]);
648
+ return sameContentIdentity(baselineIdentity, treeIdentity)
649
+ ? 'absorbed'
650
+ : 'altered-committed';
651
+ } catch {
652
+ // Content the after HEAD cannot produce leaves the baseline entry
653
+ // unaccounted for, which fails closed exactly as a lost entry does.
654
+ return 'lost';
655
+ }
656
+ }
657
+
658
+ async function baselineEntryFates(baseline, after) {
659
+ const fates = [];
660
+ for (const [path, entry] of Object.entries(baseline.projection)) {
661
+ fates.push([path, await baselineEntryFate(baseline, after, path, entry)]);
662
+ }
663
+ return fates;
664
+ }
665
+
666
+ // DR-062 §3: the accounting a receipt records, present exactly when one list
667
+ // is nonempty so that an all-preserved call keeps today's receipt shape.
668
+ function preExistingFromFates(fates) {
669
+ const absorbed = [];
670
+ const altered = [];
671
+ const lost = [];
672
+ for (const [path, fate] of fates) {
673
+ if (fate === 'absorbed') absorbed.push(path);
674
+ else if (fate === 'altered-uncommitted' || fate === 'altered-committed') {
675
+ altered.push(path);
676
+ } else if (fate === 'lost') lost.push(path);
677
+ }
678
+ if (absorbed.length + altered.length + lost.length === 0) return undefined;
679
+ return {
680
+ absorbed: absorbed.sort(),
681
+ altered: altered.sort(),
682
+ lost: lost.sort(),
683
+ };
684
+ }
685
+
531
686
  async function rawRepositoryStatus(worktree, env) {
532
687
  return runGit(
533
688
  worktree,
@@ -788,12 +943,13 @@ async function descendantCount(worktree, baselineHead, afterHead) {
788
943
  return count;
789
944
  }
790
945
 
791
- function receipt(classification, baseline, after, commitOid) {
946
+ function receipt(classification, baseline, after, commitOid, preExisting) {
792
947
  return deepFreeze({
793
948
  classification,
794
949
  baseline,
795
950
  ...(after === undefined ? {} : { after }),
796
951
  ...(commitOid === undefined ? {} : { commitOid }),
952
+ ...(preExisting === undefined ? {} : { preExisting }),
797
953
  });
798
954
  }
799
955
 
@@ -821,16 +977,19 @@ export async function classifyRepositoryReceipt(
821
977
  if (allowed.every((value) => value === 'unchanged')) {
822
978
  return receipt('concurrent-or-foreign-change', baseline, after);
823
979
  }
980
+ // DR-062 §2: a same-HEAD delta admits altered pre-existing entries beside
981
+ // new ones; only an entry whose change is nowhere keeps it ambiguous.
824
982
  if (sameHead) {
825
- if (!projectionPreservesBaseline(baseline.projection, after.projection)) {
826
- return receipt('observation-ambiguous', baseline, after);
827
- }
983
+ const fates = await baselineEntryFates(baseline, after);
984
+ const lost = fates.some(([, fate]) => fate === 'lost');
828
985
  return receipt(
829
- allowed.includes('one-descendant-commit')
986
+ !lost && allowed.includes('one-descendant-commit')
830
987
  ? 'worktree-only-change'
831
988
  : 'observation-ambiguous',
832
989
  baseline,
833
990
  after,
991
+ undefined,
992
+ preExistingFromFates(fates),
834
993
  );
835
994
  }
836
995
  // A HEAD that stopped naming a commit lost its history; every commit
@@ -847,10 +1006,24 @@ export async function classifyRepositoryReceipt(
847
1006
  if (count !== 1) {
848
1007
  return receipt('rewritten-or-non-descendant', baseline, after);
849
1008
  }
850
- if (!sameProjection) {
851
- return receipt('observation-ambiguous', baseline, after);
852
- }
853
- return receipt('one-descendant-commit', baseline, after, after.head);
1009
+ // DR-062 §2: the one commit proves itself when every baseline entry is
1010
+ // preserved, absorbed, or altered and carried by it, and the after
1011
+ // projection holds nothing else.
1012
+ const fates = await baselineEntryFates(baseline, after);
1013
+ const unattributable =
1014
+ fates.some(
1015
+ ([, fate]) => fate === 'lost' || fate === 'altered-uncommitted',
1016
+ ) ||
1017
+ Object.keys(after.projection).some(
1018
+ (path) => !hasPath(baseline.projection, path),
1019
+ );
1020
+ return receipt(
1021
+ unattributable ? 'observation-ambiguous' : 'one-descendant-commit',
1022
+ baseline,
1023
+ after,
1024
+ unattributable ? undefined : after.head,
1025
+ preExistingFromFates(fates),
1026
+ );
854
1027
  }
855
1028
 
856
1029
  export async function captureRepositoryReceipt(baseline, options = {}) {
@@ -212,7 +212,7 @@ export declare const codingMachine: import("xstate").StateMachine<CodingContext,
212
212
  } | {
213
213
  type: "resumesIrTask";
214
214
  params: unknown;
215
- }, never, "done" | "failed" | "awaitBossReply" | "ready" | "runFirstPhase" | "reviewFirstCommit" | "runIrTask" | "reviewIrTask" | "reportedReviewFailure", string, CodingInput, {
215
+ }, never, "done" | "failed" | "ready" | "awaitBossReply" | "runFirstPhase" | "reviewFirstCommit" | "runIrTask" | "reviewIrTask" | "reportedReviewFailure", string, CodingInput, {
216
216
  readonly status: "complete";
217
217
  /** Exact identity of the last CODE-owned commit. */
218
218
  readonly lastCodeCommit: string;
@@ -32,11 +32,17 @@ export interface PlaybookRepositoryObservation extends RepositoryIdentity {
32
32
  readonly projection: Readonly<Record<string, JsonValue>>;
33
33
  readonly projectionDigest: string;
34
34
  }
35
+ export interface PlaybookRepositoryPreExistingChanges {
36
+ readonly absorbed: readonly string[];
37
+ readonly altered: readonly string[];
38
+ readonly lost: readonly string[];
39
+ }
35
40
  export interface PlaybookRepositoryReceipt {
36
41
  readonly classification: RepositoryReceiptClassification;
37
42
  readonly baseline: PlaybookRepositoryObservation;
38
43
  readonly after?: PlaybookRepositoryObservation;
39
44
  readonly commitOid?: string;
45
+ readonly preExisting?: PlaybookRepositoryPreExistingChanges;
40
46
  }
41
47
  export interface RepositoryReceiptOptions {
42
48
  readonly allowedDispositions: readonly PlaybookRepositoryDisposition[];
@@ -1,6 +1,6 @@
1
1
  import { type Captain, type CaptainSession, type TuningSelection } from '@sublang/cligent/tmux-play';
2
2
  import type { Effort, PermissionPolicy } from '@sublang/cligent';
3
- import type { JsonValue, PlaybookEffectLedger, PlaybookEffectLedgerCommandBatch, PlaybookControlAction, PlaybookRuntime, PlaybookRuntimeSnapshot } from '@sublang/playbook/runtime';
3
+ import type { JsonValue, PlaybookEffectLedger, PlaybookEffectLedgerCommandBatch, PlaybookControlAction, PlaybookFailureCause, PlaybookRuntime, PlaybookRuntimeSnapshot } from '@sublang/playbook/runtime';
4
4
  import { type CaptainControllerPort } from '../captain.playbook/captain.playbook.js';
5
5
  import type { PlaybookSummaryPolicy } from './code.registry.js';
6
6
  interface SessionAgent {
@@ -176,9 +176,11 @@ type PlaybookCaptainShellSnapshotValue = PlaybookCaptainShellSnapshotFields & ({
176
176
  readonly frames: readonly PlaybookCaptainFrameSnapshot[];
177
177
  readonly retainedEffectReconciliation?: PlaybookCaptainRetainedEffectReconciliation;
178
178
  readonly pendingBossQuestions?: JsonValue;
179
+ /** DR-063 §2: the parked failure's cause travels with its error. */
179
180
  readonly lastError?: {
180
181
  readonly name: string;
181
182
  readonly message: string;
183
+ readonly cause?: PlaybookFailureCause;
182
184
  };
183
185
  });
184
186
  export type PlaybookCaptainShellSnapshot = DeepReadonly<PlaybookCaptainShellSnapshotValue>;