@junghanacs/entwurf 0.30.0 → 0.31.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 (69) hide show
  1. package/AGENTS.md +1 -0
  2. package/CHANGELOG.md +229 -0
  3. package/DELIVERY.md +1 -0
  4. package/README.md +49 -4
  5. package/VERIFY.md +23 -4
  6. package/demo/README.md +1 -1
  7. package/docs/acp-backend-rail.md +111 -18
  8. package/docs/setup-clean-host.md +104 -4
  9. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +7 -7
  10. package/mcp/entwurf-bridge/dist/pi-extensions/entwurf-capabilities.json +1 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +43 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +7 -1
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +34 -3
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +7 -1
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +28 -2
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +30 -2
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-fresh-preflight.js +92 -0
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +288 -0
  19. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +744 -0
  20. package/mcp/entwurf-bridge/src/index.ts +7 -7
  21. package/mcp/entwurf-bridge/tsconfig.build.json +8 -1
  22. package/package.json +15 -12
  23. package/pi/entwurf-capabilities.json +1 -0
  24. package/pi/pi-durable/bootstrap.mjs +281 -0
  25. package/pi/pi-durable/overlay/runtime-contacts.patch +91 -0
  26. package/pi/pi-durable/overlay/upstream-pin.json +16 -0
  27. package/pi-extensions/lib/acp/backend-adapter.ts +68 -0
  28. package/pi-extensions/lib/acp/backend.ts +41 -5
  29. package/pi-extensions/lib/entwurf-deliverability.ts +7 -1
  30. package/pi-extensions/lib/fresh-call-composition.ts +42 -7
  31. package/pi-extensions/lib/meta-sender-identity.ts +7 -1
  32. package/pi-extensions/lib/meta-session.ts +28 -2
  33. package/pi-extensions/lib/mux-fresh-call.ts +34 -2
  34. package/pi-extensions/lib/pi-durable-fresh-preflight.ts +113 -0
  35. package/pi-extensions/lib/pi-durable-runtime.ts +384 -0
  36. package/pi-extensions/meta-bridge-pi-durable.ts +1020 -0
  37. package/run.sh +214 -36
  38. package/scripts/check-fresh-call-dispatch.ts +18 -0
  39. package/scripts/check-gate-qualification.ts +4 -2
  40. package/scripts/check-herdr-plugin-build.ts +159 -57
  41. package/scripts/check-pi-durable-contact.ts +834 -0
  42. package/scripts/check-pi-durable-receive.ts +1116 -0
  43. package/scripts/check-pi-durable-send.ts +1661 -0
  44. package/scripts/fixtures/herdr-supply.json +8 -8
  45. package/scripts/lib/pi-durable-fresh-live-receipts.ts +139 -0
  46. package/scripts/lib/pi-durable-r1.ts +100 -0
  47. package/scripts/lib/pi-durable-reap.ts +27 -0
  48. package/scripts/lib/pi-durable-receive.ts +137 -0
  49. package/scripts/mutants/acp-prompt-lifecycle.json +2 -2
  50. package/scripts/mutants/acp-vendor-rejection.json +62 -0
  51. package/scripts/mutants/control-send-receipt.json +1 -1
  52. package/scripts/mutants/copilot-birth.json +2 -2
  53. package/scripts/mutants/herdr-plugin-build.json +11 -0
  54. package/scripts/mutants/herdr-supply.json +3 -3
  55. package/scripts/mutants/meta-hook-session-switch.json +4 -2
  56. package/scripts/mutants/mux-fresh-call.json +69 -3
  57. package/scripts/mutants/omp-birth.json +2 -2
  58. package/scripts/mutants/omp-fresh.json +6 -4
  59. package/scripts/mutants/pack-install.json +3 -3
  60. package/scripts/mutants/pi-durable.json +390 -0
  61. package/scripts/mutants/pi-floor.json +2 -2
  62. package/scripts/pi-durable-contact-host.mjs +105 -0
  63. package/scripts/pi-durable-receive-host.mjs +127 -0
  64. package/scripts/pi-durable-send-host.mjs +213 -0
  65. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  66. package/scripts/smoke-herdr-fresh-call-live.ts +21 -7
  67. package/scripts/smoke-pi-durable-fresh-live.ts +624 -0
  68. package/scripts/smoke-setup-verdict.sh +21 -2
  69. package/scripts/tsconfig.json +1 -0
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "$comment": "SSOT for the herdr binary this repository has MEASURED against. Nothing else may carry a version or a digest: the CI installer, the sandbox gate's boundary check and the launch-rail document all read or cite this file. Re-measuring a new herdr means running check-herdr-sandbox against it FIRST, then editing this file, then repairing the measured sentences in docs/herdr-launch-rail.md in the same commit — miss one and the gate fails on version mismatch, which is the point.",
3
- "version": "0.9.1",
4
- "tag": "v0.9.1",
5
- "tagCommit": "065ef9d6a531c49fb8bee7e818ef837065b21ee9",
3
+ "version": "0.9.3",
4
+ "tag": "v0.9.3",
5
+ "tagCommit": "7b116c05bfda646af39d2524c54e70c751f57ee8",
6
6
  "socketProtocol": 22,
7
- "measuredOn": "2026-09-17",
7
+ "measuredOn": "2026-10-03",
8
8
  "measuredArch": "aarch64",
9
9
  "releaseUrlPrefix": "https://github.com/herdrdev/herdr/releases/download",
10
10
  "integrity": {
@@ -15,15 +15,15 @@
15
15
  "assets": {
16
16
  "x86_64": {
17
17
  "name": "herdr-linux-x86_64",
18
- "sha256": "2a02fed16beb651ef006e1d43f048f652ca4dc58ad053cd2d44450563d5c54b7",
18
+ "sha256": "18a8dc65f1c2fa485884344356dea1cfd911c6f06cf46fa78e193f4087f4dba7",
19
19
  "executed": false,
20
- "executedNote": "pending the first CI run on an x86_64 runner — the digest is pinned, but no cell of this rail has ever run on this binary"
20
+ "executedNote": "pending the first CI run on an x86_64 runner — the digest is pinned (and named by GitHub's release attestation for pkg:github/herdrdev/herdr@v0.9.3), but no cell of this rail has ever run on this binary"
21
21
  },
22
22
  "aarch64": {
23
23
  "name": "herdr-linux-aarch64",
24
- "sha256": "f4ccf4de745f2cb9a39a983e9ba3703dad50ec2a58dea83026ceab721bbd8d9e",
24
+ "sha256": "4de7aa3e25678812e92960de64f7c2aaa1bca1f0f80a3c5e559837e231e1f5c0",
25
25
  "executed": true,
26
- "executedNote": "the #116 rail was re-measured on this exact binary on 2026-09-17: `check-herdr-sandbox` green, `herdr status client` reports protocol 22 (unchanged from 0.9.0), and `integration status` adds exactly one row — `letta (experimental)` — with every other row byte-identical to 0.9.0. The operator host still runs the 0.9.0 asset at that date; the binary measured here was fetched by digest into a temp dir"
26
+ "executedNote": "the #116 rail was re-measured on this exact binary on 2026-10-03 (#126), fetched by digest through scripts/install-herdr-ci.sh into a temp dir and run from an isolated snapshot whose manifest named it: `check-herdr-sandbox` green (12 assertions, private server), `herdr status client` reports protocol 22 (unchanged from 0.9.1), and `integration status` prints the same 18 rows as 0.9.1 in a sandbox HOME once that root path is normalized; pi (v9) and claude (v10) integrations installed by 0.9.1 read `current` under 0.9.3 and a 0.9.3 reinstall leaves their bytes identical. The operator host still runs the 0.9.1 asset at that date and was not touched. The 0.9.1 receipt (2026-09-17) stays dated in docs/herdr-launch-rail.md"
27
27
  }
28
28
  }
29
29
  }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The oracles of `smoke-pi-durable-fresh-live`, as pure functions over raw evidence, so the same code
3
+ * that judges a live run can re-judge its saved raw (Pi's JSONL and the pd native-store snapshot).
4
+ *
5
+ * Pi side: a message lands only as an exact payload with ONE anchored terminal sender envelope —
6
+ * `piCallbackSenders` already decides that shape; this adds the expected sender and returns the row.
7
+ * A body that merely MENTIONS the token and the sender's id (the fixture's own instruction did) is
8
+ * not a delivery.
9
+ *
10
+ * pd side: the native store's ROOT conversation only (no owner conversation or task), read through
11
+ * its entry kinds — `pi.system` (tool offering, applied as toolsAdded/toolsRemoved in order),
12
+ * `pi.assistant` (the model that answered, and the tool calls in order) and `pi.tool-result` (each
13
+ * call's settled result, joined by toolCallId).
14
+ */
15
+ import { piCallbackSenders } from "./codex-fresh-source-receipts.ts";
16
+
17
+ /** The one Pi transcript row whose exact payload arrived from `expectedSender`, or null. */
18
+ export function piExactDeliveryRow(
19
+ entries: readonly unknown[],
20
+ payload: string,
21
+ expectedSender: string,
22
+ ): { row: unknown; index: number } | null {
23
+ const hits: { row: unknown; index: number }[] = [];
24
+ entries.forEach((row, index) => {
25
+ const senders = piCallbackSenders([row], payload);
26
+ if (senders.length === 1 && senders[0] === expectedSender) hits.push({ row, index });
27
+ });
28
+ return hits.length === 1 ? (hits[0] as { row: unknown; index: number }) : null;
29
+ }
30
+
31
+ export interface PdNativeRow {
32
+ id: number;
33
+ conversation_id: number;
34
+ record: string;
35
+ }
36
+ export interface PdNativeSubmissionRow {
37
+ conversation_id: number;
38
+ status: string;
39
+ record: string;
40
+ }
41
+ export interface PdToolCall {
42
+ id: string;
43
+ name: string;
44
+ arguments: Record<string, unknown>;
45
+ result: { text: string; isError: boolean } | null;
46
+ }
47
+ export interface PdRootView {
48
+ offering: string[];
49
+ models: string[];
50
+ calls: PdToolCall[];
51
+ }
52
+
53
+ interface Message {
54
+ role?: unknown;
55
+ provider?: unknown;
56
+ model?: unknown;
57
+ content?: unknown;
58
+ toolsAdded?: unknown;
59
+ toolsRemoved?: unknown;
60
+ toolCallId?: unknown;
61
+ isError?: unknown;
62
+ }
63
+
64
+ function messagesOf(record: Record<string, unknown>): Message[] {
65
+ return Array.isArray(record.model) ? (record.model as Message[]) : [];
66
+ }
67
+ function names(list: unknown): string[] {
68
+ return Array.isArray(list)
69
+ ? list.flatMap((t) =>
70
+ t !== null && typeof t === "object" && typeof (t as { name?: unknown }).name === "string"
71
+ ? [(t as { name: string }).name]
72
+ : [],
73
+ )
74
+ : [];
75
+ }
76
+
77
+ /** The root conversation's offering, models and tool calls, from its own entries in id order. */
78
+ export function pdRootView(entries: readonly PdNativeRow[], rootConversationId: number): PdRootView {
79
+ let offering: string[] = [];
80
+ const models = new Set<string>();
81
+ const calls: PdToolCall[] = [];
82
+ const byId = new Map<string, PdToolCall>();
83
+ for (const row of [...entries].sort((a, b) => a.id - b.id)) {
84
+ if (row.conversation_id !== rootConversationId) continue;
85
+ const record = JSON.parse(row.record) as Record<string, unknown>;
86
+ for (const message of messagesOf(record)) {
87
+ if (record.kind === "pi.system") {
88
+ const removed = new Set(names(message.toolsRemoved));
89
+ offering = [...offering.filter((n) => !removed.has(n)), ...names(message.toolsAdded)];
90
+ } else if (record.kind === "pi.assistant" && message.role === "assistant") {
91
+ if (typeof message.provider === "string" && typeof message.model === "string")
92
+ models.add(`${message.provider}/${message.model}`);
93
+ for (const block of Array.isArray(message.content) ? message.content : []) {
94
+ const b = block as { type?: unknown; id?: unknown; name?: unknown; arguments?: unknown };
95
+ if (b.type !== "toolCall" || typeof b.id !== "string" || typeof b.name !== "string") continue;
96
+ const call: PdToolCall = {
97
+ id: b.id,
98
+ name: b.name,
99
+ arguments: (b.arguments ?? {}) as Record<string, unknown>,
100
+ result: null,
101
+ };
102
+ calls.push(call);
103
+ byId.set(b.id, call);
104
+ }
105
+ } else if (
106
+ record.kind === "pi.tool-result" &&
107
+ message.role === "toolResult" &&
108
+ typeof message.toolCallId === "string"
109
+ ) {
110
+ const call = byId.get(message.toolCallId);
111
+ if (call === undefined) continue;
112
+ const text = (Array.isArray(message.content) ? message.content : [])
113
+ .map((c) =>
114
+ (c as { type?: unknown; text?: unknown }).type === "text" ? String((c as { text: unknown }).text) : "",
115
+ )
116
+ .join("");
117
+ call.result = { text, isError: message.isError === true };
118
+ }
119
+ }
120
+ }
121
+ return { offering, models: [...models], calls };
122
+ }
123
+
124
+ /** The doorbell submission for exactly this mailbox message, on the root conversation. */
125
+ export function pdDoorbellSubmission(
126
+ submissions: readonly PdNativeSubmissionRow[],
127
+ rootConversationId: number,
128
+ requestId: string,
129
+ ): { status: string; record: Record<string, unknown> } | null {
130
+ const hits = submissions
131
+ .map((s) => ({ s, record: JSON.parse(s.record) as Record<string, unknown> }))
132
+ .filter(({ s, record }) => s.conversation_id === rootConversationId && record.requestId === requestId);
133
+ return hits.length === 1
134
+ ? {
135
+ status: (hits[0] as { s: PdNativeSubmissionRow }).s.status,
136
+ record: (hits[0] as { record: Record<string, unknown> }).record,
137
+ }
138
+ : null;
139
+ }
@@ -0,0 +1,100 @@
1
+ // The R1 cell's go/no-go predicates for check-pi-durable-send (#129 L-send), as pure functions so
2
+ // the exact conditions the gate stops on can be checked on their own. Each returns the reasons it
3
+ // refuses; an empty list is the only pass.
4
+
5
+ function canonicalJson(value: unknown): string {
6
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
7
+ if (value !== null && typeof value === "object") {
8
+ const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
9
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`;
10
+ }
11
+ return JSON.stringify(value);
12
+ }
13
+
14
+ export interface Verdict {
15
+ ok: boolean;
16
+ reasons: string[];
17
+ }
18
+ const verdict = (reasons: string[]): Verdict => ({ ok: reasons.length === 0, reasons });
19
+
20
+ /** The reported frame: a root `pi.tool` task running in `execute`, and the same-task slot for the call. */
21
+ export function intentFrameVerdict(frame: unknown, callId: string): Verdict {
22
+ const f = frame as {
23
+ node?: { id?: unknown; kind?: unknown; conversationId?: unknown; state?: { status?: unknown; phase?: unknown } };
24
+ slot?: { taskId?: unknown; name?: unknown; callId?: unknown; status?: unknown };
25
+ } | null;
26
+ const reasons: string[] = [];
27
+ if (f?.node?.kind !== "pi.tool") reasons.push("node is not pi.tool");
28
+ if (f?.node?.conversationId !== 1) reasons.push("node is not on the root conversation");
29
+ if (f?.node?.state?.status !== "running") reasons.push("node is not running");
30
+ if (f?.node?.state?.phase !== "execute") reasons.push("node phase is not execute");
31
+ if (f?.slot?.taskId === undefined || f.slot.taskId !== f.node?.id) reasons.push("slot taskId is not the node's");
32
+ if (f?.slot?.name !== "entwurf_v2") reasons.push("slot is not entwurf_v2");
33
+ if (f?.slot?.callId !== callId) reasons.push(`slot is not ${callId}`);
34
+ if (f?.slot?.status !== "running") reasons.push("slot is not running");
35
+ return verdict(reasons);
36
+ }
37
+
38
+ export interface PreCopyFacts {
39
+ /** host1's actual exit signal. */
40
+ hostExitSignal: string | null | undefined;
41
+ /** bridge1's /proc state when its counters were last read. */
42
+ bridgeStateAtRead: string | null;
43
+ bridgeGone: boolean;
44
+ readDelta: { rchar: number; syscr: number };
45
+ /** Wall-clock ms when `go` was written and when host1 was signalled. */
46
+ goAt: number;
47
+ killAt: number;
48
+ deadlineMs: number;
49
+ }
50
+
51
+ /** Everything that must hold before the database set is copied and the session reopened. */
52
+ export function preCopyVerdict(f: PreCopyFacts): Verdict {
53
+ const reasons: string[] = [];
54
+ if (f.hostExitSignal !== "SIGKILL") reasons.push(`host1 exit signal is ${String(f.hostExitSignal)}, not SIGKILL`);
55
+ if (f.bridgeStateAtRead !== "T") reasons.push(`bridge1 state at read is ${String(f.bridgeStateAtRead)}, not T`);
56
+ if (!f.bridgeGone) reasons.push("bridge1 is not gone");
57
+ if (f.readDelta.rchar !== 0 || f.readDelta.syscr !== 0) {
58
+ reasons.push(`bridge1 counted reads (rchar +${f.readDelta.rchar}, syscr +${f.readDelta.syscr})`);
59
+ }
60
+ const elapsed = f.killAt - f.goAt;
61
+ if (!(elapsed >= 0 && elapsed <= f.deadlineMs)) {
62
+ reasons.push(`go→kill took ${elapsed}ms, outside the ${f.deadlineMs}ms validity cutoff`);
63
+ }
64
+ return verdict(reasons);
65
+ }
66
+
67
+ export interface CheckpointFacts {
68
+ row: { id?: unknown; conversation_id?: unknown; status?: unknown } | null;
69
+ record: {
70
+ id?: unknown;
71
+ kind?: unknown;
72
+ conversationId?: unknown;
73
+ input?: { callId?: unknown };
74
+ state?: { status?: unknown; checkpoint?: { phase?: unknown; replay?: unknown; arguments?: unknown } };
75
+ } | null;
76
+ resultEntriesForCall: number;
77
+ }
78
+
79
+ /** The copied database holds THIS call's committed, unsafe intent and no result for it. */
80
+ export function checkpointVerdict(
81
+ f: CheckpointFacts,
82
+ expect: { nodeId: number; callId: string; args: unknown },
83
+ ): Verdict {
84
+ const reasons: string[] = [];
85
+ if (f.row === null || f.record === null) return verdict(["no task row for the observed task id"]);
86
+ if (f.row.id !== expect.nodeId) reasons.push("queried row id is not the observed task id");
87
+ if (f.record.id !== expect.nodeId) reasons.push("record id is not the observed task id");
88
+ if (f.record.kind !== "pi.tool") reasons.push("record is not pi.tool");
89
+ if (f.record.conversationId !== 1) reasons.push("record is not on the root conversation");
90
+ if (f.record.input?.callId !== expect.callId) reasons.push(`record input callId is not ${expect.callId}`);
91
+ if (f.record.state?.status !== "running") reasons.push("record is not running");
92
+ if (f.record.state?.checkpoint?.phase !== "execute") reasons.push("checkpoint phase is not execute");
93
+ if (f.record.state?.checkpoint?.replay !== "unsafe") reasons.push("checkpoint replay is not unsafe");
94
+ if (canonicalJson(f.record.state?.checkpoint?.arguments) !== canonicalJson(expect.args)) {
95
+ reasons.push("checkpoint arguments are not the scripted arguments");
96
+ }
97
+ if (f.resultEntriesForCall !== 0)
98
+ reasons.push(`${f.resultEntriesForCall} result entr(ies) already exist for the call`);
99
+ return verdict(reasons);
100
+ }
@@ -0,0 +1,27 @@
1
+ // The cleanup verdict of check-pi-durable-send (#129 L-send), as a pure function so the exact
2
+ // predicate the gate uses can be checked on its own.
3
+ //
4
+ // Green only when every owned process is gone, no signal failed, the scripted endpoint closed, and
5
+ // every owned host's children were OBSERVED before it went away. A host that exited before /proc
6
+ // could be asked about its children leaves an unknown, and an unknown is not a clean reap.
7
+
8
+ export interface ReapFacts {
9
+ /** Owned processes still alive with their recorded start key. */
10
+ alive: readonly number[];
11
+ /** Signal or exit failures met while reaping. */
12
+ problems: readonly string[];
13
+ endpointClosed: boolean;
14
+ endpointListening: boolean;
15
+ /** Hosts that exited before their children were observed. */
16
+ observationNotes: readonly string[];
17
+ }
18
+
19
+ export function ownedReapVerdict(facts: ReapFacts): { green: boolean; reasons: string[] } {
20
+ const reasons = [
21
+ ...(facts.alive.length > 0 ? [`still alive: ${facts.alive.join(",")}`] : []),
22
+ ...facts.problems,
23
+ ...(facts.endpointClosed && !facts.endpointListening ? [] : ["the scripted endpoint did not close"]),
24
+ ...facts.observationNotes.map((note) => `unobserved: ${note}`),
25
+ ];
26
+ return { green: reasons.length === 0, reasons };
27
+ }
@@ -0,0 +1,137 @@
1
+ // The receive cell's go/no-go predicates for check-pi-durable-receive (#129 L-receive), as pure
2
+ // functions so the exact conditions the gate stops on can be checked on their own. Each returns the
3
+ // reasons it refuses; an empty list is the only pass. Nothing here imports the adapter: the doorbell
4
+ // notice and the request id are recognised by the contract's public words, not by its constants.
5
+
6
+ export interface Verdict {
7
+ ok: boolean;
8
+ reasons: string[];
9
+ }
10
+ const verdict = (reasons: string[]): Verdict => ({ ok: reasons.length === 0, reasons });
11
+
12
+ /** The mailbox file a meta-mailbox ENQUEUE receipt names, or undefined when the text is not one. */
13
+ export function enqueuedFile(receipt: string): string | undefined {
14
+ return /^entwurf_v2 meta-mailbox → enqueued \(([^()/]+\.msg)\)$/.exec(receipt.trim())?.[1];
15
+ }
16
+
17
+ export interface HeldCaptureFacts {
18
+ hostGid: string;
19
+ hostPid: number;
20
+ hostStartKey: string;
21
+ /** The ENQUEUE receipt's file name. */
22
+ enqueued: string | undefined;
23
+ /** The host's mailbox directory listing at the hold. */
24
+ listing: string[];
25
+ /** Bytes of `<enqueued>.delivered` at the hold, or null when absent. */
26
+ deliveredBody: string | null;
27
+ sentMessage: string;
28
+ signalPresent: boolean;
29
+ /** The host's raw receiver marker at the hold, or null when absent. */
30
+ receiverMarker: Record<string, unknown> | null;
31
+ /** The newest user message text of the held request #1. */
32
+ request1UserText: string;
33
+ /** The tool names request #1 offers. */
34
+ request1Tools: string[];
35
+ }
36
+
37
+ /**
38
+ * Everything that must hold while response #1 is withheld: the sender's ENQUEUE named a file, the
39
+ * host's doorbell has already stamped exactly that file `.delivered` (a wake attempt, not a read), its
40
+ * bytes carry the sent message, the signal and the host-owned receiver marker are in place, and the
41
+ * request that the root admission started is the announce — garden and tool named, body absent.
42
+ */
43
+ export function heldCaptureVerdict(f: HeldCaptureFacts): Verdict {
44
+ const reasons: string[] = [];
45
+ if (f.enqueued === undefined) return verdict(["the sender's receipt is not a meta-mailbox ENQUEUE"]);
46
+ if (f.listing.includes(f.enqueued)) reasons.push(`${f.enqueued} is still a fresh .msg — no doorbell stamped it`);
47
+ if (!f.listing.includes(`${f.enqueued}.delivered`)) reasons.push(`${f.enqueued}.delivered is absent`);
48
+ if (f.listing.some((n) => n.endsWith(".read"))) reasons.push("a body was already archived before the hold");
49
+ if (f.deliveredBody === null || !f.deliveredBody.includes(f.sentMessage)) {
50
+ reasons.push("the delivered bytes do not carry the sent message");
51
+ }
52
+ if (!f.signalPresent) reasons.push("inbox.signal is absent");
53
+ const m = f.receiverMarker;
54
+ if (m === null) reasons.push("the host's receiver marker is absent");
55
+ else {
56
+ if (m.gardenId !== f.hostGid) reasons.push("the receiver marker names another garden");
57
+ if (m.ownerPid !== f.hostPid) reasons.push("the receiver marker is not owned by the spawned host");
58
+ if (m.ownerStartKey !== `linux:${f.hostStartKey}`)
59
+ reasons.push("the receiver marker's start key is not the host's");
60
+ if (m.ownerKind !== "pi-durable-host") reasons.push("the receiver marker is not pi-durable-host");
61
+ }
62
+ const text = f.request1UserText;
63
+ if (!text.startsWith("[entwurf inbox] 1 unread mailbox message available for garden ")) {
64
+ reasons.push("request #1's user input is not a one-message doorbell notice");
65
+ }
66
+ if (!text.includes(f.hostGid) || !text.includes("entwurf_inbox_read")) {
67
+ reasons.push("the notice does not name the host's garden and entwurf_inbox_read");
68
+ }
69
+ if (text.includes(f.sentMessage)) reasons.push("the notice carries the body");
70
+ if (!f.request1Tools.includes("entwurf_inbox_read")) reasons.push("request #1 does not offer entwurf_inbox_read");
71
+ return verdict(reasons);
72
+ }
73
+
74
+ /**
75
+ * How upstream writes the indexed `submissions.request_id` column: `JSON.stringify` of the request id
76
+ * (`[read packages/durable/src/storage/sqlite/storage.ts:64 encodeIndexedString, :762 insert, :442
77
+ * lookup]` @cd32f77), the same encoding R1 measured on `tasks.kind`. The record JSON keeps it raw.
78
+ */
79
+ export const encodedRequestIdColumn = (requestId: string): string => JSON.stringify(requestId);
80
+
81
+ export interface SubmissionFacts {
82
+ /** The `submissions` row whose parsed record carries the request id, with that record, or null. */
83
+ row: { id?: unknown; conversation_id?: unknown; request_id?: unknown; status?: unknown } | null;
84
+ record: {
85
+ id?: unknown;
86
+ conversationId?: unknown;
87
+ requestId?: unknown;
88
+ type?: unknown;
89
+ status?: unknown;
90
+ entry?: unknown;
91
+ } | null;
92
+ /** Rows whose parsed record carries the request id, over the whole table. */
93
+ rowsForRequest: number;
94
+ /** Rows whose indexed `request_id` column holds the request id as upstream encodes it. */
95
+ rowsForColumn: number;
96
+ /** The entry the record names, parsed, or null. */
97
+ entry: { kind?: unknown; model?: { role?: unknown; content?: unknown }[] } | null;
98
+ }
99
+
100
+ /**
101
+ * The copied database holds exactly one input submission for the doorbell's request id — found both
102
+ * by its record and by its indexed column — on the root conversation, whose row id AND record id are
103
+ * the id the receiver reported, placed as the root user entry carrying the notice and answered. A row
104
+ * still queued is reported as queued — never read as placed.
105
+ */
106
+ export function submissionVerdict(
107
+ f: SubmissionFacts,
108
+ expect: { submissionId: unknown; requestId: string; notice: string },
109
+ ): Verdict {
110
+ const reasons: string[] = [];
111
+ if (f.rowsForRequest !== 1) reasons.push(`${f.rowsForRequest} records carry the request id`);
112
+ if (f.rowsForColumn !== 1) reasons.push(`${f.rowsForColumn} rows hold the encoded request id in the indexed column`);
113
+ if (f.row === null || f.record === null) return verdict([...reasons, "no submission row for the request id"]);
114
+ if (f.row.id !== expect.submissionId) reasons.push("the row id is not the reported submission id");
115
+ if (f.record.id === undefined) reasons.push("the record carries no id");
116
+ else if (f.record.id !== f.row.id || f.record.id !== expect.submissionId) {
117
+ reasons.push("the record id is not the row id and the reported submission id");
118
+ }
119
+ if (f.row.conversation_id !== 1 || f.record.conversationId !== 1)
120
+ reasons.push("the row is not on the root conversation");
121
+ if (f.row.request_id !== encodedRequestIdColumn(expect.requestId) || f.record.requestId !== expect.requestId) {
122
+ reasons.push("the row's request id is not the doorbell's");
123
+ }
124
+ if (f.record.type !== "input") reasons.push("the submission is not an input");
125
+ if (f.row.status !== f.record.status) reasons.push("the row status and the record status disagree");
126
+ if (f.record.status === "queued") reasons.push("the submission is still queued, not placed");
127
+ else if (f.record.status !== "done") reasons.push(`the submission is ${String(f.record.status)}, not done`);
128
+ const message = f.entry?.model?.[0];
129
+ if (f.entry === null) reasons.push("the record names no readable entry");
130
+ else {
131
+ if (f.entry.kind !== "pi.user") reasons.push("the placed entry is not pi.user");
132
+ if (message?.role !== "user" || message.content !== expect.notice) {
133
+ reasons.push("the placed entry's content is not the notice the model received");
134
+ }
135
+ }
136
+ return verdict(reasons);
137
+ }
@@ -54,8 +54,8 @@
54
54
  "claim": "REUSE-CARRIES-CHILD-DIAGNOSTICS",
55
55
  "title": "the reuse path drops the child's stderr tail again — exactly the live sonnet failure that showed only \"ACP connection closed\"",
56
56
  "subject": "pi-extensions/lib/acp/backend.ts",
57
- "find": ["\t\t\tfinishError(err, aborted, session.stderrTail, lifecycle, session.launchObservation);"],
58
- "replace": ["\t\t\tfinishError(err, aborted, undefined, lifecycle, session.launchObservation);"],
57
+ "find": ["\t\t\tfinishError(err, aborted, session.stderrTail, lifecycle, session.launchObservation, adapter);"],
58
+ "replace": ["\t\t\tfinishError(err, aborted, undefined, lifecycle, session.launchObservation, adapter);"],
59
59
  "gate": ["bash", "run.sh", "check-acp-prompt-lifecycle"],
60
60
  "timeoutSeconds": 180,
61
61
  "signature": "[QK:REUSE-CARRIES-CHILD-DIAGNOSTICS]",
@@ -0,0 +1,62 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "lane": "acp-vendor-rejection",
4
+ "mutants": [
5
+ {
6
+ "claim": "ACP-INCOMPLETE-TOOL-NO-REPLAY-NEW",
7
+ "title": "finishError stops consulting the adapter's prompt-rejection reader — the vendor's raw 'Internal error' text becomes errorMessage again and pi replays a turn whose tools already ran",
8
+ "subject": "pi-extensions/lib/acp/backend.ts",
9
+ "find": ["\t\tconst rejection = aborted ? undefined : adapter?.readPromptRejection?.(err);"],
10
+ "replace": ["\t\tconst rejection = aborted ? undefined : undefined;"],
11
+ "gate": ["bash", "run.sh", "check-acp-vendor-rejection"],
12
+ "timeoutSeconds": 120,
13
+ "signature": "[QK:ACP-INCOMPLETE-TOOL-NO-REPLAY-NEW]",
14
+ "signatureSource": "pi-extensions/lib/acp/backend.vendor-rejection.test.ts"
15
+ },
16
+ {
17
+ "claim": "ACP-PROMPT-REJECTION-CLAUDE-ONLY",
18
+ "title": "the claude reader stops checking the vendor's errorKind — any -32603 carrying an errorKind (no_result included) is rewritten to the incomplete-tool verdict",
19
+ "subject": "pi-extensions/lib/acp/backend-adapter.ts",
20
+ "find": ["\tif (data.errorKind !== CLAUDE_INCOMPLETE_TOOL_CALL) return undefined;"],
21
+ "replace": ["\tif (data.errorKind === undefined) return undefined;"],
22
+ "gate": ["bash", "run.sh", "check-acp-vendor-rejection"],
23
+ "timeoutSeconds": 120,
24
+ "signature": "[QK:ACP-PROMPT-REJECTION-CLAUDE-ONLY]",
25
+ "signatureSource": "pi-extensions/lib/acp/backend-adapter.test.ts"
26
+ },
27
+ {
28
+ "claim": "ACP-INCOMPLETE-TOOL-RAW-EVIDENCE",
29
+ "title": "the recognised rejection is sealed with the verdict but its raw vendor message, data and stderr are dropped instead of riding a pi diagnostic",
30
+ "subject": "pi-extensions/lib/acp/backend.ts",
31
+ "find": [
32
+ "\t\t\tappendAssistantMessageDiagnostic(",
33
+ "\t\t\t\tstate.output,",
34
+ "\t\t\t\tcreateAssistantMessageDiagnostic(ACP_PROMPT_REJECTION_DIAGNOSTIC, err, {",
35
+ "\t\t\t\t\terrorKind: rejection.errorKind,",
36
+ "\t\t\t\t\tdataJson: rejection.dataJson,",
37
+ "\t\t\t\t\t...(lifecycle ? { lifecycle } : {}),",
38
+ "\t\t\t\t\t...(observed ? { launchSignal: observed } : {}),",
39
+ "\t\t\t\t\t...(tail ? { stderrTail: tail } : {}),",
40
+ "\t\t\t\t\t...(hint ? { hint } : {}),",
41
+ "\t\t\t\t}),",
42
+ "\t\t\t);"
43
+ ],
44
+ "replace": ["\t\t\t// mutant: the raw rejection is not preserved"],
45
+ "gate": ["bash", "run.sh", "check-acp-vendor-rejection"],
46
+ "timeoutSeconds": 120,
47
+ "signature": "[QK:ACP-INCOMPLETE-TOOL-RAW-EVIDENCE]",
48
+ "signatureSource": "pi-extensions/lib/acp/backend.vendor-rejection.test.ts"
49
+ },
50
+ {
51
+ "claim": "ACP-REUSE-COOPERATIVE-CANCEL",
52
+ "title": "a reused session's cooperatively cancelled turn is sealed and retained but no longer persisted — the record lags the live session",
53
+ "subject": "pi-extensions/lib/acp/backend.ts",
54
+ "find": ["\t\t\tfinishSuccess(adapter, session, promptResult);", "\t\t\tpersistRecord(session, deps);"],
55
+ "replace": ["\t\t\tfinishSuccess(adapter, session, promptResult);"],
56
+ "gate": ["bash", "run.sh", "check-acp-vendor-rejection"],
57
+ "timeoutSeconds": 120,
58
+ "signature": "[QK:ACP-REUSE-COOPERATIVE-CANCEL]",
59
+ "signatureSource": "pi-extensions/lib/acp/backend.vendor-rejection.test.ts"
60
+ }
61
+ ]
62
+ }
@@ -181,7 +181,7 @@
181
181
  "claim": "PI-CORE-PIN-TRACKS-FLOOR",
182
182
  "title": "the queue oracle's pi-agent-core pin drifts off the floor the rest of the pi runtime is pinned to, so the oracle keeps answering honestly — about a pi nobody in this repo runs",
183
183
  "subject": "package.json",
184
- "find": ["\"@earendil-works/pi-agent-core\": \"1.0.0\","],
184
+ "find": ["\"@earendil-works/pi-agent-core\": \"1.0.2\","],
185
185
  "replace": ["\"@earendil-works/pi-agent-core\": \"0.87.1\","],
186
186
  "gate": ["bash", "run.sh", "check-dep-versions"],
187
187
  "timeoutSeconds": 300,
@@ -133,10 +133,10 @@
133
133
  "title": "closing the reader half leaves the writer talking to nobody — the hook writes a copilot marker, the bridge never looks in that directory, and the citizen's sends are refused as anonymous (the #46 shape, which is why writer and reader open in one change)",
134
134
  "subject": "pi-extensions/lib/meta-sender-identity.ts",
135
135
  "find": [
136
- "export const META_SENDER_BACKENDS: readonly MetaBackend[] = [\"claude-code\", \"antigravity\", \"copilot\", \"omp\"];"
136
+ "export const META_SENDER_BACKENDS: readonly MetaBackend[] = [\n\t\"claude-code\",\n\t\"antigravity\",\n\t\"copilot\",\n\t\"omp\",\n\t\"pi-durable\",\n];"
137
137
  ],
138
138
  "replace": [
139
- "export const META_SENDER_BACKENDS: readonly MetaBackend[] = [\"claude-code\", \"antigravity\", \"omp\"];"
139
+ "export const META_SENDER_BACKENDS: readonly MetaBackend[] = [\n\t\"claude-code\",\n\t\"antigravity\",\n\t\"omp\",\n\t\"pi-durable\",\n];"
140
140
  ],
141
141
  "gate": ["bash", "run.sh", "check-copilot-birth-hook"],
142
142
  "timeoutSeconds": 300,
@@ -68,6 +68,17 @@
68
68
  "signature": "[QK:HPB-SEQUENTIAL-TWO-STAGE]",
69
69
  "signatureSource": "scripts/check-herdr-plugin-build.ts"
70
70
  },
71
+ {
72
+ "claim": "HPB-DEFAULT-LOCK-READ",
73
+ "title": "reading the default lock from the repository root rather than the plugin directory breaks the real build while source-injected fixture drives stay green; the production composition must consume this plugin's committed source and artifact fields",
74
+ "subject": "plugins/herdr/lib/build.mjs",
75
+ "find": ["\tconst lock = lockReader(PLUGIN_DIR);"],
76
+ "replace": ["\tconst lock = lockReader(CHECKOUT_ROOT);"],
77
+ "gate": ["bash", "run.sh", "check-herdr-plugin-build"],
78
+ "timeoutSeconds": 300,
79
+ "signature": "[QK:HPB-DEFAULT-LOCK-READ]",
80
+ "signatureSource": "scripts/check-herdr-plugin-build.ts"
81
+ },
71
82
  {
72
83
  "claim": "HPB-POST-BUILD-GAP",
73
84
  "title": "claiming Herdr's registration in this runner's own receipt states a step that has not run: herdr commits its checkout and registry only AFTER the build returns, so the next reconcile would be arguing with a lie",
@@ -8,7 +8,7 @@
8
8
  "subject": "scripts/check-herdr-sandbox.ts",
9
9
  "find": ["const SUPPLY_MANIFEST = \"scripts/fixtures/herdr-supply.json\";"],
10
10
  "replace": [
11
- "const SUPPLY_MANIFEST = \"scripts/fixtures/herdr-supply.json\";\nconst PINNED_FALLBACK = \"0.9.1\";\nvoid PINNED_FALLBACK;"
11
+ "const SUPPLY_MANIFEST = \"scripts/fixtures/herdr-supply.json\";\nconst PINNED_FALLBACK = \"0.9.3\";\nvoid PINNED_FALLBACK;"
12
12
  ],
13
13
  "gate": ["bash", "run.sh", "check-herdr-supply"],
14
14
  "timeoutSeconds": 120,
@@ -19,8 +19,8 @@
19
19
  "claim": "HSUP-DIGEST-SHAPE",
20
20
  "title": "a pinned digest is truncated, so the check that is supposed to catch a swapped binary can no longer fail",
21
21
  "subject": "scripts/fixtures/herdr-supply.json",
22
- "find": ["\"sha256\": \"2a02fed16beb651ef006e1d43f048f652ca4dc58ad053cd2d44450563d5c54b7\""],
23
- "replace": ["\"sha256\": \"2a02fed16beb651ef006e1d43f04\""],
22
+ "find": ["\"sha256\": \"18a8dc65f1c2fa485884344356dea1cfd911c6f06cf46fa78e193f4087f4dba7\""],
23
+ "replace": ["\"sha256\": \"18a8dc65f1c2fa485884344356de\""],
24
24
  "gate": ["bash", "run.sh", "check-herdr-supply"],
25
25
  "timeoutSeconds": 120,
26
26
  "signature": "[QK:HSUP-DIGEST-SHAPE]",
@@ -182,9 +182,11 @@
182
182
  "claim": "MHSS-JOIN-SCOPE-OWNERKIND",
183
183
  "title": "admitting copilot-extension into the sender-marker join makes every Copilot citizen permanently mailbox-undeliverable: its watch owner is a forked child whose pid never carries a sender marker, so the join can only ever answer no",
184
184
  "subject": "pi-extensions/lib/entwurf-deliverability.ts",
185
- "find": ["export const SENDER_JOINED_RECEIVER_OWNER_KINDS: readonly string[] = [\"claude-code-cli\"];"],
185
+ "find": [
186
+ "export const SENDER_JOINED_RECEIVER_OWNER_KINDS: readonly string[] = [\"claude-code-cli\", \"pi-durable-host\"];"
187
+ ],
186
188
  "replace": [
187
- "export const SENDER_JOINED_RECEIVER_OWNER_KINDS: readonly string[] = [\"claude-code-cli\", \"copilot-extension\"];"
189
+ "export const SENDER_JOINED_RECEIVER_OWNER_KINDS: readonly string[] = [\"claude-code-cli\", \"pi-durable-host\", \"copilot-extension\"];"
188
190
  ],
189
191
  "gate": ["bash", "run.sh", "check-meta-hook-session-switch"],
190
192
  "timeoutSeconds": 300,