@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.
- package/CHANGELOG.md +117 -0
- package/README.md +6 -1
- package/VERIFY.md +6 -3
- package/docs/acp-backend-rail.md +111 -18
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +43 -0
- package/package.json +4 -4
- package/pi-extensions/lib/acp/backend-adapter.ts +68 -0
- package/pi-extensions/lib/acp/backend.ts +41 -5
- package/run.sh +14 -3
- package/scripts/check-gate-qualification.ts +2 -1
- package/scripts/check-herdr-plugin-build.ts +159 -57
- package/scripts/fixtures/herdr-supply.json +8 -8
- package/scripts/mutants/acp-prompt-lifecycle.json +2 -2
- package/scripts/mutants/acp-vendor-rejection.json +62 -0
- package/scripts/mutants/herdr-plugin-build.json +11 -0
- package/scripts/mutants/herdr-supply.json +3 -3
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-herdr-fresh-call-live.ts +21 -7
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
1626
|
-
#
|
|
1627
|
-
#
|
|
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":
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
415
|
-
|
|
416
|
-
|
|
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
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
|
|
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-
|
|
436
|
-
"
|
|
437
|
-
"
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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 = (
|
|
454
|
-
kind: "
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
|
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
|
|
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
|
|
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: "
|
|
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
|
|
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(
|
|
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
|
|
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:
|
|
569
|
-
checkoutRoot:
|
|
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
|
|
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 === "
|
|
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
|
|
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(
|
|
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.
|
|
4
|
-
"tag": "v0.9.
|
|
5
|
-
"tagCommit": "
|
|
3
|
+
"version": "0.9.3",
|
|
4
|
+
"tag": "v0.9.3",
|
|
5
|
+
"tagCommit": "7b116c05bfda646af39d2524c54e70c751f57ee8",
|
|
6
6
|
"socketProtocol": 22,
|
|
7
|
-
"measuredOn": "2026-
|
|
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": "
|
|
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": "
|
|
24
|
+
"sha256": "4de7aa3e25678812e92960de64f7c2aaa1bca1f0f80a3c5e559837e231e1f5c0",
|
|
25
25
|
"executed": true,
|
|
26
|
-
"executedNote": "the #116 rail was re-measured on this exact binary on 2026-
|
|
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
|
}
|