@junghanacs/entwurf 0.34.1 → 0.35.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.
@@ -183,6 +183,7 @@ interface Mod {
183
183
  resolveEntwurfDataRoot: (env: NodeJS.ProcessEnv) => string;
184
184
  LEDGER_PHASES: string[];
185
185
  COMPONENT_STATES: string[];
186
+ resolveDoctorPluginRuntime: (env: NodeJS.ProcessEnv, opts: { repo: string; override?: string }) => string | null;
186
187
  }
187
188
 
188
189
  const mod = (await import(pathToFileURL(ACTIVATION).href)) as Mod;
@@ -204,6 +205,7 @@ const {
204
205
  resolveEntwurfDataRoot,
205
206
  LEDGER_PHASES,
206
207
  COMPONENT_STATES,
208
+ resolveDoctorPluginRuntime,
207
209
  } = mod;
208
210
 
209
211
  /** The pi pin package.json declares (an exact production dependency since #130) — the SSOT
@@ -1484,4 +1486,82 @@ function ledgerWith(
1484
1486
  );
1485
1487
  }
1486
1488
 
1489
+ // ── 29. the doctor's expectation mode comes from the activation ledger (#136) ────
1490
+ // `doctor-meta-bridge` must compute the plugin activation's ABSOLUTE expectations exactly when the
1491
+ // ledger authorised that wiring, and never adopt it from a checkout. The oracle for the returned
1492
+ // root is `runtimeRootOf`, computed here independently of the resolver.
1493
+ {
1494
+ const env = world("doctor-mode");
1495
+ seedRuntime(env);
1496
+ const active = runtimeRootOf(env);
1497
+ const runtimeRepo = path.join(active, "node_modules", "@junghanacs", "entwurf");
1498
+ const layout = resolveActivationLayout(env);
1499
+ const mode = (opts: { repo: string; override?: string }): string | null => {
1500
+ try {
1501
+ return resolveDoctorPluginRuntime(env, opts);
1502
+ } catch (err) {
1503
+ return `refused:${(err as { code?: string }).code ?? String(err)}`;
1504
+ }
1505
+ };
1506
+ // D2 — no ledger: a doctor running from the runtime package is simply not in plugin mode.
1507
+ const absent = mode({ repo: runtimeRepo });
1508
+ writeLedger(layout, ledgerFor(env, ["pi", "claude-code"]));
1509
+ // D1 — runtime package + an active ledger that activated claude-code at THIS stable root.
1510
+ const derived = mode({ repo: runtimeRepo });
1511
+ ok(
1512
+ "[QK:DOCTOR-MODE-DERIVED] a doctor running from the plugin runtime's package derives plugin-runtime mode from " +
1513
+ `an active ledger that activated claude-code at this host's stable root, and from nothing else (no-ledger=${absent} derived=${derived})`,
1514
+ absent === null && derived === active,
1515
+ );
1516
+ // D3 — the checkout never derives, and never even opens the ledger: an uncertified body is
1517
+ // planted first, so reading it would throw instead of returning null.
1518
+ fs.writeFileSync(layout.ledgerPath, "not json\n");
1519
+ const checkout = mode({ repo: REPO });
1520
+ ok(
1521
+ "[QK:DOCTOR-MODE-CHECKOUT-NEVER-DERIVES] a doctor whose repo is not the runtime package returns no plugin mode " +
1522
+ `without opening the ledger, so a checkout never adopts plugin wiring (checkout=${checkout})`,
1523
+ checkout === null,
1524
+ );
1525
+ // D5 — on a reading path an uncertified ledger dies with the reader's own refusal, never null.
1526
+ fs.unlinkSync(layout.ledgerPath);
1527
+ fs.symlinkSync(path.join(env.HOME as string, "nowhere.json"), layout.ledgerPath);
1528
+ const viaRuntime = mode({ repo: runtimeRepo });
1529
+ const viaOverride = mode({ repo: REPO, override: active });
1530
+ fs.unlinkSync(layout.ledgerPath);
1531
+ ok(
1532
+ "[QK:DOCTOR-MODE-UNCERTIFIED-DIES] an uncertified ledger on either reading path (runtime-repo derivation, explicit " +
1533
+ `override) refuses with the reader's own code, never 'not plugin mode' (runtime=${viaRuntime} override=${viaOverride})`,
1534
+ viaRuntime === "refused:activation-ledger-uncertified" && viaOverride === "refused:activation-ledger-uncertified",
1535
+ );
1536
+ // D4 — the admission clauses on a reading path, each on a CERTIFIED ledger so only the clause can
1537
+ // refuse: a phase other than active, a ledger that never activated claude-code, and a runtimeRoot
1538
+ // that is not this host's stable root. Each is refused by name, never read as 'not plugin mode'.
1539
+ writeLedger(layout, ledgerWith(env, "activating", { pi: "active", "claude-code": "pending" }));
1540
+ const notActive = mode({ repo: runtimeRepo });
1541
+ writeLedger(layout, ledgerFor(env, ["pi"]));
1542
+ const piOnly = mode({ repo: runtimeRepo });
1543
+ writeLedger(layout, {
1544
+ ...ledgerFor(env, ["pi", "claude-code"]),
1545
+ runtimeRoot: path.join(env.HOME as string, "another-runtime", "active"),
1546
+ });
1547
+ const foreignRoot = mode({ repo: runtimeRepo });
1548
+ ok(
1549
+ "[QK:DOCTOR-MODE-ADMISSION-CLAUSES] on the runtime repo a certified ledger still admits plugin mode only when " +
1550
+ "phase is active, claude-code was activated and its runtimeRoot is this host's stable root — each other case " +
1551
+ `is refused by name (activating=${notActive} pi-only=${piOnly} foreign-root=${foreignRoot})`,
1552
+ notActive === "refused:doctor-plugin-runtime-unadmitted" &&
1553
+ piOnly === "refused:doctor-plugin-runtime-unadmitted" &&
1554
+ foreignRoot === "refused:doctor-plugin-runtime-unadmitted",
1555
+ );
1556
+ // D6 — the override must agree with the ledger's root; agreeing, it is admitted from a checkout.
1557
+ writeLedger(layout, ledgerFor(env, ["pi", "claude-code"]));
1558
+ const elsewhere = mode({ repo: REPO, override: path.join(env.HOME as string, "elsewhere") });
1559
+ const agreeing = mode({ repo: REPO, override: active });
1560
+ ok(
1561
+ "[QK:DOCTOR-MODE-OVERRIDE-AGREES] an explicit --plugin-runtime is admitted only when it names the ledger's own " +
1562
+ `stable root (elsewhere=${elsewhere} agreeing=${agreeing})`,
1563
+ elsewhere === "refused:doctor-plugin-runtime-unadmitted" && agreeing === active,
1564
+ );
1565
+ }
1566
+
1487
1567
  console.log(`\ncheck-herdr-activation: ${passed} assertions passed`);
@@ -227,6 +227,8 @@ ok "fake live entwurf MCP bridge running as a child of the stand-in Claude pid (
227
227
  # --- doctor driver -----------------------------------------------------------
228
228
  DOC_OUT=""
229
229
  DOC_RC=0
230
+ # #136: arguments for the plugin-runtime block at the end; empty for every clone cell.
231
+ DOC_ARGS=()
230
232
  run_doctor() {
231
233
  set +e
232
234
  # Deliberate cross-harness poison. Before the doctor scrubbed these carriers, the
@@ -237,7 +239,7 @@ run_doctor() {
237
239
  DOC_OUT="$(PI_SESSION_ID=ambient-pi-session-must-not-win \
238
240
  PI_AGENT_ID=ambient-pi-agent/must-not-win \
239
241
  ENTWURF_META_SENDER_MARKER="$TMP/ambient-sender-marker-must-not-win.json" \
240
- bash "$REPO/scripts/meta-bridge-doctor.sh" 2>&1)"
242
+ bash "$REPO/scripts/meta-bridge-doctor.sh" ${DOC_ARGS[@]+"${DOC_ARGS[@]}"} 2>&1)"
241
243
  DOC_RC=$?
242
244
  set -e
243
245
  }
@@ -713,5 +715,88 @@ else
713
715
  bad "doctor stayed red after reverting every mutation (a restore is incomplete):"$'\n'"$(printf '%s\n' "$DOC_OUT" | grep -E '^ FAIL' | sed 's/^/ /')"
714
716
  fi
715
717
 
718
+ # --- 6. #136: a Herdr plugin activation's ABSOLUTE wiring ---------------------
719
+ # The plugin activation runs the same installer with ENTWURF_PLUGIN_RUNTIME, so Claude's MCP and
720
+ # statusLine name absolute bins under the stable runtime. The doctor must learn that mode from the
721
+ # activation ledger and certify the wiring, not call it drift. FIXTURE HONESTY: the two runtime
722
+ # bins are wrappers onto this checkout's own launcher and statusline, and the ledger is written by
723
+ # the shipped writer with a fixture artifact identity; no npm runtime is installed here.
724
+ echo "[plugin-runtime] the installer's absolute wiring, certified through the activation ledger"
725
+ ACTIVE="$XDG_DATA_HOME/entwurf/herdr-plugin/runtime/active"
726
+ mkdir -p "$ACTIVE/node_modules/.bin" "$ACTIVE/node_modules/@junghanacs/entwurf"
727
+ printf '#!/usr/bin/env bash\nexec bash "%s/mcp/entwurf-bridge/start.sh" "$@"\n' "$REPO" > "$ACTIVE/node_modules/.bin/entwurf-bridge"
728
+ printf '#!/usr/bin/env bash\nexec "%s/scripts/meta-bridge-statusline.sh" "$@"\n' "$REPO" > "$ACTIVE/node_modules/.bin/entwurf-statusline"
729
+ chmod +x "$ACTIVE/node_modules/.bin/entwurf-bridge" "$ACTIVE/node_modules/.bin/entwurf-statusline"
730
+ if ENTWURF_PLUGIN_RUNTIME="$ACTIVE" bash "$REPO/scripts/meta-bridge-install.sh" >"$TMP/install-plugin.log" 2>&1; then
731
+ ok "real meta-bridge-install.sh wires the plugin-runtime mode (absolute bins under the sandbox stable root)"
732
+ else
733
+ bad "plugin-runtime install-meta-bridge failed in the sandbox:"$'\n'"$(sed 's/^/ install| /' "$TMP/install-plugin.log")"
734
+ fi
735
+ rm -rf "$PLANTED"
736
+ mkdir -p "$PLANTED"
737
+ cp -r "$ASM/$PLUGIN/." "$PLANTED/"
738
+ write_ledger() {
739
+ node --input-type=module - "$REPO/scripts/herdr-activation.mjs" "$ACTIVE" "$1" <<'JS'
740
+ import { pathToFileURL } from "node:url";
741
+ const [modPath, active, backends] = process.argv.slice(2);
742
+ const m = await import(pathToFileURL(modPath).href);
743
+ const states = Object.fromEntries(backends.split(",").map((b) => [b, "active"]));
744
+ m.writeLedger(m.resolveActivationLayout(process.env), m.ledgerBody({
745
+ phase: "active",
746
+ runtimeRoot: active,
747
+ artifactIdentity: { kind: "npm", name: "@junghanacs/entwurf", version: "0.34.1", expectedIntegrity: "sha512-fixture", observedDigest: `sha256-${"c".repeat(64)}` },
748
+ roots: m.resolveComponentRoots(process.env),
749
+ states,
750
+ }));
751
+ JS
752
+ }
753
+ write_ledger "pi,claude-code"
754
+ DOC_ARGS=(--plugin-runtime "$ACTIVE")
755
+
756
+ # P1 — the plugin wiring the installer wrote is certified: PASS, and the doctor names its mode.
757
+ # The [QK:] token lives on the `bad` line only (see M9b).
758
+ run_doctor
759
+ if [ "$DOC_RC" -eq 0 ] && printf '%s\n' "$DOC_OUT" | grep -q 'meta-bridge doctor: PASS' \
760
+ && printf '%s\n' "$DOC_OUT" | grep -qF "mode: plugin-runtime $ACTIVE (activation ledger)" \
761
+ && printf '%s\n' "$DOC_OUT" | grep -qF 'live mcpServers.entwurf-bridge equals desired_mcp()'; then
762
+ ok "plugin-runtime wiring reaches doctor PASS through the activation ledger, and the doctor names that mode"
763
+ else
764
+ bad "[QK:DOCTOR-PLUGIN-MODE-GREEN] the plugin activation's absolute wiring did NOT reach PASS (exit $DOC_RC):"$'\n'"$(printf '%s\n' "$DOC_OUT" | grep -E '^ (FAIL|WARN)|mode:' | sed 's/^/ /')"
765
+ fi
766
+
767
+ # P2 — genuine drift stays red in plugin mode: an absolute statusLine under the same root, but not ours.
768
+ cp "$CLAUDE_CONFIG_DIR/settings.json" "$TMP/settings-p2.bak"
769
+ python3 - "$CLAUDE_CONFIG_DIR/settings.json" "$ACTIVE/node_modules/.bin/evil" <<'PY'
770
+ import json, sys
771
+ p = sys.argv[1]
772
+ d = json.load(open(p, encoding="utf-8"))
773
+ d["statusLine"]["command"] = sys.argv[2]
774
+ json.dump(d, open(p, "w", encoding="utf-8"), indent=2)
775
+ PY
776
+ expect_red "plugin-mode statusLine repointed under the same runtime root" "statusLine.command drifted"
777
+ cp "$TMP/settings-p2.bak" "$CLAUDE_CONFIG_DIR/settings.json"
778
+
779
+ # P3 — a ledger that did not activate claude-code admits no plugin mode. The refusal is named,
780
+ # and it is NOT an early exit: the store and live-owner sections still run (Hard Rule 13).
781
+ write_ledger "pi"
782
+ expect_red "override against a ledger that never activated claude-code" "doctor-plugin-runtime-unadmitted"
783
+ # Each of the three expectation sites says UNKNOWN for that reason (never a bare-bin fallback),
784
+ # and the runtime axes still run: the live command still DELIVERS, the store and owner join report.
785
+ for claim in \
786
+ 'managed settings/MCP keyset is UNKNOWN: the expectation mode was not established' \
787
+ 'statusLine expectation is UNKNOWN: the expectation mode was not established' \
788
+ 'live-vs-desired MCP ownership is UNKNOWN: the expectation mode was not established' \
789
+ 'live bridge command DELIVERS' \
790
+ '[meta-record store]' \
791
+ '[live Claude MCP owner join]'
792
+ do
793
+ if printf '%s\n' "$DOC_OUT" | sed -n '/expectation mode refused: doctor-plugin-runtime-unadmitted/,$p' | grep -qF "$claim"; then
794
+ ok "after the mode refusal the doctor still reports: $claim"
795
+ else
796
+ bad "after the mode refusal the doctor never reported: $claim"
797
+ fi
798
+ done
799
+ write_ledger "pi,claude-code"
800
+
716
801
  echo
717
802
  if [ "$fail" -eq 0 ]; then echo "check-meta-doctor-oracle: PASS"; else echo "check-meta-doctor-oracle: FAIL (see above)"; exit 1; fi
@@ -43,7 +43,7 @@
43
43
  // Pure + subprocess, no network/model — IN pnpm run check:full.
44
44
 
45
45
  import { strict as assert } from "node:assert";
46
- import { execFileSync } from "node:child_process";
46
+ import { execFileSync, spawnSync } from "node:child_process";
47
47
  import { copyFileSync, globSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
48
48
  import { tmpdir } from "node:os";
49
49
  import { basename, join } from "node:path";
@@ -650,6 +650,164 @@ function runSubcommand(sub: string, env: Record<string, string | undefined>): {
650
650
  }
651
651
  }
652
652
 
653
+ // ===========================================================================
654
+ // 8g. #137: TOML data-subject hunks, not path identity; Markdown/JSON data and
655
+ // code/signatures/machinery remain file-level. Hermetic like 8f: real history is NOT
656
+ // in a qualification snapshot.
657
+ // ==========================================================================
658
+ {
659
+ const tmp = mkdtempSync(join(tmpdir(), "entwurf-qualify-hunks-"));
660
+ const git = (...args: string[]) =>
661
+ execFileSync("git", ["-c", "core.hooksPath=/dev/null", "-c", "user.email=g@e", "-c", "user.name=g", ...args], {
662
+ cwd: tmp,
663
+ stdio: "ignore",
664
+ });
665
+ const head = () => execFileSync("git", ["rev-parse", "HEAD"], { cwd: tmp, encoding: "utf8" }).trim();
666
+ const data = 'version = "1"\n[[panes]]\ncommand = ["node", "build.mjs"]\n# outside\n';
667
+ const wrong: string[] = [];
668
+ try {
669
+ git("init", "-q", "-b", "main");
670
+ mkdirSync(join(tmp, "scripts/mutants"), { recursive: true });
671
+ mkdirSync(join(tmp, "fixture"));
672
+ mkdirSync(join(tmp, "scripts/fixtures"));
673
+ copyFileSync(join(REPO_DIR, "scripts/ci-qualify-decide.sh"), join(tmp, "scripts/ci-qualify-decide.sh"));
674
+ const manifest = JSON.stringify({
675
+ mutants: [
676
+ { subject: "fixture/data.toml", find: ["[[panes]]"] },
677
+ { subject: "fixture/data.toml", find: ['command = ["node", "build.mjs"]'] },
678
+ { subject: "fixture/data.toml", find: ["[[panes]]", 'command = ["node", "build.mjs"]'] },
679
+ { subject: "fixture/code.ts", find: ["return 1"], signatureSource: "fixture/signature.md" },
680
+ { subject: "fixture/signature.md", find: ["signature"] },
681
+ { subject: "fixture/joined.toml", find: ["alpha = 1", "beta = 2"] },
682
+ { subject: "fixture/doc.md", find: ["Legacy rows are documented: kept for history."] },
683
+ { subject: "fixture/reg.json", find: ['"mode": "self-fetch",'] },
684
+ { subject: "scripts/fixtures/pin.json", find: ['"pinned": 1'] },
685
+ { subject: "scripts/ci-qualify-decide.sh", find: ["set -euo pipefail"] },
686
+ ],
687
+ });
688
+ writeFileSync(join(tmp, "scripts/mutants/fixture.json"), manifest);
689
+ writeFileSync(join(tmp, "fixture/data.toml"), data);
690
+ writeFileSync(join(tmp, "fixture/code.ts"), "// outside\nfunction f() { return 1; }\n");
691
+ writeFileSync(join(tmp, "fixture/signature.md"), "signature\n# outside\n");
692
+ writeFileSync(join(tmp, "fixture/joined.toml"), "version = 1\nalpha = 1\nbeta = 2\n");
693
+ const doc = "# Doc\nLegacy rows are documented: kept for history.\n# outside\n";
694
+ const reg = '{\n"copilot": {\n"mode": "self-fetch",\n"level": 6\n}\n}\n';
695
+ writeFileSync(join(tmp, "fixture/doc.md"), doc);
696
+ writeFileSync(join(tmp, "fixture/reg.json"), reg);
697
+ writeFileSync(join(tmp, "scripts/fixtures/pin.json"), '{\n"version": 1,\n"pinned": 1\n}\n');
698
+ git("add", "-A");
699
+ git("commit", "-qm", "seed");
700
+ const base = head();
701
+ const nonToml: string[] = [];
702
+ const check = (name: string, expected: boolean, reason?: string, before = base, into = wrong) => {
703
+ const r = spawnSync("bash", [join(tmp, "scripts/ci-qualify-decide.sh"), before, head()], {
704
+ cwd: tmp,
705
+ encoding: "utf8",
706
+ env: { ...process.env, CI_EVENT_NAME: "push", CI_FORCED: "false" },
707
+ });
708
+ if (r.status !== 0 || r.stdout.trim() !== `run_body=${expected}` || (reason && !r.stderr.includes(reason)))
709
+ into.push(`${name}: status=${r.status}, out=${r.stdout.trim()}, err=${r.stderr.trim()}`);
710
+ };
711
+ const commit = () => {
712
+ git("add", "-A");
713
+ git("commit", "-qm", "cell");
714
+ };
715
+ const reset = () => git("reset", "--hard", base); // ONLY this test-owned throwaway repository.
716
+ writeFileSync(join(tmp, "fixture/data.toml"), data.replace('version = "1"', 'version = "2"'));
717
+ commit();
718
+ check("outside", false, "fixture/data.toml: no find range touched");
719
+ // A replay must read HEAD's manifest, not an unrelated dirty worktree's find.
720
+ writeFileSync(
721
+ join(tmp, "scripts/mutants/fixture.json"),
722
+ '{"mutants":[{"subject":"fixture/data.toml","find":["version"]}]}',
723
+ );
724
+ check("HEAD manifest, not dirty worktree", false);
725
+ reset();
726
+ for (const [name, content] of [
727
+ ["panes line", data.replace("[[panes]]", "[[panes]] # changed")],
728
+ ["command line", data.replace('command = ["node", "build.mjs"]', 'command = ["node", "build.mjs"] # changed')],
729
+ ["moved", `# inserted above\n${data}`],
730
+ ["ambiguous", `${data}[[panes]]\n`],
731
+ ["missing find", data.replace("[[panes]]", "[[other]]")],
732
+ ] as const) {
733
+ writeFileSync(join(tmp, "fixture/data.toml"), content);
734
+ commit();
735
+ check(name, true);
736
+ reset();
737
+ }
738
+ git("rm", "fixture/data.toml");
739
+ commit();
740
+ check("deleted subject", true);
741
+ reset();
742
+ git("mv", "fixture/data.toml", "fixture/renamed.toml");
743
+ commit();
744
+ check("renamed subject", true);
745
+ reset();
746
+ writeFileSync(join(tmp, "fixture/code.ts"), "// outside changed\nfunction f() { return 1; }\n");
747
+ commit();
748
+ check("code outside find remains file-level", true);
749
+ reset();
750
+ writeFileSync(join(tmp, "fixture/signature.md"), "signature\n# outside changed\n");
751
+ commit();
752
+ check("signature also data subject remains file-level", true);
753
+ reset();
754
+ writeFileSync(join(tmp, "fixture/joined.toml"), "version = 1\nalpha = 1\nbeta = 2 # changed\n");
755
+ commit();
756
+ check("only multiline find, second line touched", true);
757
+ reset();
758
+ writeFileSync(join(tmp, "scripts/fixtures/pin.json"), '{\n"version": 2,\n"pinned": 1\n}\n');
759
+ commit();
760
+ check("machinery also data subject remains file-level", true);
761
+ reset();
762
+ writeFileSync(
763
+ join(tmp, "scripts/ci-qualify-decide.sh"),
764
+ `${readFileSync(join(tmp, "scripts/ci-qualify-decide.sh"), "utf8")}\n# outside find\n`,
765
+ );
766
+ commit();
767
+ check("machinery also subject remains file-level", true);
768
+ reset();
769
+ // Outside an untouched exact-once span, a shorter presence literal (measured on DELIVERY.md)
770
+ // or a later JSON duplicate key (JSON.parse: last wins) can re-satisfy the oracle.
771
+ writeFileSync(join(tmp, "fixture/doc.md"), `${doc}Legacy rows are documented\n`);
772
+ commit();
773
+ check("markdown restatement outside find", true, "conservative:", base, nonToml);
774
+ reset();
775
+ writeFileSync(
776
+ join(tmp, "fixture/reg.json"),
777
+ reg.replace("6\n}\n}\n", '6\n},\n"copilot": { "level": 6, "mode": "self-fetch" }\n}\n'),
778
+ );
779
+ commit();
780
+ check("json duplicate key outside find", true, "conservative:", base, nonToml);
781
+ reset();
782
+ // The first quoted name must still fail open after a >pipe-buffer list:
783
+ // printf | grep -q can lose its match to SIGPIPE under pipefail.
784
+ writeFileSync(join(tmp, '000"quoted'), "x\n");
785
+ for (let i = 0; i < 768; i++) writeFileSync(join(tmp, "fixture", `${i}_${"x".repeat(180)}.txt`), "x\n");
786
+ commit();
787
+ check("large quoted changed-name list", true, "quoted changed path cannot be read honestly");
788
+ reset();
789
+ check("unreadable range", true, "fail-open 4", "f".repeat(40));
790
+ git("rm", "scripts/mutants/fixture.json");
791
+ commit();
792
+ check("missing manifests", true);
793
+ assert.ok(
794
+ wrong.length === 0,
795
+ "[QK:QUALIFY-FILTER-SUBJECT-HUNKS] only untouched, unmoved, exact-once TOML find ranges may skip; " +
796
+ "unknown ranges run, and code/signature/machinery retain file-level protection. " +
797
+ wrong.join("; "),
798
+ );
799
+ assert.ok(
800
+ nonToml.length === 0,
801
+ "[QK:QUALIFY-FILTER-TOML-ONLY-REFINEMENT] Markdown and JSON subjects stay file-level: an untouched exact-once " +
802
+ "find does not pin kill-power when a shorter presence literal or a later duplicate JSON key outside it can " +
803
+ "re-satisfy the oracle. " +
804
+ nonToml.join("; "),
805
+ );
806
+ } finally {
807
+ rmSync(tmp, { recursive: true, force: true });
808
+ }
809
+ }
810
+
653
811
  // ===========================================================================
654
812
  // 9. Every gate a mutant manifest NAMES runs inside `pnpm run check:full`
655
813
  //
@@ -28,37 +28,117 @@ fail_open() {
28
28
  }
29
29
 
30
30
  collect_paths_from_manifests() {
31
- python3 - "$REPO_DIR" <<'PY'
31
+ python3 - "$REPO_DIR" "${1:-}" <<'PY'
32
32
  import glob
33
33
  import json
34
34
  import os
35
35
  import sys
36
+ import subprocess
36
37
 
37
- repo = sys.argv[1]
38
- out = set()
39
- for path in sorted(glob.glob(os.path.join(repo, "scripts/mutants/*.json"))):
40
- with open(path, encoding="utf-8") as handle:
41
- manifest = json.load(handle)
38
+ repo, rev = sys.argv[1:]
39
+ def git(*args):
40
+ return subprocess.check_output(["git", "-C", repo, *args])
41
+ paths = (git("ls-tree", "-r", "--name-only", rev, "scripts/mutants").decode().splitlines()
42
+ if rev else sorted(glob.glob(os.path.join(repo, "scripts/mutants/*.json"))))
43
+ paths = [p for p in paths if p.endswith(".json")]
44
+ if not paths:
45
+ raise ValueError("no mutant manifests")
46
+ out, signatures, subjects = set(), set(), {}
47
+ for path in paths:
48
+ if rev:
49
+ manifest = json.loads(git("show", f"{rev}:{path}"))
50
+ else:
51
+ with open(path, encoding="utf-8") as handle:
52
+ manifest = json.load(handle)
42
53
  for mutant in manifest.get("mutants", []):
43
54
  subject = mutant.get("subject")
44
55
  if subject:
45
56
  out.add(subject)
57
+ find = mutant.get("find")
58
+ # The runner joins a find ARRAY into ONE multiline occurrence.
59
+ needle = "\n".join(find) if isinstance(find, list) and all(isinstance(s, str) for s in find) else None
60
+ subjects.setdefault(subject, []).append(needle)
46
61
  signature_source = mutant.get("signatureSource")
47
62
  if signature_source:
48
63
  out.add(signature_source)
49
- for entry in sorted(out):
50
- print(entry)
64
+ signatures.add(signature_source)
65
+ if not out or any(any(c in p for c in '\n\r\t"\\') for p in out):
66
+ raise ValueError("empty or unrepresentable manifest paths")
67
+ print(json.dumps({"paths": sorted(out), "signatures": sorted(signatures), "subjects": subjects}))
51
68
  PY
52
69
  }
53
70
 
54
- # A changed path matters when it is a mutant's subject or signature source, or
55
- # when it sits in the machinery that decides what those mutants prove.
71
+ load_manifest_surface() {
72
+ MANIFEST_DATA="$(collect_paths_from_manifests "${1:-}")" || fail_open 6 "missing/unreadable mutant manifests"
73
+ MANIFEST_PATHS="$(printf '%s' "$MANIFEST_DATA" | python3 -c 'import json,sys;print("\n".join(json.load(sys.stdin)["paths"]))')" || fail_open 6 "unreadable manifest paths"
74
+ SIGNATURE_PATHS="$(printf '%s' "$MANIFEST_DATA" | python3 -c 'import json,sys;print("\n".join(json.load(sys.stdin)["signatures"]))')" || fail_open 6 "unreadable signature paths"
75
+ }
76
+
77
+ # Return success ONLY when every exact-once TOML find span is demonstrably
78
+ # untouched and unmoved on BOTH blobs. Any uncertainty leaves the body armed.
79
+ subject_find_untouched() {
80
+ python3 - "$REPO_DIR" "$BEFORE" "$HEAD_SHA" "$1" 3<<< "$MANIFEST_DATA" <<'PY'
81
+ import json
82
+ import os
83
+ import re
84
+ import subprocess
85
+ import sys
86
+
87
+ repo, before, head, path = sys.argv[1:]
88
+ def git(*args):
89
+ return subprocess.check_output(["git", "-C", repo, *args], stderr=subprocess.DEVNULL).decode("utf-8")
90
+ def span(source, needle):
91
+ if not needle or source.count(needle) != 1:
92
+ raise ValueError("find absent or ambiguous")
93
+ start = source[:source.index(needle)].count("\n") + 1
94
+ return start, start + needle.count("\n") - int(needle.endswith("\n"))
95
+ try:
96
+ finds = json.load(os.fdopen(3))["subjects"].get(path)
97
+ if not finds or any(not needle for needle in finds):
98
+ raise ValueError("missing find spans")
99
+ old, new = git("show", f"{before}:{path}"), git("show", f"{head}:{path}")
100
+ if "\0" in old or "\0" in new:
101
+ raise ValueError("binary subject")
102
+ diff = git("diff", "--no-renames", "--no-ext-diff", "--no-textconv", "--no-color", "-U0", before, head, "--", path)
103
+ if "Binary files " in diff or "GIT binary patch" in diff:
104
+ raise ValueError("binary diff")
105
+ hunks = []
106
+ for line in diff.splitlines():
107
+ if not line.startswith("@@"):
108
+ continue
109
+ match = re.fullmatch(r"@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*)?", line)
110
+ if not match:
111
+ raise ValueError("unreadable hunk")
112
+ a, b, c, d = match.groups()
113
+ hunks.append((int(a), int(b or 1), int(c), int(d or 1)))
114
+ if not hunks:
115
+ raise ValueError("no readable text hunks")
116
+ for needle in set(finds):
117
+ os_, oe = span(old, needle)
118
+ ns, ne = span(new, needle)
119
+ if (os_, oe) != (ns, ne):
120
+ raise ValueError("find range moved")
121
+ for a, b, c, d in hunks:
122
+ if (b > 0 and a <= oe and a + b - 1 >= os_) or (d > 0 and c <= ne and c + d - 1 >= ns):
123
+ raise ValueError("find range touched")
124
+ except (ValueError, KeyError, TypeError, OSError, subprocess.SubprocessError) as error:
125
+ print(f"ci-qualify-decide: fail-open 7 {path}: {error} -> run_body=true", file=sys.stderr)
126
+ sys.exit(1)
127
+ print(f"ci-qualify-decide: {path}: no find range touched (all exact-once spans unmoved)", file=sys.stderr)
128
+ PY
129
+ }
130
+
131
+ # Signature sources and machinery MUST precede subjects: many paths are BOTH.
132
+ # Code/unknown subjects retain file-level protection; only TOML data gets hunk
133
+ # refinement (#137 non-goal: no skipping code/gate/mutant/matrix changes). TOML
134
+ # refuses a redefined key, but a shorter Markdown presence literal or a later JSON
135
+ # duplicate key outside an untouched find can still re-satisfy an oracle.
56
136
  matches_qualification_surface() {
57
137
  local file="$1"
58
138
  local subject
59
139
  while IFS= read -r subject; do
60
- [ "$file" = "$subject" ] && { echo "manifest-subject"; return 0; }
61
- done <<< "$MANIFEST_PATHS"
140
+ [ "$file" = "$subject" ] && { echo "manifest-signature-source"; return 0; }
141
+ done <<< "$SIGNATURE_PATHS"
62
142
  case "$file" in
63
143
  scripts/mutants/*) echo "glob:scripts/mutants/**"; return 0 ;;
64
144
  scripts/check-*) echo "glob:scripts/check-*"; return 0 ;;
@@ -73,6 +153,20 @@ matches_qualification_surface() {
73
153
  # that stopped covering something cannot hide behind its own change.
74
154
  scripts/ci-qualify-decide.sh) echo "exact:scripts/ci-qualify-decide.sh"; return 0 ;;
75
155
  esac
156
+ while IFS= read -r subject; do
157
+ if [ "$file" = "$subject" ]; then
158
+ if [ "${MATCH_MODE:-files}" = range ]; then
159
+ case "$file" in
160
+ *.toml) ;;
161
+ *) echo "conservative:non-toml-subject"; return 0 ;;
162
+ esac
163
+ if subject_find_untouched "$file"; then
164
+ return 1
165
+ fi
166
+ fi
167
+ echo "manifest-subject"; return 0
168
+ fi
169
+ done <<< "$MANIFEST_PATHS"
76
170
  return 1
77
171
  }
78
172
 
@@ -98,13 +192,12 @@ decide_from_files() {
98
192
  fi
99
193
  }
100
194
 
101
- MANIFEST_PATHS="$(collect_paths_from_manifests)"
102
- [ -n "$MANIFEST_PATHS" ] || { echo "ABORT: no mutant manifests read from $REPO_DIR/scripts/mutants" >&2; exit 1; }
103
-
104
195
  if [ "${1:-}" = "--files-from" ]; then
196
+ MATCH_MODE=files
197
+ load_manifest_surface
105
198
  SRC="${2:-}"
106
199
  [ -n "$SRC" ] || { echo "ABORT: --files-from needs a path or -" >&2; exit 1; }
107
- if [ "$SRC" = "-" ]; then FILES="$(cat)"; else FILES="$(cat "$SRC")"; fi
200
+ if [ "$SRC" = "-" ]; then FILES="$(cat)"; else FILES="$(cat "$SRC")"; fi || fail_open 4 "unreadable files-from input"
108
201
  decide_from_files "$FILES"
109
202
  exit 0
110
203
  fi
@@ -154,6 +247,10 @@ esac
154
247
  git -C "$REPO_DIR" cat-file -e "$BEFORE^{commit}" 2>/dev/null || fail_open 4 "before=$BEFORE is not a commit object in this checkout"
155
248
  git -C "$REPO_DIR" cat-file -e "$HEAD_SHA^{commit}" 2>/dev/null || fail_open 4 "head=$HEAD_SHA is not a commit object in this checkout"
156
249
 
157
- CHANGED="$(git -C "$REPO_DIR" diff --name-only "$BEFORE".."$HEAD_SHA" 2>/dev/null)" || fail_open 4 "git diff $BEFORE..$HEAD_SHA failed"
250
+ MATCH_MODE=range
251
+ load_manifest_surface "$HEAD_SHA"
252
+ CHANGED="$(git -C "$REPO_DIR" diff --no-renames --no-ext-diff --no-textconv --name-only "$BEFORE".."$HEAD_SHA" 2>/dev/null)" || fail_open 4 "git diff $BEFORE..$HEAD_SHA failed"
253
+ # Git quotes unrepresentable changed names; never interpret those as absent paths.
254
+ if grep -q '^"' <<< "$CHANGED"; then fail_open 4 "quoted changed path cannot be read honestly"; fi
158
255
 
159
256
  decide_from_files "$CHANGED"
@@ -28,7 +28,7 @@
28
28
 
29
29
  import fs from "node:fs";
30
30
  import path from "node:path";
31
- import { artifactStage, certifyArtifactIdentity, sameArtifactRequest } from "./herdr-runtime.mjs";
31
+ import { artifactStage, certifyArtifactIdentity, resolveRuntimeLayout, sameArtifactRequest } from "./herdr-runtime.mjs";
32
32
 
33
33
  /** Ledger format. Bump only with a reader that understands both. */
34
34
  export const ACTIVATION_SCHEMA_VERSION = 2;
@@ -502,6 +502,55 @@ export function piStatePaths(env) {
502
502
  });
503
503
  }
504
504
 
505
+ /**
506
+ * Which expectations `doctor-meta-bridge` must compute (#136): the stable active root when this
507
+ * host's Claude wiring was written by a plugin activation, otherwise null (clone / installed).
508
+ *
509
+ * The plugin activation writes Claude's MCP and statusLine as ABSOLUTE commands under the stable
510
+ * runtime. A doctor that does not know that expects bare bins and calls correct wiring drifted. The
511
+ * mode is read from the record that authorised the wiring, the certified activation ledger, never
512
+ * from settings, environment or a path guess:
513
+ * - phase `active`, `claude-code` activated, and the ledger's runtimeRoot is THIS host's stable root;
514
+ * - without an override, only a doctor running FROM that runtime's package derives the mode. A
515
+ * checkout's doctor returns null without opening the ledger, so it never adopts plugin wiring;
516
+ * - an explicit override must agree with the ledger, or it is refused.
517
+ * An absent ledger is "not plugin mode"; an uncertified one throws the reader's own refusal.
518
+ */
519
+ export function resolveDoctorPluginRuntime(env, { repo, override } = {}) {
520
+ const activeDir = resolveRuntimeLayout(env).activeDir;
521
+ if (override === undefined) {
522
+ const runtimePackage = path.join(activeDir, "node_modules", "@junghanacs", "entwurf");
523
+ let same = false;
524
+ try {
525
+ same = fs.realpathSync(repo) === fs.realpathSync(runtimePackage);
526
+ } catch {
527
+ // Bounded probe: no runtime package at the stable address means this repo is not it.
528
+ same = false;
529
+ }
530
+ if (!same) return null;
531
+ }
532
+ const ledger = readCertifiedLedger(resolveActivationLayout(env));
533
+ if (ledger === null) {
534
+ if (override === undefined) return null;
535
+ throw new ActivationError(
536
+ "doctor-plugin-runtime-unadmitted",
537
+ "--plugin-runtime was given but no activation ledger exists",
538
+ );
539
+ }
540
+ const refuse = (clause) => {
541
+ throw new ActivationError("doctor-plugin-runtime-unadmitted", clause);
542
+ };
543
+ if (ledger.phase !== "active") refuse(`activation ledger phase is ${ledger.phase}, not active`);
544
+ if (!ledger.activatedBackends.includes("claude-code")) refuse("the activation ledger did not activate claude-code");
545
+ if (ledger.runtimeRoot !== activeDir) {
546
+ refuse(`the ledger's runtimeRoot ${ledger.runtimeRoot} is not this host's stable runtime root ${activeDir}`);
547
+ }
548
+ if (override !== undefined && override !== activeDir) {
549
+ refuse(`--plugin-runtime ${override} does not name the ledger's runtime root ${activeDir}`);
550
+ }
551
+ return activeDir;
552
+ }
553
+
505
554
  /** A component row, in the one shape the certifier accepts. */
506
555
  export function componentRow(backend, state) {
507
556
  return Object.freeze({ backend, state });
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * herdr-doctor-mode — the one call `meta-bridge-doctor.sh` makes to learn its expectation mode (#136).
4
+ *
5
+ * node herdr-doctor-mode.mjs <repo> [<plugin-runtime override>]
6
+ *
7
+ * Prints the stable active root (plugin-runtime mode) or nothing (clone / installed), exit 0.
8
+ * A refusal prints `code: detail` to stderr and exits 3. Any other exit is a crash the doctor
9
+ * reports as UNKNOWN. All judgement lives in `resolveDoctorPluginRuntime`; nothing is decided here.
10
+ */
11
+ import { resolveDoctorPluginRuntime } from "./herdr-activation.mjs";
12
+
13
+ const [repo, override] = process.argv.slice(2);
14
+ if (!repo) {
15
+ process.stderr.write("usage: herdr-doctor-mode.mjs <repo> [<plugin-runtime>]\n");
16
+ process.exit(2);
17
+ }
18
+ let root;
19
+ try {
20
+ root = resolveDoctorPluginRuntime(process.env, { repo, override });
21
+ } catch (err) {
22
+ if (err?.name !== "ActivationError") throw err;
23
+ process.stderr.write(`${err.message}\n`);
24
+ process.exit(3);
25
+ }
26
+ if (root !== null) process.stdout.write(`${root}\n`);