@junghanacs/entwurf 0.33.0 → 0.34.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.
@@ -1,5 +1,5 @@
1
1
  diff --git a/packages/coding-agent/src/experimental/durable/runtime.ts b/packages/coding-agent/src/experimental/durable/runtime.ts
2
- index fcd6e523..929dbc79 100644
2
+ index fcd6e523..7f00e27f 100644
3
3
  --- a/packages/coding-agent/src/experimental/durable/runtime.ts
4
4
  +++ b/packages/coding-agent/src/experimental/durable/runtime.ts
5
5
  @@ -8,7 +8,9 @@ import {
@@ -12,7 +12,19 @@ index fcd6e523..929dbc79 100644
12
12
  type ModelRef,
13
13
  ROOT_CONVERSATION_ID,
14
14
  type Submission,
15
- @@ -81,6 +83,10 @@ export interface DurableController {
15
+ @@ -56,8 +58,10 @@ export interface DurableView {
16
+ readonly conversations: readonly ConversationSummary[];
17
+ readonly models: readonly ModelSummary[];
18
+ readonly notices: readonly Notice[];
19
+ - /** The live task graph while the task panel is open. */
20
+ + /** The live task graph, observed for the session's lifetime whether or not its panel is shown. */
21
+ readonly tasks?: TaskGraph;
22
+ + /** Whether the task panel is shown; /tasks toggles only this. */
23
+ + readonly tasksPanel: boolean;
24
+ }
25
+
26
+ export interface DurableViewSource {
27
+ @@ -81,6 +85,10 @@ export interface DurableController {
16
28
  export interface OpenDurableOptions {
17
29
  readonly cwd?: string;
18
30
  readonly continueSession?: boolean;
@@ -23,7 +35,7 @@ index fcd6e523..929dbc79 100644
23
35
  }
24
36
 
25
37
  export interface OpenDurableResult {
26
- @@ -88,6 +94,8 @@ export interface OpenDurableResult {
38
+ @@ -88,6 +96,8 @@ export interface OpenDurableResult {
27
39
  readonly controller: DurableController;
28
40
  /** pi's settings, for the TUI's theme and terminal capabilities. */
29
41
  readonly settings: SettingsManager;
@@ -32,7 +44,7 @@ index fcd6e523..929dbc79 100644
32
44
  close(): Promise<void>;
33
45
  }
34
46
 
35
- @@ -122,6 +130,9 @@ function titleOf(entry: EntryRecord | undefined): { title?: string } {
47
+ @@ -122,6 +132,9 @@ function titleOf(entry: EntryRecord | undefined): { title?: string } {
36
48
  }
37
49
 
38
50
  export async function openDurable(options: OpenDurableOptions = {}): Promise<OpenDurableResult> {
@@ -42,7 +54,7 @@ index fcd6e523..929dbc79 100644
42
54
  const location = await selectSession(options.cwd ?? process.cwd(), options.continueSession ?? false);
43
55
  const envs = new ExecutionEnvs(location.cwd);
44
56
  let harness: Harness | undefined;
45
- @@ -132,6 +143,7 @@ export async function openDurable(options: OpenDurableOptions = {}): Promise<Ope
57
+ @@ -132,6 +145,7 @@ export async function openDurable(options: OpenDurableOptions = {}): Promise<Ope
46
58
  const settings = createHarnessSettings(settingsManager);
47
59
  const registry = createCodingRegistry(settingsManager, location.cwd);
48
60
  registry.install(Subagent);
@@ -50,7 +62,7 @@ index fcd6e523..929dbc79 100644
50
62
 
51
63
  const pendingReports: unknown[] = [];
52
64
  let report: (error: unknown) => void = (error) => pendingReports.push(error);
53
- @@ -146,7 +158,29 @@ export async function openDurable(options: OpenDurableOptions = {}): Promise<Ope
65
+ @@ -146,7 +160,29 @@ export async function openDurable(options: OpenDurableOptions = {}): Promise<Ope
54
66
  },
55
67
  context,
56
68
  );
@@ -81,7 +93,49 @@ index fcd6e523..929dbc79 100644
81
93
  const root = await harness.root(context, {
82
94
  agent: {
83
95
  cwd: location.cwd,
84
- @@ -342,6 +376,7 @@ export async function openDurable(options: OpenDurableOptions = {}): Promise<Ope
96
+ @@ -179,6 +215,7 @@ export async function openDurable(options: OpenDurableOptions = {}): Promise<Ope
97
+ conversations: summaries,
98
+ models: models(),
99
+ notices: [],
100
+ + tasksPanel: true,
101
+ };
102
+ const listeners = new Set<() => void>();
103
+ let notifying = false;
104
+ @@ -296,17 +333,8 @@ export async function openDurable(options: OpenDurableOptions = {}): Promise<Ope
105
+ const thinking: ModelThinkingLevel = agentOf(state.conversation).thinkingLevel ?? "off";
106
+ await current.configure({ model: ref, thinkingLevel: clampThinkingLevel(model, thinking) }, context);
107
+ }),
108
+ - toggleTasks: () =>
109
+ - command(async () => {
110
+ - if (tasks !== undefined) {
111
+ - closeTasks();
112
+ - update({ tasks: undefined });
113
+ - return;
114
+ - }
115
+ - const graph = await opened.taskGraph(context);
116
+ - tasks = graph;
117
+ - unsubscribeTasks = graph.subscribe((value) => update({ tasks: value }));
118
+ - }),
119
+ + // Only the panel: the graph stays observed, so live background work is never out of view.
120
+ + toggleTasks: () => command(async () => update({ tasksPanel: !state.tasksPanel })),
121
+ switchConversation: (id) =>
122
+ command(async () => {
123
+ const next = await opened.conversation(id, context);
124
+ @@ -326,8 +354,11 @@ export async function openDurable(options: OpenDurableOptions = {}): Promise<Ope
125
+ notice("warning", `Saved model is unavailable: ${saved.provider}/${saved.modelId}`);
126
+ }
127
+ if (initial?.fallbackMessage !== undefined) notice("info", initial.fallbackMessage);
128
+ - // The task panel starts open; /tasks hides it.
129
+ - await controller.toggleTasks();
130
+ + // The task graph is observed until close; its panel starts open and /tasks hides it.
131
+ + const graph = await opened.taskGraph(context);
132
+ + tasks = graph;
133
+ + update({ tasks: graph.value });
134
+ + unsubscribeTasks = graph.subscribe((value) => update({ tasks: value }));
135
+ // Recovered work from an interrupted turn continues now.
136
+ harness.resume();
137
+
138
+ @@ -342,6 +373,7 @@ export async function openDurable(options: OpenDurableOptions = {}): Promise<Ope
85
139
  },
86
140
  controller,
87
141
  settings: settingsManager,
@@ -89,3 +143,141 @@ index fcd6e523..929dbc79 100644
89
143
  close() {
90
144
  closing ??= (async () => {
91
145
  unsubscribe();
146
+ diff --git a/packages/coding-agent/src/experimental/durable/tui.ts b/packages/coding-agent/src/experimental/durable/tui.ts
147
+ index 397b276c..6a8fce8f 100644
148
+ --- a/packages/coding-agent/src/experimental/durable/tui.ts
149
+ +++ b/packages/coding-agent/src/experimental/durable/tui.ts
150
+ @@ -23,12 +23,14 @@ import {
151
+ SelectList,
152
+ type SelectListTheme,
153
+ Spacer,
154
+ + type StackEntry,
155
+ setCapabilityOverrides,
156
+ setKeybindings,
157
+ Text,
158
+ TruncatedText,
159
+ TuiAltScreen,
160
+ VStack,
161
+ + visibleWidth,
162
+ } from "@earendil-works/pi-tui";
163
+ import { getAgentDir } from "../../config.ts";
164
+ import { KeybindingsManager } from "../../core/keybindings.ts";
165
+ @@ -150,10 +152,75 @@ interface Handlers {
166
+ cycleThinking(): void;
167
+ }
168
+
169
+ +/**
170
+ + * The live background-task badge: the dock's first row, never shrunk, so live background work stays on
171
+ + * screen however small the terminal gets. It counts the graph's background tasks whatever their kind and
172
+ + * names the ones that are not running; with none it renders nothing and the dock is upstream's.
173
+ + */
174
+ +export class TaskBadge implements Component {
175
+ + #count = 0;
176
+ + #detail = "";
177
+ +
178
+ + get count(): number {
179
+ + return this.#count;
180
+ + }
181
+ +
182
+ + set(graph: TaskGraph | undefined): void {
183
+ + const nodes = Object.values(graph?.tasks ?? {}).filter((node) => node.background);
184
+ + const tally = (state: string, match: (node: TaskGraphNode) => boolean): string[] => {
185
+ + const n = nodes.filter(match).length;
186
+ + return n === 0 ? [] : [`${n} ${state}`];
187
+ + };
188
+ + this.#count = nodes.length;
189
+ + this.#detail = [
190
+ + ...tally("aborting", (node) => node.abortRequested),
191
+ + ...tally("completing", (node) => !node.abortRequested && node.state.status === "completing"),
192
+ + ...tally("waiting", (node) => !node.abortRequested && node.state.status === "waiting"),
193
+ + ...tally("pending", (node) => !node.abortRequested && node.state.status === "pending"),
194
+ + ].join(", ");
195
+ + }
196
+ +
197
+ + /** The widest form that fits, padded when there is room; narrower terminals drop detail, then words. */
198
+ + render(width: number): string[] {
199
+ + const n = this.#count;
200
+ + if (n === 0) return [];
201
+ + const tasks = `⏳ ${n} ${n === 1 ? "task" : "tasks"}`;
202
+ + const detailed = this.#detail ? [`${tasks} (${this.#detail})`] : [];
203
+ + const forms = [...detailed, tasks, `⏳ ${n}`, `⏳${n}`, `*${n}`, "*+", "*"];
204
+ + for (const form of forms) {
205
+ + if (visibleWidth(form) < width) return [theme.fg("accent", ` ${form}`)];
206
+ + if (visibleWidth(form) === width) return [theme.fg("accent", form)];
207
+ + }
208
+ + return [theme.fg("accent", "*")];
209
+ + }
210
+ +
211
+ + invalidate(): void {}
212
+ +}
213
+ +
214
+ +/**
215
+ + * The screen: the transcript above the dock. The badge is the dock's first row and never shrinks, so the
216
+ + * renderer, which clips from the bottom, drops the editor and the footer before it; a one-row terminal
217
+ + * with live background work shows the badge instead of the transcript.
218
+ + */
219
+ +export function durableLayout(transcript: Component, badge: TaskBadge, dock: readonly StackEntry[]): VStack {
220
+ + return new VStack([
221
+ + {
222
+ + component: transcript,
223
+ + basis: 0,
224
+ + grow: 1,
225
+ + shrink: 1,
226
+ + minSize: 1,
227
+ + visible: (viewport) => badge.count === 0 || viewport.height > 1,
228
+ + },
229
+ + { component: new VStack([{ component: badge, shrink: 0 }, ...dock]), basis: "auto", grow: 0, shrink: 1, minSize: 1 },
230
+ + ]);
231
+ +}
232
+ +
233
+ class DurableTui {
234
+ static readonly #renderers: Record<string, ToolRenderers> = createAllToolRenderers();
235
+ readonly #ui: TuiAltScreen;
236
+ readonly #chat = new Container();
237
+ + readonly #taskBadge = new TaskBadge();
238
+ readonly #tasks = new Container();
239
+ readonly #queue = new Container();
240
+ readonly #notices = new Container();
241
+ @@ -216,15 +283,9 @@ class DurableTui {
242
+ content.addChild(new Spacer(1));
243
+ const transcript = new ScrollView(content, { follow: "end", primary: true, overscroll: "chain" });
244
+ this.#transcript = transcript;
245
+ - const dock = new VStack([
246
+ - { component: this.#tasks, shrink: 1, minSize: 0 },
247
+ - { component: this.#queue, shrink: 1, minSize: 0 },
248
+ - { component: this.#notices, shrink: 1, minSize: 0 },
249
+ - { component: this.#editorContainer, shrink: 1, minSize: 3 },
250
+ - { component: this.#footer, shrink: 1, minSize: 0 },
251
+ - ]);
252
+ for (const component of [
253
+ this.#chat,
254
+ + this.#taskBadge,
255
+ this.#tasks,
256
+ this.#queue,
257
+ this.#notices,
258
+ @@ -234,9 +295,12 @@ class DurableTui {
259
+ this.#ui.addChild(component);
260
+ }
261
+ this.#ui.setLayoutRoot(
262
+ - new VStack([
263
+ - { component: transcript, basis: 0, grow: 1, shrink: 1, minSize: 1 },
264
+ - { component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 },
265
+ + durableLayout(transcript, this.#taskBadge, [
266
+ + { component: this.#tasks, shrink: 1, minSize: 0 },
267
+ + { component: this.#queue, shrink: 1, minSize: 0 },
268
+ + { component: this.#notices, shrink: 1, minSize: 0 },
269
+ + { component: this.#editorContainer, shrink: 1, minSize: 3 },
270
+ + { component: this.#footer, shrink: 1, minSize: 0 },
271
+ ]),
272
+ );
273
+ this.#ui.setFocus(this.#editor);
274
+ @@ -299,7 +363,8 @@ class DurableTui {
275
+ );
276
+ }
277
+ }
278
+ - this.#syncTasks(view.tasks);
279
+ + this.#taskBadge.set(view.tasks);
280
+ + this.#syncTasks(view.tasksPanel ? view.tasks : undefined);
281
+ this.#syncQueue((view.conversation.docs["pi.inbox"] ?? { items: [] }) as InboxState);
282
+ this.#syncNotices(view);
283
+ this.#editor.borderColor = theme.getThinkingBorderColor(agentOf(view.conversation).thinkingLevel ?? "off");
@@ -23,14 +23,14 @@
23
23
  "source": "packages/coding-agent/src/experimental/durable/runtime.ts",
24
24
  "patched": true,
25
25
  "blob": "fcd6e5239e999ebf17dea0335fb3dc9cd7f7ce45",
26
- "sha256": "ca0ee10dc904a57d46bb33b37fcc4efe48dfa08b2f0d10e79e7bc22136871f0a"
26
+ "sha256": "bfd88844fae61e8bd32d57b5b986f48056c64eebb5466d9826318a0783ac9005"
27
27
  },
28
28
  {
29
29
  "name": "tui.js",
30
30
  "source": "packages/coding-agent/src/experimental/durable/tui.ts",
31
- "patched": false,
31
+ "patched": true,
32
32
  "blob": "397b276cbeb1c90e1fbc96c3cc59e6a25f92dad7",
33
- "sha256": "bef1ea4bd6e8ab150273752a9b94552d8ce672b518ba3d1ed1a209b8405be1b3"
33
+ "sha256": "f61cc68e5d32552a8196785093ae9e2f4a946088fe71582bb05690517db8c5b4"
34
34
  },
35
35
  {
36
36
  "name": "harness-setup.js",
package/run.sh CHANGED
@@ -134,7 +134,7 @@ Usage:
134
134
  ./run.sh check-omp-birth-hook # #87 gate: drives the real OMP assembler into a temp dir, imports the ASSEMBLED index.ts into a MOCK omp host and fires session_start/session_switch. tui mints one backend:"omp" record + a sender marker keyed to the host's OWN pid (the one-process join) + the garden id on the status line; print/rpc/json mint NOTHING (hasUI is true on the rpc rows, as in the vendor); switch attaches on the same native id and mints the replacement on a new one; the CERTIFIED receiver reader still finds no marker. Also the four-root joint binding: extension and omp-labeled bridge child resolve the same sessions/mailbox/senders/receivers bundle against test-built literals (never the production resolver as its own oracle), under a poisoned PI_CODING_AGENT_DIR and under four distinct ENTWURF_META_* overrides; the override grammar is absolute-or-~ and both halves plus the doctor refuse anything else by name; and a drifted provenance label selects no root policy at all. Hermetic; no omp, no model turn
135
135
  ./run.sh check-pi-durable-contact # #129 L-identity gate, CHECKOUT-ONLY (an installed package refuses it in run_ts). Opens the REAL durable app from THIS checkout's verified carrier (pi/pi-durable/carrier/ under its resolver, against the checkout's pinned pi SDK set — refused by name first) with the pi-durable contact, births the citizen and runs its tools adapter-direct through a privately emitted branch bridge (ENTWURF_PI_DURABLE_BRIDGE_ENTRY; its registry must match this checkout). Matrix roots {default, explicit} x {fresh, --continue}: one record keyed by the durable session dir, sender marker on the spawned host pid, exactly one bridge child whose /proc PPID is that host, poisoned identity carriers kept from it, its four root carriers bound to the roots written, the receiver ARMED (raw marker owned by the host pid + start key as pi-durable-host, joined to the sender marker; removed on close), self = the record and replyable, peers from an outside bridge, same garden id on reopen; the bundle's compiled owner join must admit pi-durable-host. Reaps only the host/bridges it owns on any failure. Harness-bypassed: no model turn, no ToolTask. Missing inputs → SKIP (97), never a pass
136
136
  ./run.sh check-pi-durable-send # #129 L-send FIRST CELL, CHECKOUT-ONLY. Same input as check-pi-durable-contact. One native durable turn: the app's own controller input → GenerationTask → ToolTask → the contact's entwurf_v2 → the private bridge → a seeded self-fetch target (C2 recipe, owned by a separate idle child). S = a 127.0.0.1 scripted OpenAI-compatible endpoint configured through the app's documented models.json (dummy key, PI_OFFLINE); H = the native ToolTask path; V (a vendor turn) is NOT run. Oracles: the wire (tools offered, v2 schema = the bridge's tools/list, exactly two requests, the tool result + receipt in the second), the raw mailbox (.msg 1, signal, sender = the host record), the native view (one call, its settled result, root current). Not exactly-once, not recovery, not a recipient turn. Missing inputs → SKIP (97)
137
- ./run.sh check-pi-durable-beside # #129 mutant execution coordinate (VERIFY rule 3): run_vitest over the five pi-durable beside tests (adapter, carrier/SDK-set verifier, preflight, owner join, carrier relocation/resolver/emitter) — the pi-durable qualification lane's gate argv. Same files check-tests-beside-behavior already runs.
137
+ ./run.sh check-pi-durable-beside # #129 mutant execution coordinate (VERIFY rule 3): run_vitest over the six pi-durable beside tests (adapter, carrier/SDK-set verifier, preflight, owner join, carrier relocation/resolver/emitter/task footer, native-module file and directory ingress) — the pi-durable qualification lane's gate argv. Same files check-tests-beside-behavior already runs.
138
138
  ./run.sh check-pi-durable-receive # #129 L-receive FIRST CELL, CHECKOUT-ONLY. Same input (the bundle must carry the pi-durable-host owner join). The host driver gives the app NO input; a seeded claude-code sender (marker owned by a separate idle child, handed to a gate-owned sending bridge through ENTWURF_META_SENDER_MARKER) enqueues one message, the host's doorbell admits its notice to the ROOT conversation, and the scripted model drains it with entwurf_inbox_read. S withholds response #1 until the ENQUEUE receipt, the .delivered bytes, the signal, the host-owned receiver marker and the notice are captured. Oracles: receiver() admission, native view, raw archive (.delivered.read + lastReadAt), wire (exactly two requests, drain result = view), and after a green reap a forensic read-only SQLite copy (submission row by request id = reported id, root, input, done, placed entry = the notice). V NOT run. Missing inputs → SKIP (97)
139
139
  ./run.sh check-copilot-receive-arm # #82 RAIL 5 gate: the REAL receiver installer + the REAL extension.mjs forked with a stubbed SDK. Arms only after birth, marker owned by the WATCHER pid, self-fetch dispatch answer, doorbell carries the garden id and NOT the body, id-drift/foreign-parent refusals. Hermetic; no Copilot, no model turn
140
140
  ./run.sh check-copilot-launch # #82 RAIL 7 gate: the MANAGED launch `entwurf copilot`, driven through its public address against a FAKE VENDOR on a sandbox PATH. Receiver precondition refusals, EXTENSIONS token + operator token preservation, injected defaults before the `--` terminator, byte-identical argv, the 11 explicit permission/surface policy overrides that suppress `--yolo`, exec (not fork) pid identity, exit passthrough, recursion refusal. Hermetic; no Copilot, no model turn
@@ -208,7 +208,7 @@ Usage:
208
208
  ./run.sh smoke-entwurf-chain-live # LIVE cross-harness delivery chain: native Claude Code -> pi GPT -> pi ACP Sonnet -> mailbox terminus, proving sender identity/replyable at every hop and a real read receipt at the end. Prerequisites (claude on PATH, pi credentials per backend) report protocol SKIP, never a pass
209
209
  ./run.sh check-release-gate-outcomes # release-gate STEP OUTCOME protocol (P1): one skip exit code shared by the shell + TS halves, classifier never rounds a skip up to a pass, `--cut` refuses a MUST SKIP while a bare diagnostic stays exit 0, no LIVE smoke keeps the old exit-0 skip shape, and both real skip surfaces are INVOKED and observed to propagate the code
210
210
  ./run.sh check-gate-manifests # the HEAD of that qualification, on its own: runner self-test + the committed manifests validated against the origin index + the declared lane inventory. Executes ZERO committed mutants (only the self-test's synthetic fixture mutants, in tmp fixture repos) and never snapshots this repo (~20s), which is why it lives in check:hermetic while the body stays on CI/release-gate — three of the five reds qualification ever produced in CI died right here, before a mutant gate ran (#99 B-3)
211
- ./run.sh check-gate-qualification # kill-proof qualification (the gate-of-gates): runner self-test (classifier truth table + synthetic negatives incl. wrong-reason/hang/control-red/impurity) + committed mutant manifests (scripts/mutants/*.json) run in an isolated snapshot repo under control→mutant→restore→control; the real checkout is never written. Evidence = claim IDs + killed mutant IDs, never assertion counts
211
+ ./run.sh check-gate-qualification # kill-proof qualification (the gate-of-gates): runner self-test (classifier truth table + synthetic negatives incl. wrong-reason/hang/control-red/impurity) + committed mutant manifests (scripts/mutants/*.json) run in an isolated snapshot repo under control→mutant→restore→control; the real checkout is never written. Evidence = claim IDs + killed mutant IDs, never assertion counts. Bare = the whole body (the prepared SHA's CI call); `--group '<argv JSON>' --receipt <abs path outside the repo>` = one affected exact-argv group, a PARTIAL receipt (development's run, #133)
212
212
  ./run.sh check-probe-ordering # §11-7 ordering-probe deterministic gate: raw-client SAMENESS pinned to backend.ts (sequence/args/timeouts/permission policy — the probe may never measure a lookalike), phase attribution incl. set-model, probe-mode fixture wire markers (delay honored, probeRunId REQUIRED, smoke-acp-mcp-live legacy compat), event-log door integrity (reserved keys refused at write; unknown marker name, broken sort axis, or a payload the classifier cannot judge on is MALFORMED, never a quiet event), and the §11-7 paired-verdict truth table (P0/I0 outside the space, phase-qualified D, B promotion ladder, C, A two-delay rule). Offline/deterministic; one product claim (no production prompt cutoff) is replant-qualified via scripts/mutants/probe-ordering.json, while the other assertions are direct [CHECK:*] contracts
213
213
  ./run.sh check-probe-cli-shim # §11-7-c B-name-snapshot PRODUCER gate: the CLI shim driven as a REAL process against fake CLIs (no API, no cost). Proves what a defect would buy — FABRICATED evidence (a malformed init is never reported as an empty name set; the boot report carries the true target path+sha256 the classifier verifies against the roster), a DESTROYED turn (byte transparency across mid-UTF8/CRLF/oversized/unterminated framing, exit-code fidelity, signal re-raise, inbound signal forwarding, stderr passthrough, stdout backpressure), and LEAKED operator state (exact-allowlist env scrub, no argv/env/prompt body in the log). Offline/deterministic; 20 direct [CHECK:*] contracts, deliberately no longer replant-qualified after #70 subtraction
214
214
  ./run.sh smoke-agy-statusline-state # agy ambient garden-id statusLine install/doctor/inverse regression. Offline/deterministic
@@ -1520,8 +1520,8 @@ check_pi_durable_beside() {
1520
1520
  # from re-running every beside test. The six files are one contact: the adapter and its packaged
1521
1521
  # bootstrap, the installed carrier/SDK-set verifier (which also reads the three checkout gates as
1522
1522
  # SOURCE), the fresh preflight, the owner join that admits the durable host, the carrier's
1523
- # relocation grammar, resolver and maintainer emitter (#130), and the bootstrap's explicit
1524
- # --native-module ingress (#130 P2).
1523
+ # relocation grammar, resolver and maintainer emitter (#130) with the carrier's task footer (#134),
1524
+ # and the bootstrap's explicit --native-module ingress (#130 P2) and its --native-module-dir (#134).
1525
1525
  run_vitest pi-extensions/meta-bridge-pi-durable.test.ts pi-extensions/lib/pi-durable-runtime.test.ts pi-extensions/lib/pi-durable-fresh-preflight.test.ts pi-extensions/lib/entwurf-deliverability.owner-join.test.ts pi-extensions/lib/pi-durable-carrier.test.ts pi-extensions/lib/pi-durable-native-module.test.ts
1526
1526
  }
1527
1527
 
@@ -4025,8 +4025,8 @@ ORACLE
4025
4025
  # copied from this checkout into the sandbox — it never ships (test/ is outside the package files).
4026
4026
  # Its initialization appends `init` to the receipt this cell names; the same public launch must then
4027
4027
  # reach the same carrier model refusal, so the installed bootstrap loaded the module before the
4028
- # runtime and the native registry installed its extension (carrier/runtime.js 146) before that
4029
- # refusal (runtime.js 162). A module claiming an extension name the app installs itself is refused by
4028
+ # runtime and the native registry installed its extension (carrier/runtime.js 148) before that
4029
+ # refusal (runtime.js 164). A module claiming an extension name the app installs itself is refused by
4030
4030
  # name before anything opens: no durable session directory, no citizen, in a fresh sandbox home.
4031
4031
  # Native SDK file opens here are ordinary (the module runs in the native process); none is judged.
4032
4032
  local mod_dir="$box/pd-module" mod_home="$box/pd-module-home" mod_env=()
@@ -4048,6 +4048,32 @@ ORACLE
4048
4048
  fi
4049
4049
  echo "[check-pack-install] pi-durable native module: the installed bootstrap initialized one explicit module before the carrier's runtime (then the same model refusal), and refused a reserved extension name before anything opened"
4050
4050
 
4051
+ # (c3) #134: the paired --native-module-dir on the INSTALLED package. One named directory holding the
4052
+ # same synthetic module under a matching name beside a helper and a dotfile that would throw if
4053
+ # opened: the installed bootstrap must read only the top-level *.extension.mjs, initialize it once
4054
+ # (one `init` receipt) and reach the same carrier model refusal. A directory without a matching
4055
+ # module is refused by name before anything opens: no durable session directory, no citizen.
4056
+ local dir_mods="$box/pd-module-dir" dir_empty="$box/pd-module-dir-empty" dir_home="$box/pd-module-dir-home" dir_env=()
4057
+ mkdir -p "$dir_mods" "$dir_empty" "$dir_home/.pi/agent"
4058
+ cp "$REPO_DIR/test/fixtures/pi-durable-native-module/mock.mjs" "$dir_mods/10-mock.extension.mjs"
4059
+ printf 'throw new Error("pack-install: helper opened");\n' > "$dir_mods/helper.mjs"
4060
+ printf 'throw new Error("pack-install: dotfile opened");\n' > "$dir_mods/.hidden.extension.mjs"
4061
+ cp "$dir_mods/helper.mjs" "$dir_empty/index.mjs"
4062
+ rc=0; out=$(cd "$launch_cwd" && env "${launch_env[@]}" PI_OFFLINE=1 MOCK_NATIVE_RECEIPT="$dir_mods/receipt.log" "$l1s/node_modules/.bin/entwurf" pi-durable --provider entwurf-pack-probe --model no-such-model --width task-wide --native-module-dir "$dir_mods" 2>&1 </dev/null) || rc=$?
4063
+ if [ "$rc" -eq 0 ] || [ "$(cat "$dir_mods/receipt.log" 2>/dev/null)" != "init" ] || ! grep -q 'Could not resolve model: Unknown provider "entwurf-pack-probe"' <<<"$out" || ! grep -q "at openDurable (file://.*/pi/pi-durable/carrier/runtime.js" <<<"$out" || grep -q -e "native-module-" -e "pack-install: .* opened" <<<"$out"; then
4064
+ fail "[QK:PACK-INSTALL-DURABLE-NATIVE-MODULE-DIR] [check-pack-install] the installed 'entwurf pi-durable --native-module-dir' did not initialize exactly its one top-level *.extension.mjs and then reach the carrier openDurable's own model refusal (rc=$rc, receipt $(cat "$dir_mods/receipt.log" 2>/dev/null | tr '\n' ' ')):"
4065
+ echo "$out" | tail -15 | sed 's/^/ /' >&2
4066
+ return 1
4067
+ fi
4068
+ mapfile -t dir_env < <(pack_install_consumer_env "$dir_home")
4069
+ rc=0; out=$(cd "$launch_cwd" && env "${dir_env[@]}" PI_OFFLINE=1 "$l1s/node_modules/.bin/entwurf" pi-durable --provider entwurf-pack-probe --model no-such-model --width task-wide --native-module-dir "$dir_empty" 2>&1 </dev/null) || rc=$?
4070
+ if [ "$rc" -eq 0 ] || ! grep -q "native-module-dir-empty: " <<<"$out" || grep -q -e "Unknown provider" -e "pack-install: .* opened" <<<"$out" || [ -e "$dir_home/.pi/agent/experimental" ] || [ -e "$dir_home/.pi/agent/meta-sessions" ]; then
4071
+ fail "[QK:PACK-INSTALL-DURABLE-NATIVE-MODULE-DIR] [check-pack-install] the installed launch did not refuse a directory without a module by name before anything opened (rc=$rc):"
4072
+ echo "$out" | tail -15 | sed 's/^/ /' >&2
4073
+ return 1
4074
+ fi
4075
+ echo "[check-pack-install] pi-durable module directory: the installed bootstrap initialized only the named directory's one top-level *.extension.mjs before the carrier's runtime (then the same model refusal), and refused a directory without a module before anything opened"
4076
+
4051
4077
  # (d) Host vs native are two subjects. The installed package's native SDK set is the pin (above);
4052
4078
  # the ordinary Pi HOST on PATH is judged by its OWN real `pi --version` against the derived range.
4053
4079
  # Real host binaries, no forged version: a sandbox install of the previous pi generation must make the
@@ -7483,7 +7509,13 @@ case "$cmd" in
7483
7509
  # (HEAD + work-surface content hash asserted identical before/after). The runner is negative-controlled
7484
7510
  # first: zero-match/multi-match/survived/wrong-reason/hang(+pgroup grandchild kill)/
7485
7511
  # control-red/impurity all must classify red before any real manifest counts.
7486
- run_ts scripts/check-gate-qualification.ts
7512
+ # Arguments reach the runner's own parser unchanged (#133): `--group '<argv JSON>'
7513
+ # --receipt <abs path>` is the affected PARTIAL a development lane runs, and a malformed,
7514
+ # missing or repeated flag is refused there before any body or snapshot. Dropping them
7515
+ # turned a requested PARTIAL into the whole body. Bare, it stays the whole body — the
7516
+ # prepared SHA's CI call. Never retry bare after a refusal.
7517
+ shift || true
7518
+ run_ts scripts/check-gate-qualification.ts "$@"
7487
7519
  ;;
7488
7520
  check-probe-ordering)
7489
7521
  # §11-7 ordering-probe gate (docs/acp-backend-rail.md §11-7): the raw-client seam is
@@ -49,7 +49,7 @@ import { createHash } from "node:crypto";
49
49
  import * as fs from "node:fs";
50
50
  import * as os from "node:os";
51
51
  import * as path from "node:path";
52
- import { fileURLToPath } from "node:url";
52
+ import { fileURLToPath, pathToFileURL } from "node:url";
53
53
  import {
54
54
  classifyMutantRun,
55
55
  countOccurrences,
@@ -1178,6 +1178,63 @@ function buildDeclaredOutputFixture(): string {
1178
1178
  parsedCompose.expectPath === "/e.txt",
1179
1179
  );
1180
1180
 
1181
+ // The operator's door to this parser is `run.sh check-gate-qualification` (#133): it
1182
+ // once dropped every argument, so a requested PARTIAL started the whole body. The
1183
+ // cells run a byte copy of this checkout's run.sh whose runner is a fixture: the REAL
1184
+ // parser, then a print where the body would start — so neither a refusal nor a mutated
1185
+ // wrapper can launch a body or a snapshot from here. Mutation acceptance stays the
1186
+ // runner's own; this proves only the door's control flow.
1187
+ const door = reclaimOnExit(fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-qualification-door-")));
1188
+ fs.mkdirSync(path.join(door, "scripts"));
1189
+ fs.copyFileSync(path.join(REPO_DIR, "run.sh"), path.join(door, "run.sh"));
1190
+ const parserUrl = pathToFileURL(path.join(REPO_DIR, "scripts", "lib", "qualification-receipt.ts")).href;
1191
+ fs.writeFileSync(
1192
+ path.join(door, "scripts", "check-gate-qualification.ts"),
1193
+ [
1194
+ `import { parseQualificationArgs } from ${JSON.stringify(parserUrl)};`,
1195
+ "const argv = process.argv.slice(2);",
1196
+ "let parsed;",
1197
+ "try { parsed = parseQualificationArgs(argv); }",
1198
+ 'catch (err) { process.stderr.write("refused: " + (err instanceof Error ? err.message : String(err)) + "\\n"); process.exit(2); }',
1199
+ 'process.stdout.write(JSON.stringify({ argv, parsed }) + "\\n");',
1200
+ "",
1201
+ ].join("\n"),
1202
+ );
1203
+ const viaDoor = (argv: string[]) => {
1204
+ const r = spawnSync("bash", [path.join(door, "run.sh"), "check-gate-qualification", ...argv], {
1205
+ cwd: door,
1206
+ encoding: "utf8",
1207
+ env: { PATH: process.env.PATH ?? "", HOME: door },
1208
+ timeout: 30_000,
1209
+ });
1210
+ const started = r.stdout.trim() === "" ? undefined : JSON.parse(r.stdout);
1211
+ return { status: r.status, stderr: r.stderr, started };
1212
+ };
1213
+ const requested = ["--group", '["bash","run.sh","check pi *"]', "--receipt", "/x/r.json"];
1214
+ const partial = viaDoor(requested);
1215
+ const bare = viaDoor([]);
1216
+ ok(
1217
+ "[QK:QUALIFY-DOOR-FORWARDS-ARGV] run.sh check-gate-qualification hands --group/--receipt to the parser unchanged (a space and a glob inside the argv JSON included) and the parser reads one PARTIAL group; called bare it still reads the whole body — the prepared SHA's CI call",
1218
+ partial.status === 0 &&
1219
+ JSON.stringify(partial.started?.argv) === JSON.stringify(requested) &&
1220
+ partial.started?.parsed.mode === "group" &&
1221
+ partial.started.parsed.receiptPath === "/x/r.json" &&
1222
+ bare.status === 0 &&
1223
+ JSON.stringify(bare.started?.argv) === "[]" &&
1224
+ bare.started?.parsed.mode === "full",
1225
+ );
1226
+ const doorRefusals: Array<[string[], string]> = [
1227
+ [["--group", '["bash","run.sh","check-x"]'], "--group needs --receipt"],
1228
+ [["--group", '["a"]', "--group", '["a"]', "--receipt", "/x/r.json"], "repeated flag"],
1229
+ [["--grop", '["a"]', "--receipt", "/x/r.json"], "unknown argument"],
1230
+ [["--group", "check-x", "--receipt", "/x/r.json"], "JSON array"],
1231
+ ];
1232
+ const refused = doorRefusals.map(([argv, naming]) => ({ naming, run: viaDoor(argv) }));
1233
+ ok(
1234
+ "[QK:QUALIFY-DOOR-REFUSES-BEFORE-BODY] through run.sh, a missing --receipt, a repeated flag, a misspelled flag and a non-array --group are each refused by name and nothing starts — never a whole body in place of the PARTIAL that was asked for",
1235
+ refused.every(({ naming, run }) => run.status === 2 && run.stderr.includes(naming) && run.started === undefined),
1236
+ );
1237
+
1181
1238
  const tmpRoot = reclaimOnExit(fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-receipt-selftest-")));
1182
1239
  const { dir: origin, externalTarget } = buildFixtureOrigin();
1183
1240
  try {
@@ -3405,12 +3462,12 @@ let manifestCount: number;
3405
3462
  "pi-bridge-sender": 4,
3406
3463
  "pi-mcp-bridge": 2,
3407
3464
  "pi-mcp-register": 3,
3408
- "pi-durable": 47,
3465
+ "pi-durable": 58,
3409
3466
  "copilot-receive": 20,
3410
3467
  "entwurf-peers": 1,
3411
3468
  "fresh-call-dispatch": 11,
3412
3469
  "fresh-cut": 3,
3413
- "gate-qualification": 8,
3470
+ "gate-qualification": 10,
3414
3471
  "herdr-placement": 13,
3415
3472
  "herdr-plugin": 25,
3416
3473
  "herdr-plugin-profile": 14,
@@ -1098,7 +1098,7 @@ console.log(
1098
1098
  "a run.sh wrapper declining its own prerequisite (including the measured LIVE=1 no-cortex-connection cell); and every " +
1099
1099
  "LIVE smoke is either wired into release_gate or excluded by a sentence the docs still carry; and the moved " +
1100
1100
  "check-gate-qualification stays reachable on its owners (absent from the default chain, exactly once in CI, " +
1101
- "exactly once as a release-gate MUST step) and the CI step qualifies the FULL floor, which runs before it, " +
1101
+ "absent from release_gate: the one body is the prepared SHA's CI step) and the CI step qualifies the FULL floor, which runs before it, " +
1102
1102
  "while the exact-SHA release oracle requires that BODY step to have concluded success at the release SHA, " +
1103
1103
  "and the CI filter that decides when that body runs covers every mutant subject and still runs it for all " +
1104
1104
  "five historical reds; and " +
@@ -10,9 +10,10 @@
10
10
  * Inputs, and nothing else: the pinned commit's OBJECTS (each declared file and the license, read as
11
11
  * `<commit>:<path>` — never a worktree, so the repository's checkout state cannot enter), the packaged
12
12
  * overlay patch, and this Node's `stripTypeScriptTypes` in strip mode (upstream runs these exact
13
- * sources under Node's strip-only TypeScript). The patch is applied by git's own engine to a private
14
- * scratch copy; the base blob and the patched result must carry the blob ids the patch's `index` line
15
- * names. Each stripped file is then relocated by pi/pi-durable/carrier-relocation.mjs, and the targets
13
+ * sources under Node's strip-only TypeScript). The one patch carries exactly one section per file the
14
+ * pin declares `patched`, and no other section (#134). Each section is applied by git's own engine to a
15
+ * private scratch copy of its file; the base blob and the patched result must carry the blob ids that
16
+ * section's `index` line names. Each stripped file is then relocated by pi/pi-durable/carrier-relocation.mjs, and the targets
16
17
  * the six files import must equal the pin's declared `distTargets` exactly.
17
18
  *
18
19
  * `--write` emits pi/pi-durable/carrier/{<files>,LICENSE} and fills the pin's generated fields
@@ -118,22 +119,60 @@ function readPin(): CarrierPin {
118
119
  `the emitter is ${EMITTER_TOOL} in ${EMITTER_MODE} mode, not ${JSON.stringify(pin.carrier.emitter)}`,
119
120
  );
120
121
  }
121
- if (pin.carrier.files.filter((f) => f.patched).length !== 1) {
122
- throw refuse("carrier-pin-malformed", "exactly one carrier file carries the overlay patch");
122
+ if (!pin.carrier.files.some((f) => f.patched)) {
123
+ throw refuse("carrier-pin-malformed", "at least one carrier file carries the overlay patch");
123
124
  }
124
125
  return pin;
125
126
  }
126
127
 
127
- /** The overlay patch's one file and the two blob ids its `index` line names. */
128
- function readPatch(pin: CarrierPin): { bytes: Buffer; file: string; base: string; result: string } {
129
- const bytes = fs.readFileSync(path.join(DURABLE, "overlay", pin.patches[0]));
130
- const text = bytes.toString("utf8");
131
- const files = [...text.matchAll(/^diff --git a\/(\S+) b\/(\S+)$/gm)];
132
- const index = [...text.matchAll(/^index ([0-9a-f]+)\.\.([0-9a-f]+) 100644$/gm)];
133
- if (files.length !== 1 || files[0][1] !== files[0][2] || index.length !== 1) {
134
- throw refuse("carrier-patch-shape", `${pin.patches[0]} must change exactly one existing file`);
128
+ /** One file's section of the overlay patch: its own bytes and the two blob ids its `index` line names. */
129
+ export interface PatchSection {
130
+ bytes: Buffer;
131
+ base: string;
132
+ result: string;
133
+ }
134
+
135
+ /**
136
+ * The overlay patch split into its per-file sections, keyed by the existing file each one changes, and
137
+ * matched against the source paths the pin declares `patched`: exactly one section per declared file and
138
+ * no section for any other file. Anything else is the patch's shape, refused by name.
139
+ */
140
+ export function patchSections(
141
+ text: string,
142
+ patched: readonly string[],
143
+ name = "the overlay patch",
144
+ ): Map<string, PatchSection> {
145
+ const starts = [...text.matchAll(/^diff --git /gm)].map((m) => m.index);
146
+ if (starts.length === 0 || starts[0] !== 0) {
147
+ throw refuse("carrier-patch-shape", `${name} must start with its first file section`);
148
+ }
149
+ const sections = new Map<string, PatchSection>();
150
+ for (const [at, start] of starts.entries()) {
151
+ const chunk = text.slice(start, starts[at + 1] ?? text.length);
152
+ const files = [...chunk.matchAll(/^diff --git a\/(\S+) b\/(\S+)$/gm)];
153
+ const index = [...chunk.matchAll(/^index ([0-9a-f]+)\.\.([0-9a-f]+) 100644$/gm)];
154
+ if (files.length !== 1 || files[0][1] !== files[0][2] || index.length !== 1) {
155
+ throw refuse("carrier-patch-shape", `every section of ${name} changes exactly one existing file`);
156
+ }
157
+ const file = files[0][1];
158
+ if (sections.has(file)) throw refuse("carrier-patch-shape", `${name} changes ${file} in two sections`);
159
+ sections.set(file, { bytes: Buffer.from(chunk, "utf8"), base: index[0][1], result: index[0][2] });
135
160
  }
136
- return { bytes, file: files[0][1], base: index[0][1], result: index[0][2] };
161
+ const undeclared = [...sections.keys()].filter((file) => !patched.includes(file));
162
+ const missing = patched.filter((file) => !sections.has(file));
163
+ if (undeclared.length > 0 || missing.length > 0) {
164
+ throw refuse(
165
+ "carrier-patch-shape",
166
+ `${name} must change exactly the files the pin declares patched (undeclared: ${undeclared.join(", ") || "none"}; missing: ${missing.join(", ") || "none"})`,
167
+ );
168
+ }
169
+ return sections;
170
+ }
171
+
172
+ function readPatch(pin: CarrierPin): Map<string, PatchSection> {
173
+ const text = fs.readFileSync(path.join(DURABLE, "overlay", pin.patches[0]), "utf8");
174
+ const patched = pin.carrier.files.filter((f) => f.patched).map((f) => f.source);
175
+ return patchSections(text, patched, pin.patches[0]);
137
176
  }
138
177
 
139
178
  /** `base` with the patch applied by git, in a private scratch tree outside any repository. */
@@ -172,7 +211,7 @@ function emit(source: string, pin: CarrierPin): Emission {
172
211
  if (git(source, ["cat-file", "-t", pin.commit]).toString().trim() !== "commit") {
173
212
  throw refuse("carrier-source-unreadable", `${pin.commit} is not a commit in ${source}`);
174
213
  }
175
- const patch = readPatch(pin);
214
+ const sections = readPatch(pin);
176
215
  const names = pin.carrier.files.map((f) => f.name);
177
216
  const targets = pin.carrier.distTargets;
178
217
  if (new Set(targets).size !== targets.length) throw refuse("carrier-pin-malformed", "distTargets repeats a path");
@@ -183,18 +222,19 @@ function emit(source: string, pin: CarrierPin): Emission {
183
222
  const original = readSourceObject(source, `${pin.commit}:${declared.source}`);
184
223
  const blob = blobId(original);
185
224
  let text = original;
186
- if (declared.patched) {
187
- if (declared.source !== patch.file || !blob.startsWith(patch.base)) {
225
+ const section = declared.patched ? sections.get(declared.source) : undefined;
226
+ if (section !== undefined) {
227
+ if (!blob.startsWith(section.base)) {
188
228
  throw refuse(
189
229
  "carrier-patch-base",
190
- `the patch changes ${patch.file} from ${patch.base}; ${declared.source} is ${blob}`,
230
+ `the patch changes ${declared.source} from ${section.base}; the pinned object is ${blob}`,
191
231
  );
192
232
  }
193
- text = applyPatch(declared.source, original, patch.bytes);
194
- if (!blobId(text).startsWith(patch.result)) {
233
+ text = applyPatch(declared.source, original, section.bytes);
234
+ if (!blobId(text).startsWith(section.result)) {
195
235
  throw refuse(
196
236
  "carrier-patch-result",
197
- `patched ${declared.source} is ${blobId(text)}, the patch names ${patch.result}`,
237
+ `patched ${declared.source} is ${blobId(text)}, the patch names ${section.result}`,
198
238
  );
199
239
  }
200
240
  }
@@ -95,6 +95,30 @@
95
95
  "timeoutSeconds": 120,
96
96
  "signature": "[QK:QUALIFY-PRIOR-RUN-EVIDENCE-KEPT]",
97
97
  "signatureSource": "scripts/check-gate-qualification.ts"
98
+ },
99
+ {
100
+ "claim": "QUALIFY-DOOR-FORWARDS-ARGV",
101
+ "title": "run.sh check-gate-qualification drops its arguments again — a requested --group/--receipt PARTIAL reaches the runner as a bare call and starts the whole ~900-mutant body nobody asked for (#133: the wrapper defect that made every development PARTIAL a whole run)",
102
+ "subject": "run.sh",
103
+ "find": [" run_ts scripts/check-gate-qualification.ts \"$@\""],
104
+ "replace": [" run_ts scripts/check-gate-qualification.ts"],
105
+ "gate": ["bash", "run.sh", "check-gate-manifests"],
106
+ "timeoutSeconds": 120,
107
+ "signature": "[QK:QUALIFY-DOOR-FORWARDS-ARGV]",
108
+ "signatureSource": "scripts/check-gate-qualification.ts"
109
+ },
110
+ {
111
+ "claim": "QUALIFY-DOOR-REFUSES-BEFORE-BODY",
112
+ "title": "run.sh check-gate-qualification retries bare when the runner refuses — a malformed, missing or repeated flag is named, then the whole body starts anyway in place of the PARTIAL that was asked for (#133)",
113
+ "subject": "run.sh",
114
+ "find": [" run_ts scripts/check-gate-qualification.ts \"$@\""],
115
+ "replace": [
116
+ " run_ts scripts/check-gate-qualification.ts \"$@\" || run_ts scripts/check-gate-qualification.ts"
117
+ ],
118
+ "gate": ["bash", "run.sh", "check-gate-manifests"],
119
+ "timeoutSeconds": 120,
120
+ "signature": "[QK:QUALIFY-DOOR-REFUSES-BEFORE-BODY]",
121
+ "signatureSource": "scripts/check-gate-qualification.ts"
98
122
  }
99
123
  ]
100
124
  }