@junghanacs/entwurf 0.33.0 → 0.34.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -187,12 +187,18 @@ function tree(root: string): string[] {
187
187
  * installed package's activation verb is — absent, incapable (exits 0 on an empty request), or a
188
188
  * faithful recorder that refuses an empty request by name and logs the argv it was given.
189
189
  */
190
- function fixtureAcquire(opts: { activate: "absent" | "incapable" | "recorder"; log?: string; fail?: boolean }) {
190
+ function fixtureAcquire(opts: {
191
+ activate: "absent" | "incapable" | "recorder";
192
+ log?: string;
193
+ fail?: boolean;
194
+ name?: string;
195
+ }) {
196
+ const name = opts.name ?? PACKAGE;
191
197
  return ({ identity, prefix }: AcquireArgs) => {
192
198
  const version = identity.version ?? FIXTURE_VERSION;
193
- const root = path.join(prefix, "node_modules", PACKAGE);
199
+ const root = path.join(prefix, "node_modules", name);
194
200
  fs.mkdirSync(path.join(root, path.dirname(COMPILED_ENTRY)), { recursive: true });
195
- fs.writeFileSync(path.join(root, "package.json"), `${JSON.stringify({ name: PACKAGE, version })}\n`);
201
+ fs.writeFileSync(path.join(root, "package.json"), `${JSON.stringify({ name, version })}\n`);
196
202
  fs.writeFileSync(path.join(root, COMPILED_ENTRY), "// compiled entry fixture\n");
197
203
  const bin = path.join(prefix, "node_modules", ".bin");
198
204
  fs.mkdirSync(bin, { recursive: true });
@@ -222,7 +228,7 @@ function fixtureAcquire(opts: { activate: "absent" | "incapable" | "recorder"; l
222
228
  observedDigest: `sha256-${createHash("sha256")
223
229
  .update(identity.commit ?? "x")
224
230
  .digest("hex")}`,
225
- packageName: PACKAGE,
231
+ packageName: name,
226
232
  packageVersion: version,
227
233
  };
228
234
  };
@@ -238,7 +244,7 @@ function drive(
238
244
  env: NodeJS.ProcessEnv,
239
245
  text: string | { error: string } | { status: number; stderr: string },
240
246
  deps: Record<string, unknown> = {},
241
- ): { code: number | null; refusal: string | null; out: string; progress: string[] } {
247
+ ): { code: number | null; refusal: string | null; detail: string; out: string; progress: string[] } {
242
248
  let out = "";
243
249
  // The operator's terminal is a SEAM here, never the real `/dev/tty`: a gate that narrated into
244
250
  // whatever terminal happened to be running it would be writing outside its own sandbox.
@@ -269,11 +275,12 @@ function drive(
269
275
  readRuntimeLock: () => FIXTURE_NPM_LOCK,
270
276
  ...deps,
271
277
  });
272
- return { code, refusal: null, out, progress: progressLines };
278
+ return { code, refusal: null, detail: "", out, progress: progressLines };
273
279
  } catch (err) {
274
280
  return {
275
281
  code: null,
276
282
  refusal: (err as { code?: string }).code ?? `not-a-HerdrBuildError:${String(err)}`,
283
+ detail: (err as { detail?: string }).detail ?? String(err),
277
284
  out,
278
285
  progress: progressLines,
279
286
  };
@@ -727,6 +734,228 @@ function drive(
727
734
  );
728
735
  }
729
736
 
737
+ // ── 6c. the selected components' owners are asked before the first byte (#135) ─
738
+ /** Run one of the shipped Pi ownership writers against a sandbox, exactly as run.sh does. */
739
+ function piOwner(env: NodeJS.ProcessEnv, script: string, args: string[]): SpawnResult {
740
+ return spawnSync("python3", [path.join(REPO, "scripts", script), ...args], {
741
+ encoding: "utf8",
742
+ env,
743
+ }) as unknown as SpawnResult;
744
+ }
745
+ function piSettingsOf(env: NodeJS.ProcessEnv): string {
746
+ return path.join(env.HOME as string, ".pi", "agent", "settings.json");
747
+ }
748
+ function piStateOf(env: NodeJS.ProcessEnv, half: "package" | "provider"): string {
749
+ return path.join(env.XDG_DATA_HOME as string, "entwurf", `pi-${half}`, "install-state.json");
750
+ }
751
+ /** A LIVE foreign owner: a real directory standing for a developer checkout, registered for real. */
752
+ function foreignPiOwner(env: NodeJS.ProcessEnv, halves: ("package" | "provider")[]): string {
753
+ const checkout = path.join(env.HOME as string, "dev", "entwurf");
754
+ fs.mkdirSync(checkout, { recursive: true });
755
+ fs.mkdirSync(path.dirname(piSettingsOf(env)), { recursive: true });
756
+ fs.writeFileSync(piSettingsOf(env), '{"theme":"dark"}\n');
757
+ if (halves.includes("package")) {
758
+ const r = piOwner(env, "register-pi-package.py", [
759
+ piSettingsOf(env),
760
+ checkout,
761
+ "--scope",
762
+ "user",
763
+ "--state",
764
+ piStateOf(env, "package"),
765
+ ]);
766
+ assert.equal(r.status, 0, `fixture: seeding the foreign package owner failed: ${r.stderr}`);
767
+ }
768
+ if (halves.includes("provider")) {
769
+ const r = piOwner(env, "register-pi-provider.py", [
770
+ "install",
771
+ piSettingsOf(env),
772
+ checkout,
773
+ "--scope",
774
+ "user",
775
+ "--state",
776
+ piStateOf(env, "provider"),
777
+ ]);
778
+ assert.equal(r.status, 0, `fixture: seeding the foreign provider owner failed: ${r.stderr}`);
779
+ }
780
+ return checkout;
781
+ }
782
+ /** An acquisition that must never be reached: it counts, and it throws a sentinel instead of fetching. */
783
+ function countingSentinel(): { acquire: () => never; calls: () => number } {
784
+ let calls = 0;
785
+ return {
786
+ acquire: () => {
787
+ calls++;
788
+ throw new Error("HPB_OWNERSHIP_SENTINEL_NO_NETWORK");
789
+ },
790
+ calls: () => calls,
791
+ };
792
+ }
793
+ {
794
+ const env = world("owner-package");
795
+ foreignPiOwner(env, ["package"]);
796
+ const before = treeDigest(env.HOME as string);
797
+ const sentinel = countingSentinel();
798
+ const r = drive(env, listing({ pi: "current (v8) (/home/u/.pi)" }), {
799
+ acquire: sentinel.acquire,
800
+ resolveCommit: () => COMMIT_A,
801
+ });
802
+ const after = treeDigest(env.HOME as string);
803
+ ok(
804
+ "[QK:HPB-OWNERSHIP-BEFORE-ACQUISITION] a Pi user-scope registration owned by another LIVE root refuses the " +
805
+ "plugin install BEFORE anything is fetched or written: the SAME shipped package-owner preflight the installed " +
806
+ "activation runs is asked against the prospective plugin root, so the refusal names the component and the " +
807
+ "acquisition is never called — no installing journal, runtime, cache or ledger appears and every byte under " +
808
+ "the sandbox HOME is unchanged. The first cut asked only after bootstrap, so a known refusal still fetched and " +
809
+ `installed a runtime first (refusal=${r.refusal} acquired=${sentinel.calls()} home-unchanged=${before === after} ` +
810
+ `journal=${fs.existsSync(journalPathOf(env))} detail=${JSON.stringify(r.detail.slice(0, 160))})`,
811
+ r.refusal === "herdr-build-component-ownership-refused" &&
812
+ r.detail.includes("pi-package") &&
813
+ sentinel.calls() === 0 &&
814
+ before === after &&
815
+ !fs.existsSync(journalPathOf(env)),
816
+ );
817
+ }
818
+ {
819
+ // The provider key carries its OWN ownership record; a host whose package entry is free but whose
820
+ // provider key another root owns must refuse just as early.
821
+ const env = world("owner-provider");
822
+ foreignPiOwner(env, ["provider"]);
823
+ const before = treeDigest(env.HOME as string);
824
+ const sentinel = countingSentinel();
825
+ const r = drive(env, listing({ pi: "current (v8) (/home/u/.pi)" }), {
826
+ acquire: sentinel.acquire,
827
+ resolveCommit: () => COMMIT_A,
828
+ });
829
+ ok(
830
+ "[QK:HPB-OWNERSHIP-PROVIDER-HALF] the provider half is asked on its own: a free package entry with a provider " +
831
+ "key another LIVE root owns still refuses before acquisition, names `pi-provider`, and leaves the sandbox " +
832
+ `HOME byte-identical (refusal=${r.refusal} acquired=${sentinel.calls()} home-unchanged=${before === treeDigest(env.HOME as string)} detail=${JSON.stringify(r.detail.slice(0, 120))})`,
833
+ r.refusal === "herdr-build-component-ownership-refused" &&
834
+ r.detail.includes("pi-provider") &&
835
+ !r.detail.includes("pi-package:") &&
836
+ sentinel.calls() === 0 &&
837
+ before === treeDigest(env.HOME as string),
838
+ );
839
+ }
840
+ {
841
+ // Claude's preflight is its PLAN check (documents and managed-key shapes), run against the
842
+ // prospective root in plugin mode. It is not a claim about which root owns Claude.
843
+ const env = world("owner-claude-plan");
844
+ fs.mkdirSync(path.join(env.HOME as string, ".claude"), { recursive: true });
845
+ fs.writeFileSync(path.join(env.HOME as string, ".claude", "settings.json"), "[]\n");
846
+ const before = treeDigest(env.HOME as string);
847
+ const sentinel = countingSentinel();
848
+ const r = drive(env, listing({ claude: "current (v2) (/home/u/.claude)" }), {
849
+ acquire: sentinel.acquire,
850
+ resolveCommit: () => COMMIT_A,
851
+ });
852
+ ok(
853
+ "[QK:HPB-OWNERSHIP-CLAUDE-PLAN] a selected Claude whose install plan its own state owner refuses (a settings " +
854
+ "file that is not a JSON object) refuses before acquisition, names `claude-code`, and writes nothing " +
855
+ `(refusal=${r.refusal} acquired=${sentinel.calls()} home-unchanged=${before === treeDigest(env.HOME as string)} detail=${JSON.stringify(r.detail.slice(0, 120))})`,
856
+ r.refusal === "herdr-build-component-ownership-refused" &&
857
+ r.detail.includes("claude-code") &&
858
+ sentinel.calls() === 0 &&
859
+ before === treeDigest(env.HOME as string),
860
+ );
861
+ }
862
+ {
863
+ // Only the SELECTED components are asked: a backend Herdr has not integrated cannot veto another's install.
864
+ const piOnly = world("owner-unselected-claude");
865
+ fs.mkdirSync(path.join(piOnly.HOME as string, ".claude"), { recursive: true });
866
+ fs.writeFileSync(path.join(piOnly.HOME as string, ".claude", "settings.json"), "[]\n");
867
+ const piLog = path.join(piOnly.HOME as string, "argv.log");
868
+ const piRun = drive(piOnly, listing({ pi: "current (v8) (/home/u/.pi)" }), {
869
+ acquire: fixtureAcquire({ activate: "recorder", log: piLog }),
870
+ resolveCommit: () => COMMIT_A,
871
+ });
872
+ const claudeOnly = world("owner-unselected-pi");
873
+ foreignPiOwner(claudeOnly, ["package", "provider"]);
874
+ const claudeLog = path.join(claudeOnly.HOME as string, "argv.log");
875
+ const claudeRun = drive(claudeOnly, listing({ claude: "current (v2) (/home/u/.claude)" }), {
876
+ acquire: fixtureAcquire({ activate: "recorder", log: claudeLog }),
877
+ resolveCommit: () => COMMIT_A,
878
+ });
879
+ const logged = (f: string) => (fs.existsSync(f) ? fs.readFileSync(f, "utf8").trim() : "");
880
+ ok(
881
+ "[QK:HPB-OWNERSHIP-SELECTED-ONLY] only the backends in `A` are asked: Pi alone installs over a Claude settings " +
882
+ "file its owner would refuse, and Claude alone installs over a Pi registration another root owns — an " +
883
+ "unselected backend's owner never vetoes the selected one " +
884
+ `(pi-only=${piRun.code}/${piRun.refusal}/${JSON.stringify(logged(piLog))} claude-only=${claudeRun.code}/${claudeRun.refusal}/${JSON.stringify(logged(claudeLog))})`,
885
+ piRun.code === 0 && logged(piLog) === "pi" && claudeRun.code === 0 && logged(claudeLog) === "claude-code",
886
+ );
887
+ }
888
+ {
889
+ // A SAME-owner reinstall under a symlinked XDG_DATA_HOME. The installed verb sees its own root in
890
+ // PHYSICAL form (Node realpaths the ESM entry) and the provider owner records that root without
891
+ // resolving it, so the early ask must use the physical form too, while the stable command stays the
892
+ // lexical runtime address.
893
+ const home = reclaimOnExit(fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-hpb-owner-symlinked-")));
894
+ const realData = path.join(home, "real-data");
895
+ fs.mkdirSync(realData, { recursive: true });
896
+ fs.symlinkSync(realData, path.join(home, "data"));
897
+ const env: NodeJS.ProcessEnv = {
898
+ HOME: home,
899
+ XDG_DATA_HOME: path.join(home, "data"),
900
+ XDG_CACHE_HOME: path.join(home, "cache"),
901
+ XDG_STATE_HOME: path.join(home, "state"),
902
+ PATH: process.env.PATH,
903
+ };
904
+ const physicalRoot = path.join(realData, "entwurf", "herdr-plugin", "runtime", "active", "node_modules", PACKAGE);
905
+ fs.mkdirSync(path.dirname(piSettingsOf(env)), { recursive: true });
906
+ fs.writeFileSync(piSettingsOf(env), '{"theme":"dark"}\n');
907
+ const pkg = piOwner(env, "register-pi-package.py", [
908
+ piSettingsOf(env),
909
+ physicalRoot,
910
+ "--scope",
911
+ "user",
912
+ "--state",
913
+ piStateOf(env, "package"),
914
+ ]);
915
+ const prov = piOwner(env, "register-pi-provider.py", [
916
+ "install",
917
+ piSettingsOf(env),
918
+ physicalRoot,
919
+ "--scope",
920
+ "user",
921
+ "--state",
922
+ piStateOf(env, "provider"),
923
+ "--plugin-runtime",
924
+ activeDirOf(env),
925
+ ]);
926
+ assert.equal(pkg.status, 0, `fixture: same-owner package seed failed: ${pkg.stderr}`);
927
+ assert.equal(prov.status, 0, `fixture: same-owner provider seed failed: ${prov.stderr}`);
928
+ const log = path.join(home, "argv.log");
929
+ const r = drive(env, listing({ pi: "current (v8) (/home/u/.pi)" }), {
930
+ acquire: fixtureAcquire({ activate: "recorder", log }),
931
+ resolveCommit: () => COMMIT_A,
932
+ });
933
+ ok(
934
+ "[QK:HPB-OWNERSHIP-PHYSICAL-ROOT] a same-owner reinstall under a symlinked XDG_DATA_HOME is admitted, not " +
935
+ "mistaken for a foreign root: the early owner preflight asks with the PHYSICAL prospective package root " +
936
+ "(the form the installed verb records as its own) and passes the LEXICAL stable runtime as the plugin-mode " +
937
+ "runtime flag (the only form the provider owner accepts), so the build reaches activation " +
938
+ `(code=${r.code} refusal=${r.refusal} detail=${JSON.stringify(r.detail.slice(0, 160))})`,
939
+ r.code === 0 && fs.existsSync(log) && fs.readFileSync(log, "utf8").trim() === "pi",
940
+ );
941
+ }
942
+ {
943
+ // The owners were asked about one root; the installed verb must run from that same root.
944
+ const env = world("owner-root-tie");
945
+ const log = path.join(env.HOME as string, "argv.log");
946
+ const r = drive(env, listing({ pi: "current (v8) (/home/u/.pi)" }), {
947
+ acquire: fixtureAcquire({ activate: "recorder", log, name: "@elsewhere/entwurf" }),
948
+ readRuntimeLock: () => FIXTURE_CHECKOUT_LOCK,
949
+ resolveCommit: () => COMMIT_A,
950
+ });
951
+ ok(
952
+ "[QK:HPB-PLANNED-ROOT-TIE] the root the component owners were asked about is the root the installed verb runs " +
953
+ "from: an artifact that lands under another package name is refused by name before activation, so the early " +
954
+ `answer can never have been about a different root (refusal=${r.refusal} activated=${fs.existsSync(log)} detail=${JSON.stringify(r.detail.slice(0, 160))})`,
955
+ r.refusal === "herdr-build-artifact-drifted" && !fs.existsSync(log),
956
+ );
957
+ }
958
+
730
959
  // ── 7. the runner claims nothing about Herdr's own commit ──────────────────────
731
960
  {
732
961
  const env = world("post-build-gap");
@@ -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
  }
@@ -155,6 +155,22 @@ export function writeLedger(layout, entry) {
155
155
  * host it was never describing.
156
156
  */
157
157
  export function readCertifiedLedger(layout) {
158
+ // lstat FIRST (#135). `readFileSync` follows links, so a dangling symlink at the ledger address
159
+ // read as ENOENT — "no ledger" — and every verb then acted as if nothing had ever been activated.
160
+ // Only a regular file, or nothing at all, may stand there.
161
+ let kind;
162
+ try {
163
+ kind = fs.lstatSync(layout.ledgerPath, { throwIfNoEntry: false });
164
+ } catch (err) {
165
+ throw new ActivationError("activation-ledger-uncertified", `${layout.ledgerPath}: ${err.message}`);
166
+ }
167
+ if (kind === undefined) return null;
168
+ if (!kind.isFile()) {
169
+ throw new ActivationError(
170
+ "activation-ledger-uncertified",
171
+ `${layout.ledgerPath} is ${kind.isSymbolicLink() ? "a symlink" : kind.isDirectory() ? "a directory" : "not a regular file"}; only a regular file or nothing may stand at the ledger address`,
172
+ );
173
+ }
158
174
  let raw;
159
175
  try {
160
176
  raw = fs.readFileSync(layout.ledgerPath, "utf8");
@@ -33,11 +33,11 @@ import {
33
33
  certifyRootsAgainstLedger,
34
34
  componentStates,
35
35
  ledgerBody,
36
+ piStatePaths,
36
37
  planActivation,
37
38
  readCertifiedLedger,
38
39
  resolveActivationLayout,
39
40
  resolveComponentRoots,
40
- resolveEntwurfDataRoot,
41
41
  writeLedger,
42
42
  } from "./herdr-activation.mjs";
43
43
  import {
@@ -49,8 +49,109 @@ import {
49
49
 
50
50
  const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
51
51
 
52
- function run(command, args, env) {
53
- return spawnSync(command, args, { encoding: "utf8", env, stdio: ["ignore", "pipe", "pipe"] });
52
+ function run(command, args, env, spawn = spawnSync) {
53
+ return spawn(command, args, { encoding: "utf8", env, stdio: ["ignore", "pipe", "pipe"] });
54
+ }
55
+
56
+ /**
57
+ * Ask every selected component's OWN ownership authority, read-only, whether a write for
58
+ * `packageRoot` would be admitted. Returns the refused `[name, result]` pairs; writes nothing.
59
+ *
60
+ * TWO CALL POSITIONS, ONE LIST (#135). The installed verb below asks it about itself, after the
61
+ * runtime exists. The Herdr build asks it BEFORE any byte of runtime work, with the checkout's copy
62
+ * of the same owners and the PROSPECTIVE installed root — so an owner the late check would refuse
63
+ * is refused while nothing has been fetched. `scriptRoot` is whose owner scripts run; `packageRoot`
64
+ * is the root they judge. The prospective root must be in the form this verb will later see as its
65
+ * own PACKAGE_ROOT (physical: Node realpaths the ESM entry), because the provider owner records the
66
+ * root without resolving it; `runtimeRoot` stays the lexical stable address the commands are
67
+ * derived from.
68
+ *
69
+ * Claude's preflight is its document/plan-shape check. It does not judge which root owns Claude.
70
+ */
71
+ export function preflightSelectedComponents(
72
+ env,
73
+ { scriptRoot, packageRoot, runtimeRoot, selected, spawn = spawnSync },
74
+ ) {
75
+ const preflights = [];
76
+ if (selected.includes("claude-code")) {
77
+ preflights.push([
78
+ "claude-code",
79
+ // The mode rides the ARGV, because that is what the state owner reads. The env var is the
80
+ // installer script's channel; passing only that preflights the DEFAULT mode and then
81
+ // installs the plugin one — a green check of work nobody is going to do.
82
+ run(
83
+ "python3",
84
+ [
85
+ path.join(scriptRoot, "scripts", "meta-bridge-state.py"),
86
+ "preflight-install",
87
+ "--repo",
88
+ packageRoot,
89
+ "--plugin-runtime",
90
+ runtimeRoot,
91
+ ],
92
+ { ...env, ENTWURF_PLUGIN_RUNTIME: runtimeRoot },
93
+ spawn,
94
+ ),
95
+ ]);
96
+ }
97
+ if (selected.includes("pi")) {
98
+ const agentSettings = path.join(resolveComponentRoots(env).piAgentDir.path, "settings.json");
99
+ // ONE resolution for both owner records, the one their writers use (`run.sh` `${XDG_DATA_HOME:-…}`).
100
+ const piState = piStatePaths(env);
101
+ // BOTH halves. The user-scope citizen is a package registration and a provider key with
102
+ // SEPARATE ownership records, and a foreign or corrupt package state refuses independently —
103
+ // preflighting only the provider would let the first ledger write land on a host the package
104
+ // writer was always going to refuse.
105
+ preflights.push([
106
+ "pi-package",
107
+ run(
108
+ "python3",
109
+ [
110
+ path.join(scriptRoot, "scripts", "register-pi-package.py"),
111
+ agentSettings,
112
+ packageRoot,
113
+ "--scope",
114
+ "user",
115
+ "--state",
116
+ piState.packageState,
117
+ "--preflight",
118
+ ],
119
+ env,
120
+ spawn,
121
+ ),
122
+ ]);
123
+ preflights.push([
124
+ "pi-provider",
125
+ run(
126
+ "python3",
127
+ [
128
+ path.join(scriptRoot, "scripts", "register-pi-provider.py"),
129
+ "install",
130
+ agentSettings,
131
+ packageRoot,
132
+ "--scope",
133
+ "user",
134
+ "--state",
135
+ piState.providerState,
136
+ "--plugin-runtime",
137
+ runtimeRoot,
138
+ "--preflight",
139
+ ],
140
+ env,
141
+ spawn,
142
+ ),
143
+ ]);
144
+ }
145
+ return preflights.filter(([, r]) => r.status !== 0);
146
+ }
147
+
148
+ /** The owner's own last line for each refused component, for the operator to act on. */
149
+ export function describeRefusedComponents(refused) {
150
+ return refused
151
+ .map(
152
+ ([name, r]) => `${name}: ${(r.stderr || r.stdout || (r.error ? r.error.message : "")).trim().split("\n").pop()}`,
153
+ )
154
+ .join("; ");
54
155
  }
55
156
 
56
157
  /** The only input: a subset of P, parsed from argv and refused if it is anything else. */
@@ -113,79 +214,14 @@ export function activate(env, requested) {
113
214
  const plan = planActivation({ ledger, requested });
114
215
  const selected = [...plan.reconcile, ...plan.add];
115
216
 
116
- // 3. preflight EVERY selected component before the first byte.
117
- const preflights = [];
118
- if (selected.includes("claude-code")) {
119
- preflights.push([
120
- "claude-code",
121
- // The mode rides the ARGV, because that is what the state owner reads. The env var is the
122
- // installer script's channel; passing only that preflights the DEFAULT mode and then
123
- // installs the plugin one — a green check of work nobody is going to do.
124
- run(
125
- "python3",
126
- [
127
- path.join(PACKAGE_ROOT, "scripts", "meta-bridge-state.py"),
128
- "preflight-install",
129
- "--repo",
130
- PACKAGE_ROOT,
131
- "--plugin-runtime",
132
- runtime.activeDir,
133
- ],
134
- { ...env, ENTWURF_PLUGIN_RUNTIME: runtime.activeDir },
135
- ),
136
- ]);
137
- }
138
- if (selected.includes("pi")) {
139
- const agentSettings = path.join(roots.piAgentDir.path, "settings.json");
140
- const dataRoot = resolveEntwurfDataRoot(env);
141
- // BOTH halves. The user-scope citizen is a package registration and a provider key with
142
- // SEPARATE ownership records, and a foreign or corrupt package state refuses independently —
143
- // preflighting only the provider would let the first ledger write land on a host the package
144
- // writer was always going to refuse.
145
- preflights.push([
146
- "pi-package",
147
- run(
148
- "python3",
149
- [
150
- path.join(PACKAGE_ROOT, "scripts", "register-pi-package.py"),
151
- agentSettings,
152
- PACKAGE_ROOT,
153
- "--scope",
154
- "user",
155
- "--state",
156
- path.join(dataRoot, "pi-package", "install-state.json"),
157
- "--preflight",
158
- ],
159
- env,
160
- ),
161
- ]);
162
- preflights.push([
163
- "pi-provider",
164
- run(
165
- "python3",
166
- [
167
- path.join(PACKAGE_ROOT, "scripts", "register-pi-provider.py"),
168
- "install",
169
- agentSettings,
170
- PACKAGE_ROOT,
171
- "--scope",
172
- "user",
173
- "--state",
174
- path.join(
175
- env.XDG_DATA_HOME ?? path.join(env.HOME, ".local", "share"),
176
- "entwurf",
177
- "pi-provider",
178
- "install-state.json",
179
- ),
180
- "--plugin-runtime",
181
- runtime.activeDir,
182
- "--preflight",
183
- ],
184
- env,
185
- ),
186
- ]);
187
- }
188
- const refused = preflights.filter(([, r]) => r.status !== 0);
217
+ // 3. preflight EVERY selected component before the first byte — the same list the Herdr build
218
+ // already asked about the prospective root; this is the installed recheck.
219
+ const refused = preflightSelectedComponents(env, {
220
+ scriptRoot: PACKAGE_ROOT,
221
+ packageRoot: PACKAGE_ROOT,
222
+ runtimeRoot: runtime.activeDir,
223
+ selected,
224
+ });
189
225
  if (refused.length > 0) {
190
226
  for (const [name, r] of refused) {
191
227
  process.stderr.write(` ${name}: ${(r.stderr || r.stdout || "").trim().split("\n").pop()}\n`);