@junghanacs/entwurf 0.31.0 → 0.32.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 (56) hide show
  1. package/AGENTS.md +3 -2
  2. package/CHANGELOG.md +82 -0
  3. package/README.md +46 -36
  4. package/VERIFY.md +35 -16
  5. package/demo/README.md +1 -1
  6. package/docs/acp-backend-rail.md +1 -1
  7. package/docs/setup-clean-host.md +112 -95
  8. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +1 -1
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +1 -1
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +8 -10
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-fresh-preflight.js +3 -3
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +153 -205
  13. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +6 -4
  14. package/mcp/entwurf-bridge/src/index.ts +1 -1
  15. package/package.json +14 -17
  16. package/pi/pi-durable/bootstrap.mjs +142 -19
  17. package/pi/pi-durable/carrier/LICENSE +21 -0
  18. package/pi/pi-durable/carrier/harness-setup.js +123 -0
  19. package/pi/pi-durable/carrier/prompt.js +74 -0
  20. package/pi/pi-durable/carrier/runtime.js +402 -0
  21. package/pi/pi-durable/carrier/sessions.js +56 -0
  22. package/pi/pi-durable/carrier/subagent.js +55 -0
  23. package/pi/pi-durable/carrier/tui.js +655 -0
  24. package/pi/pi-durable/carrier-relocation.mjs +160 -0
  25. package/pi/pi-durable/carrier-resolver.mjs +38 -0
  26. package/pi/pi-durable/overlay/upstream-pin.json +103 -11
  27. package/pi-extensions/lib/fresh-call-composition.ts +1 -1
  28. package/pi-extensions/lib/mux-fresh-call.ts +8 -10
  29. package/pi-extensions/lib/pi-durable-fresh-preflight.ts +3 -3
  30. package/pi-extensions/lib/pi-durable-runtime.ts +197 -263
  31. package/pi-extensions/meta-bridge-pi-durable.ts +11 -7
  32. package/run.sh +508 -235
  33. package/scripts/check-entwurf-bridge-pi-free.ts +7 -5
  34. package/scripts/check-fresh-call-dispatch.ts +1 -1
  35. package/scripts/check-gate-qualification.ts +86 -34
  36. package/scripts/check-herdr-activation.ts +5 -4
  37. package/scripts/check-pi-durable-contact.ts +26 -93
  38. package/scripts/check-pi-durable-receive.ts +22 -59
  39. package/scripts/check-pi-durable-send.ts +235 -75
  40. package/scripts/emit-pi-durable-carrier.ts +355 -0
  41. package/scripts/lib/mutation-qualify.ts +21 -45
  42. package/scripts/lib/pi-durable-operator-guard.ts +89 -0
  43. package/scripts/lib/qualification-receipt.ts +7 -11
  44. package/scripts/mutants/control-send-receipt.json +1 -1
  45. package/scripts/mutants/gate-qualification.json +13 -0
  46. package/scripts/mutants/mux-fresh-call.json +13 -13
  47. package/scripts/mutants/pack-install.json +3 -3
  48. package/scripts/mutants/pi-durable.json +442 -109
  49. package/scripts/mutants/pi-floor.json +24 -2
  50. package/scripts/pi-durable-contact-host.mjs +3 -3
  51. package/scripts/pi-durable-receive-host.mjs +3 -3
  52. package/scripts/pi-durable-send-host.mjs +76 -5
  53. package/scripts/smoke-herdr-raw-install-live.sh +1 -1
  54. package/scripts/smoke-pi-durable-fresh-live.ts +3 -3
  55. package/scripts/smoke-setup-verdict.sh +15 -15
  56. package/scripts/smoke-user-scope-citizen.sh +1 -1
@@ -1,80 +1,59 @@
1
1
  /**
2
- * pi-durable-runtime — where the operator-provided durable runtime lives, and whether it is the pin
3
- * (#129 W, L3).
2
+ * pi-durable-runtime — whether THIS package's pi-durable carrier can run, and the resolver it runs
3
+ * under (#129 W, #130).
4
4
  *
5
- * The experimental durable app is SOURCE-ONLY upstream: the published Pi CLI ships no
6
- * `dist/experimental`, and `@earendil-works/pi-durable` on npm is the library, not this app. So its
7
- * runtime is a checkout the OPERATOR provides — the pinned commit plus this package's overlay patch
8
- * and the pinned model data — and Entwurf never downloads, installs, moves or deletes it. This leaf
9
- * only DETECTS it, at ONE fixed place:
5
+ * The experimental durable app is source-only upstream: published `@earendil-works/pi-coding-agent`
6
+ * ships no `dist/experimental`. Entwurf therefore ships the app itself as a CARRIER — the pinned
7
+ * upstream files, type-stripped and relocated (pi/pi-durable/carrier/, emitted only by the
8
+ * maintainer's scripts/emit-pi-durable-carrier.ts) — and declares the published SDK set it runs
9
+ * against as exact production dependencies. Nothing is provisioned beside the package: there is no
10
+ * runtime directory, no source checkout and no model data to copy (it rides the published pi-ai).
10
11
  *
11
- * $XDG_DATA_HOME/entwurf/pi-durable/runtime (an absolute XDG_DATA_HOME; else ~/.local/share)
12
+ * The carrier counts as runnable only when, read from where this package is installed:
13
+ * - every pinned carrier file, the license, the resolver and its relocation grammar are present
14
+ * (else `pi-durable-package-incomplete`);
15
+ * - each carrier file and the license carry the pinned sha256 (else `pi-durable-carrier-drift`);
16
+ * - every declared SDK member and direct dependency resolves from the carrier, and every member
17
+ * copy the carrier can reach — at any depth, nested copies included — resolves each of its own
18
+ * member dependencies (else `pi-durable-sdk-absent`);
19
+ * - each of those PHYSICAL edges lands on a package that is the one it names, at exactly the
20
+ * declared version (else `pi-durable-sdk-mismatch`). The members declare each other with caret ranges, so semver
21
+ * satisfaction is not the contract — the bytes a carrier import actually binds are. Sharing one
22
+ * copy with a host install of the same version is an ordinary layout and passes; how many copies
23
+ * of a version exist is not asked.
12
24
  *
13
- * There is no public path override. `ENTWURF_PI_DURABLE_RUNTIME` stays the CHECKOUT-ONLY input of
14
- * the `check-pi-durable-*` gates; nothing here reads it.
25
+ * Every refusal names the first failing item, in repair order. This leaf is the native-set check
26
+ * only: the ordinary Pi HOST on PATH is a different subject (`pi_supported_range` in run.sh).
15
27
  *
16
- * A present runtime counts as the pin only when HEAD equals the pinned commit, the index equals HEAD
17
- * (nothing staged), `git diff --binary` equals the overlay patch bytes exactly, no non-ignored file is
18
- * untracked, the experimental source resolver is there, and the git-ignored model data matches the
19
- * pin's recorded digests. Anything else is present-but-wrong, a named refusal — never a guess and
20
- * never a repair. The index term is not optional: `git diff` compares the worktree to the INDEX, so a
21
- * staged edit to any other tracked file — or a staged new file, which is not "untracked" either —
22
- * would otherwise leave the diff equal to the patch.
23
- *
24
- * The diff bytes are compared, so the configuration knobs known to move them are pinned on the
25
- * command line to the form the packaged patch is in: abbreviation length, prefixes, colour,
26
- * external/textconv drivers, hunk algorithm and context, relative paths, and blank-context spacing
27
- * (`diff.suppressBlankEmpty`; the packaged patch has blank context lines). That is a bounded list,
28
- * not every knob git has: e.g. `diff.orderFile` (multi-file order) and line-ending conversion
29
- * (`core.autocrlf`/`core.eol`) are not neutralized, and a byte change they cause reads as pin drift.
30
- * Configuration is not disabled wholesale — `safe.directory` lives in global/system config.
31
- *
32
- * The overlay data (pin + patch) ships IN this package and is found relative to this module, at the
33
- * two depths the module is shipped at: the source checkout (`<root>/pi-extensions/lib/`) and the
34
- * compiled bridge closure (`<root>/mcp/entwurf-bridge/dist/pi-extensions/lib/`) — the same two-depth
35
- * arithmetic `metaCapabilitiesFilePath` answers. The compiled durable units are named from that same
36
- * root, so a launcher, a preflight and the bootstrap all read ONE package closure.
37
- *
38
- * Read-only: `git diff` and `git ls-files` run against a private COPY of the runtime's index
39
- * (`GIT_INDEX_FILE`), with GIT_OPTIONAL_LOCKS=0. The copy is not ceremony: `git diff` was measured
40
- * to refresh and rewrite a stat-dirty index even with optional locks off, so asking the operator's
41
- * own index would mutate their checkout. No network, no install, no credential, settings or session read.
42
- * Each `git` is a direct child with a wall-clock bound; one that does not answer in time is unverifiable.
28
+ * Read-only: file reads and Node's own package resolution (`module.findPackageJSON`) from the
29
+ * carrier's location. No git, no network, no environment, no credential, settings or session read.
43
30
  */
44
31
 
45
- import { spawnSync } from "node:child_process";
46
32
  import { createHash } from "node:crypto";
47
33
  import * as fs from "node:fs";
48
- import * as os from "node:os";
34
+ import { findPackageJSON } from "node:module";
49
35
  import * as path from "node:path";
36
+ import { pathToFileURL } from "node:url";
50
37
 
51
- /** The fixed locator under the data home. Not configurable. */
52
- export const PI_DURABLE_RUNTIME_SEGMENTS = ["entwurf", "pi-durable", "runtime"] as const;
53
-
54
- /** The upstream source resolver the durable app runs under, inside the runtime checkout. */
55
- export const PI_DURABLE_RESOLVER_RELATIVE = path.join(
56
- "packages",
57
- "coding-agent",
58
- "src",
59
- "experimental",
60
- "source-resolver.ts",
61
- );
62
-
63
- /** Every way the runtime can be refused. Stable contract: these cross the launcher, setup and the
38
+ /** Every way the carrier can be refused. Stable contract: these cross the launcher, setup and the
64
39
  * fresh-call surfaces as named refusals. */
65
40
  export type PiDurableRuntimeRejectReason =
66
41
  | "pi-durable-package-incomplete"
67
- | "pi-durable-runtime-locator-unset"
68
- | "pi-durable-runtime-absent"
69
- | "pi-durable-runtime-unverifiable"
70
- | "pi-durable-runtime-pin-drift"
71
- | "pi-durable-runtime-modeldata-drift";
42
+ | "pi-durable-carrier-drift"
43
+ | "pi-durable-sdk-absent"
44
+ | "pi-durable-sdk-mismatch";
72
45
 
73
- /** The package closure this module belongs to: the overlay data and the compiled durable units. */
46
+ /** The package closure this module belongs to: the pin, the carrier and the compiled durable units. */
74
47
  export interface PiDurablePackageLayout {
75
48
  readonly root: string;
76
49
  readonly overlayDir: string;
77
- /** The packaged bootstrap the launcher executes under the runtime's resolver. */
50
+ /** The emitted upstream app files and their license. */
51
+ readonly carrierDir: string;
52
+ /** The `--import` resolver the launcher runs the bootstrap under. */
53
+ readonly resolver: string;
54
+ /** The relocation grammar the resolver imports. */
55
+ readonly relocation: string;
56
+ /** The packaged bootstrap the launcher executes under the resolver. */
78
57
  readonly bootstrap: string;
79
58
  /** The compiled durable contact (birth, doorbell, visible identity decoration). */
80
59
  readonly adapter: string;
@@ -86,11 +65,15 @@ export interface PiDurablePackageLayout {
86
65
 
87
66
  /** The layout rooted at `root`, as plain paths. */
88
67
  export function piDurableLayoutAt(root: string): PiDurablePackageLayout {
68
+ const durable = path.join(root, "pi", "pi-durable");
89
69
  const dist = path.join(root, "mcp", "entwurf-bridge", "dist");
90
70
  return {
91
71
  root,
92
- overlayDir: path.join(root, "pi", "pi-durable", "overlay"),
93
- bootstrap: path.join(root, "pi", "pi-durable", "bootstrap.mjs"),
72
+ overlayDir: path.join(durable, "overlay"),
73
+ carrierDir: path.join(durable, "carrier"),
74
+ resolver: path.join(durable, "carrier-resolver.mjs"),
75
+ relocation: path.join(durable, "carrier-relocation.mjs"),
76
+ bootstrap: path.join(durable, "bootstrap.mjs"),
94
77
  adapter: path.join(dist, "pi-extensions", "meta-bridge-pi-durable.js"),
95
78
  bridgeEntry: path.join(dist, "mcp", "entwurf-bridge", "src", "index.js"),
96
79
  deliverability: path.join(dist, "pi-extensions", "lib", "entwurf-deliverability.js"),
@@ -99,7 +82,8 @@ export function piDurableLayoutAt(root: string): PiDurablePackageLayout {
99
82
 
100
83
  /**
101
84
  * The package closure, found from where THIS module lives: the source depth first, then the compiled
102
- * depth. `null` when neither carries the overlay pin — a packaging defect, refused by name.
85
+ * depth (`<root>/mcp/entwurf-bridge/dist/pi-extensions/lib/`). `null` when neither carries the pin —
86
+ * a packaging defect, refused by name.
103
87
  */
104
88
  export function piDurablePackageLayout(from: string = import.meta.dirname): PiDurablePackageLayout | null {
105
89
  for (const up of [
@@ -113,235 +97,189 @@ export function piDurablePackageLayout(from: string = import.meta.dirname): PiDu
113
97
  return null;
114
98
  }
115
99
 
116
- /** The fixed runtime location for this environment, or `null` when neither an absolute
117
- * XDG_DATA_HOME nor HOME exists to anchor it. A relative XDG_DATA_HOME is ignored (XDG spec). */
118
- export function piDurableRuntimeDir(env: NodeJS.ProcessEnv): string | null {
119
- const xdg = env.XDG_DATA_HOME;
120
- if (typeof xdg === "string" && xdg.length > 0 && path.isAbsolute(xdg)) {
121
- return path.join(xdg, ...PI_DURABLE_RUNTIME_SEGMENTS);
122
- }
123
- const home = env.HOME;
124
- if (typeof home === "string" && home.length > 0 && path.isAbsolute(home)) {
125
- return path.join(home, ".local", "share", ...PI_DURABLE_RUNTIME_SEGMENTS);
126
- }
127
- return null;
100
+ /** What the verifier reads from the packaged pin. */
101
+ export interface PiDurableCarrierPin {
102
+ readonly files: readonly { readonly name: string; readonly sha256: string }[];
103
+ readonly license: { readonly sha256: string };
104
+ /** `@earendil-works/*` members: name → exact version. */
105
+ readonly members: Readonly<Record<string, string>>;
106
+ /** Third-party packages a carrier file imports itself: name → exact version. */
107
+ readonly direct: Readonly<Record<string, string>>;
128
108
  }
129
109
 
130
- export interface PiDurablePin {
131
- readonly commit: string;
132
- readonly patches: readonly string[];
133
- readonly modelData: {
134
- readonly path: string;
135
- readonly files: number;
136
- readonly aggregateSha256: string;
137
- readonly manifestSha256: string;
138
- };
139
- }
110
+ const SHA256 = /^[0-9a-f]{64}$/;
111
+ const EXACT = /^\d+\.\d+\.\d+$/;
140
112
 
141
- /** The packaged pin and its one patch. A malformed pin throws (crash, don't warn). */
142
- export function readPiDurablePin(overlayDir: string): { pin: PiDurablePin; patchBytes: Buffer } {
143
- const pin = JSON.parse(fs.readFileSync(path.join(overlayDir, "upstream-pin.json"), "utf8")) as PiDurablePin;
144
- if (typeof pin.commit !== "string" || !/^[0-9a-f]{40}$/.test(pin.commit)) {
145
- throw new Error(`pi-durable pin: commit is not a full sha (${JSON.stringify(pin.commit)})`);
146
- }
147
- if (!Array.isArray(pin.patches) || pin.patches.length !== 1 || typeof pin.patches[0] !== "string") {
148
- throw new Error("pi-durable pin: exactly one overlay patch is expected");
149
- }
150
- const md = pin.modelData;
113
+ /** The packaged pin's carrier and SDK declarations. A malformed pin throws (crash, don't warn). */
114
+ export function readPiDurableCarrierPin(overlayDir: string): PiDurableCarrierPin {
115
+ const pin = JSON.parse(fs.readFileSync(path.join(overlayDir, "upstream-pin.json"), "utf8"));
116
+ const files = pin?.carrier?.files;
117
+ const license = pin?.carrier?.license;
118
+ const members = pin?.sdk?.members;
119
+ const direct = pin?.sdk?.direct;
120
+ const versions = (set: unknown) =>
121
+ typeof set === "object" && set !== null && Object.values(set).every((v) => typeof v === "string" && EXACT.test(v));
151
122
  if (
152
- typeof md !== "object" ||
153
- md === null ||
154
- typeof md.path !== "string" ||
155
- typeof md.files !== "number" ||
156
- typeof md.aggregateSha256 !== "string" ||
157
- typeof md.manifestSha256 !== "string"
123
+ !Array.isArray(files) ||
124
+ files.length === 0 ||
125
+ !files.every((f) => typeof f?.name === "string" && /^[a-z0-9-]+\.js$/.test(f.name) && SHA256.test(f?.sha256)) ||
126
+ !SHA256.test(license?.sha256) ||
127
+ !versions(members) ||
128
+ Object.keys(members).length === 0 ||
129
+ !versions(direct)
158
130
  ) {
159
- throw new Error("pi-durable pin: modelData is malformed");
131
+ throw new Error("pi-durable pin: carrier files/license digests and exact sdk members/direct versions are required");
160
132
  }
161
- return { pin, patchBytes: fs.readFileSync(path.join(overlayDir, pin.patches[0])) };
133
+ return { files, license, members, direct };
162
134
  }
163
135
 
164
- const sha256 = (data: string | Buffer): string => createHash("sha256").update(data).digest("hex");
165
-
166
- /** The patch shape the packaged patch is in, fixed on the command line for the knobs the header lists:
167
- * git's default output with 8-hex `index` abbreviations. Two of those knobs are config-only and ride
168
- * beside these as `-c` at the call: `core.abbrev=8` ("auto" scales with the clone's object count) and
169
- * `diff.suppressBlankEmpty=false` (true drops the space before an empty context line). */
170
- export const PI_DURABLE_DIFF_SHAPE = [
171
- "--binary",
172
- "--no-ext-diff",
173
- "--no-color",
174
- "--no-textconv",
175
- "--src-prefix=a/",
176
- "--dst-prefix=b/",
177
- "--no-relative",
178
- "--diff-algorithm=myers",
179
- "--indent-heuristic",
180
- "-U3",
181
- "--inter-hunk-context=0",
182
- ] as const;
183
-
184
- /** Wall-clock bound for each local `git` the verifier runs (it also runs inside the bridge process). */
185
- export const PI_DURABLE_GIT_TIMEOUT_MS = 30_000;
136
+ /** One physical dependency edge: `to` as resolved from `from`'s location. */
137
+ export interface PiDurableSdkEdge {
138
+ /** `carrier` or the member the edge leaves. */
139
+ readonly from: string;
140
+ readonly to: string;
141
+ readonly want: string;
142
+ /** The resolved package's version, or `null` when it does not resolve. */
143
+ readonly got: string | null;
144
+ /** The name the resolved package.json declares, or `null` — must be `to` itself. */
145
+ readonly name: string | null;
146
+ /** The resolved package directory, or `null`. */
147
+ readonly at: string | null;
148
+ }
186
149
 
187
- /** What a runtime directory holds, read without changing it. */
150
+ /** What the installed package holds, read without changing it. */
188
151
  export interface PiDurableRuntimeFacts {
189
- readonly runtimeDir: string;
190
- readonly present: boolean;
191
- /** `git` answered all four questions (rev-parse, cached diff, diff, ls-files). */
192
- readonly gitAnswered: boolean;
193
- readonly head: string;
194
- /** The index equals HEAD: nothing is staged. */
195
- readonly indexMatchesHead: boolean;
196
- readonly diffEqualsPatch: boolean;
197
- readonly untracked: string;
198
- readonly resolverPresent: boolean;
199
- readonly modelDataFiles: number;
200
- readonly modelDataAggregate: string;
201
- readonly manifestSha256: string;
152
+ /** A carrier/resolver/relocation file that is not there. */
153
+ readonly missing: readonly string[];
154
+ /** A carrier file (or LICENSE) whose bytes are not the pinned ones. */
155
+ readonly drifted: readonly string[];
156
+ readonly edges: readonly PiDurableSdkEdge[];
202
157
  }
203
158
 
204
- export function inspectPiDurableRuntime(runtimeDir: string, overlayDir: string): PiDurableRuntimeFacts {
205
- const { pin, patchBytes } = readPiDurablePin(overlayDir);
206
- const present = (() => {
207
- try {
208
- return fs.statSync(runtimeDir).isDirectory();
209
- } catch {
210
- // Bounded environment probe (Hard Rule 15's stated exception): absence is the answer.
211
- return false;
212
- }
213
- })();
214
- const base = { ...process.env, GIT_OPTIONAL_LOCKS: "0" };
215
- const git = (args: string[], env: NodeJS.ProcessEnv = base) =>
216
- spawnSync("git", ["-C", runtimeDir, ...args], {
217
- maxBuffer: 64 * 1024 * 1024,
218
- env,
219
- timeout: PI_DURABLE_GIT_TIMEOUT_MS,
220
- });
221
- const headRun = present ? git(["rev-parse", "HEAD"]) : undefined;
222
- // A private copy of the index, so the refresh `git diff` performs lands in a file this process owns.
223
- let scratch: string | undefined;
224
- let cachedRun: ReturnType<typeof git> | undefined;
225
- let diffRun: ReturnType<typeof git> | undefined;
226
- let untrackedRun: ReturnType<typeof git> | undefined;
159
+ const sha256 = (data: Buffer): string => createHash("sha256").update(data).digest("hex");
160
+
161
+ /** `name` resolved from `fromFile` by Node's own package resolution: its version and directory. */
162
+ function resolveEdge(from: string, to: string, want: string, fromFile: string): PiDurableSdkEdge {
163
+ let manifest: string | undefined;
227
164
  try {
228
- const indexPath = present ? git(["rev-parse", "--git-path", "index"]) : undefined;
229
- const index = indexPath?.status === 0 ? path.resolve(runtimeDir, indexPath.stdout.toString().trim()) : undefined;
230
- if (index !== undefined && fs.existsSync(index)) {
231
- scratch = fs.mkdtempSync(path.join(os.tmpdir(), "pi-durable-index-"));
232
- const copy = path.join(scratch, "index");
233
- fs.copyFileSync(index, copy);
234
- const onCopy = { ...base, GIT_INDEX_FILE: copy };
235
- // HEAD → index: exit 0 = nothing staged, 1 = something staged; anything else is no answer.
236
- cachedRun = git(["diff", "--cached", "--quiet", "--no-ext-diff", "--no-relative"], onCopy);
237
- diffRun = git(
238
- ["-c", "core.abbrev=8", "-c", "diff.suppressBlankEmpty=false", "diff", ...PI_DURABLE_DIFF_SHAPE],
239
- onCopy,
240
- );
241
- untrackedRun = git(["ls-files", "--others", "--exclude-standard"], onCopy);
242
- }
243
- } finally {
244
- if (scratch !== undefined) fs.rmSync(scratch, { recursive: true, force: true });
165
+ manifest = findPackageJSON(to, pathToFileURL(fromFile));
166
+ } catch {
167
+ // Bounded environment probe: an unresolvable package is the `got: null` answer.
168
+ manifest = undefined;
245
169
  }
246
- const gitAnswered =
247
- headRun?.status === 0 &&
248
- (cachedRun?.status === 0 || cachedRun?.status === 1) &&
249
- diffRun?.status === 0 &&
250
- untrackedRun?.status === 0;
251
- const dataDir = path.join(runtimeDir, pin.modelData.path);
252
- const dataFiles = (() => {
253
- try {
254
- return fs
255
- .readdirSync(dataDir)
256
- .filter((n) => n.endsWith(".json") && !n.startsWith("."))
257
- .sort();
258
- } catch {
259
- return [];
260
- }
261
- })();
262
- const manifest = path.join(dataDir, ".manifest.json");
170
+ if (manifest === undefined) return { from, to, want, got: null, name: null, at: null };
171
+ const json = JSON.parse(fs.readFileSync(manifest, "utf8"));
263
172
  return {
264
- runtimeDir,
265
- present,
266
- gitAnswered,
267
- head: headRun?.stdout?.toString().trim() ?? "",
268
- indexMatchesHead: cachedRun?.status === 0,
269
- diffEqualsPatch: diffRun?.status === 0 && Buffer.isBuffer(diffRun.stdout) && diffRun.stdout.equals(patchBytes),
270
- untracked: untrackedRun?.stdout?.toString().trim() ?? "",
271
- resolverPresent: present && fs.existsSync(path.join(runtimeDir, PI_DURABLE_RESOLVER_RELATIVE)),
272
- modelDataFiles: dataFiles.length,
273
- modelDataAggregate: sha256(
274
- dataFiles.map((n) => `${sha256(fs.readFileSync(path.join(dataDir, n)))} ${n}\n`).join(""),
275
- ),
276
- manifestSha256: fs.existsSync(manifest) ? sha256(fs.readFileSync(manifest)) : "absent",
173
+ from,
174
+ to,
175
+ want,
176
+ got: typeof json.version === "string" ? json.version : null,
177
+ name: typeof json.name === "string" ? json.name : null,
178
+ at: fs.realpathSync(path.dirname(manifest)),
277
179
  };
278
180
  }
279
181
 
280
- /** The verdict over those facts against the pin: `null` is "this is the pin". */
281
- export function piDurableRuntimeVerdict(
282
- facts: PiDurableRuntimeFacts,
283
- pin: PiDurablePin,
284
- ): PiDurableRuntimeRejectReason | null {
285
- if (!facts.present) return "pi-durable-runtime-absent";
286
- if (!facts.gitAnswered) return "pi-durable-runtime-unverifiable";
287
- if (!facts.indexMatchesHead) return "pi-durable-runtime-pin-drift";
288
- if (facts.head !== pin.commit || !facts.diffEqualsPatch || facts.untracked !== "" || !facts.resolverPresent) {
289
- return "pi-durable-runtime-pin-drift";
182
+ export function inspectPiDurableRuntime(layout: PiDurablePackageLayout): PiDurableRuntimeFacts {
183
+ const pin = readPiDurableCarrierPin(layout.overlayDir);
184
+ const missing: string[] = [];
185
+ const drifted: string[] = [];
186
+ const pinned = [...pin.files, { name: "LICENSE", sha256: pin.license.sha256 }];
187
+ for (const file of pinned) {
188
+ const at = path.join(layout.carrierDir, file.name);
189
+ if (!fs.existsSync(at)) missing.push(path.relative(layout.root, at));
190
+ else if (sha256(fs.readFileSync(at)) !== file.sha256) drifted.push(path.relative(layout.root, at));
290
191
  }
291
- if (
292
- facts.modelDataFiles !== pin.modelData.files ||
293
- facts.modelDataAggregate !== pin.modelData.aggregateSha256 ||
294
- facts.manifestSha256 !== pin.modelData.manifestSha256
295
- ) {
296
- return "pi-durable-runtime-modeldata-drift";
192
+ for (const at of [layout.resolver, layout.relocation]) {
193
+ if (!fs.existsSync(at)) missing.push(path.relative(layout.root, at));
297
194
  }
195
+ const edges: PiDurableSdkEdge[] = [];
196
+ // From the carrier's own location: the bare imports its files make resolve from there.
197
+ const base = path.join(layout.carrierDir, pin.files[0].name);
198
+ // Every member COPY the carrier can reach is walked, at any depth: a nested copy's own member
199
+ // dependencies are edges too. One physical directory is walked once (shared and duplicate copies
200
+ // are ordinary; a member cycle terminates), keyed by its realpath.
201
+ const pending: { name: string; at: string }[] = [];
202
+ for (const [name, want] of Object.entries({ ...pin.members, ...pin.direct })) {
203
+ const edge = resolveEdge("carrier", name, want, base);
204
+ edges.push(edge);
205
+ if (name in pin.members && edge.at !== null) pending.push({ name, at: edge.at });
206
+ }
207
+ const walked = new Set<string>();
208
+ for (let next = pending.shift(); next !== undefined; next = pending.shift()) {
209
+ if (walked.has(next.at)) continue;
210
+ walked.add(next.at);
211
+ const manifest = path.join(next.at, "package.json");
212
+ const deps = JSON.parse(fs.readFileSync(manifest, "utf8")).dependencies ?? {};
213
+ for (const dep of Object.keys(deps)
214
+ .filter((d) => d in pin.members)
215
+ .sort()) {
216
+ const edge = resolveEdge(next.name, dep, pin.members[dep], manifest);
217
+ edges.push(edge);
218
+ if (edge.at !== null) pending.push({ name: dep, at: edge.at });
219
+ }
220
+ }
221
+ return { missing, drifted, edges };
222
+ }
223
+
224
+ const edgeText = (e: PiDurableSdkEdge): string =>
225
+ `${e.from} → ${e.to} wants ${e.want}, ${
226
+ e.got === null
227
+ ? "resolves nothing"
228
+ : `binds ${e.name === e.to ? "" : `${e.name ?? "(unnamed)"}@`}${e.got} at ${e.at}`
229
+ }`;
230
+
231
+ /** The verdict over those facts, in repair order: `null` is "the carrier can run". */
232
+ export function piDurableRuntimeVerdict(
233
+ facts: PiDurableRuntimeFacts,
234
+ ): { reason: PiDurableRuntimeRejectReason; detail: string } | null {
235
+ if (facts.missing.length > 0)
236
+ return { reason: "pi-durable-package-incomplete", detail: `missing ${facts.missing.join(", ")}` };
237
+ if (facts.drifted.length > 0)
238
+ return { reason: "pi-durable-carrier-drift", detail: `changed ${facts.drifted.join(", ")}` };
239
+ const absent = facts.edges.find((e) => e.got === null);
240
+ if (absent !== undefined) return { reason: "pi-durable-sdk-absent", detail: edgeText(absent) };
241
+ const mismatch = facts.edges.find((e) => e.got !== e.want || e.name !== e.to);
242
+ if (mismatch !== undefined) return { reason: "pi-durable-sdk-mismatch", detail: edgeText(mismatch) };
298
243
  return null;
299
244
  }
300
245
 
301
246
  export type PiDurableRuntimeCheck =
302
- | { ok: true; runtimeDir: string; resolver: string }
303
- | { ok: false; reason: PiDurableRuntimeRejectReason; runtimeDir: string | null };
247
+ | { ok: true; resolver: string; edges: readonly PiDurableSdkEdge[] }
248
+ | { ok: false; reason: PiDurableRuntimeRejectReason; detail: string };
304
249
 
305
- /** Locate and verify, in that order. The launcher, setup and the fresh preflight all ask this. */
306
- export function checkPiDurableRuntime(
307
- env: NodeJS.ProcessEnv,
308
- layout: PiDurablePackageLayout | null,
309
- ): PiDurableRuntimeCheck {
310
- if (layout === null) return { ok: false, reason: "pi-durable-package-incomplete", runtimeDir: null };
311
- const runtimeDir = piDurableRuntimeDir(env);
312
- if (runtimeDir === null) return { ok: false, reason: "pi-durable-runtime-locator-unset", runtimeDir: null };
313
- const { pin } = readPiDurablePin(layout.overlayDir);
314
- const reason = piDurableRuntimeVerdict(inspectPiDurableRuntime(runtimeDir, layout.overlayDir), pin);
315
- if (reason !== null) return { ok: false, reason, runtimeDir };
316
- return { ok: true, runtimeDir, resolver: path.join(runtimeDir, PI_DURABLE_RESOLVER_RELATIVE) };
250
+ /** Locate and verify. The launcher, setup, the fresh preflight and the durable gates all ask this. */
251
+ export function checkPiDurableRuntime(layout: PiDurablePackageLayout | null): PiDurableRuntimeCheck {
252
+ if (layout === null) {
253
+ return { ok: false, reason: "pi-durable-package-incomplete", detail: "no pi-durable pin next to this code" };
254
+ }
255
+ const facts = inspectPiDurableRuntime(layout);
256
+ const verdict = piDurableRuntimeVerdict(facts);
257
+ if (verdict !== null) return { ok: false, ...verdict };
258
+ return { ok: true, resolver: layout.resolver, edges: facts.edges };
317
259
  }
318
260
 
319
261
  export const PI_DURABLE_RUNTIME_HINT: Record<PiDurableRuntimeRejectReason, string> = {
320
262
  "pi-durable-package-incomplete":
321
- "this Entwurf package carries no pi-durable overlay pin next to its code — reinstall Entwurf; the package itself is incomplete",
322
- "pi-durable-runtime-locator-unset":
323
- "neither an absolute XDG_DATA_HOME nor HOME is set, so the fixed runtime location cannot be named",
324
- "pi-durable-runtime-absent":
325
- "no operator-provided durable runtime at the fixed location — Entwurf never installs it: provide the pinned upstream checkout plus the overlay patch and model data there (docs/setup-clean-host.md)",
326
- "pi-durable-runtime-unverifiable":
327
- "the runtime location exists but git could not read it as a checkout, so it cannot be proven to be the pin",
328
- "pi-durable-runtime-pin-drift":
329
- "the runtime is not the pin: HEAD, the overlay patch bytes, an untracked file or the source resolver differs from the packaged pi/pi-durable/overlay/upstream-pin.json — re-provision it at the pinned commit with exactly the packaged patch",
330
- "pi-durable-runtime-modeldata-drift":
331
- "the runtime's git-ignored model data is not the pinned bytes — restore the model data the pin records",
263
+ "this Entwurf package is missing part of its pi-durable carrier — reinstall Entwurf; nothing outside the package supplies it",
264
+ "pi-durable-carrier-drift":
265
+ "a pi-durable carrier file is not the bytes pi/pi-durable/overlay/upstream-pin.json records — reinstall Entwurf; the carrier is never edited in place",
266
+ "pi-durable-sdk-absent":
267
+ "a declared pi SDK package does not resolve from the carrier — reinstall Entwurf so its dependencies are installed",
268
+ "pi-durable-sdk-mismatch":
269
+ "a pi SDK package the carrier binds is not the declared version (another install's copy, an override or a partial upgrade) — reinstall Entwurf into a tree whose @earendil-works packages are the pinned version",
332
270
  };
333
271
 
334
- /** Exit codes of the CLI below, read by `run.sh`: 0 verified, 4 absent (setup SKIP), 3 anything else. */
335
- export const PI_DURABLE_RUNTIME_EXIT = { verified: 0, absent: 4, refused: 3 } as const;
272
+ /** Exit codes of the CLI below, read by `run.sh`: 0 verified, 3 refused. There is no "absent"
273
+ * answer any more: the carrier ships in the package, so a missing piece is an incomplete install. */
274
+ export const PI_DURABLE_RUNTIME_EXIT = { verified: 0, refused: 3 } as const;
336
275
 
337
276
  /**
338
- * `resolve`: print the verified runtime's source-resolver path (stdout) and exit 0, or print
339
- * `<reason>: <hint>` (stderr) and exit 4 for an absent runtime / 3 for every other refusal.
340
- * The launcher execs node under that resolver; setup maps the three codes to PASS / SKIP / FAIL.
277
+ * `resolve`: print the verified carrier's resolver path (stdout) and exit 0, or print
278
+ * `<reason>: <detail> — <hint>` (stderr) and exit 3. The launcher execs node under that resolver;
279
+ * setup maps the two codes to PASS / FAIL.
341
280
  */
342
281
  export function runPiDurableRuntimeCli(
343
282
  argv: readonly string[],
344
- env: NodeJS.ProcessEnv,
345
283
  layout: PiDurablePackageLayout | null,
346
284
  out: { stdout: (line: string) => void; stderr: (line: string) => void },
347
285
  ): number {
@@ -349,17 +287,13 @@ export function runPiDurableRuntimeCli(
349
287
  out.stderr("usage: pi-durable-runtime resolve");
350
288
  return 2;
351
289
  }
352
- const checked = checkPiDurableRuntime(env, layout);
290
+ const checked = checkPiDurableRuntime(layout);
353
291
  if (checked.ok) {
354
292
  out.stdout(checked.resolver);
355
293
  return PI_DURABLE_RUNTIME_EXIT.verified;
356
294
  }
357
- out.stderr(
358
- `${checked.reason}${checked.runtimeDir === null ? "" : ` (${checked.runtimeDir})`}: ${PI_DURABLE_RUNTIME_HINT[checked.reason]}`,
359
- );
360
- return checked.reason === "pi-durable-runtime-absent"
361
- ? PI_DURABLE_RUNTIME_EXIT.absent
362
- : PI_DURABLE_RUNTIME_EXIT.refused;
295
+ out.stderr(`${checked.reason}: ${checked.detail} — ${PI_DURABLE_RUNTIME_HINT[checked.reason]}`);
296
+ return PI_DURABLE_RUNTIME_EXIT.refused;
363
297
  }
364
298
 
365
299
  // RUN AS THE CLI ONLY WHEN THIS FILE IS THE PROCESS ENTRY. Decided by an argv comparison, as in
@@ -377,7 +311,7 @@ const invokedDirectly = (() => {
377
311
  }
378
312
  })();
379
313
  if (invokedDirectly) {
380
- process.exitCode = runPiDurableRuntimeCli(process.argv.slice(2), process.env, piDurablePackageLayout(), {
314
+ process.exitCode = runPiDurableRuntimeCli(process.argv.slice(2), piDurablePackageLayout(), {
381
315
  stdout: (line) => process.stdout.write(`${line}\n`),
382
316
  stderr: (line) => process.stderr.write(`${line}\n`),
383
317
  });
@@ -2,8 +2,9 @@
2
2
  * meta-bridge-pi-durable — the durable app's native contact, identity leaf (#129 L-identity).
3
3
  *
4
4
  * WHAT THIS IS. The long-running durable app (`@earendil-works/pi-coding-agent`
5
- * `experimental/durable`, source-only upstream) owns its runtime, TUI, models/auth, storage,
6
- * prompt and recovery. This module is the whole Entwurf side of its contact:
5
+ * `experimental/durable`, which upstream omits from its npm distribution — Entwurf's package
6
+ * supplies it as a pinned carrier, #130, not as an operator prerequisite) owns its runtime, TUI,
7
+ * models/auth, storage, prompt and recovery. This module is the whole Entwurf side of its contact:
7
8
  *
8
9
  * createPiDurableContact({ bridgeEntry })
9
10
  * .extension tools-only durable Extension, handed to the overlay's
@@ -13,8 +14,9 @@
13
14
  * release the tools
14
15
  *
15
16
  * The bootstrap that composes the two lives with the overlay (`pi/pi-durable/bootstrap.mjs`), not
16
- * here: this module never imports the durable runtime, so Entwurf carries no path into a
17
- * vendor checkout and no `@earendil-works/pi-durable` dependency.
17
+ * here: this module never imports or initializes the durable app runtime and carries no path into
18
+ * a vendor checkout. That is this module's import boundary, not the package's dependency list: the
19
+ * package does declare the exact `@earendil-works/pi-durable` (1.0.4) for its carrier (#130).
18
20
  *
19
21
  * WHY BIRTH IS AFTER OPEN, BEHIND A GATE. `[read @cd32f77]` The native session id is chosen
20
22
  * inside `openDurable` (`runtime.ts:125`, `sessions.ts:18-55`), and `openDurable` calls
@@ -272,9 +274,11 @@ export interface PiDurableRoots {
272
274
  }
273
275
 
274
276
  // ---------------------------------------------------------------------------
275
- // Durable surface, typed NARROWLY and locally (pi-durable 1.0.2 @cd32f77,
276
- // `harness/types.ts`). `defineExtension`/`defineTool` are identity functions
277
- // (`harness/define.ts:6-17`), so a plain object is the extension.
277
+ // Durable surface, typed NARROWLY and locally — read at pi-durable 1.0.2 @cd32f77 (#129,
278
+ // `harness/types.ts`; historical coordinates, not re-anchored here). The current supply is the
279
+ // 1.0.4 carrier at `7c10bd43`, whose authority is `pi/pi-durable/overlay/upstream-pin.json`.
280
+ // `defineExtension`/`defineTool` are identity functions (`harness/define.ts:6-17`), so a plain
281
+ // object is the extension.
278
282
  // ---------------------------------------------------------------------------
279
283
 
280
284
  /** `DurableView.session` — `runtime.ts:53`. */