@junghanacs/entwurf 0.45.0 → 0.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +3 -2
- package/BASELINE.md +110 -1
- package/CHANGELOG.md +169 -0
- package/CONTRIBUTING.md +1 -1
- package/ENTWURF-CLI.json +1229 -0
- package/ENTWURF.json +63 -15
- package/FAQ.md +4 -4
- package/README.md +124 -188
- package/VERIFY.md +2 -2
- package/docs/external-mcp-host.md +3 -3
- package/docs/setup-clean-host.md +37 -12
- package/mcp/entwurf-bridge/dist/ENTWURF.json +63 -15
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +19 -8
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +483 -18
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +47 -10
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +613 -33
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-resume-call.js +497 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +3 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +50 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-fresh-preflight.js +7 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-location.js +347 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-owner-fence.js +1132 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +1 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +18 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +30 -15
- package/mcp/entwurf-bridge/dist/scripts/herdr-rails.mjs +40 -7
- package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +1 -27
- package/mcp/entwurf-bridge/dist/scripts/meta-bridge-prune.js +1 -1
- package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +1 -1
- package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +1 -1
- package/mcp/entwurf-bridge/src/index.ts +28 -8
- package/mcp/entwurf-bridge/tsconfig.build.json +7 -1
- package/native/win32-x64/manifest.json +1 -1
- package/package.json +6 -3
- package/pi/pi-durable/bootstrap.mjs +179 -6
- package/pi/pi-durable/carrier/runtime.js +108 -9
- package/pi/pi-durable/carrier/session-name.js +11 -0
- package/pi/pi-durable/carrier/sessions.js +96 -20
- package/pi/pi-durable/overlay/upstream-pin.json +11 -5
- package/pi-extensions/lib/entwurf-v2-visible-resume.ts +710 -38
- package/pi-extensions/lib/fresh-call-dispatch.ts +64 -12
- package/pi-extensions/lib/herdr-fresh-call.ts +919 -40
- package/pi-extensions/lib/herdr-resume-call.ts +810 -0
- package/pi-extensions/lib/meta-session.ts +13 -8
- package/pi-extensions/lib/mux-resume-call.ts +77 -4
- package/pi-extensions/lib/pi-durable-fresh-preflight.ts +9 -2
- package/pi-extensions/lib/pi-durable-location.ts +446 -0
- package/pi-extensions/lib/pi-durable-owner-fence.ts +1461 -0
- package/pi-extensions/lib/pi-durable-runtime.ts +4 -0
- package/pi-extensions/lib/resume-launch-identity.ts +31 -12
- package/pi-extensions/meta-bridge-pi-durable.ts +29 -15
- package/run.sh +411 -657
- package/scripts/check-claude-launch.ts +341 -0
- package/scripts/check-entwurf-v2-surface.ts +2 -2
- package/scripts/check-fresh-call-dispatch.ts +70 -9
- package/scripts/check-fresh-cut-gate.sh +23 -9
- package/scripts/check-gate-qualification.ts +18 -13
- package/scripts/check-herdr-activation.ts +301 -23
- package/scripts/check-herdr-fresh-call.ts +161 -25
- package/scripts/check-herdr-plugin-build.ts +173 -1
- package/scripts/check-herdr-plugin.ts +28 -2
- package/scripts/check-herdr-runtime-bootstrap.ts +136 -0
- package/scripts/check-install-surface.ts +1236 -6
- package/scripts/check-meta-doctor-oracle.sh +1 -1
- package/scripts/check-meta-facts.ts +20 -1
- package/scripts/check-mux-resume-call.ts +28 -0
- package/scripts/check-peer-facts.ts +39 -2
- package/scripts/cli-help.mjs +222 -0
- package/scripts/dev-bin.sh +39 -2
- package/scripts/herdr-activation.mjs +28 -6
- package/scripts/herdr-operator-link.mjs +389 -0
- package/scripts/herdr-plugin-activate.mjs +5 -0
- package/scripts/herdr-plugin-deactivate.mjs +18 -5
- package/scripts/herdr-rails.mjs +43 -7
- package/scripts/herdr-runtime.mjs +27 -0
- package/scripts/meta-bridge-fresh-cut.ts +1 -27
- package/scripts/meta-bridge-prune.ts +1 -1
- package/scripts/meta-facts.ts +1 -1
- package/scripts/mutants/agent-guidance.json +4 -4
- package/scripts/mutants/claude-launch.json +142 -0
- package/scripts/mutants/cli-help-source.json +420 -0
- package/scripts/mutants/dev-gate-dispatch.json +109 -0
- package/scripts/mutants/fresh-call-dispatch.json +38 -2
- package/scripts/mutants/fresh-cut.json +15 -0
- package/scripts/mutants/herdr-activation.json +128 -0
- package/scripts/mutants/herdr-fresh-call.json +278 -5
- package/scripts/mutants/herdr-operator-link.json +318 -0
- package/scripts/mutants/herdr-plugin-build.json +55 -0
- package/scripts/mutants/herdr-plugin.json +17 -0
- package/scripts/mutants/herdr-resume-call.json +288 -0
- package/scripts/mutants/herdr-runtime-bootstrap.json +48 -0
- package/scripts/mutants/meta-facts.json +15 -0
- package/scripts/mutants/mux-resume-call.json +63 -2
- package/scripts/mutants/peer-facts.json +15 -0
- package/scripts/mutants/pi-durable.json +979 -1
- package/scripts/mutants/source-install.json +35 -0
- package/scripts/mutants/v2-surface.json +2 -2
- package/scripts/mutants/v2-visible-resume.json +255 -6
- package/scripts/peer-facts.ts +1 -1
- package/scripts/smoke-agy-install-state.sh +66 -0
- package/scripts/smoke-meta-prune.sh +23 -0
- package/scripts/smoke-omp-fresh-live.ts +12 -3
- package/scripts/smoke-omp-receive-live.ts +7 -1
- package/scripts/smoke-pi-durable-fresh-live.ts +12 -2
- package/team.json.example +39 -0
|
@@ -767,7 +767,7 @@ import { pathToFileURL } from "node:url";
|
|
|
767
767
|
const [modPath, active, backends] = process.argv.slice(2);
|
|
768
768
|
const m = await import(pathToFileURL(modPath).href);
|
|
769
769
|
const states = Object.fromEntries(backends.split(",").map((b) => [b, "active"]));
|
|
770
|
-
m.writeLedger(m.resolveActivationLayout(process.env), m.ledgerBody({
|
|
770
|
+
m.writeLedger(m.resolveActivationLayout(process.env), 2, m.ledgerBody({
|
|
771
771
|
phase: "active",
|
|
772
772
|
runtimeRoot: active,
|
|
773
773
|
artifactIdentity: { kind: "npm", name: "@junghanacs/entwurf", version: "0.34.1", expectedIntegrity: "sha512-fixture", observedDigest: `sha256-${"c".repeat(64)}` },
|
|
@@ -236,7 +236,26 @@ function parseProjection(run: CliRun, label: string): Projection {
|
|
|
236
236
|
// ── usage ───────────────────────────────────────────────────────────────────
|
|
237
237
|
{
|
|
238
238
|
ok("[QK:MF-USAGE] two positional args are a usage error (exit 2)", cli(["/a", "/b"]).status === 2);
|
|
239
|
-
|
|
239
|
+
// The dash refusal's one line is now only the address of the description (#153). This child gets
|
|
240
|
+
// its own scratch HOME, XDG roots, PI dir and store, so nothing it prints leans on the operator's.
|
|
241
|
+
const usageHome = reclaimOnExit(fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-meta-facts-usage-")));
|
|
242
|
+
const dash = cli(["--help"], {
|
|
243
|
+
HOME: usageHome,
|
|
244
|
+
XDG_DATA_HOME: path.join(usageHome, ".local", "share"),
|
|
245
|
+
XDG_STATE_HOME: path.join(usageHome, ".local", "state"),
|
|
246
|
+
XDG_CACHE_HOME: path.join(usageHome, ".cache"),
|
|
247
|
+
XDG_CONFIG_HOME: path.join(usageHome, ".config"),
|
|
248
|
+
PI_CODING_AGENT_DIR: path.join(usageHome, ".pi", "agent"),
|
|
249
|
+
ENTWURF_META_SESSIONS_DIR: path.join(usageHome, "store"),
|
|
250
|
+
});
|
|
251
|
+
ok("a dash argv is a usage error, not a store directory", dash.status === 2);
|
|
252
|
+
ok(
|
|
253
|
+
"[QK:MF-HELP-POINTER] the dash refusal keeps exit 2 and an empty stdout, and its one stderr line points at `entwurf help meta-facts`",
|
|
254
|
+
dash.status === 2 &&
|
|
255
|
+
dash.stdout === "" &&
|
|
256
|
+
dash.stderr ===
|
|
257
|
+
"meta-facts: see `entwurf help meta-facts` (from that checkout's root: `./run.sh help meta-facts`)\n",
|
|
258
|
+
);
|
|
240
259
|
}
|
|
241
260
|
|
|
242
261
|
// ── reachability: dispatch + compiled twin ──────────────────────────────────
|
|
@@ -29,6 +29,10 @@
|
|
|
29
29
|
* MUXRESUME-NO-IDENTITY this module never learns which citizen it is reopening
|
|
30
30
|
* MUXRESUME-SURFACE-SEAM both surfaces hand the launcher IN; neither lets the v2
|
|
31
31
|
* composition import mux
|
|
32
|
+
* MUXRESUME-V2-NO-HERDR-IMPORT the v2 composition imports no herdr module either (#157 G1a)
|
|
33
|
+
* MUXRESUME-HERDR-COMPOSED the bridge delegates the resume launch to the shared rail root,
|
|
34
|
+
* handing it this leaf's mapping, and reads no rail fact itself
|
|
35
|
+
* (the root's own choice is FCD-RESUME-RAIL-SHARED, beside it)
|
|
32
36
|
*/
|
|
33
37
|
|
|
34
38
|
import assert from "node:assert/strict";
|
|
@@ -265,6 +269,12 @@ function main(): void {
|
|
|
265
269
|
"[QK:MUXRESUME-SURFACE-SEAM] the v2 composition never IMPORTS mux — the launcher is passed IN, which is what keeps docs/mux-launch-rail.md §11 true in both directions",
|
|
266
270
|
v2ImportSpecifiers.length > 0 && !v2ImportSpecifiers.some((s) => /mux-/.test(s)),
|
|
267
271
|
);
|
|
272
|
+
// #157 G1a: the herdr resume launch reaches v2 through the same seam, so the same fence holds
|
|
273
|
+
// for it. v2 learns which rail ran only from the `rail` its launch handle carries.
|
|
274
|
+
ok(
|
|
275
|
+
"[QK:MUXRESUME-V2-NO-HERDR-IMPORT] the v2 composition imports no herdr module either — the herdr resume launch is passed IN through the same seam, so neither rail's CLI enters entwurf core",
|
|
276
|
+
v2ImportSpecifiers.length > 0 && !v2ImportSpecifiers.some((s) => /herdr-/.test(s)),
|
|
277
|
+
);
|
|
268
278
|
ok(
|
|
269
279
|
"the bridge is the composition root: it imports the mux launcher AND the v2 composition and joins them itself",
|
|
270
280
|
/mux-resume-call/.test(MCP_SURFACE) && /entwurf-v2-visible-resume/.test(MCP_SURFACE),
|
|
@@ -287,6 +297,24 @@ function main(): void {
|
|
|
287
297
|
/async \(\{ target \}, extra\) =>/.test(MCP_RESUME_BLOCK) &&
|
|
288
298
|
/await visibleResume\(target, makeVisibleResumeDeps\(visibleResumeLaunch\)\)/.test(MCP_RESUME_BLOCK),
|
|
289
299
|
);
|
|
300
|
+
// #157 G1a: the bridge is still where this leaf meets the v2 composition, but the rail is NOT
|
|
301
|
+
// chosen here. The shared root (`dispatchResumeLaunch` in fresh-call-dispatch, held there by
|
|
302
|
+
// FCD-RESUME-RAIL-SHARED) reads the capability fact; the bridge hands it this leaf's fixed
|
|
303
|
+
// mapping and imports no herdr module (its reading no rail fact is FCD-SURFACE-ROOT-ONLY). The
|
|
304
|
+
// launchers are closures inside the bridge entry, so their wiring is read from the bridge source.
|
|
305
|
+
const MCP_IMPORTS = [...MCP_SURFACE.matchAll(/from\s+"([^"]+)"/g)].map((m) => m[1]);
|
|
306
|
+
const TMUX_RESUME_BLOCK = (MCP_SURFACE.split("const tmuxResumeLaunch")[1] ?? "").split("\n};")[0];
|
|
307
|
+
ok(
|
|
308
|
+
"[QK:MUXRESUME-HERDR-COMPOSED] the bridge hands every resume launch to the shared rail root together with this tmux leaf's mapping, and imports no herdr module of its own — a bridge that called this leaf directly would refuse every resume inside herdr, and one that reached a herdr launch itself would be a second copy of the rail choice",
|
|
309
|
+
/^const visibleResumeLaunch: VisibleResumeDeps\["launch"\] = \(input\) => dispatchResumeLaunch\(input, tmuxResumeLaunch\);$/m.test(
|
|
310
|
+
MCP_SURFACE,
|
|
311
|
+
) &&
|
|
312
|
+
/resumeCall\(\{ backend: input\.backend, cwd: input\.cwd, runtimeArgs: input\.runtimeArgs \}\)/.test(
|
|
313
|
+
TMUX_RESUME_BLOCK,
|
|
314
|
+
) &&
|
|
315
|
+
MCP_IMPORTS.includes("../../../pi-extensions/lib/fresh-call-dispatch.ts") &&
|
|
316
|
+
!MCP_IMPORTS.some((s) => /herdr-/.test(s)),
|
|
317
|
+
);
|
|
290
318
|
|
|
291
319
|
// ── the description contract ────────────────────────────────────────────────
|
|
292
320
|
// Not prose parity — three claims an operator acts on, plus the host's own cap.
|
|
@@ -300,13 +300,33 @@ function parseProjection(run: CliRun, label: string): Projection {
|
|
|
300
300
|
console.log(" ok an unreadable store is exit 3 with no JSON");
|
|
301
301
|
passed++;
|
|
302
302
|
|
|
303
|
-
|
|
303
|
+
// This child gets its own scratch HOME, XDG roots, PI dir and store on top of cli()'s, so the
|
|
304
|
+
// refusal it prints leans on nothing of the operator's.
|
|
305
|
+
const usageHome = tmp("usage");
|
|
306
|
+
const flag = cli(["--help"], {
|
|
307
|
+
HOME: usageHome,
|
|
308
|
+
XDG_DATA_HOME: path.join(usageHome, ".local", "share"),
|
|
309
|
+
XDG_STATE_HOME: path.join(usageHome, ".local", "state"),
|
|
310
|
+
XDG_CACHE_HOME: path.join(usageHome, ".cache"),
|
|
311
|
+
XDG_CONFIG_HOME: path.join(usageHome, ".config"),
|
|
312
|
+
PI_CODING_AGENT_DIR: path.join(usageHome, ".pi", "agent"),
|
|
313
|
+
ENTWURF_META_SESSIONS_DIR: path.join(usageHome, "store"),
|
|
314
|
+
});
|
|
304
315
|
assert.ok(
|
|
305
316
|
flag.status === 2 && flag.stdout === "",
|
|
306
317
|
"[QK:PF-USAGE] a dash argv is a flag this command does not have — reading it as a store directory would answer `--help` with `empty store, exit 0`, a silent wrong fact",
|
|
307
318
|
);
|
|
308
319
|
console.log(" ok a dash argv is a usage error, not a store directory");
|
|
309
320
|
passed++;
|
|
321
|
+
// The leaf's one stderr line is now only the address of the description (#153); the description
|
|
322
|
+
// itself is ENTWURF-CLI.json's, which `entwurf peer-facts --help` answers before this leaf runs.
|
|
323
|
+
ok(
|
|
324
|
+
"[QK:PF-HELP-POINTER] the dash refusal keeps exit 2 and an empty stdout, and its one stderr line points at `entwurf help peer-facts`",
|
|
325
|
+
flag.status === 2 &&
|
|
326
|
+
flag.stdout === "" &&
|
|
327
|
+
flag.stderr ===
|
|
328
|
+
"peer-facts: see `entwurf help peer-facts` (from that checkout's root: `./run.sh help peer-facts`)\n",
|
|
329
|
+
);
|
|
310
330
|
ok("a second positional is a usage error", cli(["/a", "/b"]).status === 2);
|
|
311
331
|
}
|
|
312
332
|
|
|
@@ -317,7 +337,24 @@ function parseProjection(run: CliRun, label: string): Projection {
|
|
|
317
337
|
"run.sh dispatches peer-facts through the single run_ts fence",
|
|
318
338
|
/\n {2}peer-facts\)\n(?:.*\n)*?\s*run_ts scripts\/peer-facts\.ts "\$@"\n/.test(runSh),
|
|
319
339
|
);
|
|
320
|
-
|
|
340
|
+
// The description is owned by ENTWURF-CLI.json now, so ask the public surface rather than any
|
|
341
|
+
// file: what the root help actually prints. Only PATH crosses; the root help runs no command.
|
|
342
|
+
const rootHelp = spawnSync("bash", [path.join(REPO, "run.sh"), "--help"], {
|
|
343
|
+
encoding: "utf8",
|
|
344
|
+
env: { PATH: process.env.PATH ?? "/usr/bin:/bin" },
|
|
345
|
+
});
|
|
346
|
+
ok("the root help describes peer-facts", rootHelp.status === 0 && /^ {2}entwurf peer-facts /m.test(rootHelp.stdout));
|
|
347
|
+
// The scene that opened #153's help work: an agent with no briefing asks `entwurf peer-facts
|
|
348
|
+
// --help`. run.sh answers it from the description before dispatch, so the store is never read and
|
|
349
|
+
// the leaf's own dash refusal (PF-USAGE, exit 2) is not what the operator meets.
|
|
350
|
+
const askHelp = spawnSync("bash", [path.join(REPO, "run.sh"), "peer-facts", "--help"], {
|
|
351
|
+
encoding: "utf8",
|
|
352
|
+
env: { PATH: process.env.PATH ?? "/usr/bin:/bin" },
|
|
353
|
+
});
|
|
354
|
+
ok(
|
|
355
|
+
"`entwurf peer-facts --help` is that command's help on stdout, exit 0",
|
|
356
|
+
askHelp.status === 0 && askHelp.stderr === "" && askHelp.stdout.startsWith("Usage: entwurf peer-facts\n"),
|
|
357
|
+
);
|
|
321
358
|
const build = fs.readFileSync(path.join(REPO, "mcp/entwurf-bridge/tsconfig.build.json"), "utf8");
|
|
322
359
|
ok(
|
|
323
360
|
"the bridge build emits the compiled twin (installed hosts cannot strip-types)",
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders the entwurf command line's own description from root ENTWURF-CLI.json, its one editable
|
|
3
|
+
* source: the short root page (`entwurf --help`), the full catalog (`entwurf help all`), one
|
|
4
|
+
* command's page, and one guide topic's page. Plain `node` in every mode (no strip-types, no
|
|
5
|
+
* compiled twin): it imports node builtins only and reads one module-relative file, never
|
|
6
|
+
* cwd/HOME/env, so a checkout and an installed package answer from the bytes they carry. The whole
|
|
7
|
+
* file is checked before the first write in every mode; a missing or malformed source is a named
|
|
8
|
+
* failure (exit 1) on stderr with nothing on stdout — there is no older text to fall back to.
|
|
9
|
+
*
|
|
10
|
+
* argv is exactly one of: nothing (the root page), `--all` (the catalog), `--command <name>` (that
|
|
11
|
+
* command's page — what `<command> --help` asks), or `--name <name>` (what `entwurf help <name>`
|
|
12
|
+
* asks: a command's page, else a guide topic's page). A name neither describes is exit 2 with one
|
|
13
|
+
* stderr line. Anything else is exit 2. A topic is static text: it reads none of the settings it
|
|
14
|
+
* talks about, and it is never a command, so `<topic> --help` is refused like any undescribed name.
|
|
15
|
+
*
|
|
16
|
+
* The root page names catalog entries and prints their synopses from the catalog, so a command it
|
|
17
|
+
* shows is always one the catalog describes. A command name in the data owns a description, not a
|
|
18
|
+
* dispatch: run.sh's case and its source-defined gate functions stay the only things that run
|
|
19
|
+
* anything, and check-install-surface compares the two independently.
|
|
20
|
+
*/
|
|
21
|
+
import { readFileSync } from "node:fs";
|
|
22
|
+
|
|
23
|
+
const SOURCE = new URL("../ENTWURF-CLI.json", import.meta.url);
|
|
24
|
+
const PREFIX = " entwurf ";
|
|
25
|
+
|
|
26
|
+
function fail(detail) {
|
|
27
|
+
process.stderr.write(`entwurf: ENTWURF-CLI.json ${detail}\n`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function refuse(detail) {
|
|
32
|
+
process.stderr.write(`entwurf: ${detail}\n`);
|
|
33
|
+
process.exit(2);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function object(value, at, wanted, optional = []) {
|
|
37
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) fail(`${at}: expected an object`);
|
|
38
|
+
const got = Object.keys(value)
|
|
39
|
+
.filter((k) => !optional.includes(k))
|
|
40
|
+
.sort();
|
|
41
|
+
const want = [...wanted].sort();
|
|
42
|
+
if (got.length !== want.length || got.some((k, i) => k !== want[i])) {
|
|
43
|
+
fail(
|
|
44
|
+
`${at}: keys must be exactly ${wanted.join(", ")}${optional.length ? ` (optionally ${optional.join(", ")})` : ""}`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function text(value, at, { empty = false } = {}) {
|
|
51
|
+
if (typeof value !== "string" || (!empty && value.length === 0) || value.includes("\n")) {
|
|
52
|
+
fail(`${at}: expected a ${empty ? "" : "non-empty "}single-line string`);
|
|
53
|
+
}
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
const line = (value, at) => text(value, at);
|
|
57
|
+
|
|
58
|
+
function array(value, at) {
|
|
59
|
+
if (!Array.isArray(value) || value.length === 0) fail(`${at}: expected a non-empty array`);
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** One aligned row: the head, padded to its column, then the text. The column must leave a gap. */
|
|
64
|
+
function aligned(head, column, body, at) {
|
|
65
|
+
if (!Number.isSafeInteger(column) || column <= head.length) {
|
|
66
|
+
fail(`${at}: column must be an integer past ${JSON.stringify(head.trim())} (> ${head.length})`);
|
|
67
|
+
}
|
|
68
|
+
return `${head.padEnd(column)}${body}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const argv = process.argv.slice(2);
|
|
72
|
+
const all = argv.length === 1 && argv[0] === "--all";
|
|
73
|
+
const wanted = argv.length === 2 && (argv[0] === "--command" || argv[0] === "--name") ? argv[1] : undefined;
|
|
74
|
+
// Only `entwurf help <name>` may reach a guide; `<command> --help` asks about something to run.
|
|
75
|
+
const topicsToo = argv[0] === "--name";
|
|
76
|
+
if (argv.length !== 0 && !all && wanted === undefined) {
|
|
77
|
+
refuse("cli-help.mjs takes nothing, --all, --command <name> or --name <name>");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let raw;
|
|
81
|
+
try {
|
|
82
|
+
raw = readFileSync(SOURCE, "utf8");
|
|
83
|
+
} catch (err) {
|
|
84
|
+
fail(`unreadable: ${err.code ?? err.message}`);
|
|
85
|
+
}
|
|
86
|
+
let data;
|
|
87
|
+
try {
|
|
88
|
+
data = JSON.parse(raw);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
fail(`is not JSON: ${err.message}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
object(data, "root", ["_about", "schemaVersion", "root", "topics", "usage"]);
|
|
94
|
+
object(data._about, "_about", ["purpose", "ownership"]);
|
|
95
|
+
line(data._about.purpose, "_about.purpose");
|
|
96
|
+
line(data._about.ownership, "_about.ownership");
|
|
97
|
+
if (data.schemaVersion !== 4) fail("schemaVersion: unsupported");
|
|
98
|
+
|
|
99
|
+
// ── the catalog: every described command, the page `entwurf help all` prints ──
|
|
100
|
+
const usage = object(data.usage, "usage", ["title", "commandGroups", "notes"]);
|
|
101
|
+
const notes = object(usage.notes, "usage.notes", ["title", "lines"]);
|
|
102
|
+
|
|
103
|
+
const out = [line(usage.title, "usage.title")];
|
|
104
|
+
const entries = new Map();
|
|
105
|
+
array(usage.commandGroups, "usage.commandGroups").forEach((group, g) => {
|
|
106
|
+
if (g > 0) out.push("");
|
|
107
|
+
array(group, `usage.commandGroups[${g}]`).forEach((entry, e) => {
|
|
108
|
+
const at = `usage.commandGroups[${g}][${e}]`;
|
|
109
|
+
object(entry, at, ["synopsis", "column", "summary"], ["details"]);
|
|
110
|
+
const synopsis = line(entry.synopsis, `${at}.synopsis`);
|
|
111
|
+
const name = synopsis.split(" ", 1)[0];
|
|
112
|
+
if (!/^[a-z][a-z0-9-]*$/.test(name)) fail(`${at}.synopsis: ${JSON.stringify(name)} is not a command name`);
|
|
113
|
+
// `all` names the catalog itself (`entwurf help all`), so no command may take it.
|
|
114
|
+
if (name === "all") fail(`${at}.synopsis: "all" is reserved for \`entwurf help all\``);
|
|
115
|
+
if (entries.has(name)) fail(`${at}.synopsis: ${name} is described twice`);
|
|
116
|
+
const head = PREFIX + synopsis;
|
|
117
|
+
if (!Number.isSafeInteger(entry.column) || entry.column <= head.length) {
|
|
118
|
+
fail(`${at}.column: must be an integer past the synopsis (> ${head.length})`);
|
|
119
|
+
}
|
|
120
|
+
const summary = line(entry.summary, `${at}.summary`);
|
|
121
|
+
// Details are a command's own longer text, printed only by its help: blank lines separate
|
|
122
|
+
// its parts, so they may be empty, but the block neither starts nor ends on one.
|
|
123
|
+
const details =
|
|
124
|
+
entry.details === undefined
|
|
125
|
+
? []
|
|
126
|
+
: array(entry.details, `${at}.details`).map((d, i) => text(d, `${at}.details[${i}]`, { empty: true }));
|
|
127
|
+
if (details.length > 0 && (details[0] === "" || details.at(-1) === "")) {
|
|
128
|
+
fail(`${at}.details: must not start or end with an empty line`);
|
|
129
|
+
}
|
|
130
|
+
entries.set(name, { synopsis, summary, details });
|
|
131
|
+
out.push(`${head.padEnd(entry.column)}# ${summary}`);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
out.push("", line(notes.title, "usage.notes.title"));
|
|
135
|
+
for (const [n, note] of array(notes.lines, "usage.notes.lines").entries()) {
|
|
136
|
+
out.push(` - ${line(note, `usage.notes.lines[${n}]`)}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── the root page: a few commands by task, named from the catalog, then examples ──
|
|
140
|
+
const root = object(data.root, "root", ["title", "usage", "sections", "examples", "footer"]);
|
|
141
|
+
const rootUsage = object(root.usage, "root.usage", ["title", "column", "rows"]);
|
|
142
|
+
const compact = [line(root.title, "root.title"), "", line(rootUsage.title, "root.usage.title")];
|
|
143
|
+
array(rootUsage.rows, "root.usage.rows").forEach((row, r) => {
|
|
144
|
+
const at = `root.usage.rows[${r}]`;
|
|
145
|
+
object(row, at, ["form"], ["text"]);
|
|
146
|
+
const head = PREFIX + line(row.form, `${at}.form`);
|
|
147
|
+
compact.push(row.text === undefined ? head : aligned(head, rootUsage.column, line(row.text, `${at}.text`), at));
|
|
148
|
+
});
|
|
149
|
+
array(root.sections, "root.sections").forEach((section, s) => {
|
|
150
|
+
const at = `root.sections[${s}]`;
|
|
151
|
+
object(section, at, ["title", "column", "rows"]);
|
|
152
|
+
compact.push("", line(section.title, `${at}.title`));
|
|
153
|
+
array(section.rows, `${at}.rows`).forEach((row, r) => {
|
|
154
|
+
const rowAt = `${at}.rows[${r}]`;
|
|
155
|
+
object(row, rowAt, ["name", "text"]);
|
|
156
|
+
const entry = entries.get(line(row.name, `${rowAt}.name`));
|
|
157
|
+
if (entry === undefined) fail(`${rowAt}.name: ${JSON.stringify(row.name)} is not a command the catalog describes`);
|
|
158
|
+
compact.push(aligned(PREFIX + entry.synopsis, section.column, line(row.text, `${rowAt}.text`), rowAt));
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
const examples = object(root.examples, "root.examples", ["title", "items"]);
|
|
162
|
+
compact.push("", line(examples.title, "root.examples.title"));
|
|
163
|
+
array(examples.items, "root.examples.items").forEach((item, i) => {
|
|
164
|
+
const at = `root.examples.items[${i}]`;
|
|
165
|
+
object(item, at, ["comment", "command"]);
|
|
166
|
+
if (i > 0) compact.push("");
|
|
167
|
+
compact.push(` # ${line(item.comment, `${at}.comment`)}`);
|
|
168
|
+
const command = line(item.command, `${at}.command`);
|
|
169
|
+
// An example runs a real command: `entwurf <described name> …`, or `entwurf help` (`help`,
|
|
170
|
+
// `help all`, `help <described name>`). Nothing here parses shell beyond those words.
|
|
171
|
+
const [lead, first, second] = command.split(" ");
|
|
172
|
+
if (lead !== "entwurf" || first === undefined) fail(`${at}.command: an example starts with \`entwurf <command>\``);
|
|
173
|
+
const named = first === "help" && second !== undefined ? second : first;
|
|
174
|
+
const helpItself = first === "help" && (second === undefined || second === "all");
|
|
175
|
+
if (!helpItself && !entries.has(named))
|
|
176
|
+
fail(`${at}.command: ${JSON.stringify(named)} is not a command the catalog describes`);
|
|
177
|
+
compact.push(` ${command}`);
|
|
178
|
+
});
|
|
179
|
+
compact.push("");
|
|
180
|
+
for (const [f, footer] of array(root.footer, "root.footer").entries()) compact.push(line(footer, `root.footer[${f}]`));
|
|
181
|
+
|
|
182
|
+
// ── guide topics: read-only pages `entwurf help <name>` prints; never commands ──
|
|
183
|
+
const topics = new Map();
|
|
184
|
+
array(data.topics, "topics").forEach((topic, t) => {
|
|
185
|
+
const at = `topics[${t}]`;
|
|
186
|
+
object(topic, at, ["name", "title", "lines"]);
|
|
187
|
+
const name = line(topic.name, `${at}.name`);
|
|
188
|
+
if (!/^[a-z][a-z0-9-]*$/.test(name)) fail(`${at}.name: ${JSON.stringify(name)} is not a topic name`);
|
|
189
|
+
// A topic shares `entwurf help <name>` with the commands, so one word must answer with one page:
|
|
190
|
+
// no command's name, not `all`, and no other topic's.
|
|
191
|
+
if (name === "all" || entries.has(name) || topics.has(name))
|
|
192
|
+
fail(`${at}.name: ${JSON.stringify(name)} is already a command, \`all\` or another topic`);
|
|
193
|
+
const title = line(topic.title, `${at}.title`);
|
|
194
|
+
// Blank lines separate a page's parts, so they may be empty, but the page neither starts nor ends on one.
|
|
195
|
+
const lines = array(topic.lines, `${at}.lines`).map((l, i) => text(l, `${at}.lines[${i}]`, { empty: true }));
|
|
196
|
+
if (lines[0] === "" || lines.at(-1) === "") fail(`${at}.lines: must not start or end with an empty line`);
|
|
197
|
+
topics.set(name, { title, lines });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// A reader that stops early (`entwurf help all | head`) closes the pipe: that ends the page, it is
|
|
201
|
+
// not a failure to report.
|
|
202
|
+
process.stdout.on("error", (err) => {
|
|
203
|
+
if (err.code === "EPIPE") process.exit(0);
|
|
204
|
+
throw err;
|
|
205
|
+
});
|
|
206
|
+
if (wanted === undefined) {
|
|
207
|
+
process.stdout.write(`${(all ? out : compact).join("\n")}\n`);
|
|
208
|
+
} else {
|
|
209
|
+
const entry = entries.get(wanted);
|
|
210
|
+
const topic = topicsToo ? topics.get(wanted) : undefined;
|
|
211
|
+
// JSON quoting keeps any control character in the name on this one line.
|
|
212
|
+
if (!entry && !topic) {
|
|
213
|
+
refuse(`no help for ${JSON.stringify(wanted)} — \`entwurf help all\` lists the described commands`);
|
|
214
|
+
}
|
|
215
|
+
if (entry) {
|
|
216
|
+
const page = [`Usage: entwurf ${entry.synopsis}`, "", entry.summary];
|
|
217
|
+
if (entry.details.length > 0) page.push("", ...entry.details);
|
|
218
|
+
process.stdout.write(`${page.join("\n")}\n`);
|
|
219
|
+
} else {
|
|
220
|
+
process.stdout.write(`${[topic.title, "", ...topic.lines].join("\n")}\n`);
|
|
221
|
+
}
|
|
222
|
+
}
|
package/scripts/dev-bin.sh
CHANGED
|
@@ -15,6 +15,14 @@
|
|
|
15
15
|
# entwurf-agy-imprint -> scripts/agy-imprint.sh (agy PreInvocation birth hook)
|
|
16
16
|
# entwurf-copilot-statusline -> scripts/copilot-statusline.sh (copilot settings.statusLine command)
|
|
17
17
|
#
|
|
18
|
+
# SOURCE CHECKOUTS ONLY (#153). expose and remove refuse, exit 4, before their first write —
|
|
19
|
+
# legacy migration included — when THIS script belongs to an installed package: its own physical
|
|
20
|
+
# location (symlinks resolved, as run.sh resolves its own) under */node_modules/*, the same seam
|
|
21
|
+
# run.sh's setup_mode/run_ts branch on. Never cwd, never a target override. An installed package's
|
|
22
|
+
# commands come from its package manager's bin linking; the moved-checkout rule below is a
|
|
23
|
+
# developer's authority over its own checkouts and is not inherited by an installed root.
|
|
24
|
+
# verify and exclusive only read, so they stay available everywhere.
|
|
25
|
+
#
|
|
18
26
|
# Ownership discipline (mirrors the agy adopt/refuse rule, 봉인 7), applied PER BIN:
|
|
19
27
|
# - create/refresh ONLY a link that is OURS — absent, or a symlink into our target, or the
|
|
20
28
|
# exact link our state recorded (target may have moved checkouts).
|
|
@@ -27,11 +35,13 @@
|
|
|
27
35
|
# guess). New name appears first (atomic rename), old name is gone after. A re-appearing legacy
|
|
28
36
|
# name on a later expose is re-reported (drift visibility).
|
|
29
37
|
#
|
|
30
|
-
# expose [name] create/refresh managed link(s) (idempotent; REFUSE foreign → exit 3
|
|
38
|
+
# expose [name] create/refresh managed link(s) (idempotent; REFUSE foreign → exit 3;
|
|
39
|
+
# from an installed package → exit 4, nothing written).
|
|
31
40
|
# no name = all managed bins, each attempted INDEPENDENTLY (#86 C1): one
|
|
32
41
|
# foreign refusal no longer aborts the loop, the trailing summary reports
|
|
33
42
|
# attempted/ok/refused truthfully, and any refusal still exits 3.
|
|
34
|
-
# remove [name] honest inverse from state (remove only OUR link; REFUSE if it became foreign
|
|
43
|
+
# remove [name] honest inverse from state (remove only OUR link; REFUSE if it became foreign;
|
|
44
|
+
# from an installed package → exit 4, nothing removed).
|
|
35
45
|
# no name = all managed bins.
|
|
36
46
|
# verify <name> require OUR symlink → target and that exact link as the PATH winner.
|
|
37
47
|
# exclusive require that NO other root answers to any package.json bin name — not only
|
|
@@ -110,6 +120,31 @@ link_is_ours() {
|
|
|
110
120
|
return 1 # regular file / dir → foreign
|
|
111
121
|
}
|
|
112
122
|
|
|
123
|
+
# The package root this script belongs to, by its own physical location: a symlinked script or a
|
|
124
|
+
# directory alias still answers with the tree it actually lives in.
|
|
125
|
+
own_package_root() {
|
|
126
|
+
local src="${BASH_SOURCE[0]}" dir
|
|
127
|
+
while [ -L "$src" ]; do
|
|
128
|
+
dir="$(cd -P -- "$(dirname -- "$src")" && pwd)"
|
|
129
|
+
src="$(readlink "$src")"
|
|
130
|
+
case "$src" in /*) ;; *) src="$dir/$src" ;; esac
|
|
131
|
+
done
|
|
132
|
+
cd -P -- "$(dirname -- "$src")/.." && pwd
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
refuse_installed_mutation() { # $1 = verb; exits 4 when this script lives in an installed package
|
|
136
|
+
local root
|
|
137
|
+
root="$(own_package_root)"
|
|
138
|
+
case "$root" in
|
|
139
|
+
*/node_modules/*)
|
|
140
|
+
warn "[dev-bin] REFUSE $1: this dev-bin.sh belongs to an installed package ($root)."
|
|
141
|
+
warn " dev-bin manages a SOURCE checkout's links only; an installed package's commands come from its package manager's bin linking."
|
|
142
|
+
warn " nothing was written or removed (no link, no state, no legacy migration)."
|
|
143
|
+
exit 4
|
|
144
|
+
;;
|
|
145
|
+
esac
|
|
146
|
+
}
|
|
147
|
+
|
|
113
148
|
path_has_bindir() { case ":$PATH:" in *":$BIN_DIR:"*) return 0 ;; *) return 1 ;; esac; }
|
|
114
149
|
|
|
115
150
|
write_state() { # $1=state_file $2=link $3=target $4=detect
|
|
@@ -317,6 +352,7 @@ remove_one() { # $1 = name
|
|
|
317
352
|
}
|
|
318
353
|
|
|
319
354
|
do_expose() { # $1 = optional bin name
|
|
355
|
+
refuse_installed_mutation expose
|
|
320
356
|
migrate_legacy
|
|
321
357
|
if [ -n "${1:-}" ]; then expose_one "$1"; return $?; fi
|
|
322
358
|
# No-arg = the setup composition. Attempt EVERY unit independently (#86 C1):
|
|
@@ -338,6 +374,7 @@ do_expose() { # $1 = optional bin name
|
|
|
338
374
|
log "[dev-bin expose] summary: attempted=$attempted ok=$exposed refused=0"
|
|
339
375
|
}
|
|
340
376
|
do_remove() { # $1 = optional bin name
|
|
377
|
+
refuse_installed_mutation remove
|
|
341
378
|
if [ -n "${1:-}" ]; then remove_one "$1"; else for b in $MANAGED_BINS; do remove_one "$b"; done; fi
|
|
342
379
|
}
|
|
343
380
|
|
|
@@ -30,8 +30,16 @@ import fs from "node:fs";
|
|
|
30
30
|
import path from "node:path";
|
|
31
31
|
import { artifactStage, certifyArtifactIdentity, resolveRuntimeLayout, sameArtifactRequest } from "./herdr-runtime.mjs";
|
|
32
32
|
|
|
33
|
-
/**
|
|
34
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Ledger formats this reader admits (#153). The key set is the same for both; 3 is a FLOOR, not a new
|
|
35
|
+
* shape: a reader that admits only 2 refuses a 3-ledger before its first write, which is how an older
|
|
36
|
+
* artifact is kept from acting on a host it no longer understands. Add a version only together with a
|
|
37
|
+
* reader that understands every version already in this set.
|
|
38
|
+
*/
|
|
39
|
+
export const ACTIVATION_SCHEMA_VERSIONS = Object.freeze([2, 3]);
|
|
40
|
+
|
|
41
|
+
/** The version a ledger is born with. Every later write carries the version it read — never this. */
|
|
42
|
+
export const FRESH_ACTIVATION_SCHEMA_VERSION = 2;
|
|
35
43
|
|
|
36
44
|
/** Set P, frozen. The ledger may never name anything else. */
|
|
37
45
|
export const ACTIVATABLE_BACKENDS = Object.freeze(["pi", "claude-code"]);
|
|
@@ -140,10 +148,24 @@ function certifyRoot(where, value) {
|
|
|
140
148
|
}
|
|
141
149
|
}
|
|
142
150
|
|
|
143
|
-
/**
|
|
144
|
-
|
|
151
|
+
/**
|
|
152
|
+
* Ledgers are replaced, never patched: write beside, then rename. The version is an explicit
|
|
153
|
+
* argument and the only one: an entry carrying its own `schemaVersion` is refused rather than
|
|
154
|
+
* merged, so no spread order can let a body silently raise or lower the floor. Both refusals come
|
|
155
|
+
* before the first byte.
|
|
156
|
+
*/
|
|
157
|
+
export function writeLedger(layout, version, entry) {
|
|
158
|
+
if (!ACTIVATION_SCHEMA_VERSIONS.includes(version)) {
|
|
159
|
+
throw new ActivationError("activation-ledger-version-invalid", `schemaVersion ${JSON.stringify(version)}`);
|
|
160
|
+
}
|
|
161
|
+
if (Object.hasOwn(entry, "schemaVersion")) {
|
|
162
|
+
throw new ActivationError(
|
|
163
|
+
"activation-ledger-version-invalid",
|
|
164
|
+
"the entry carries its own schemaVersion; the version is the explicit argument alone",
|
|
165
|
+
);
|
|
166
|
+
}
|
|
145
167
|
fs.mkdirSync(layout.stateRoot, { recursive: true });
|
|
146
|
-
const body = `${JSON.stringify({ schemaVersion:
|
|
168
|
+
const body = `${JSON.stringify({ schemaVersion: version, ...entry }, null, 2)}\n`;
|
|
147
169
|
const tmp = `${layout.ledgerPath}.tmp`;
|
|
148
170
|
fs.writeFileSync(tmp, body);
|
|
149
171
|
fs.renameSync(tmp, layout.ledgerPath);
|
|
@@ -190,7 +212,7 @@ export function readCertifiedLedger(layout) {
|
|
|
190
212
|
if (Object.keys(parsed).sort().join(",") !== [...LEDGER_KEYS].sort().join(",")) {
|
|
191
213
|
throw new ActivationError("activation-ledger-uncertified", `key set: ${Object.keys(parsed).sort()}`);
|
|
192
214
|
}
|
|
193
|
-
if (parsed.schemaVersion
|
|
215
|
+
if (!ACTIVATION_SCHEMA_VERSIONS.includes(parsed.schemaVersion)) {
|
|
194
216
|
throw new ActivationError("activation-ledger-uncertified", `schemaVersion ${parsed.schemaVersion}`);
|
|
195
217
|
}
|
|
196
218
|
if (typeof parsed.runtimeRoot !== "string" || !path.isAbsolute(parsed.runtimeRoot)) {
|