@junghanacs/entwurf 0.30.0 → 0.30.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.
@@ -21,6 +21,7 @@
21
21
  import { createRequire } from "node:module";
22
22
  import { homedir } from "node:os";
23
23
  import { fileURLToPath } from "node:url";
24
+ import { RequestError } from "@agentclientprotocol/sdk";
24
25
 
25
26
  import type { AcpConnectionLike } from "./acp-client.js";
26
27
  import { enrichMcpServersWithEnvelope, type ResolvedAcpConfig } from "./config.js";
@@ -235,14 +236,73 @@ export interface AcpBackendAdapter {
235
236
  * entwurf actually reads from a NOTIFICATION cannot desync from a response
236
237
  * shape it never inspects. */
237
238
  sealsTurnAccounting?: true;
239
+
240
+ /** OPTIONAL capability (#127): recognise a typed vendor rejection of `session/prompt`
241
+ * whose raw text must not become pi's `errorMessage`. pi decides a whole-prompt replay
242
+ * from `errorMessage` alone, and a vendor's "Internal error" — or any digit run in the
243
+ * ids it carries — reads as transient, so a turn whose tools already ran would be resent
244
+ * from a cold session. A recognised rejection returns a fixed, authored verdict for
245
+ * `errorMessage`; backend.ts keeps the raw rejection as a pi diagnostic instead.
246
+ *
247
+ * ABSENT means every failure of this backend keeps the verbatim-first `errorMessage`.
248
+ * The vocabulary is the vendor's, so only the adapter that speaks it may declare it. */
249
+ readPromptRejection?(err: unknown): AcpPromptRejection | undefined;
250
+ }
251
+
252
+ /** A vendor rejection an adapter recognised (`readPromptRejection`). */
253
+ export interface AcpPromptRejection {
254
+ /** The vendor's own failure kind, as it named it on the wire. */
255
+ errorKind: string;
256
+ /** Entwurf-authored and fixed — no vendor text, ids or numbers: this is what pi classifies. */
257
+ verdict: string;
258
+ /** The JSON-RPC `data`, re-serialized as evidence (it arrived as parsed JSON). */
259
+ dataJson: string;
238
260
  }
239
261
 
262
+ /** The pi diagnostic type under which backend.ts preserves a recognised rejection verbatim. */
263
+ export const ACP_PROMPT_REJECTION_DIAGNOSTIC = "entwurf_acp_prompt_rejection";
264
+
265
+ /** The verdict for claude-agent-acp's `incomplete_tool_call` (#127; upstream #1212, 0.85.0+). */
266
+ export const INCOMPLETE_TOOL_CALL_VERDICT =
267
+ "ACP turn failed because foreground tool results are incomplete. Some tools may already have run; automatic replay is unsafe. Inspect the preserved vendor rejection before continuing.";
268
+
240
269
  // ---------------------------------------------------------------------------
241
270
  // claude adapter — the first implementation (the rail's reference backend)
242
271
  // ---------------------------------------------------------------------------
243
272
 
244
273
  const SUPPORTED_CLAUDE_IDS: ReadonlySet<string> = new Set(SUPPORTED_ANTHROPIC_MODEL_IDS);
245
274
 
275
+ /** JSON-RPC internal error — the code claude-agent-acp rejects an unfinished-tool turn with. */
276
+ const JSONRPC_INTERNAL_ERROR = -32603;
277
+ /** claude-agent-acp's own kind for a turn that hit `end_turn` with foreground tools unanswered
278
+ * (read at v0.85.1 `src/acp-agent.ts:4286-4340`, `errorKindData("incomplete_tool_call")`). */
279
+ const CLAUDE_INCOMPLETE_TOOL_CALL = "incomplete_tool_call";
280
+
281
+ /**
282
+ * The claude reader for `readPromptRejection` (#127). Only the root wire SDK's own decode
283
+ * counts: its client rejects a JSON-RPC error response with `new RequestError(code, message,
284
+ * data)`, `data` being the parsed `error.data` (sdk 1.4.0 `dist/jsonrpc.js:842-844`). A
285
+ * look-alike object, another code, or another `errorKind` is not this failure and keeps the
286
+ * verbatim-first path.
287
+ */
288
+ function readClaudePromptRejection(err: unknown): AcpPromptRejection | undefined {
289
+ if (!(err instanceof RequestError) || err.code !== JSONRPC_INTERNAL_ERROR) return undefined;
290
+ const data = err.data;
291
+ if (typeof data !== "object" || data === null || !("errorKind" in data)) return undefined;
292
+ if (data.errorKind !== CLAUDE_INCOMPLETE_TOOL_CALL) return undefined;
293
+ return { errorKind: CLAUDE_INCOMPLETE_TOOL_CALL, verdict: INCOMPLETE_TOOL_CALL_VERDICT, dataJson: jsonText(data) };
294
+ }
295
+
296
+ /** Evidence text for a wire value. A value that came off the wire serializes; one that cannot
297
+ * (a hand-built fake) is named as such, so recording evidence never breaks the turn's seal. */
298
+ function jsonText(value: unknown): string {
299
+ try {
300
+ return JSON.stringify(value) ?? String(value);
301
+ } catch (err) {
302
+ return `[unserializable: ${err instanceof Error ? err.name : typeof err}]`;
303
+ }
304
+ }
305
+
246
306
  /**
247
307
  * Resolve the claude launch — an ENTWURF-OWNED launcher, or the env override for debug.
248
308
  * This is the single source for the claude launch spec; backend.ts holds no private copy.
@@ -359,6 +419,10 @@ export const claudeAdapter: AcpBackendAdapter = {
359
419
  // The token partition is NOT declared here: ACP's only token carrier is a
360
420
  // per-turn round-trip aggregate, which is not what pi's four fields mean.
361
421
  sealsTurnAccounting: true,
422
+
423
+ // #127: claude-agent-acp 0.85.0+ rejects an `end_turn` with unanswered foreground tools as
424
+ // -32603 `incomplete_tool_call` (upstream #1212). Its raw text reads as transient to pi.
425
+ readPromptRejection: readClaudePromptRejection,
362
426
  };
363
427
 
364
428
  // ---------------------------------------------------------------------------
@@ -527,6 +591,10 @@ export const cortexAdapter: AcpBackendAdapter = {
527
591
  // backend.ts seals nothing for a backend without this flag, so cortex's
528
592
  // emitted usage is byte-identical to what it was before #93. Declaring this
529
593
  // with a guess would mint exactly the silent misaccounting #93 exists to end.
594
+
595
+ // NO readPromptRejection — also deliberate. Nobody has read which failures cortex's ACP
596
+ // server rejects a prompt with, so every cortex failure keeps the verbatim-first
597
+ // errorMessage. Borrowing claude's `incomplete_tool_call` reader would be a guess.
530
598
  };
531
599
 
532
600
  const ADAPTERS: readonly AcpBackendAdapter[] = [claudeAdapter, cortexAdapter];
@@ -45,7 +45,12 @@ import { readFileSync } from "node:fs";
45
45
  import { Readable, Writable } from "node:stream";
46
46
  import { ndJsonStream, PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
47
47
  import type { Api, AssistantMessage, Model, SimpleStreamOptions, TranscriptContext } from "@earendil-works/pi-ai";
48
- import { createAssistantMessageEventStream, getCurrentTools } from "@earendil-works/pi-ai";
48
+ import {
49
+ appendAssistantMessageDiagnostic,
50
+ createAssistantMessageDiagnostic,
51
+ createAssistantMessageEventStream,
52
+ getCurrentTools,
53
+ } from "@earendil-works/pi-ai";
49
54
  import {
50
55
  type AcpClientHandlers,
51
56
  type AcpConnectionLike,
@@ -53,7 +58,11 @@ import {
53
58
  connectAcpClient,
54
59
  } from "./acp-client.js";
55
60
  import { prependNewPromptAugment } from "./augment.js";
56
- import { type AcpBackendAdapter, resolveAcpBackendAdapter } from "./backend-adapter.js";
61
+ import {
62
+ ACP_PROMPT_REJECTION_DIAGNOSTIC,
63
+ type AcpBackendAdapter,
64
+ resolveAcpBackendAdapter,
65
+ } from "./backend-adapter.js";
57
66
  import {
58
67
  enrichMcpServersWithEnvelope,
59
68
  mcpServerNames,
@@ -1435,13 +1444,18 @@ export function streamAcpTurn(
1435
1444
  stderrTail?: string[],
1436
1445
  lifecycle?: string,
1437
1446
  launchObservation?: AcpLaunchObservation,
1447
+ /** Passed only from the two prompt-phase catches, where the turn's adapter is resolved. */
1448
+ adapter?: AcpBackendAdapter,
1438
1449
  ): void {
1439
1450
  finalizeAcpStreamState(state);
1440
1451
  state.output.stopReason = aborted ? "aborted" : "error";
1441
1452
  const base = err instanceof Error ? err.message : String(err);
1442
1453
  // The FIRST failure stays first and verbatim (it is what the backend
1443
1454
  // actually said); the lifecycle line is added, never substituted, so a
1444
- // reader can still match the transport's own text.
1455
+ // reader can still match the transport's own text. One typed exception
1456
+ // below: a vendor rejection the adapter recognises keeps that verbatim text
1457
+ // as a pi diagnostic instead, because pi reads errorMessage to decide a
1458
+ // whole-prompt replay (#127).
1445
1459
  const withLifecycle = lifecycle ? `${base}\n${lifecycle}` : base;
1446
1460
  // Its OWN line, above the vendor tail: an entwurf-owned observation must not
1447
1461
  // have to be recovered by reading vendor prose.
@@ -1453,7 +1467,28 @@ export function streamAcpTurn(
1453
1467
  // overflow gets an actionable hint appended, so "API Error" stops hiding
1454
1468
  // the turn-scoped full-transcript-replay cause.
1455
1469
  const hint = aborted ? undefined : actionableAcpBackendHint(full);
1456
- state.output.errorMessage = hint ? `${full}\n\n${hint}` : full;
1470
+ // #127: pi's retry and overflow classifiers read `errorMessage` ALONE, and a
1471
+ // vendor's "Internal error" or any digit run in its ids/stderr reads as transient
1472
+ // — a replay of a turn whose tools already ran. A rejection the adapter names gets
1473
+ // its fixed verdict there; every raw part rides one diagnostic, appended after any
1474
+ // existing ones. An abort keeps the abort path.
1475
+ const rejection = aborted ? undefined : adapter?.readPromptRejection?.(err);
1476
+ if (rejection) {
1477
+ appendAssistantMessageDiagnostic(
1478
+ state.output,
1479
+ createAssistantMessageDiagnostic(ACP_PROMPT_REJECTION_DIAGNOSTIC, err, {
1480
+ errorKind: rejection.errorKind,
1481
+ dataJson: rejection.dataJson,
1482
+ ...(lifecycle ? { lifecycle } : {}),
1483
+ ...(observed ? { launchSignal: observed } : {}),
1484
+ ...(tail ? { stderrTail: tail } : {}),
1485
+ ...(hint ? { hint } : {}),
1486
+ }),
1487
+ );
1488
+ state.output.errorMessage = rejection.verdict;
1489
+ } else {
1490
+ state.output.errorMessage = hint ? `${full}\n\n${hint}` : full;
1491
+ }
1457
1492
  stream.push({ type: "error", reason: aborted ? "aborted" : "error", error: state.output });
1458
1493
  stream.end();
1459
1494
  }
@@ -1915,6 +1950,7 @@ export function streamAcpTurn(
1915
1950
  session?.stderrTail ?? stderrTail,
1916
1951
  lifecycle,
1917
1952
  session?.launchObservation ?? launchObservation,
1953
+ adapter,
1918
1954
  );
1919
1955
  // error/abort → drop the (uncertain) session and close its child; an
1920
1956
  // uncertain connection must never be reused (GPT ④).
@@ -1982,7 +2018,7 @@ export function streamAcpTurn(
1982
2018
  // Without the session-scoped tail a mid-turn child death on a resident
1983
2019
  // session surfaced as a bare "ACP connection closed" with nothing to read
1984
2020
  // it by.
1985
- finishError(err, aborted, session.stderrTail, lifecycle, session.launchObservation);
2021
+ finishError(err, aborted, session.stderrTail, lifecycle, session.launchObservation, adapter);
1986
2022
  // error/abort on a reused session → drop it and close the child (GPT ④).
1987
2023
  session.retiring = true;
1988
2024
  retainedChildren.delete(session.child);
package/run.sh CHANGED
@@ -1348,6 +1348,14 @@ check_acp_foreground_only() {
1348
1348
  run_vitest pi-extensions/lib/acp/backend-adapter.test.ts
1349
1349
  }
1350
1350
 
1351
+ check_acp_vendor_rejection() {
1352
+ # Behaviour-adjacent tests are discovered by the glob lane. This narrow case is the
1353
+ # mutant execution coordinate: include the adapter admission and real turn pipeline,
1354
+ # and keep run_vitest's failed-title attribution for qualification.
1355
+ section "ACP vendor rejection admission (mutant execution coordinate)"
1356
+ run_vitest pi-extensions/lib/acp/backend-adapter.test.ts pi-extensions/lib/acp/backend.vendor-rejection.test.ts
1357
+ }
1358
+
1351
1359
  check_pi_mcp_register() {
1352
1360
  # NOT a discovery path — check-tests-beside-behavior finds the test by glob. This case is the
1353
1361
  # MUTANT EXECUTION COORDINATE for scripts/mutants/pi-mcp-register.json (same reasoning as
@@ -1622,9 +1630,9 @@ smoke_acp_session_reuse_live() {
1622
1630
  # needs LIVE=1. Forces process-scoped (pushes --entwurf-control into argv) and
1623
1631
  # drives TWO real ACP turns over ONE reused claude-agent-acp child via the real
1624
1632
  # streamShellAcp: turn 1 introduces a codeword (full transcript), turn 2 sends
1625
- # ONLY the latest user delta and must recall the codeword — proving the child
1626
- # was reused and the live ACP session kept turn-1 history (a respawn-per-turn
1627
- # backend would forget it). The one-shot exit0 half is owned by
1633
+ # ONLY the latest user delta. The REUSE lifecycle notice, with no new bootstrap,
1634
+ # is the verdict; codeword recall is supporting only (a rebuilt full transcript
1635
+ # could recall it too). The one-shot exit0 half is owned by
1628
1636
  # smoke-acp-provider-live.
1629
1637
  # Model override: ENTWURF_ACP_PROVIDER_MODEL (default claude-sonnet-5).
1630
1638
  # LIVE=1 ./run.sh smoke-acp-session-reuse-live
@@ -7716,6 +7724,9 @@ case "$cmd" in
7716
7724
  check-acp-prompt-lifecycle)
7717
7725
  check_acp_prompt_lifecycle
7718
7726
  ;;
7727
+ check-acp-vendor-rejection)
7728
+ check_acp_vendor_rejection
7729
+ ;;
7719
7730
  check-acp-launch-namespace)
7720
7731
  check_acp_launch_namespace
7721
7732
  ;;
@@ -3328,6 +3328,7 @@ let manifestCount: number;
3328
3328
  "acp-stop-reason": 6,
3329
3329
  "acp-stream-hooks": 10,
3330
3330
  "acp-usage-accounting": 12,
3331
+ "acp-vendor-rejection": 4,
3331
3332
  "agy-permission": 6,
3332
3333
  "bridge-boot-resume": 5,
3333
3334
  "bridge-command-boot": 12,
@@ -3354,7 +3355,7 @@ let manifestCount: number;
3354
3355
  "herdr-plugin": 25,
3355
3356
  "herdr-plugin-profile": 14,
3356
3357
  "herdr-activation": 25,
3357
- "herdr-plugin-build": 13,
3358
+ "herdr-plugin-build": 14,
3358
3359
  "herdr-runtime-bootstrap": 31,
3359
3360
  "herdr-fresh-call": 38,
3360
3361
  "herdr-supply": 11,
@@ -53,6 +53,20 @@ const COMPILED_ENTRY = path.join("mcp", "entwurf-bridge", "dist", "mcp", "entwur
53
53
  const COMMIT_A = "a".repeat(40);
54
54
  const COMMIT_B = "b".repeat(40);
55
55
 
56
+ // Fixture inputs, not registry assertions. The npm coherence check reads this checkout's
57
+ // package.json, so its version is input; neither an unpublished version nor the committed
58
+ // carrier may choose which source these deterministic cells exercise. No network is used.
59
+ const FIXTURE_VERSION = (JSON.parse(fs.readFileSync(path.join(REPO, "package.json"), "utf8")) as { version: string })
60
+ .version;
61
+ const FIXTURE_NPM_LOCK = Object.freeze({
62
+ source: "npm",
63
+ name: PACKAGE,
64
+ version: FIXTURE_VERSION,
65
+ integrity: `sha512-${createHash("sha512").update("hpb-npm-fixture").digest("base64")}`,
66
+ });
67
+ const FIXTURE_OTHER_INTEGRITY = `sha512-${createHash("sha512").update("hpb-npm-other-fixture").digest("base64")}`;
68
+ const FIXTURE_CHECKOUT_LOCK = Object.freeze({ source: "herdr-checkout", repository: "junghan0611/entwurf" });
69
+
56
70
  interface SpawnResult {
57
71
  status: number | null;
58
72
  stdout: string;
@@ -175,9 +189,10 @@ function tree(root: string): string[] {
175
189
  */
176
190
  function fixtureAcquire(opts: { activate: "absent" | "incapable" | "recorder"; log?: string; fail?: boolean }) {
177
191
  return ({ identity, prefix }: AcquireArgs) => {
192
+ const version = identity.version ?? FIXTURE_VERSION;
178
193
  const root = path.join(prefix, "node_modules", PACKAGE);
179
194
  fs.mkdirSync(path.join(root, path.dirname(COMPILED_ENTRY)), { recursive: true });
180
- fs.writeFileSync(path.join(root, "package.json"), `${JSON.stringify({ name: PACKAGE, version: "0.21.0" })}\n`);
195
+ fs.writeFileSync(path.join(root, "package.json"), `${JSON.stringify({ name: PACKAGE, version })}\n`);
181
196
  fs.writeFileSync(path.join(root, COMPILED_ENTRY), "// compiled entry fixture\n");
182
197
  const bin = path.join(prefix, "node_modules", ".bin");
183
198
  fs.mkdirSync(bin, { recursive: true });
@@ -208,7 +223,7 @@ function fixtureAcquire(opts: { activate: "absent" | "incapable" | "recorder"; l
208
223
  .update(identity.commit ?? "x")
209
224
  .digest("hex")}`,
210
225
  packageName: PACKAGE,
211
- packageVersion: "0.21.0",
226
+ packageVersion: version,
212
227
  };
213
228
  };
214
229
  }
@@ -249,6 +264,9 @@ function drive(
249
264
  spawn,
250
265
  write: (t: string) => (out += t),
251
266
  progress,
267
+ // Existing gate-only seam. Production still reads the committed lock, and its
268
+ // real source/coherence is independently checked by check-herdr-runtime-bootstrap.
269
+ readRuntimeLock: () => FIXTURE_NPM_LOCK,
252
270
  ...deps,
253
271
  });
254
272
  return { code, refusal: null, out, progress: progressLines };
@@ -408,57 +426,121 @@ function drive(
408
426
 
409
427
  // ── 6. the two-stage journey, as the RUNNER composes it ────────────────────────
410
428
  {
411
- const env = world("two-stage");
429
+ // Declared axes: both closed sources × {initial A, widened A, changed B, repeated B}.
430
+ // npm identity is the unchanged lock, not a commit; checkout identity IS the commit.
431
+ const cells = [FIXTURE_NPM_LOCK, FIXTURE_CHECKOUT_LOCK].map((lock) => {
432
+ const env = world(`two-stage-${lock.source}`);
433
+ const log = path.join(env.HOME as string, "argv.log");
434
+ let acquisitions = 0;
435
+ const counted = (args: AcquireArgs) => {
436
+ acquisitions++;
437
+ return fixtureAcquire({ activate: "recorder", log })(args);
438
+ };
439
+ const stages = [COMMIT_A, COMMIT_A, COMMIT_B, COMMIT_B].map((commit, index) => {
440
+ const rows: Record<string, string> = { pi: "current (v8) (/home/u/.pi)" };
441
+ if (index > 0) rows.claude = "current (v5) (/home/u/.claude)";
442
+ const result = drive(env, listing(rows), {
443
+ acquire: counted,
444
+ readRuntimeLock: () => lock,
445
+ resolveCommit: () => commit,
446
+ });
447
+ return { code: result.code, acquisitions };
448
+ });
449
+ const calls = fs.readFileSync(log, "utf8").trim().split("\n");
450
+ const { artifactIdentity: identity } = JSON.parse(fs.readFileSync(journalPathOf(env), "utf8")) as {
451
+ artifactIdentity: Record<string, string>;
452
+ };
453
+ const expectedCounts = lock.source === "npm" ? [1, 1, 1, 1] : [1, 1, 2, 2];
454
+ const exactIdentity =
455
+ lock.source === "npm"
456
+ ? identity.name === PACKAGE &&
457
+ identity.version === FIXTURE_VERSION &&
458
+ identity.expectedIntegrity === FIXTURE_NPM_LOCK.integrity
459
+ : identity.repository === FIXTURE_CHECKOUT_LOCK.repository &&
460
+ identity.commit === COMMIT_B &&
461
+ identity.packageName === PACKAGE &&
462
+ identity.packageVersion === FIXTURE_VERSION;
463
+ return {
464
+ source: lock.source,
465
+ stages,
466
+ calls,
467
+ identity,
468
+ passed:
469
+ stages.every((stage) => stage.code === 0) &&
470
+ JSON.stringify(stages.map((stage) => stage.acquisitions)) === JSON.stringify(expectedCounts) &&
471
+ JSON.stringify(calls) === JSON.stringify(["pi", "pi,claude-code", "pi,claude-code", "pi,claude-code"]) &&
472
+ identity.kind === lock.source &&
473
+ exactIdentity,
474
+ };
475
+ });
476
+ ok(
477
+ "[QK:HPB-SEQUENTIAL-TWO-STAGE] both closed sources hand down `{pi}` then the widened `{pi, claude-code}` " +
478
+ "on every reinstall, never OpenCode. An unchanged npm lock acquires once; checkout acquires again ONLY " +
479
+ "when A becomes B, and repeated B acquires nothing. Counts are exact at EACH step, not an aggregate lower " +
480
+ "bound. Installed identity must retain the input kind and its exact version/integrity or commit/completeness. " +
481
+ "The real writer bytes are HAC-TWO-STAGE-ADD-ONLY; this cell owns runner composition. " +
482
+ `Fixture identities do not claim registry acceptance (cells=${JSON.stringify(cells)})`,
483
+ cells.length === 2 && cells.every((cell) => cell.passed),
484
+ );
485
+ }
486
+
487
+ // ── 5b. the production default reader composes with the committed lock ─────────
488
+ {
489
+ // Independent input oracle: read the committed JSON directly, not the runtime reader
490
+ // whose invocation this cell certifies. All acquisition/activation bytes remain fixture.
491
+ const committed = JSON.parse(fs.readFileSync(path.join(PLUGIN_DIR, "runtime-lock.json"), "utf8")) as
492
+ | { source: "npm"; name: string; version: string; integrity: string }
493
+ | { source: "herdr-checkout"; repository: string };
494
+ const env = world("default-lock-reader");
412
495
  const log = path.join(env.HOME as string, "argv.log");
413
496
  let acquisitions = 0;
414
- const counted = (args: AcquireArgs) => {
415
- acquisitions++;
416
- return fixtureAcquire({ activate: "recorder", log })(args);
417
- };
418
- const stage1 = drive(env, listing({ pi: "current (v8) (/home/u/.pi)" }), {
419
- acquire: counted,
420
- resolveCommit: () => COMMIT_A,
421
- });
422
- const stage2 = drive(env, listing({ pi: "current (v8) (/home/u/.pi)", claude: "current (v5) (/home/u/.claude)" }), {
423
- acquire: counted,
497
+ const acquire = fixtureAcquire({ activate: "recorder", log });
498
+ const result = drive(env, listing({ pi: "current (v8) (/home/u/.pi)" }), {
499
+ readRuntimeLock: undefined,
424
500
  resolveCommit: () => COMMIT_A,
501
+ acquire: (args: AcquireArgs) => {
502
+ acquisitions++;
503
+ return acquire(args);
504
+ },
425
505
  });
426
- const stage3 = drive(env, listing({ pi: "current (v8) (/home/u/.pi)", claude: "current (v5) (/home/u/.claude)" }), {
427
- acquire: counted,
428
- resolveCommit: () => COMMIT_B,
429
- });
430
- const calls = fs.readFileSync(log, "utf8").trim().split("\n");
431
- const journal = JSON.parse(fs.readFileSync(journalPathOf(env), "utf8")) as {
432
- artifactIdentity: { commit: string };
433
- };
506
+ const journal = fs.existsSync(journalPathOf(env))
507
+ ? (JSON.parse(fs.readFileSync(journalPathOf(env), "utf8")) as { artifactIdentity: Record<string, string> })
508
+ : null;
509
+ const identity = journal?.artifactIdentity;
510
+ const exactIdentity =
511
+ identity !== undefined &&
512
+ identity.kind === committed.source &&
513
+ (committed.source === "npm"
514
+ ? identity.name === committed.name &&
515
+ identity.version === committed.version &&
516
+ identity.expectedIntegrity === committed.integrity
517
+ : identity.repository === committed.repository &&
518
+ identity.commit === COMMIT_A &&
519
+ identity.packageName === PACKAGE &&
520
+ identity.packageVersion === FIXTURE_VERSION);
434
521
  ok(
435
- "[QK:HPB-SEQUENTIAL-TWO-STAGE] the sequence a real operator performs, as the runner composes it: `{pi}` first, " +
436
- "then `{pi, claude-code}` after they integrated Claude Code, then a NEW commit of the same package version. " +
437
- "The unchanged commit acquires nothing while still reconciling the widened set; the new commit reinstalls. " +
438
- "OpenCode is present in every listing and appears in NO activation argv — the bytes those activations write " +
439
- "are `check-herdr-activation`'s HAC-TWO-STAGE-ADD-ONLY, which drives the real Pi writers; what this cell owns " +
440
- `is what the runner hands down (codes=${stage1.code}/${stage2.code}/${stage3.code} acquisitions=${acquisitions} calls=${JSON.stringify(calls)} commit=${journal.artifactIdentity.commit.slice(0, 8)})`,
441
- stage1.code === 0 &&
442
- stage2.code === 0 &&
443
- stage3.code === 0 &&
444
- acquisitions === 2 &&
445
- JSON.stringify(calls) === JSON.stringify(["pi", "pi,claude-code", "pi,claude-code"]) &&
446
- !calls.some((c) => c.includes("opencode")) &&
447
- journal.artifactIdentity.commit === COMMIT_B,
522
+ "[QK:HPB-DEFAULT-LOCK-READ] without a reader override, runBuild reads this plugin's committed lock " +
523
+ "from PLUGIN_DIR, not the repository root or a synthetic fixture. The acquired identity retains the " +
524
+ "independently read source and exact artifact fields; installed activation still runs once for pi. " +
525
+ `This is composition proof, not registry acquisition (code=${result.code} refusal=${result.refusal} acquisitions=${acquisitions} identity=${JSON.stringify(identity)})`,
526
+ result.code === 0 &&
527
+ acquisitions === 1 &&
528
+ exactIdentity &&
529
+ fs.existsSync(log) &&
530
+ fs.readFileSync(log, "utf8") === "pi\n",
448
531
  );
449
532
  }
450
533
 
451
534
  // ── 6a. the authority check happens BEFORE any runtime work ───────────────────
452
535
  {
453
- const IDENTITY = (commit: string) => ({
454
- kind: "herdr-checkout",
455
- repository: "junghan0611/entwurf",
456
- commit,
457
- packageName: PACKAGE,
458
- packageVersion: "0.21.0",
459
- observedDigest: `sha256-${createHash("sha256").update(commit).digest("hex")}`,
536
+ const IDENTITY = (seed: string) => ({
537
+ kind: "npm",
538
+ name: PACKAGE,
539
+ version: FIXTURE_VERSION,
540
+ expectedIntegrity: FIXTURE_NPM_LOCK.integrity,
541
+ observedDigest: `sha256-${createHash("sha256").update(seed).digest("hex")}`,
460
542
  });
461
- /** A host that already has a runtime at commit A and a ledger that says so. */
543
+ /** A host with the explicit npm fixture installed and a ledger describing it. */
462
544
  const seeded = (tag: string, backends: string[]): NodeJS.ProcessEnv => {
463
545
  const env = world(tag);
464
546
  const log = path.join(env.HOME as string, "argv.log");
@@ -468,12 +550,17 @@ function drive(
468
550
  });
469
551
  const states: Record<string, string> = {};
470
552
  for (const b of backends) states[b] = "active";
553
+ const artifactIdentity = (
554
+ JSON.parse(fs.readFileSync(journalPathOf(env), "utf8")) as {
555
+ artifactIdentity: Record<string, unknown>;
556
+ }
557
+ ).artifactIdentity;
471
558
  activation.writeLedger(
472
559
  activation.resolveActivationLayout(env),
473
560
  activation.ledgerBody({
474
561
  phase: "active",
475
562
  runtimeRoot: activeDirOf(env),
476
- artifactIdentity: IDENTITY(COMMIT_A),
563
+ artifactIdentity,
477
564
  roots: activation.resolveComponentRoots(env),
478
565
  states,
479
566
  }),
@@ -518,21 +605,22 @@ function drive(
518
605
  const emptiedDirSeen = treeDigest(oracleRoot) !== oracleBase;
519
606
  const oracleHonest = sameTree && sameLengthEditSeen && restored && modeSeen && emptiedDirSeen;
520
607
 
521
- // (a) a SOURCE switch: the committed lock would now name npm over a checkout-sourced activation.
608
+ // (a) a SOURCE switch: the committed lock would name checkout over the active npm runtime.
522
609
  // (a) is asked of the SHARED authority directly: the runner reads its lock from the committed
523
610
  // file, and this gate does not add a seam that would let any caller choose the source.
524
611
  const src = seeded("plan-source", ["pi"]);
525
612
  const srcBefore = snapshot(src);
526
613
  const srcRefusal = refusalOf(() =>
527
614
  runner.certifyActivationPlan(src, {
528
- lock: { source: "npm", name: PACKAGE, version: "0.21.0", integrity: "sha512-x" },
615
+ lock: { source: "herdr-checkout", repository: "junghan0611/entwurf" },
529
616
  checkoutRoot: fixtureCheckoutRoot(),
530
617
  requested: ["pi"],
618
+ resolveCommit: () => COMMIT_A,
531
619
  }),
532
620
  );
533
621
  const srcUnchanged = unchanged(src, srcBefore);
534
622
 
535
- // (b) a new commit whose request DROPS a backend the ledger holds.
623
+ // (b) a changed npm integrity whose request DROPS a backend the ledger holds.
536
624
  const drop = seeded("plan-drop", ["pi", "claude-code"]);
537
625
  const dropBefore = snapshot(drop);
538
626
  let dropAcquired = 0;
@@ -541,6 +629,7 @@ function drive(
541
629
  dropAcquired++;
542
630
  return fixtureAcquire({ activate: "recorder", log: path.join(drop.HOME as string, "argv.log") })(args);
543
631
  },
632
+ readRuntimeLock: () => ({ ...FIXTURE_NPM_LOCK, integrity: FIXTURE_OTHER_INTEGRITY }),
544
633
  resolveCommit: () => COMMIT_B,
545
634
  });
546
635
 
@@ -549,7 +638,17 @@ function drive(
549
638
  const tornLedger = activation.resolveActivationLayout(torn).ledgerPath;
550
639
  fs.writeFileSync(
551
640
  tornLedger,
552
- `${JSON.stringify({ ...(JSON.parse(fs.readFileSync(tornLedger, "utf8")) as Record<string, unknown>), artifactIdentity: IDENTITY(COMMIT_B) }, null, 2)}\n`,
641
+ `${JSON.stringify(
642
+ {
643
+ ...(JSON.parse(fs.readFileSync(tornLedger, "utf8")) as Record<string, unknown>),
644
+ artifactIdentity: {
645
+ ...IDENTITY(COMMIT_B),
646
+ expectedIntegrity: FIXTURE_OTHER_INTEGRITY,
647
+ },
648
+ },
649
+ null,
650
+ 2,
651
+ )}\n`,
553
652
  );
554
653
  const tornBefore = snapshot(torn);
555
654
  let tornAcquired = 0;
@@ -561,14 +660,13 @@ function drive(
561
660
  resolveCommit: () => COMMIT_A,
562
661
  });
563
662
 
564
- // (d) the LEGAL case still passes: same source, new commit, request covers the ledger. The
565
- // authority is asked on the PRE-install state, which is the only state it is ever asked about.
663
+ // (d) the LEGAL case still passes: same exact npm source and lock, request covers the ledger.
664
+ // The authority is asked on the PRE-install state, which is the only state it is ever asked about.
566
665
  const legal = seeded("plan-legal", ["pi"]);
567
666
  const legalPlan = runner.certifyActivationPlan(legal, {
568
- lock: { source: "herdr-checkout", repository: "junghan0611/entwurf" },
569
- checkoutRoot: fixtureCheckoutRoot(),
667
+ lock: FIXTURE_NPM_LOCK,
668
+ checkoutRoot: REPO,
570
669
  requested: ["pi"],
571
- resolveCommit: () => COMMIT_B,
572
670
  });
573
671
  const legalDrive = drive(legal, listing({ pi: "current (v8) (/home/u/.pi)" }), {
574
672
  acquire: fixtureAcquire({ activate: "recorder", log: path.join(legal.HOME as string, "argv.log") }),
@@ -577,7 +675,7 @@ function drive(
577
675
 
578
676
  ok(
579
677
  "[QK:HPB-PRE-BOOTSTRAP-AUTHORITY] the activation authority is asked BEFORE any runtime work, through the SAME " +
580
- "shipped functions the installed verb runs: a source switch, a new commit whose request drops an activated " +
678
+ "shipped functions the installed verb runs: a source switch, a request that drops an activated " +
581
679
  "backend, and a ledger that already disagrees with the runtime standing there are each refused with the " +
582
680
  "runtime tree, its journal, our cache, the ledger and the activation log ALL byte-identical and the " +
583
681
  "acquisition never called. The first cut bootstrapped first and let the installed verb refuse afterwards, " +
@@ -596,7 +694,7 @@ function drive(
596
694
  tornAcquired === 0 &&
597
695
  unchanged(torn, tornBefore) &&
598
696
  legalDrive.code === 0 &&
599
- legalPlan.disposition === "rebind",
697
+ legalPlan.disposition === "match",
600
698
  );
601
699
  }
602
700
 
@@ -607,15 +705,19 @@ function drive(
607
705
  const commits = [COMMIT_A, COMMIT_B, COMMIT_B];
608
706
  let call = 0;
609
707
  const moving = () => commits[Math.min(call++, commits.length - 1)] as string;
708
+ // Candidate source is a closed, retained branch. This test seam does not expose a caller input:
709
+ // production `runBuild` reads only the committed lock, while the gate drives the branch whose
710
+ // identity can move between D1 and D2.
610
711
  const drifted = drive(env, listing({ pi: "current (v8) (/home/u/.pi)" }), {
611
712
  acquire: fixtureAcquire({ activate: "recorder", log }),
713
+ readRuntimeLock: () => FIXTURE_CHECKOUT_LOCK,
612
714
  resolveCommit: moving,
613
715
  });
614
716
  const journal = JSON.parse(fs.readFileSync(journalPathOf(env), "utf8")) as { artifactIdentity: { commit: string } };
615
717
  const activated = fs.existsSync(log) ? fs.readFileSync(log, "utf8").trim() : "";
616
718
  ok(
617
719
  "[QK:HPB-BOOTSTRAP-IDENTITY-EXACT] the artifact that lands must be EXACTLY the one the authority check admitted: " +
618
- "when the source moves between the judgement and the install, the run refuses by name instead of handing the " +
720
+ "when the candidate source moves between the judgement and the install, the run refuses by name instead of handing the " +
619
721
  "installed activation verb a different artifact than the one this build was cleared to place — the verb would " +
620
722
  "then re-judge, and pass or fail on a question nobody asked. The refusal is honest about what did happen: the " +
621
723
  `runtime was replaced, and NO activation ran (refusal=${drifted.refusal} landed=${journal.artifactIdentity.commit.slice(0, 8)} admitted=${COMMIT_A.slice(0, 8)} activations=${JSON.stringify(activated)})`,
@@ -709,7 +811,7 @@ function drive(
709
811
  ok(
710
812
  "[QK:HPB-PROGRESS-NAMED-SEQUENCE] a build narrates the five steps IN ORDER and names the long one — herdr pipes " +
711
813
  "both of this process's streams into a buffer it DISCARDS on success (`src/cli/plugin.rs:1328-1373` @ c77af189), " +
712
- "so an operator who has just answered the install prompt sees nothing at all through a multi-minute `npm pack` " +
814
+ "so an operator who has just answered the install prompt sees nothing at all through a multi-minute registry fetch " +
713
815
  "and reads it as a hang. The sequence is reported, not logged: each step names the work about to start, the " +
714
816
  "acquisition step says out loud that silence is expected and which source it is reaching for, and a run with " +
715
817
  "nothing to activate takes exactly ONE step and then closes — it must not narrate work it never did. " +
@@ -721,7 +823,7 @@ function drive(
721
823
  full.progress[0].includes("integration status") &&
722
824
  full.progress[1].includes("pi, claude-code") &&
723
825
  longStep.includes("long step") &&
724
- longStep.includes(COMMIT_A.slice(0, 8)) &&
826
+ longStep.includes(`${PACKAGE}@${FIXTURE_VERSION}`) &&
725
827
  full.progress[3].includes("what landed") &&
726
828
  full.progress[4].includes("wiring pi, claude-code") &&
727
829
  full.progress[5].startsWith("done: ") &&
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "$comment": "SSOT for the herdr binary this repository has MEASURED against. Nothing else may carry a version or a digest: the CI installer, the sandbox gate's boundary check and the launch-rail document all read or cite this file. Re-measuring a new herdr means running check-herdr-sandbox against it FIRST, then editing this file, then repairing the measured sentences in docs/herdr-launch-rail.md in the same commit — miss one and the gate fails on version mismatch, which is the point.",
3
- "version": "0.9.1",
4
- "tag": "v0.9.1",
5
- "tagCommit": "065ef9d6a531c49fb8bee7e818ef837065b21ee9",
3
+ "version": "0.9.3",
4
+ "tag": "v0.9.3",
5
+ "tagCommit": "7b116c05bfda646af39d2524c54e70c751f57ee8",
6
6
  "socketProtocol": 22,
7
- "measuredOn": "2026-09-17",
7
+ "measuredOn": "2026-10-03",
8
8
  "measuredArch": "aarch64",
9
9
  "releaseUrlPrefix": "https://github.com/herdrdev/herdr/releases/download",
10
10
  "integrity": {
@@ -15,15 +15,15 @@
15
15
  "assets": {
16
16
  "x86_64": {
17
17
  "name": "herdr-linux-x86_64",
18
- "sha256": "2a02fed16beb651ef006e1d43f048f652ca4dc58ad053cd2d44450563d5c54b7",
18
+ "sha256": "18a8dc65f1c2fa485884344356dea1cfd911c6f06cf46fa78e193f4087f4dba7",
19
19
  "executed": false,
20
- "executedNote": "pending the first CI run on an x86_64 runner — the digest is pinned, but no cell of this rail has ever run on this binary"
20
+ "executedNote": "pending the first CI run on an x86_64 runner — the digest is pinned (and named by GitHub's release attestation for pkg:github/herdrdev/herdr@v0.9.3), but no cell of this rail has ever run on this binary"
21
21
  },
22
22
  "aarch64": {
23
23
  "name": "herdr-linux-aarch64",
24
- "sha256": "f4ccf4de745f2cb9a39a983e9ba3703dad50ec2a58dea83026ceab721bbd8d9e",
24
+ "sha256": "4de7aa3e25678812e92960de64f7c2aaa1bca1f0f80a3c5e559837e231e1f5c0",
25
25
  "executed": true,
26
- "executedNote": "the #116 rail was re-measured on this exact binary on 2026-09-17: `check-herdr-sandbox` green, `herdr status client` reports protocol 22 (unchanged from 0.9.0), and `integration status` adds exactly one row — `letta (experimental)` — with every other row byte-identical to 0.9.0. The operator host still runs the 0.9.0 asset at that date; the binary measured here was fetched by digest into a temp dir"
26
+ "executedNote": "the #116 rail was re-measured on this exact binary on 2026-10-03 (#126), fetched by digest through scripts/install-herdr-ci.sh into a temp dir and run from an isolated snapshot whose manifest named it: `check-herdr-sandbox` green (12 assertions, private server), `herdr status client` reports protocol 22 (unchanged from 0.9.1), and `integration status` prints the same 18 rows as 0.9.1 in a sandbox HOME once that root path is normalized; pi (v9) and claude (v10) integrations installed by 0.9.1 read `current` under 0.9.3 and a 0.9.3 reinstall leaves their bytes identical. The operator host still runs the 0.9.1 asset at that date and was not touched. The 0.9.1 receipt (2026-09-17) stays dated in docs/herdr-launch-rail.md"
27
27
  }
28
28
  }
29
29
  }