@junghanacs/entwurf 0.22.0 → 0.23.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/AGENTS.md +4 -1
- package/BASELINE.md +1 -0
- package/CHANGELOG.md +514 -0
- package/DELIVERY.md +10 -2
- package/README.md +43 -4
- package/VERIFY.md +47 -0
- package/docs/acp-backend-rail.md +96 -17
- package/docs/setup-clean-host.md +11 -1
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +39 -40
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +2 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-declaration.js +609 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +95 -56
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +40 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +6 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +7 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peer-observe.js +67 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +9 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +24 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +2 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +423 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +98 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +1018 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +213 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +46 -273
- package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +109 -0
- package/mcp/entwurf-bridge/src/index.ts +41 -41
- package/mcp/entwurf-bridge/tsconfig.build.json +1 -0
- package/package.json +9 -8
- package/pi-extensions/entwurf-control.ts +91 -52
- package/pi-extensions/lib/acp/acp-client.ts +3 -3
- package/pi-extensions/lib/acp/backend-adapter.ts +3 -3
- package/pi-extensions/lib/acp/backend.ts +4 -4
- package/pi-extensions/lib/acp/event-mapper.ts +5 -5
- package/pi-extensions/lib/acp/tool-surface.ts +2 -1
- package/pi-extensions/lib/codex-declaration.js +612 -0
- package/pi-extensions/lib/codex-fresh-preflight.ts +107 -51
- package/pi-extensions/lib/entwurf-control-rpc.ts +46 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +13 -2
- package/pi-extensions/lib/entwurf-facts.ts +14 -1
- package/pi-extensions/lib/entwurf-peer-observe.ts +72 -2
- package/pi-extensions/lib/entwurf-peers-render.ts +9 -1
- package/pi-extensions/lib/entwurf-self-address.ts +33 -0
- package/pi-extensions/lib/entwurf-v2-contract.ts +2 -2
- package/pi-extensions/lib/entwurf-v2-lock.ts +1 -1
- package/pi-extensions/lib/entwurf-v2-runner.ts +1 -1
- package/pi-extensions/lib/fresh-call-composition.ts +493 -0
- package/pi-extensions/lib/fresh-call-dispatch.ts +142 -0
- package/pi-extensions/lib/herdr-fresh-call.ts +1378 -0
- package/pi-extensions/lib/herdr-placement.ts +276 -0
- package/pi-extensions/lib/mux-fresh-call.ts +95 -301
- package/run.sh +588 -204
- package/scripts/check-acp-usage-accounting.ts +9 -9
- package/scripts/check-codex-birth-hook.ts +187 -0
- package/scripts/check-entwurf-control-rpc.ts +138 -0
- package/scripts/check-entwurf-fact-provider.ts +4 -2
- package/scripts/check-entwurf-peers-surface.ts +3 -1
- package/scripts/check-entwurf-self-address.ts +68 -0
- package/scripts/check-fresh-call-dispatch.ts +301 -0
- package/scripts/check-gate-qualification.ts +17 -4
- package/scripts/check-herdr-activation.ts +1293 -0
- package/scripts/check-herdr-fresh-call.ts +1250 -0
- package/scripts/check-herdr-placement.ts +383 -0
- package/scripts/check-herdr-plugin-build.ts +842 -0
- package/scripts/check-herdr-plugin-profile.ts +371 -0
- package/scripts/check-herdr-plugin.ts +523 -0
- package/scripts/check-herdr-runtime-bootstrap.ts +1264 -0
- package/scripts/check-herdr-sandbox.ts +456 -0
- package/scripts/check-herdr-supply.ts +184 -0
- package/scripts/check-mux-launcher-fence.ts +138 -0
- package/scripts/check-peer-facts.ts +331 -0
- package/scripts/check-pi-launch.ts +358 -0
- package/scripts/check-release-gate-outcomes.ts +19 -0
- package/scripts/check-typing-call-fence.ts +296 -0
- package/scripts/codex-birth-doctor.sh +159 -16
- package/scripts/codex-birth-install.sh +178 -72
- package/scripts/codex-birth-uninstall.sh +162 -7
- package/scripts/fixtures/herdr-supply.json +29 -0
- package/scripts/herdr-activation.mjs +536 -0
- package/scripts/herdr-plugin-activate.mjs +270 -0
- package/scripts/herdr-plugin-deactivate.mjs +193 -0
- package/scripts/herdr-runtime.mjs +1203 -0
- package/scripts/install-herdr-ci.sh +96 -0
- package/scripts/inventory-verification-surface.ts +86 -10
- package/scripts/lib/codex-fresh-live-protocol.ts +2 -2
- package/scripts/lib/tree-digest.ts +97 -0
- package/scripts/meta-bridge-install.sh +19 -2
- package/scripts/meta-bridge-state.py +167 -11
- package/scripts/mutants/codex-caller-seat.json +6 -8
- package/scripts/mutants/codex-native.json +195 -29
- package/scripts/mutants/control-socket-disconnect.json +57 -0
- package/scripts/mutants/fresh-call-dispatch.json +154 -0
- package/scripts/mutants/herdr-activation.json +305 -0
- package/scripts/mutants/herdr-fresh-call.json +448 -0
- package/scripts/mutants/herdr-placement.json +161 -0
- package/scripts/mutants/herdr-plugin-build.json +160 -0
- package/scripts/mutants/herdr-plugin-profile.json +202 -0
- package/scripts/mutants/herdr-plugin.json +133 -0
- package/scripts/mutants/herdr-runtime-bootstrap.json +411 -0
- package/scripts/mutants/herdr-supply.json +133 -0
- package/scripts/mutants/meta-hook-session-switch.json +2 -2
- package/scripts/mutants/mux-fresh-call.json +104 -17
- package/scripts/mutants/mux-launcher-fence.json +13 -0
- package/scripts/mutants/omp-fresh.json +4 -4
- package/scripts/mutants/peer-facts.json +98 -0
- package/scripts/mutants/pi-launch.json +113 -0
- package/scripts/mutants/resume-args.json +6 -6
- package/scripts/mutants/self-address.json +33 -0
- package/scripts/mutants/typing-call-fence.json +17 -0
- package/scripts/mutants/v2-surface.json +2 -2
- package/scripts/mutants/v2-visible-resume.json +17 -17
- package/scripts/peer-facts.ts +120 -0
- package/scripts/register-pi-package.py +8 -0
- package/scripts/register-pi-provider.py +88 -12
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-codex-birth.sh +281 -12
- package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
- package/scripts/smoke-herdr-fresh-call-live.ts +1027 -0
- package/scripts/smoke-herdr-plugin-build-live.ts +543 -0
- package/scripts/smoke-herdr-raw-install-live.sh +849 -0
- package/scripts/tsconfig.json +5 -0
- package/scripts/check-entwurf-facts.ts +0 -385
- package/scripts/check-entwurf-resume-args.ts +0 -122
- package/scripts/check-entwurf-v2-decider.ts +0 -657
- package/scripts/check-entwurf-v2-lock.ts +0 -371
- package/scripts/check-entwurf-v2-mailbox.ts +0 -261
- package/scripts/check-entwurf-v2-matrix.ts +0 -401
- package/scripts/check-entwurf-v2-native-push.ts +0 -221
- package/scripts/check-entwurf-v2-release.ts +0 -151
- package/scripts/check-entwurf-v2-runner.ts +0 -448
- package/scripts/check-entwurf-v2-send-fallback.ts +0 -350
- package/scripts/check-entwurf-v2-send.ts +0 -458
- package/scripts/check-entwurf-v2-visible-resume.ts +0 -445
- package/scripts/check-socket-discovery.ts +0 -401
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check-herdr-plugin — deterministic gate for the #116 M2-b Herdr plugin
|
|
3
|
+
* (`plugins/herdr/`: the manifest and the one read-only status pane).
|
|
4
|
+
*
|
|
5
|
+
* TWO HALVES, AND THEY PROVE DIFFERENT THINGS.
|
|
6
|
+
*
|
|
7
|
+
* Static — the manifest is read with the same TOML parser the package already depends on
|
|
8
|
+
* and checked against the shape herdr 0.9.0 actually accepts (`min_herdr_version`
|
|
9
|
+
* required and semver, pane ids dot-free, popup-only sizing, argv arrays). The sections
|
|
10
|
+
* that are ABSENT are asserted as hard as the ones present: `[[events]]`/`[[startup]]`
|
|
11
|
+
* would grow a watcher, `[[actions]]` would send a report nobody can read into a 64 KiB
|
|
12
|
+
* log. `[[build]]` is no longer among them (#116 M3-b3) — the single runner it may call is
|
|
13
|
+
* `check-herdr-plugin-build`'s subject.
|
|
14
|
+
*
|
|
15
|
+
* Behavioural — the REAL pane entry is spawned with a stub `entwurf` and a stub
|
|
16
|
+
* `HERDR_BIN_PATH`, and every stub call is appended to a log, so "exactly one read each"
|
|
17
|
+
* is counted rather than asserted about the source. Success, skip, four distinct failure
|
|
18
|
+
* names, the ambiguous pane, diagnostics, and the no-write claim are each driven for real.
|
|
19
|
+
* A 1,002-citizen fixture pins the render budget: the read stays whole, but a thousand
|
|
20
|
+
* historical rows may not become a thousand table rows.
|
|
21
|
+
*
|
|
22
|
+
* WHAT A STUB CANNOT PROVE, AND WHAT CAUGHT IT. A stub answers any argv, so it cannot
|
|
23
|
+
* tell you the argv is right. The first private-herdr cell for this plugin returned
|
|
24
|
+
* `herdr-agent-list-failed / exit 2: usage: herdr agent list` — `agent list` takes no
|
|
25
|
+
* `--json` (unlike `plugin list`, which does), and the symmetry had been assumed. The
|
|
26
|
+
* gate now pins the exact argv, but the pin is only as true as the measurement behind
|
|
27
|
+
* it: the reading that corrects it is a real herdr, not this file.
|
|
28
|
+
*
|
|
29
|
+
* WHY A STUB HERE IS NOT THE FIXTURE THE LANE FORBIDS. `check-herdr-sandbox` may not
|
|
30
|
+
* invent a fake herdr binary, because there the subject IS herdr's launch protocol and a
|
|
31
|
+
* fake one would fake the measurement. Here the subject is a CONSUMER: what is under test
|
|
32
|
+
* is how this plugin parses, joins and refuses given a listing, and no real herdr can be
|
|
33
|
+
* made to produce a chosen `agent_status`/pane pair without the LIVE axis. The stub stands
|
|
34
|
+
* for the caller's input, never for herdr's behaviour — nothing in this file claims a fact
|
|
35
|
+
* ABOUT herdr.
|
|
36
|
+
*
|
|
37
|
+
* Every [QK:HPL-*] token appears exactly once, on the assertion that fails for that claim.
|
|
38
|
+
* No herdr binary, no Entwurf install, no network, no model turn, no writes outside mkdtemp.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import assert from "node:assert/strict";
|
|
42
|
+
import { spawnSync } from "node:child_process";
|
|
43
|
+
import fs from "node:fs";
|
|
44
|
+
import os from "node:os";
|
|
45
|
+
import path from "node:path";
|
|
46
|
+
import { parse as parseToml } from "smol-toml";
|
|
47
|
+
import { reclaimOnExit } from "./lib/reclaim-on-exit.ts";
|
|
48
|
+
|
|
49
|
+
let passed = 0;
|
|
50
|
+
function ok(label: string, cond: boolean): void {
|
|
51
|
+
assert.ok(cond, label);
|
|
52
|
+
console.log(` ok ${label}`);
|
|
53
|
+
passed++;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const REPO = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
|
|
57
|
+
const PLUGIN_DIR = path.join(REPO, "plugins", "herdr");
|
|
58
|
+
const ENTRY = path.join(PLUGIN_DIR, "lib", "status.mjs");
|
|
59
|
+
|
|
60
|
+
function tmp(prefix: string): string {
|
|
61
|
+
return reclaimOnExit(fs.mkdtempSync(path.join(os.tmpdir(), `entwurf-herdr-plugin-${prefix}-`)));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── static: the manifest is the shape herdr 0.9.0 accepts, and nothing more ──
|
|
65
|
+
interface Manifest {
|
|
66
|
+
id?: unknown;
|
|
67
|
+
name?: unknown;
|
|
68
|
+
version?: unknown;
|
|
69
|
+
min_herdr_version?: unknown;
|
|
70
|
+
platforms?: unknown;
|
|
71
|
+
panes?: { id?: unknown; title?: unknown; placement?: unknown; command?: unknown }[];
|
|
72
|
+
[k: string]: unknown;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const manifest = parseToml(fs.readFileSync(path.join(PLUGIN_DIR, "herdr-plugin.toml"), "utf8")) as Manifest;
|
|
76
|
+
{
|
|
77
|
+
ok("plugin id is the live GitHub owner, not the retired org", manifest.id === "junghan0611.entwurf");
|
|
78
|
+
ok(
|
|
79
|
+
"the plugin carries its OWN version, independent of the package",
|
|
80
|
+
typeof manifest.version === "string" && /^\d+\.\d+\.\d+$/.test(manifest.version),
|
|
81
|
+
);
|
|
82
|
+
// herdr refuses to link a plugin whose floor is newer than the running binary, and it
|
|
83
|
+
// requires the key: `validate_min_herdr_version(None)` is an error, not a default.
|
|
84
|
+
ok("min_herdr_version is the measured floor 0.9.0, as a semver", manifest.min_herdr_version === "0.9.0");
|
|
85
|
+
ok("platforms declares only what was measured", JSON.stringify(manifest.platforms) === JSON.stringify(["linux"]));
|
|
86
|
+
|
|
87
|
+
const panes = manifest.panes ?? [];
|
|
88
|
+
ok("exactly one pane entrypoint", panes.length === 1);
|
|
89
|
+
const pane = panes[0] ?? {};
|
|
90
|
+
ok("the pane is the status overlay", pane.id === "status" && pane.placement === "overlay");
|
|
91
|
+
// herdr's `normalize_action_id` refuses a dot in a pane/action id.
|
|
92
|
+
ok("the pane id carries no dot", typeof pane.id === "string" && !pane.id.includes("."));
|
|
93
|
+
ok(
|
|
94
|
+
"the pane command is an argv array (herdr runs no shell) naming only node",
|
|
95
|
+
Array.isArray(pane.command) && pane.command[0] === "node" && pane.command[1] === "lib/status.mjs",
|
|
96
|
+
);
|
|
97
|
+
// Sizing is popup-only in herdr; declaring it on an overlay is `invalid_plugin_pane_size`.
|
|
98
|
+
ok("no popup-only sizing on an overlay pane", !("width" in pane) && !("height" in pane));
|
|
99
|
+
|
|
100
|
+
// `[[build]]` LEFT this list in #116 M3-b3: a Herdr user's one install command is the only door
|
|
101
|
+
// this plugin gets, so the activation has to happen there. It is owned by
|
|
102
|
+
// `check-herdr-plugin-build`, which pins the single runner it may call; what stays forbidden here
|
|
103
|
+
// is everything that would VOLUNTEER — a startup hook on every server start, an event hook where
|
|
104
|
+
// a watcher would grow, an action whose output goes to a 64 KiB log nobody reads.
|
|
105
|
+
assert.deepEqual(
|
|
106
|
+
["startup", "events", "actions", "link_handlers"].filter((k) => k in manifest),
|
|
107
|
+
[],
|
|
108
|
+
"[QK:HPL-MANIFEST-NO-FORBIDDEN-SECTIONS] the manifest declares no startup, event, action or link-handler section — a startup or event hook is where the watcher this lane refuses would grow, and an action sends a report nobody can read into a 64 KiB log. The one `[[build]]` section is `check-herdr-plugin-build`'s subject, not an exception carved out here",
|
|
109
|
+
);
|
|
110
|
+
console.log(" ok the manifest declares no startup / event / action / link-handler section");
|
|
111
|
+
passed++;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── the join the plugin must never re-implement ─────────────────────────────
|
|
115
|
+
{
|
|
116
|
+
const source = fs.readFileSync(ENTRY, "utf8");
|
|
117
|
+
// Comments are allowed to NAME the thing they refuse; code may not do it. Strip the
|
|
118
|
+
// prose first so a header that explains the refusal cannot fail its own assertion.
|
|
119
|
+
const code = source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
|
120
|
+
assert.ok(
|
|
121
|
+
!/nativeSessionId/.test(code) && !/\.jsonl/.test(code) && !/agent_session/.test(code) && !/uuid/i.test(code),
|
|
122
|
+
"[QK:HPL-JOIN-PANE-ID-ONLY] the pane's code never touches a native session id, a session filename, an agent_session triple or a uuid — the pi half of that join is a vendor floor measured in pi-extensions/lib/herdr-placement.ts, and a second copy out here would be a fork no gate and no document covers; the whole join this plugin is allowed to make is one opaque pane-id string equality",
|
|
123
|
+
);
|
|
124
|
+
console.log(" ok the pane's code re-implements none of the placement join");
|
|
125
|
+
passed++;
|
|
126
|
+
ok(
|
|
127
|
+
"the join is the opaque pane id, compared as a string",
|
|
128
|
+
code.includes('placement.kind === "herdr-pane"') && code.includes("placement.paneId"),
|
|
129
|
+
);
|
|
130
|
+
// Not a word scan — the closing line of the report NAMES entwurf_v2 on purpose, to tell
|
|
131
|
+
// the reader where delivery actually lives. What must be absent is the ability to DO it:
|
|
132
|
+
// one spawn helper, and only the two read argvs anywhere in the file.
|
|
133
|
+
ok("there is exactly one child-process call site", code.split("spawnSync(").length - 1 === 1);
|
|
134
|
+
// The SET of argvs, not the sequence: how many times each read happens is counted for
|
|
135
|
+
// real below, and duplicating that check here would steal that claim's kill.
|
|
136
|
+
const argvs = [
|
|
137
|
+
...new Set([...code.matchAll(/readJson\([^,]+,\s*(\[[^\]]*\])/g)].map((m) => (m[1] ?? "").replace(/\s+/g, ""))),
|
|
138
|
+
].sort();
|
|
139
|
+
ok(
|
|
140
|
+
"the only argvs it can spawn are the two reads — nothing installs, configures, or delivers",
|
|
141
|
+
JSON.stringify(argvs) === JSON.stringify(['["agent","list"]', '["peer-facts"]']),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── behavioural: the real entry, stubbed inputs, counted calls ──────────────
|
|
146
|
+
interface World {
|
|
147
|
+
dir: string;
|
|
148
|
+
callLog: string;
|
|
149
|
+
binDir: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function world(prefix: string): World {
|
|
153
|
+
const dir = tmp(prefix);
|
|
154
|
+
const binDir = path.join(dir, "bin");
|
|
155
|
+
fs.mkdirSync(binDir);
|
|
156
|
+
return { dir, callLog: path.join(dir, "calls"), binDir };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** A stub that records its argv and then answers with the given bytes / exit code. */
|
|
160
|
+
function stub(w: World, name: string, payload: string, exitCode = 0): string {
|
|
161
|
+
const payloadFile = path.join(w.dir, `${name}.payload`);
|
|
162
|
+
fs.writeFileSync(payloadFile, payload);
|
|
163
|
+
const p = path.join(w.binDir, name);
|
|
164
|
+
fs.writeFileSync(
|
|
165
|
+
p,
|
|
166
|
+
`#!/bin/sh\nprintf '%s %s\\n' "${name}" "$*" >> "${w.callLog}"\ncat "${payloadFile}"\nexit ${exitCode}\n`,
|
|
167
|
+
);
|
|
168
|
+
fs.chmodSync(p, 0o755);
|
|
169
|
+
return p;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
interface Run {
|
|
173
|
+
status: number | null;
|
|
174
|
+
stdout: string;
|
|
175
|
+
calls: string[];
|
|
176
|
+
stateDir: string;
|
|
177
|
+
configDir: string;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Runs the pane entry exactly as herdr would: argv, plugin root as cwd, the plugin env
|
|
182
|
+
* herdr injects. stdin is an empty closed pipe, which is the non-TTY end of the
|
|
183
|
+
* hold-open wait — a busy loop or a missing wait would both show up as a hang here.
|
|
184
|
+
*/
|
|
185
|
+
function run(w: World, env: Record<string, string | undefined> = {}): Run {
|
|
186
|
+
const stateDir = path.join(w.dir, "state");
|
|
187
|
+
const configDir = path.join(w.dir, "config");
|
|
188
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
189
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
190
|
+
const home = path.join(w.dir, "home");
|
|
191
|
+
fs.mkdirSync(home, { recursive: true });
|
|
192
|
+
const res = spawnSync(process.execPath, [ENTRY], {
|
|
193
|
+
cwd: PLUGIN_DIR,
|
|
194
|
+
encoding: "utf8",
|
|
195
|
+
input: "",
|
|
196
|
+
timeout: 60_000,
|
|
197
|
+
env: {
|
|
198
|
+
PATH: `${w.binDir}:${process.env.PATH ?? ""}`,
|
|
199
|
+
HOME: home,
|
|
200
|
+
XDG_DATA_HOME: path.join(home, ".local", "share"),
|
|
201
|
+
HERDR_ENV: "1",
|
|
202
|
+
HERDR_PLUGIN_ID: "junghan0611.entwurf",
|
|
203
|
+
HERDR_PLUGIN_ROOT: PLUGIN_DIR,
|
|
204
|
+
HERDR_PLUGIN_STATE_DIR: stateDir,
|
|
205
|
+
HERDR_PLUGIN_CONFIG_DIR: configDir,
|
|
206
|
+
HERDR_PLUGIN_ENTRYPOINT_ID: "status",
|
|
207
|
+
HERDR_BIN_PATH: path.join(w.binDir, "herdr"),
|
|
208
|
+
...env,
|
|
209
|
+
} as NodeJS.ProcessEnv,
|
|
210
|
+
});
|
|
211
|
+
const calls = fs.existsSync(w.callLog)
|
|
212
|
+
? fs
|
|
213
|
+
.readFileSync(w.callLog, "utf8")
|
|
214
|
+
.split("\n")
|
|
215
|
+
.filter((l) => l !== "")
|
|
216
|
+
: [];
|
|
217
|
+
return { status: res.status, stdout: res.stdout ?? "", calls, stateDir, configDir };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const PEERS = JSON.stringify({
|
|
221
|
+
schemaVersion: 1,
|
|
222
|
+
storeDir: "/s",
|
|
223
|
+
peers: [
|
|
224
|
+
{
|
|
225
|
+
gardenId: "20260915T111111-aaaaaa",
|
|
226
|
+
backend: "claude-code",
|
|
227
|
+
nativeSessionId: "n-1",
|
|
228
|
+
cwd: "/w/one",
|
|
229
|
+
model: null,
|
|
230
|
+
createdAt: "",
|
|
231
|
+
recordUpdatedAt: "",
|
|
232
|
+
liveness: "unsupported",
|
|
233
|
+
receiver: "none",
|
|
234
|
+
transcript: "exists",
|
|
235
|
+
placement: { kind: "herdr-pane", paneId: "w1:p3" },
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
gardenId: "20260915T222222-bbbbbb",
|
|
239
|
+
backend: "pi",
|
|
240
|
+
nativeSessionId: "n-2",
|
|
241
|
+
cwd: "/w/two",
|
|
242
|
+
model: "p/m",
|
|
243
|
+
createdAt: "",
|
|
244
|
+
recordUpdatedAt: "",
|
|
245
|
+
liveness: "alive",
|
|
246
|
+
receiver: "n/a",
|
|
247
|
+
transcript: "exists",
|
|
248
|
+
placement: { kind: "unobserved" },
|
|
249
|
+
},
|
|
250
|
+
],
|
|
251
|
+
diagnostics: [
|
|
252
|
+
{
|
|
253
|
+
kind: "record-less-socket",
|
|
254
|
+
gardenId: "20260915T333333-cccccc",
|
|
255
|
+
liveness: "dead",
|
|
256
|
+
message: "a socket no meta-record claims",
|
|
257
|
+
},
|
|
258
|
+
],
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const agentList = (agents: unknown[]): string =>
|
|
262
|
+
JSON.stringify({ id: "cli:agent:list", result: { agents, type: "agent_list" } });
|
|
263
|
+
|
|
264
|
+
// ── the ordinary open ───────────────────────────────────────────────────────
|
|
265
|
+
{
|
|
266
|
+
const w = world("ok");
|
|
267
|
+
stub(w, "entwurf", PEERS);
|
|
268
|
+
stub(w, "herdr", agentList([{ pane_id: "w1:p3", agent_status: "working", terminal_id: "t1" }]));
|
|
269
|
+
const r = run(w);
|
|
270
|
+
|
|
271
|
+
ok("the pane renders and exits 0", r.status === 0);
|
|
272
|
+
ok("the citizen a placement owner reported is listed", r.stdout.includes("20260915T111111-aaaaaa"));
|
|
273
|
+
ok("the four identity columns are present", /garden id\s+backend\s+cwd\s+placement/.test(r.stdout));
|
|
274
|
+
ok("a joined citizen shows its pane, attributed to herdr", r.stdout.includes("herdr w1:p3"));
|
|
275
|
+
|
|
276
|
+
// THE ONE READ PER OPEN. This is counted from the stubs' own log, so it cannot be
|
|
277
|
+
// satisfied by a source that merely looks like it reads once.
|
|
278
|
+
assert.deepEqual(
|
|
279
|
+
r.calls,
|
|
280
|
+
["entwurf peer-facts", "herdr agent list"],
|
|
281
|
+
`[QK:HPL-ONE-OPEN-ONE-READ] one pane open issues exactly one \`entwurf peer-facts\` and one \`herdr agent list\`, in that order and never again — peer-facts observes every citizen unbounded (measured 4.1s on a 1,150-record store), and herdr publishes no "the session reference landed" event, so any refresh, retry or poll here is both the slow path and the discovery watcher docs/mux-launch-rail.md §7 refuses (got ${JSON.stringify(r.calls)})`,
|
|
282
|
+
);
|
|
283
|
+
console.log(" ok one pane open issues exactly one peer-facts and one agent list, and never again");
|
|
284
|
+
passed++;
|
|
285
|
+
|
|
286
|
+
// ACTIVITY IS HERDR'S, IN ITS OWN COLUMN, UNDER ITS OWN NAME.
|
|
287
|
+
assert.ok(
|
|
288
|
+
r.stdout.includes("herdr-reported activity") &&
|
|
289
|
+
r.stdout.indexOf("placement") < r.stdout.indexOf("herdr-reported activity") &&
|
|
290
|
+
!/liveness/.test(r.stdout.split("\n")[2] ?? ""),
|
|
291
|
+
"[QK:HPL-ACTIVITY-IS-SEPARATE] herdr's agent_status is rendered in its own column under the name `herdr-reported activity`, after placement and never merged with it — it is herdr's judgement about a screen, not delivery evidence (docs/herdr-launch-rail.md §9), and a column that blurs the two is how it would start being read as liveness",
|
|
292
|
+
);
|
|
293
|
+
console.log(" ok herdr activity has its own named column, separate from placement");
|
|
294
|
+
passed++;
|
|
295
|
+
|
|
296
|
+
assert.ok(
|
|
297
|
+
r.stdout.includes("record-less-socket") && r.stdout.includes("a socket no meta-record claims"),
|
|
298
|
+
"[QK:HPL-DIAGNOSTICS-SHOWN] the provider's diagnostics reach the operator — dropping them shows a store carrying hazards as a clean listing, which is the one lie a status pane can tell while looking green",
|
|
299
|
+
);
|
|
300
|
+
console.log(" ok the provider's diagnostics reach the operator");
|
|
301
|
+
passed++;
|
|
302
|
+
|
|
303
|
+
// SEPARATE CLAIM: a hazard without its subject is a report nobody can act on.
|
|
304
|
+
assert.ok(
|
|
305
|
+
r.stdout.includes("gardenId=20260915T333333-cccccc") && r.stdout.includes("liveness=dead"),
|
|
306
|
+
"[QK:HPL-DIAGNOSTIC-SUBJECTS] a diagnostic carries its SUBJECT to the screen — which garden id the record-less socket is, which filename failed to read — because `a hazard exists somewhere` is a line an operator can do nothing with; the fields are rendered in sorted key order so two runs read the same",
|
|
307
|
+
);
|
|
308
|
+
console.log(" ok a diagnostic carries its subject fields, deterministically ordered");
|
|
309
|
+
passed++;
|
|
310
|
+
|
|
311
|
+
// IT WRITES NOTHING. The dirs herdr hands a plugin for exactly this purpose stay empty.
|
|
312
|
+
assert.ok(
|
|
313
|
+
fs.readdirSync(r.stateDir).length === 0 && fs.readdirSync(r.configDir).length === 0,
|
|
314
|
+
"[QK:HPL-NO-WRITE] the pane writes nothing — HERDR_PLUGIN_STATE_DIR and HERDR_PLUGIN_CONFIG_DIR are still empty afterwards; a status surface that keeps state is a surface that can be stale, and this one is only ever as old as the keypress that opened it",
|
|
315
|
+
);
|
|
316
|
+
console.log(" ok the pane writes nothing to its state or config dir");
|
|
317
|
+
passed++;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ── the render budget: a whole read, a table that stays readable ────────────
|
|
321
|
+
{
|
|
322
|
+
const w = world("many");
|
|
323
|
+
// 1,000 historical citizens the placement owner does not have, one that it does, one
|
|
324
|
+
// nobody could look at. This is the operator's own store in miniature: measured
|
|
325
|
+
// 2026-09-15, 1,162 records, all unobserved, 17,437 lines of peer-facts output.
|
|
326
|
+
const many = Array.from({ length: 1000 }, (_, i) => ({
|
|
327
|
+
gardenId: `20260101T00000${i % 10}-${i.toString(16).padStart(6, "0")}`,
|
|
328
|
+
backend: "pi",
|
|
329
|
+
nativeSessionId: `n-${i}`,
|
|
330
|
+
cwd: "/old",
|
|
331
|
+
model: null,
|
|
332
|
+
createdAt: "",
|
|
333
|
+
recordUpdatedAt: "",
|
|
334
|
+
liveness: "dead",
|
|
335
|
+
receiver: "n/a",
|
|
336
|
+
transcript: "absent",
|
|
337
|
+
placement: { kind: "none" },
|
|
338
|
+
}));
|
|
339
|
+
const visible = {
|
|
340
|
+
gardenId: "20260915T444444-dddddd",
|
|
341
|
+
backend: "claude-code",
|
|
342
|
+
nativeSessionId: "n-vis",
|
|
343
|
+
cwd: "/here",
|
|
344
|
+
model: null,
|
|
345
|
+
createdAt: "",
|
|
346
|
+
recordUpdatedAt: "",
|
|
347
|
+
liveness: "unsupported",
|
|
348
|
+
receiver: "none",
|
|
349
|
+
transcript: "exists",
|
|
350
|
+
placement: { kind: "herdr-pane", paneId: "w1:p9" },
|
|
351
|
+
};
|
|
352
|
+
const blind = { ...visible, gardenId: "20260915T555555-eeeeee", placement: { kind: "unobserved" } };
|
|
353
|
+
stub(
|
|
354
|
+
w,
|
|
355
|
+
"entwurf",
|
|
356
|
+
JSON.stringify({ schemaVersion: 1, storeDir: "/s", peers: [...many, visible, blind], diagnostics: [] }),
|
|
357
|
+
);
|
|
358
|
+
stub(w, "herdr", agentList([{ pane_id: "w1:p9", agent_status: "idle" }]));
|
|
359
|
+
const r = run(w);
|
|
360
|
+
|
|
361
|
+
const drawn = r.stdout.split("\n").filter((l) => /^20\d{6}T/.test(l)).length;
|
|
362
|
+
assert.ok(
|
|
363
|
+
r.status === 0 &&
|
|
364
|
+
drawn === 1 &&
|
|
365
|
+
r.stdout.includes("20260915T444444-dddddd") &&
|
|
366
|
+
/1000 citizen\(s\) not shown/.test(r.stdout) &&
|
|
367
|
+
/nobody could observe placement/.test(r.stdout),
|
|
368
|
+
`[QK:HPL-RENDER-IS-THE-VISIBLE-ONES] the table holds the citizens a placement owner REPORTED and counts the rest instead of drawing them — the read stays whole (a rationed measurement would fabricate \`unobserved\`), but 1,000 historical rows in a status pane means searching for the two citizens that are actually here, and a status surface you must search has already failed (drew ${drawn} rows)`,
|
|
369
|
+
);
|
|
370
|
+
console.log(` ok 1,002 citizens render as ${drawn} table row plus counted summaries`);
|
|
371
|
+
passed++;
|
|
372
|
+
ok(
|
|
373
|
+
"the two hidden kinds are counted apart — a completed negative read is not the same as no read",
|
|
374
|
+
/1000 citizen\(s\) not shown: the placement owner was read in full/.test(r.stdout) &&
|
|
375
|
+
/1 citizen\(s\) not shown: nobody could observe placement/.test(r.stdout),
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
// Nothing visible at all is its own answer, not an empty frame.
|
|
379
|
+
const w2 = world("empty-table");
|
|
380
|
+
stub(w2, "entwurf", JSON.stringify({ schemaVersion: 1, storeDir: "/s", peers: many, diagnostics: [] }));
|
|
381
|
+
stub(w2, "herdr", agentList([]));
|
|
382
|
+
const r2 = run(w2);
|
|
383
|
+
ok(
|
|
384
|
+
"no visible citizen renders `(none)`, not a bare header",
|
|
385
|
+
r2.status === 0 && r2.stdout.includes("(none)") && !r2.stdout.includes("garden id backend"),
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ── two agents on one pane: label it, never pick ────────────────────────────
|
|
390
|
+
{
|
|
391
|
+
const w = world("ambiguous");
|
|
392
|
+
stub(w, "entwurf", PEERS);
|
|
393
|
+
stub(
|
|
394
|
+
w,
|
|
395
|
+
"herdr",
|
|
396
|
+
agentList([
|
|
397
|
+
{ pane_id: "w1:p3", agent_status: "working", terminal_id: "t1" },
|
|
398
|
+
{ pane_id: "w1:p3", agent_status: "idle", terminal_id: "t2" },
|
|
399
|
+
]),
|
|
400
|
+
);
|
|
401
|
+
const r = run(w);
|
|
402
|
+
assert.ok(
|
|
403
|
+
r.status === 0 &&
|
|
404
|
+
/20260915T111111-aaaaaa.*ambiguous/.test(r.stdout) &&
|
|
405
|
+
!/20260915T111111-aaaaaa.*working/.test(r.stdout),
|
|
406
|
+
"[QK:HPL-AMBIGUOUS-NOT-GUESSED] two agent rows claiming one pane read as `ambiguous`, not as whichever came first — the worth of this column is that it never guesses, and first-wins is a guess wearing a value's clothes",
|
|
407
|
+
);
|
|
408
|
+
console.log(" ok two agents on one pane read as ambiguous, not first-wins");
|
|
409
|
+
passed++;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ── a host without Entwurf is a SKIP, not a failure ─────────────────────────
|
|
413
|
+
{
|
|
414
|
+
const w = world("skip");
|
|
415
|
+
stub(w, "herdr", agentList([]));
|
|
416
|
+
// PATH holds the herdr stub but no `entwurf`, and ENTWURF_BIN is unset.
|
|
417
|
+
const r = run(w, { PATH: w.binDir });
|
|
418
|
+
assert.ok(
|
|
419
|
+
r.status === 0 && r.stdout.trim() === "entwurf-not-found" && r.calls.length === 0,
|
|
420
|
+
`[QK:HPL-SKIP-NOT-FAILURE] a host with no Entwurf prints exactly \`entwurf-not-found\` and exits 0, reading nothing — absence is a skip and only a BROKEN thing is red, because installing Entwurf is not a plugin's business (AGENTS.md Hard Rule 17) (got status ${r.status}, stdout ${JSON.stringify(r.stdout)})`,
|
|
421
|
+
);
|
|
422
|
+
console.log(" ok a host with no Entwurf skips by name at exit 0, reading nothing");
|
|
423
|
+
passed++;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ── a listing that could not be read is never an empty table ────────────────
|
|
427
|
+
{
|
|
428
|
+
const cases: {
|
|
429
|
+
label: string;
|
|
430
|
+
peerPayload: string;
|
|
431
|
+
peerExit: number;
|
|
432
|
+
herdrPayload: string;
|
|
433
|
+
herdrExit: number;
|
|
434
|
+
name: string;
|
|
435
|
+
}[] = [
|
|
436
|
+
{
|
|
437
|
+
label: "peer-facts nonzero",
|
|
438
|
+
peerPayload: "",
|
|
439
|
+
peerExit: 3,
|
|
440
|
+
herdrPayload: agentList([]),
|
|
441
|
+
herdrExit: 0,
|
|
442
|
+
name: "peer-facts-failed",
|
|
443
|
+
},
|
|
444
|
+
{
|
|
445
|
+
label: "peer-facts unparsable",
|
|
446
|
+
peerPayload: "{ not json",
|
|
447
|
+
peerExit: 0,
|
|
448
|
+
herdrPayload: agentList([]),
|
|
449
|
+
herdrExit: 0,
|
|
450
|
+
name: "peer-facts-unparsable",
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
label: "agent list nonzero",
|
|
454
|
+
peerPayload: PEERS,
|
|
455
|
+
peerExit: 0,
|
|
456
|
+
herdrPayload: "",
|
|
457
|
+
herdrExit: 1,
|
|
458
|
+
name: "herdr-agent-list-failed",
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
label: "agent list unparsable",
|
|
462
|
+
peerPayload: PEERS,
|
|
463
|
+
peerExit: 0,
|
|
464
|
+
herdrPayload: "<html>",
|
|
465
|
+
herdrExit: 0,
|
|
466
|
+
name: "herdr-agent-list-unparsable",
|
|
467
|
+
},
|
|
468
|
+
];
|
|
469
|
+
// ONE assertion for all four, so the claim owns its own kill: a per-case ok() above it
|
|
470
|
+
// would fail first and certify nothing.
|
|
471
|
+
const verdicts = cases.map((c) => {
|
|
472
|
+
const w = world("red");
|
|
473
|
+
stub(w, "entwurf", c.peerPayload, c.peerExit);
|
|
474
|
+
stub(w, "herdr", c.herdrPayload, c.herdrExit);
|
|
475
|
+
const r = run(w);
|
|
476
|
+
const named = r.stdout.startsWith(c.name);
|
|
477
|
+
const red = r.status !== 0;
|
|
478
|
+
const noTable = !r.stdout.includes("garden id");
|
|
479
|
+
return {
|
|
480
|
+
c,
|
|
481
|
+
named,
|
|
482
|
+
red,
|
|
483
|
+
noTable,
|
|
484
|
+
shown: `${c.label}: status=${r.status} first=${JSON.stringify(r.stdout.split("\n")[0])}`,
|
|
485
|
+
};
|
|
486
|
+
});
|
|
487
|
+
assert.ok(
|
|
488
|
+
verdicts.every((v) => v.named && v.red && v.noTable),
|
|
489
|
+
`[QK:HPL-FAILED-READ-IS-RED] every unreadable listing — nonzero exit or unparsable bytes, on either side — is a NAMED non-zero refusal and never a table; a listing that could not be read must not be indistinguishable from a listing with nothing in it (${verdicts.map((v) => v.shown).join(" | ")})`,
|
|
490
|
+
);
|
|
491
|
+
console.log(` ok all ${verdicts.length} unreadable listings are named reds, never an empty table`);
|
|
492
|
+
passed++;
|
|
493
|
+
|
|
494
|
+
const w = world("no-herdr-bin");
|
|
495
|
+
stub(w, "entwurf", PEERS);
|
|
496
|
+
const r = run(w, { HERDR_BIN_PATH: "" });
|
|
497
|
+
ok(
|
|
498
|
+
"an absent HERDR_BIN_PATH is named, not guessed at",
|
|
499
|
+
r.stdout.trim() === "herdr-bin-path-missing" && r.status !== 0,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ── the plugin stays out of the npm artifact ────────────────────────────────
|
|
504
|
+
{
|
|
505
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(REPO, "package.json"), "utf8")) as { files?: string[] };
|
|
506
|
+
ok(
|
|
507
|
+
"plugins/ is not in the package files list (herdr owns this lifecycle, not npm)",
|
|
508
|
+
!(pkg.files ?? []).some((f) => f.startsWith("plugins")),
|
|
509
|
+
);
|
|
510
|
+
ok(
|
|
511
|
+
"the README documents the link and install lines",
|
|
512
|
+
(() => {
|
|
513
|
+
const readme = fs.readFileSync(path.join(PLUGIN_DIR, "README.md"), "utf8");
|
|
514
|
+
return (
|
|
515
|
+
readme.includes('herdr plugin link "$PWD/plugins/herdr"') &&
|
|
516
|
+
readme.includes("herdr plugin install junghan0611/entwurf/plugins/herdr") &&
|
|
517
|
+
readme.includes("herdr plugin unlink junghan0611.entwurf")
|
|
518
|
+
);
|
|
519
|
+
})(),
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
console.log(`\ncheck-herdr-plugin: ${passed} assertions passed`);
|