@cadraos/sdk 0.3.12 → 0.3.14-p102.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 (39) hide show
  1. package/dist/board-host/index.cjs +217 -129
  2. package/dist/board-host/index.cjs.map +1 -1
  3. package/dist/board-host/index.d.cts +268 -3
  4. package/dist/board-host/index.d.ts +268 -3
  5. package/dist/board-host/index.js +218 -131
  6. package/dist/board-host/index.js.map +1 -1
  7. package/dist/board-host/server/index.d.cts +218 -2
  8. package/dist/board-host/server/index.d.ts +218 -2
  9. package/dist/chat/index.cjs +528 -47
  10. package/dist/chat/index.cjs.map +1 -1
  11. package/dist/chat/index.d.cts +29 -0
  12. package/dist/chat/index.d.ts +29 -0
  13. package/dist/chat/index.js +528 -47
  14. package/dist/chat/index.js.map +1 -1
  15. package/dist/index.cjs +453 -38
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.d.cts +81 -1
  18. package/dist/index.d.ts +81 -1
  19. package/dist/index.js +453 -38
  20. package/dist/index.js.map +1 -1
  21. package/dist/kanban/engine/index.cjs +1535 -434
  22. package/dist/kanban/engine/index.cjs.map +1 -1
  23. package/dist/kanban/engine/index.d.cts +628 -6
  24. package/dist/kanban/engine/index.d.ts +628 -6
  25. package/dist/kanban/engine/index.js +1520 -435
  26. package/dist/kanban/engine/index.js.map +1 -1
  27. package/dist/kanban/index.cjs +751 -60
  28. package/dist/kanban/index.cjs.map +1 -1
  29. package/dist/kanban/index.d.cts +555 -9
  30. package/dist/kanban/index.d.ts +555 -9
  31. package/dist/kanban/index.js +747 -61
  32. package/dist/kanban/index.js.map +1 -1
  33. package/dist/teams/index.cjs +453 -38
  34. package/dist/teams/index.cjs.map +1 -1
  35. package/dist/teams/index.d.cts +27 -0
  36. package/dist/teams/index.d.ts +27 -0
  37. package/dist/teams/index.js +453 -38
  38. package/dist/teams/index.js.map +1 -1
  39. package/package.json +1 -1
@@ -443,7 +443,17 @@ interface KanbanLane {
443
443
  * a tick fans out ⌈cap / chunkSize⌉ runs. Ignored for `per-card`/`event` lanes.
444
444
  */
445
445
  chunkSize?: number;
446
+ /**
447
+ * p46 Handoff contract, OPT-IN per lane (D1). Absent ⇒ `off` (existing boards
448
+ * are byte-identical). `verdict` ⇒ the engine sends
449
+ * `HANDOFF_VERDICT_JSON_SCHEMA` as the run's `context.outputSchema` and a
450
+ * completion MUST carry a valid verdict (else the run fails with a FAILED
451
+ * handoff). Active only on hosts whose adapter implements the ledger group.
452
+ */
453
+ handoff?: KanbanLaneHandoffMode;
446
454
  }
455
+ /** Per-lane Handoff mode (p46 D1). */
456
+ type KanbanLaneHandoffMode = "off" | "verdict";
447
457
  /**
448
458
  * A declared edge between two lanes in the lifecycle.
449
459
  *
@@ -539,6 +549,11 @@ interface KanbanCard {
539
549
  configVersion?: number;
540
550
  /** Arbitrary shared context carried across transitions. */
541
551
  sharedContext?: Record<string, unknown>;
552
+ /**
553
+ * p46 (P46-002): the card's id in an external host (a Jira issue key).
554
+ * Absent on native / crm / yobo cards.
555
+ */
556
+ externalRef?: string;
542
557
  /**
543
558
  * Execution UUID of the card's current/last run, if any.
544
559
  *
@@ -601,6 +616,18 @@ interface CreateCardInput {
601
616
  * in the lifecycle's initial lane.
602
617
  */
603
618
  lane?: string;
619
+ /**
620
+ * p46 (P46-002): the card's id in an EXTERNAL host (a Jira issue key, e.g.
621
+ * `CAD-231`). Optional. When set, a card is created at most ONCE per
622
+ * `(boardConfigUuid, externalRef)`: a repeat create returns the existing card
623
+ * and dispatches nothing. Hosts that don't store it simply ignore it.
624
+ */
625
+ externalRef?: string;
626
+ /**
627
+ * p46 (P46-002): opaque host facts stored with the card (Jira `statusId`,
628
+ * `mover`, `detached`, …). Passed through to `adapter.createCard` untouched.
629
+ */
630
+ externalMeta?: Record<string, unknown>;
604
631
  }
605
632
  /**
606
633
  * Who performed a transition / stage-run (server-engine side).
@@ -718,7 +745,125 @@ interface ApplyTransitionInput {
718
745
  contextPatch?: Record<string, unknown>;
719
746
  /** Execution UUID to stamp on the card (handler advances). */
720
747
  executionUuid?: string;
748
+ /**
749
+ * p46 (P46-002, F8): set ONLY by `engine.hostMove` (a move the external host
750
+ * made — e.g. a Jira drag mirrored by the sync). A host-aware adapter, inside
751
+ * the same card lock as the `fromLane` CAS, (1) refuses the move with a
752
+ * `HostFenceError` while the card has any `host.*` op `pending` (D19 — never
753
+ * mistake our own half-done move for a human one), (2) writes `externalMeta`,
754
+ * then (3) moves. Adapters that don't know it ignore it (optional, additive).
755
+ */
756
+ hostObservation?: {
757
+ externalMeta?: Record<string, unknown>;
758
+ /**
759
+ * ISO time the host state was READ. The fence also refuses the move when a
760
+ * `host.*` op for the card settled (done/failed) after this instant — the
761
+ * read may predate our own write landing in the host.
762
+ */
763
+ observedAt?: string;
764
+ };
765
+ }
766
+
767
+ /**
768
+ * Handoff v1 — the one AI↔human contract every board lane speaks (p46 §0.3).
769
+ *
770
+ * A Handoff is the record written whenever work crosses between an agent and a
771
+ * person on a card: an agent's verdict ("decide A/B/C", "approve", "test this"),
772
+ * an engine-written FAILED / BLOCKED, or the human's answer carried forward to
773
+ * the next agent (`human_to_ai`). Every worker type produces the same object and
774
+ * every host reads it.
775
+ *
776
+ * This module is DEPENDENCY-FREE and client-safe (types, the verdict JSON
777
+ * schema, the prompt renderer, the link guard). The ajv-backed verdict PARSER
778
+ * lives in `./handoff-parse.ts` and is exported only from the server engine
779
+ * entry (`@cadraos/sdk/kanban/engine`), so the browser bundle never carries ajv.
780
+ *
781
+ * @module @cadraos/sdk/kanban
782
+ */
783
+ type HandoffKind = "DECIDE" | "APPROVE" | "TEST" | "REVIEW" | "FAILED" | "BLOCKED" | "FYI";
784
+ interface HandoffOption {
785
+ /** Single capital letter, `^[A-Z]$`. */
786
+ id: string;
787
+ /** ≤80 chars. */
788
+ label: string;
789
+ /** ≤300 chars. */
790
+ consequence: string;
791
+ }
792
+ interface HandoffDecision {
793
+ /** ≤500 chars. */
794
+ question: string;
795
+ /** 2–4 options. */
796
+ options: HandoffOption[];
797
+ /** ∈ options[].id */
798
+ recommended: string;
799
+ /** ∈ options[].id */
800
+ defaultIfSilent?: string;
801
+ }
802
+ interface HandoffState {
803
+ repos: string[];
804
+ tests: string | null;
805
+ story: string | null;
806
+ costUsd: number | null;
807
+ }
808
+ interface HandoffAnswer {
809
+ option: string | null;
810
+ /** ≤4000 chars. */
811
+ text: string | null;
812
+ by: string;
813
+ at: string;
814
+ via: "board" | "channel";
815
+ source: string;
816
+ }
817
+ interface HandoffV1 {
818
+ v: 1;
819
+ /** = agentic_handoff.uuid */
820
+ id: string;
821
+ /** Card uuid. */
822
+ cardId: string;
823
+ direction: "ai_to_human" | "human_to_ai";
824
+ /** Lane KEYS. */
825
+ fromLane: string;
826
+ toLane: string;
827
+ /** ISO-8601. */
828
+ at: string;
829
+ by: {
830
+ type: "session" | "user" | "watchdog" | "timeout" | "health";
831
+ id: string;
832
+ };
833
+ kind: HandoffKind;
834
+ /** ≤200 chars. */
835
+ headline: string;
836
+ /** ≤20 × ≤300 chars. */
837
+ done: string[];
838
+ state: HandoffState;
839
+ decision?: HandoffDecision;
840
+ /** ≤300 chars. */
841
+ howToAnswer: string;
842
+ /** ≤10; url must be http(s): or a same-origin path starting "/". */
843
+ links: {
844
+ label: string;
845
+ url: string;
846
+ }[];
847
+ session: {
848
+ id: string | null;
849
+ runnerId: string | null;
850
+ resumableUntil: string | null;
851
+ };
852
+ /** BLOCKED only. */
853
+ blocked?: {
854
+ check: string;
855
+ reason: string;
856
+ fixUrl: string;
857
+ };
858
+ answer?: HandoffAnswer;
721
859
  }
860
+ /** Ledger lifecycle of a stored handoff. */
861
+ type HandoffStatus = "open" | "answered" | "resolved" | "consumed";
862
+ /** A stored handoff: the v1 payload plus its ledger status. */
863
+ type HandoffRecord = HandoffV1 & {
864
+ status: HandoffStatus;
865
+ consumedAt: string | null;
866
+ };
722
867
 
723
868
  /**
724
869
  * KanbanStorageAdapter — the SERVER-ENGINE persistence contract.
@@ -801,6 +946,16 @@ interface KanbanStorageAdapter {
801
946
  * config and passes it as `initialLane`.
802
947
  */
803
948
  createCard(input: CreateCardServerInput): Promise<KanbanCard>;
949
+ /**
950
+ * p46 (P46-002) OPTIONAL: the card with this `externalRef` on this board, or
951
+ * null. When present, `engine.createCard({externalRef})` looks it up first and
952
+ * returns an existing card WITHOUT dispatching (exactly-once create per issue
953
+ * key). A host that stores `externalRef` SHOULD also make `createCard` itself
954
+ * idempotent on `(boardConfigUuid, externalRef)` (unique index + `ON CONFLICT
955
+ * DO NOTHING` → return the existing row) for the lookup/insert race. Hosts
956
+ * without external refs (crm/yobo) omit it.
957
+ */
958
+ getCardByExternalRef?(boardConfigUuid: string, externalRef: string): Promise<KanbanCard | null>;
804
959
  /**
805
960
  * Patch a card's mutable fields.
806
961
  */
@@ -917,7 +1072,7 @@ interface KanbanStorageAdapter {
917
1072
  toLane?: string;
918
1073
  contextPatch?: Record<string, unknown>;
919
1074
  actor: Actor;
920
- }): Promise<{
1075
+ } & StageRunLedgerWrite): Promise<{
921
1076
  applied: boolean;
922
1077
  }>;
923
1078
  /**
@@ -930,7 +1085,7 @@ interface KanbanStorageAdapter {
930
1085
  */
931
1086
  failStageRun(idemKey: string, f: {
932
1087
  error: string;
933
- }): Promise<void>;
1088
+ } & StageRunLedgerWrite): Promise<void>;
934
1089
  /**
935
1090
  * OPTIONAL: record that a stage run was dispatched — set status `running`
936
1091
  * and stamp the `executionUuid` (so the UI can stream the live run before
@@ -944,6 +1099,65 @@ interface KanbanStorageAdapter {
944
1099
  * Read a stage run by its idempotency key, or null if none.
945
1100
  */
946
1101
  getStageRun(idemKey: string): Promise<StageRun | null>;
1102
+ /** OPTIONAL (ledger): insert a handoff (adapter assigns `id`), or read the existing one on conflict. */
1103
+ appendHandoff?(h: {
1104
+ card: KanbanCard;
1105
+ stageId: string;
1106
+ configVersion: number;
1107
+ idemKey?: string;
1108
+ attempt?: number;
1109
+ dedupeKey?: string;
1110
+ payload: Omit<HandoffV1, "id">;
1111
+ }): Promise<{
1112
+ created: boolean;
1113
+ handoff: HandoffV1;
1114
+ }>;
1115
+ /** OPTIONAL (ledger): a card's handoffs, NEWEST FIRST. */
1116
+ listHandoffs?(cardId: string, opts?: {
1117
+ limit?: number;
1118
+ }): Promise<HandoffRecord[]>;
1119
+ /** OPTIONAL (ledger): CAS `open → answered` (ai_to_human, kind ∈ DECIDE|APPROVE|TEST|REVIEW). */
1120
+ answerHandoff?(cardId: string, handoffId: string, answer: NonNullable<HandoffV1["answer"]>, userId?: number): Promise<{
1121
+ applied: boolean;
1122
+ }>;
1123
+ /** OPTIONAL (ledger): CAS `open → resolved` (BLOCKED / FAILED). */
1124
+ resolveHandoff?(handoffId: string): Promise<{
1125
+ applied: boolean;
1126
+ }>;
1127
+ /** OPTIONAL (ledger): `human_to_ai` rows `open → consumed` by the dispatch `idemKey`. */
1128
+ consumeHandoffs?(ids: string[], idemKey: string): Promise<void>;
1129
+ /** OPTIONAL (outbox): insert-or-read an op; `run: false` iff it is already `done`. */
1130
+ claimOp?(op: {
1131
+ opId: string;
1132
+ kind: string;
1133
+ cardId?: string;
1134
+ handoffId?: string;
1135
+ payload: unknown;
1136
+ }): Promise<{
1137
+ run: boolean;
1138
+ }>;
1139
+ /** OPTIONAL (outbox): mark an op `done`. */
1140
+ completeOp?(opId: string): Promise<void>;
1141
+ /** OPTIONAL (outbox): mark an op `failed` with its error (replayable). */
1142
+ failOp?(opId: string, error: string): Promise<void>;
1143
+ }
1144
+ /**
1145
+ * The optional ledger write carried on `completeStageRun` / `failStageRun`
1146
+ * (p46 F3). Ignored by adapters without the ledger group.
1147
+ *
1148
+ * When present, the adapter writes — INSIDE THE SAME TRANSACTION as the run /
1149
+ * card update — the `agentic_handoff` row (using `payload.id` as its uuid;
1150
+ * `ON CONFLICT (dedupe_key) DO NOTHING`) and, only when that row was newly
1151
+ * inserted, a `pending` op row with id `notifyOpId`. If the run update is a
1152
+ * no-op (`completeStageRun` on an already-completed run; `failStageRun` on a
1153
+ * completed run) nothing is written. A throw anywhere rolls ALL of it back.
1154
+ */
1155
+ interface StageRunLedgerWrite {
1156
+ handoff?: {
1157
+ payload: HandoffV1;
1158
+ dedupeKey: string;
1159
+ };
1160
+ notifyOpId?: string;
947
1161
  }
948
1162
 
949
1163
  /**
@@ -1418,6 +1632,8 @@ interface WireStage {
1418
1632
  */
1419
1633
  dispatch?: KanbanLaneDispatch;
1420
1634
  chunk_size?: number | null;
1635
+ /** p46 Handoff mode (D1). Absent ⇒ `off`. Mapped to `KanbanLane.handoff`. */
1636
+ handoff?: "off" | "verdict" | null;
1421
1637
  }
1422
1638
  /**
1423
1639
  * A resolved board config: stable uuid + version + the mapped `KanbanBoard`
@@ -443,7 +443,17 @@ interface KanbanLane {
443
443
  * a tick fans out ⌈cap / chunkSize⌉ runs. Ignored for `per-card`/`event` lanes.
444
444
  */
445
445
  chunkSize?: number;
446
+ /**
447
+ * p46 Handoff contract, OPT-IN per lane (D1). Absent ⇒ `off` (existing boards
448
+ * are byte-identical). `verdict` ⇒ the engine sends
449
+ * `HANDOFF_VERDICT_JSON_SCHEMA` as the run's `context.outputSchema` and a
450
+ * completion MUST carry a valid verdict (else the run fails with a FAILED
451
+ * handoff). Active only on hosts whose adapter implements the ledger group.
452
+ */
453
+ handoff?: KanbanLaneHandoffMode;
446
454
  }
455
+ /** Per-lane Handoff mode (p46 D1). */
456
+ type KanbanLaneHandoffMode = "off" | "verdict";
447
457
  /**
448
458
  * A declared edge between two lanes in the lifecycle.
449
459
  *
@@ -539,6 +549,11 @@ interface KanbanCard {
539
549
  configVersion?: number;
540
550
  /** Arbitrary shared context carried across transitions. */
541
551
  sharedContext?: Record<string, unknown>;
552
+ /**
553
+ * p46 (P46-002): the card's id in an external host (a Jira issue key).
554
+ * Absent on native / crm / yobo cards.
555
+ */
556
+ externalRef?: string;
542
557
  /**
543
558
  * Execution UUID of the card's current/last run, if any.
544
559
  *
@@ -601,6 +616,18 @@ interface CreateCardInput {
601
616
  * in the lifecycle's initial lane.
602
617
  */
603
618
  lane?: string;
619
+ /**
620
+ * p46 (P46-002): the card's id in an EXTERNAL host (a Jira issue key, e.g.
621
+ * `CAD-231`). Optional. When set, a card is created at most ONCE per
622
+ * `(boardConfigUuid, externalRef)`: a repeat create returns the existing card
623
+ * and dispatches nothing. Hosts that don't store it simply ignore it.
624
+ */
625
+ externalRef?: string;
626
+ /**
627
+ * p46 (P46-002): opaque host facts stored with the card (Jira `statusId`,
628
+ * `mover`, `detached`, …). Passed through to `adapter.createCard` untouched.
629
+ */
630
+ externalMeta?: Record<string, unknown>;
604
631
  }
605
632
  /**
606
633
  * Who performed a transition / stage-run (server-engine side).
@@ -718,7 +745,125 @@ interface ApplyTransitionInput {
718
745
  contextPatch?: Record<string, unknown>;
719
746
  /** Execution UUID to stamp on the card (handler advances). */
720
747
  executionUuid?: string;
748
+ /**
749
+ * p46 (P46-002, F8): set ONLY by `engine.hostMove` (a move the external host
750
+ * made — e.g. a Jira drag mirrored by the sync). A host-aware adapter, inside
751
+ * the same card lock as the `fromLane` CAS, (1) refuses the move with a
752
+ * `HostFenceError` while the card has any `host.*` op `pending` (D19 — never
753
+ * mistake our own half-done move for a human one), (2) writes `externalMeta`,
754
+ * then (3) moves. Adapters that don't know it ignore it (optional, additive).
755
+ */
756
+ hostObservation?: {
757
+ externalMeta?: Record<string, unknown>;
758
+ /**
759
+ * ISO time the host state was READ. The fence also refuses the move when a
760
+ * `host.*` op for the card settled (done/failed) after this instant — the
761
+ * read may predate our own write landing in the host.
762
+ */
763
+ observedAt?: string;
764
+ };
765
+ }
766
+
767
+ /**
768
+ * Handoff v1 — the one AI↔human contract every board lane speaks (p46 §0.3).
769
+ *
770
+ * A Handoff is the record written whenever work crosses between an agent and a
771
+ * person on a card: an agent's verdict ("decide A/B/C", "approve", "test this"),
772
+ * an engine-written FAILED / BLOCKED, or the human's answer carried forward to
773
+ * the next agent (`human_to_ai`). Every worker type produces the same object and
774
+ * every host reads it.
775
+ *
776
+ * This module is DEPENDENCY-FREE and client-safe (types, the verdict JSON
777
+ * schema, the prompt renderer, the link guard). The ajv-backed verdict PARSER
778
+ * lives in `./handoff-parse.ts` and is exported only from the server engine
779
+ * entry (`@cadraos/sdk/kanban/engine`), so the browser bundle never carries ajv.
780
+ *
781
+ * @module @cadraos/sdk/kanban
782
+ */
783
+ type HandoffKind = "DECIDE" | "APPROVE" | "TEST" | "REVIEW" | "FAILED" | "BLOCKED" | "FYI";
784
+ interface HandoffOption {
785
+ /** Single capital letter, `^[A-Z]$`. */
786
+ id: string;
787
+ /** ≤80 chars. */
788
+ label: string;
789
+ /** ≤300 chars. */
790
+ consequence: string;
791
+ }
792
+ interface HandoffDecision {
793
+ /** ≤500 chars. */
794
+ question: string;
795
+ /** 2–4 options. */
796
+ options: HandoffOption[];
797
+ /** ∈ options[].id */
798
+ recommended: string;
799
+ /** ∈ options[].id */
800
+ defaultIfSilent?: string;
801
+ }
802
+ interface HandoffState {
803
+ repos: string[];
804
+ tests: string | null;
805
+ story: string | null;
806
+ costUsd: number | null;
807
+ }
808
+ interface HandoffAnswer {
809
+ option: string | null;
810
+ /** ≤4000 chars. */
811
+ text: string | null;
812
+ by: string;
813
+ at: string;
814
+ via: "board" | "channel";
815
+ source: string;
816
+ }
817
+ interface HandoffV1 {
818
+ v: 1;
819
+ /** = agentic_handoff.uuid */
820
+ id: string;
821
+ /** Card uuid. */
822
+ cardId: string;
823
+ direction: "ai_to_human" | "human_to_ai";
824
+ /** Lane KEYS. */
825
+ fromLane: string;
826
+ toLane: string;
827
+ /** ISO-8601. */
828
+ at: string;
829
+ by: {
830
+ type: "session" | "user" | "watchdog" | "timeout" | "health";
831
+ id: string;
832
+ };
833
+ kind: HandoffKind;
834
+ /** ≤200 chars. */
835
+ headline: string;
836
+ /** ≤20 × ≤300 chars. */
837
+ done: string[];
838
+ state: HandoffState;
839
+ decision?: HandoffDecision;
840
+ /** ≤300 chars. */
841
+ howToAnswer: string;
842
+ /** ≤10; url must be http(s): or a same-origin path starting "/". */
843
+ links: {
844
+ label: string;
845
+ url: string;
846
+ }[];
847
+ session: {
848
+ id: string | null;
849
+ runnerId: string | null;
850
+ resumableUntil: string | null;
851
+ };
852
+ /** BLOCKED only. */
853
+ blocked?: {
854
+ check: string;
855
+ reason: string;
856
+ fixUrl: string;
857
+ };
858
+ answer?: HandoffAnswer;
721
859
  }
860
+ /** Ledger lifecycle of a stored handoff. */
861
+ type HandoffStatus = "open" | "answered" | "resolved" | "consumed";
862
+ /** A stored handoff: the v1 payload plus its ledger status. */
863
+ type HandoffRecord = HandoffV1 & {
864
+ status: HandoffStatus;
865
+ consumedAt: string | null;
866
+ };
722
867
 
723
868
  /**
724
869
  * KanbanStorageAdapter — the SERVER-ENGINE persistence contract.
@@ -801,6 +946,16 @@ interface KanbanStorageAdapter {
801
946
  * config and passes it as `initialLane`.
802
947
  */
803
948
  createCard(input: CreateCardServerInput): Promise<KanbanCard>;
949
+ /**
950
+ * p46 (P46-002) OPTIONAL: the card with this `externalRef` on this board, or
951
+ * null. When present, `engine.createCard({externalRef})` looks it up first and
952
+ * returns an existing card WITHOUT dispatching (exactly-once create per issue
953
+ * key). A host that stores `externalRef` SHOULD also make `createCard` itself
954
+ * idempotent on `(boardConfigUuid, externalRef)` (unique index + `ON CONFLICT
955
+ * DO NOTHING` → return the existing row) for the lookup/insert race. Hosts
956
+ * without external refs (crm/yobo) omit it.
957
+ */
958
+ getCardByExternalRef?(boardConfigUuid: string, externalRef: string): Promise<KanbanCard | null>;
804
959
  /**
805
960
  * Patch a card's mutable fields.
806
961
  */
@@ -917,7 +1072,7 @@ interface KanbanStorageAdapter {
917
1072
  toLane?: string;
918
1073
  contextPatch?: Record<string, unknown>;
919
1074
  actor: Actor;
920
- }): Promise<{
1075
+ } & StageRunLedgerWrite): Promise<{
921
1076
  applied: boolean;
922
1077
  }>;
923
1078
  /**
@@ -930,7 +1085,7 @@ interface KanbanStorageAdapter {
930
1085
  */
931
1086
  failStageRun(idemKey: string, f: {
932
1087
  error: string;
933
- }): Promise<void>;
1088
+ } & StageRunLedgerWrite): Promise<void>;
934
1089
  /**
935
1090
  * OPTIONAL: record that a stage run was dispatched — set status `running`
936
1091
  * and stamp the `executionUuid` (so the UI can stream the live run before
@@ -944,6 +1099,65 @@ interface KanbanStorageAdapter {
944
1099
  * Read a stage run by its idempotency key, or null if none.
945
1100
  */
946
1101
  getStageRun(idemKey: string): Promise<StageRun | null>;
1102
+ /** OPTIONAL (ledger): insert a handoff (adapter assigns `id`), or read the existing one on conflict. */
1103
+ appendHandoff?(h: {
1104
+ card: KanbanCard;
1105
+ stageId: string;
1106
+ configVersion: number;
1107
+ idemKey?: string;
1108
+ attempt?: number;
1109
+ dedupeKey?: string;
1110
+ payload: Omit<HandoffV1, "id">;
1111
+ }): Promise<{
1112
+ created: boolean;
1113
+ handoff: HandoffV1;
1114
+ }>;
1115
+ /** OPTIONAL (ledger): a card's handoffs, NEWEST FIRST. */
1116
+ listHandoffs?(cardId: string, opts?: {
1117
+ limit?: number;
1118
+ }): Promise<HandoffRecord[]>;
1119
+ /** OPTIONAL (ledger): CAS `open → answered` (ai_to_human, kind ∈ DECIDE|APPROVE|TEST|REVIEW). */
1120
+ answerHandoff?(cardId: string, handoffId: string, answer: NonNullable<HandoffV1["answer"]>, userId?: number): Promise<{
1121
+ applied: boolean;
1122
+ }>;
1123
+ /** OPTIONAL (ledger): CAS `open → resolved` (BLOCKED / FAILED). */
1124
+ resolveHandoff?(handoffId: string): Promise<{
1125
+ applied: boolean;
1126
+ }>;
1127
+ /** OPTIONAL (ledger): `human_to_ai` rows `open → consumed` by the dispatch `idemKey`. */
1128
+ consumeHandoffs?(ids: string[], idemKey: string): Promise<void>;
1129
+ /** OPTIONAL (outbox): insert-or-read an op; `run: false` iff it is already `done`. */
1130
+ claimOp?(op: {
1131
+ opId: string;
1132
+ kind: string;
1133
+ cardId?: string;
1134
+ handoffId?: string;
1135
+ payload: unknown;
1136
+ }): Promise<{
1137
+ run: boolean;
1138
+ }>;
1139
+ /** OPTIONAL (outbox): mark an op `done`. */
1140
+ completeOp?(opId: string): Promise<void>;
1141
+ /** OPTIONAL (outbox): mark an op `failed` with its error (replayable). */
1142
+ failOp?(opId: string, error: string): Promise<void>;
1143
+ }
1144
+ /**
1145
+ * The optional ledger write carried on `completeStageRun` / `failStageRun`
1146
+ * (p46 F3). Ignored by adapters without the ledger group.
1147
+ *
1148
+ * When present, the adapter writes — INSIDE THE SAME TRANSACTION as the run /
1149
+ * card update — the `agentic_handoff` row (using `payload.id` as its uuid;
1150
+ * `ON CONFLICT (dedupe_key) DO NOTHING`) and, only when that row was newly
1151
+ * inserted, a `pending` op row with id `notifyOpId`. If the run update is a
1152
+ * no-op (`completeStageRun` on an already-completed run; `failStageRun` on a
1153
+ * completed run) nothing is written. A throw anywhere rolls ALL of it back.
1154
+ */
1155
+ interface StageRunLedgerWrite {
1156
+ handoff?: {
1157
+ payload: HandoffV1;
1158
+ dedupeKey: string;
1159
+ };
1160
+ notifyOpId?: string;
947
1161
  }
948
1162
 
949
1163
  /**
@@ -1418,6 +1632,8 @@ interface WireStage {
1418
1632
  */
1419
1633
  dispatch?: KanbanLaneDispatch;
1420
1634
  chunk_size?: number | null;
1635
+ /** p46 Handoff mode (D1). Absent ⇒ `off`. Mapped to `KanbanLane.handoff`. */
1636
+ handoff?: "off" | "verdict" | null;
1421
1637
  }
1422
1638
  /**
1423
1639
  * A resolved board config: stable uuid + version + the mapped `KanbanBoard`