@sleepy_panda_srl/portulan 0.1.1 → 0.1.2

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.
@@ -61,7 +61,8 @@ import fs from "node:fs";
61
61
  import path from "node:path";
62
62
  import { pathToFileURL } from "node:url";
63
63
 
64
- import { resolvePack, rootPlan } from "./compile.mjs";
64
+ // `shadowedCopy` rides along: one carrier for *is this pack shadowed*, per #316.
65
+ import { resolvePack, rootPlan, shadowedCopy } from "./compile.mjs";
65
66
  import { AUTO, discoverPackRoots, namedWithAuto } from "./discover.mjs";
66
67
 
67
68
  /**
@@ -288,6 +289,46 @@ export function resolverFor({ workspaceDir, manifest, repoRoot = ".", named = []
288
289
  if (plan.refusal) throw new Error(`recipe-set: ${plan.refusal}`);
289
290
  if (plan.couldNotRun) throw new Error(`recipe-set: ${plan.couldNotRun}`);
290
291
  const roots = plan.roots ?? [];
292
+ const originAt = (r) => (plan.origins ?? []).find((o) => path.resolve(o.root) === path.resolve(r))?.origin;
293
+
294
+ // **A shadowed pack is refused here too** (#318), and this tool's ground is the strongest of the
295
+ // three. `compile` refuses even an agreeing shadow because its artifact records which root
296
+ // answered; `index` refuses one although its artifact deliberately does not. Here the answering
297
+ // root is not merely recorded — **it is spliced into the output**: `${PACK_ROOT}` expands to the
298
+ // root that answered, so two copies whose manifests are byte-identical still yield different `run`
299
+ // lines pointing at different files. What CI runs and what a bare local run runs would diverge,
300
+ // with nothing in either output to say so.
301
+ //
302
+ // So the agreeing case is not a carve-out anyone could argue for here, and the honest predicate was
303
+ // never manifest comparison: it is output equality, computable only by resolving both worlds.
304
+ // Refusing the ambiguity is what remains.
305
+ //
306
+ // **Checked HERE rather than inside the returned closure**, which is where a first cut put it. The
307
+ // closure runs during `recipeSet`, outside the `try` that wraps this constructor — so the refusal
308
+ // escaped as an uncaught throw, exit 1 with a stack trace, in a tool whose whole contract is exit 2
309
+ // for could-not-run. Measured, not reasoned about. Throwing from the constructor puts it on the
310
+ // path `plan.refusal` already uses and the caller already catches.
311
+ //
312
+ // **Only where the caller did not choose** — a named root replaces the derived one, `auto` is
313
+ // discovery elected — which is `compile`'s guard shape, bound to it deliberately.
314
+ if (!forced && plan.source !== "named") {
315
+ const declared = Array.isArray(manifest?.packs) ? manifest.packs : [];
316
+ for (const ref of declared) {
317
+ const found = resolvePack(String(ref), roots);
318
+ if (!found?.dir) continue;
319
+ // Path-resolved, matching `compile` — see the sibling note in `index.mjs`.
320
+ const behind = shadowedCopy(String(ref), originAt(found.root), roots, originAt);
321
+ if (!behind) continue;
322
+ throw new Error(
323
+ `recipe-set: \`${ref}\` is SHADOWED — it resolved under ${found.root}, a root discovered on ` +
324
+ `this host, while the root ${path.relative(path.resolve(repoRoot), behind.root)} also carries it. ` +
325
+ "A composed recipe's ${PACK_ROOT} expands to whichever root answered, so the two compose to " +
326
+ "run lines pointing at different files. Refusing to pick: name the root — `--pack-root packs` " +
327
+ "for the tree, which is what CI runs, or `--pack-root auto` for the installed copy.",
328
+ );
329
+ }
330
+ }
331
+
291
332
  return (ref) => {
292
333
  const found = resolvePack(ref, roots);
293
334
  // `resolvePack` returns `manifest` as the PATH to `pack.json`, not as the parsed object —
@@ -75,6 +75,33 @@
75
75
  * every manifest in this repository already has. A manifest formatted otherwise is reformatted, and
76
76
  * that is a real cost rather than a hidden one.
77
77
  *
78
+ * ## A pack two roots answer for is refused, not picked
79
+ *
80
+ * On a host carrying an installed copy of a composed pack **and** the tree's own, resolution is
81
+ * discovered-first and first-match-wins, so the installed copy answers. That copy lives outside the
82
+ * plugin root, containment fails, and this tool concluded the tree's declared skills path belonged to
83
+ * no composed pack — printing `--write` as the remedy, where `--write` is the mutation that strips a
84
+ * correct declaration out of a tracked manifest
85
+ * ([#317](https://github.com/sleepy-panda-srl/portulan/issues/317)).
86
+ *
87
+ * So this refuses at RESOLUTION — exit 2 — where `compile` refuses for the same family
88
+ * ([#316](https://github.com/sleepy-panda-srl/portulan/issues/316)), and for the same reason: the
89
+ * printer, `--check` and `--write` inherit ONE rule rather than three. Refusing on `--write` alone
90
+ * would leave `--check` exiting **1**, asserting the *repository* had drifted when it had not — and an
91
+ * exit code is the machine-read API, so a false 1 is a false verdict about the tree.
92
+ *
93
+ * **The predicate is imported, not re-spelled.** `shadowedCopy` and `packDifferences` live in
94
+ * `./compile.mjs`. That comparison had already been wrong twice in `doctor` before it became one
95
+ * carrier, and a third copy written inside a fix for its own family is precisely the defect
96
+ * `../.portulan/proposals/0020-a-fix-is-not-done-at-the-site-it-was-found.md` names.
97
+ *
98
+ * **Where this tool's reason is SHARPER than `compile`'s, and the message says so.** `compile` refuses
99
+ * a shadow whose manifests agree because `recordedOrigin` writes the answering root into the artifact,
100
+ * so agreement in the manifests is not agreement in the bytes. Here agreement is beside the point in a
101
+ * more direct way: the two copies sit on opposite sides of the plugin root, so which one answers
102
+ * decides whether this pack contributes a `skills` path **at all**. Version parity does not save it,
103
+ * which is why this fires on hosts where `compile`'s difference-driven half would have nothing to say.
104
+ *
78
105
  * Zero dependencies, no network, no install step — the same constraints as every tool here.
79
106
  */
80
107
 
@@ -93,7 +120,7 @@ import { pathToFileURL } from "node:url";
93
120
  // root wins outright; otherwise discovery is unioned with the derived root, discovered first** — asking
94
121
  // only selects the strict degrade. This tool exited **2** on the ordinary drafted workspace until then,
95
122
  // which is what the row's "optional where discovery finds a root" was not.)_
96
- import { resolvePack, rootPlan } from "./compile.mjs";
123
+ import { packDifferences, recordedOrigin, resolvePack, rootPlan, shadowedCopy } from "./compile.mjs";
97
124
  import { AUTO, discoverPackRoots, namedWithAuto } from "./discover.mjs";
98
125
 
99
126
  /**
@@ -425,6 +452,114 @@ export function declaredFor(set, pluginManifest, pluginRoot) {
425
452
  return [...portion.outside, ...set.paths.map((p) => p.path)];
426
453
  }
427
454
 
455
+ /**
456
+ * The refusal a shadowed pack earns, or `null` when no declared pack is shadowed.
457
+ *
458
+ * Separated from `run` so the sentence a reader gets is testable without a command line, and kept in
459
+ * this file rather than pushed into `compile.mjs` because the CONSEQUENCE clause is this tool's own:
460
+ * `compile` talks about emitted policy, and this one talks about whether a `skills` path is derived.
461
+ * What is shared — *is there a second copy behind the one that answered, and do the two differ* — is
462
+ * imported, and that split is the point.
463
+ *
464
+ * Only the **unasked** path reaches here. `--pack-root <dir>` names a root, which replaces the derived
465
+ * one and leaves nothing behind it to shadow; `--pack-root auto` is discovery ELECTED, and refusing
466
+ * somebody for answering the question the refusal asks would be incoherent. Both spellings are offered
467
+ * in the message for that reason — naming only the tree would make the refusal decide the question it
468
+ * claims to hand back.
469
+ */
470
+ export function shadowRefusal({ packs, roots, plan, repoRoot, pluginRoot }) {
471
+ const originOf = (r) => (plan?.origins ?? []).find((o) => path.resolve(o.root) === path.resolve(r))?.origin;
472
+ for (const ref of Array.isArray(packs) ? packs : []) {
473
+ const found = resolvePack(ref, roots);
474
+ if (!found?.dir || !found.manifest) continue;
475
+ const behind = shadowedCopy(found.name, recordedOrigin(found.root, plan, repoRoot), roots, originOf);
476
+ if (!behind) continue;
477
+
478
+ const there = path.relative(repoRoot, behind.root) || behind.root;
479
+ // **The tree spelling is the root we just named, not the conventional guess.** Both arms below
480
+ // hard-coded `--pack-root packs`, and the derived root is `path.resolve(workspaceDir, tree,
481
+ // "packs")` — so a workspace whose `tree` is `../nested/` derives `nested/packs` and was being
482
+ // told to pass a directory that does not carry the pack. That is this branch's own defect class
483
+ // one more time: a message prescribing a remedy it had not checked, in the sentence whose whole
484
+ // job is to hand back an accurate choice. `there` is already the relative spelling of that root,
485
+ // and for the conventional layout it is exactly `packs`, so the common output is unchanged.
486
+ //
487
+ // The `verify/plugin.sh` clause is gated with it, because that rail pins the literal
488
+ // `--pack-root packs`: claiming the equivalence for `nested/packs` would replace one false
489
+ // sentence with another. Raised through the promoted suppressed-note channel, which carries
490
+ // findings the inline round does not.
491
+ const rail = there === "packs" ? ", which is what `verify/plugin.sh` checks" : "";
492
+
493
+ // **Read and parse are two failures with two repairs**, and one sentence for both misdiagnoses
494
+ // the commoner one — the defect Copilot raised against `run`'s workspace arm on #229, whose
495
+ // comment sits one screen below this and which the first cut of this block reintroduced
496
+ // anyway. A `SyntaxError` from `JSON.parse` carries no `.code`, so a collapsed arm reporting
497
+ // `error.code ?? error.message` under *could not be read* sends somebody with a malformed
498
+ // manifest to look at permissions. `readManifest` and `resolverFor` both keep the two apart;
499
+ // so does this. It also names WHICH of the two copies is the broken one, which the collapsed
500
+ // form could not — with two manifests in play, "one of the two" is half a diagnosis.
501
+ const load = (file) => {
502
+ let text;
503
+ try {
504
+ text = fs.readFileSync(file, "utf8");
505
+ } catch (error) {
506
+ return { why: `could not be read — ${error.code ?? error.message}` };
507
+ }
508
+ try {
509
+ return { value: JSON.parse(text) };
510
+ } catch (error) {
511
+ return { why: `does not parse as JSON — ${error.message}` };
512
+ }
513
+ };
514
+ const here = load(found.manifest);
515
+ const beneath = load(behind.manifest);
516
+ const broken = here.why ? { at: found.root, why: here.why } : beneath.why ? { at: there, why: beneath.why } : null;
517
+ if (broken) {
518
+ // Could-not-run wearing its own sentence rather than the SHADOWED one: with a manifest
519
+ // unreadable, *what differs* is unanswerable, and a refusal that guessed would be claiming
520
+ // to have compared two things it could not read. The remedy is the same either way.
521
+ return (
522
+ `the pack \`${found.name}\` resolved under the root ${found.root} while the root ${there} also ` +
523
+ `carries it, and the copy under ${broken.at} ${broken.why} — so which one this would derive ` +
524
+ `from could not be established. Name the root: \`--pack-root ${there}\` derives from the tree${rail}, ` +
525
+ "`--pack-root auto` from the installed copy"
526
+ );
527
+ }
528
+ const mine = here.value;
529
+ const other = beneath.value;
530
+
531
+ // **Containment, computed rather than assumed.** The sharp case — one copy inside the plugin
532
+ // root and one outside — is what makes `--write` destructive, but it is not a law: a
533
+ // `--plugin-root` pointing elsewhere puts both outside, and the refusal still stands because
534
+ // the derived path is relative to whichever root answered. Asserting the sharp case
535
+ // unconditionally would be this message's first lie, in the sentence that exists to be exact.
536
+ //
537
+ // Tested against the ROOTS, which is also what `packPortion` partitions on — `resolvePack`
538
+ // builds `dir` as `<root>/<category>/<pack>`, so the two can never disagree here, and using the
539
+ // root keeps this predicate spelled the same way as the derivation it is predicting.
540
+ const differs = packDifferences(mine, other);
541
+ const split = escapes(pluginRoot, path.resolve(found.root)) !== escapes(pluginRoot, path.resolve(behind.root));
542
+ // **Both ROOTS by path — the roots, not the pack directories.** The sibling refusal in
543
+ // `compile` shipped `dir` while calling it a root, which mislabels the thing it names in a
544
+ // message whose own claim is to name both; the root is also the half a reader types back into
545
+ // `--pack-root`. Corrected here in the same stroke rather than left to be found twice.
546
+ return (
547
+ `the pack \`${found.name}\` is SHADOWED — it resolved under ${found.root}, a root discovered on this ` +
548
+ `host, while the root ${there} also carries it. ` +
549
+ (differs.length
550
+ ? `They differ by ${differs.join(" and ")}, and which root answers decides the derived set anyway: `
551
+ : "Their manifests agree, and here that changes nothing: ") +
552
+ (split
553
+ ? "the two sit on opposite sides of this plugin root, so one of them derives a `skills` path for " +
554
+ "this pack and the other derives none — `--write` would act on whichever answered. "
555
+ : "the two roots derive different `skills` paths, and `--write` would act on whichever answered. ") +
556
+ `Refusing to pick: name the root instead — \`--pack-root ${there}\` derives from the tree${rail}, ` +
557
+ "and `--pack-root auto` from the installed copy"
558
+ );
559
+ }
560
+ return null;
561
+ }
562
+
428
563
  /**
429
564
  * A resolver over a workspace's declared pack roots.
430
565
  *
@@ -545,8 +680,11 @@ function readManifest(file, pluginRoot) {
545
680
  * The runnable surface: the printer CI and an adopter's own pipeline call, plus `--check` and
546
681
  * `--write`.
547
682
  *
548
- * `options.manifest` and `options.resolve` are injection points for the suite, the way `init` injects
549
- * its reader. No production caller passes either; every flag below is the real surface.
683
+ * `options.manifest`, `options.resolve` and `options.discovery` are injection points for the suite, the
684
+ * way `init` injects its reader. No production caller passes any of them; every flag below is the real
685
+ * surface. `discovery` is injected as a THUNK like the production one, because the guarantee that a
686
+ * branch which cannot use the answer never touches the host is a property of the ARGUMENT — a suite
687
+ * handed a pre-computed value would be testing a shape production never takes.
550
688
  */
551
689
  export function run(argv = [], options = {}) {
552
690
  const { stdout = process.stdout, stderr = process.stderr } = options;
@@ -623,7 +761,7 @@ export function run(argv = [], options = {}) {
623
761
  // caller wiring no thunk stays hermetic on every arm.)_
624
762
  const plan = rootPlan(workspaceDir, manifest, {
625
763
  named: namedRoots,
626
- discovery: () => discoverPackRoots(),
764
+ discovery: options.discovery ?? (() => discoverPackRoots()),
627
765
  forced: wantsDiscovery,
628
766
  });
629
767
  if (plan.refusal || plan.couldNotRun) {
@@ -631,6 +769,30 @@ export function run(argv = [], options = {}) {
631
769
  return 2;
632
770
  }
633
771
  const roots = plan.roots ?? [];
772
+ // **Refused here, above the mode branches, so the printer, `--check` and `--write` inherit one
773
+ // rule** (#317). The damage this prevents is specific to the write path — the tool printed
774
+ // `run with --write` while `--write` would have stripped a correct, tracked `skills` entry on
775
+ // the strength of what happens to be installed on the machine — but guarding only `--write`
776
+ // would leave `--check` exiting 1 about a repository that had not drifted, which is a false
777
+ // verdict handed to a machine. `compile` settled the same question the same way for #316.
778
+ //
779
+ // A caller that injected its own `resolve` never reaches this, and that is deliberate rather
780
+ // than a hole: it chose its own roots, which is the same election `--pack-root` makes.
781
+ //
782
+ // **The `named` half of this condition is defence in depth, and it was measured HERE rather
783
+ // than inherited from `compile`'s note.** Forcing the whole condition to `true` reds exactly
784
+ // one case — the `--pack-root auto` exemption — while the named-root case goes on passing: a
785
+ // named root REPLACES the derived one, so the plan carries no discovered root, `shadowedCopy`
786
+ // finds nothing behind it, and there is no shadow to refuse. The protection is structural, and
787
+ // no test in this suite binds the clause. It stays because it states the intent at the site
788
+ // where a future change to `resolutionRoots` could quietly make it load-bearing.
789
+ if (!wantsDiscovery && plan.source !== "named") {
790
+ const shadowed = shadowRefusal({ packs: manifest?.packs, roots, plan, repoRoot, pluginRoot });
791
+ if (shadowed) {
792
+ stderr.write(`skills-set: ${shadowed}\n`);
793
+ return 2;
794
+ }
795
+ }
634
796
  resolve = resolverFor({ workspaceDir, manifest, roots });
635
797
  // The same roots stand in as the owned set when nothing resolves — so a workspace that has
636
798
  // stopped composing gets its stale entries cleaned wherever its packs WOULD have been, not
package/cli/stop-gate.mjs CHANGED
@@ -43,10 +43,126 @@ import { recipeSet } from "./recipe-set.mjs";
43
43
  // The project root is TOLD to this runner rather than derived from where this file sits — see the same
44
44
  // paragraph in ./gate.mjs for why. In short: this file used to live inside the workspace it was reading,
45
45
  // which is the one layout it could ever work in, and is why it shipped in no artifact an adopter received.
46
+ //
47
+ // **These stay the TOLD values, and since #220's second arm they are no longer the whole answer.** The
48
+ // tree a stop is ABOUT is resolved per invocation by `resolveSessionTree` from the payload's `cwd`,
49
+ // because the told root is the repository this hook governs and not necessarily the working tree the
50
+ // session used. Three things deliberately keep reading the told values whatever the session's cwd is:
51
+ // the refusal COUNTER (a resolution that flips mid-session would rename its file and reset spent
52
+ // counts — a cap weakening nobody chose), and both halves of the recipe — `defaultRecipe()` and the
53
+ // `cwd` its command runs in. That last one is not a nicety: the gate executes the manifest's `run`
54
+ // through `bash -c`, so a recipe read from a workspace found under a payload-supplied path would be
55
+ // arbitrary command execution seeded by stdin. The recipe's jurisdiction is the workspace this hook
56
+ // was wired into.
46
57
  const PROJECT = process.env.CLAUDE_PROJECT_DIR || process.cwd();
47
58
  const WORKSPACE = path.resolve(PROJECT, process.env.PORTULAN_WORKSPACE || ".portulan");
48
59
  const REPO = PROJECT;
49
60
 
61
+ /**
62
+ * WHICH TREE this stop is about — the session's own, or the one the hook was told.
63
+ *
64
+ * The told root is what the hook is wired with (`${CLAUDE_PROJECT_DIR}` in `.claude/settings.json`).
65
+ * It is not always the tree the session worked in: a session running in a git worktree, or simply
66
+ * `cd`-ed elsewhere, has both, and #220's second arm is that the gate answered about the told one.
67
+ * **The failure that arm exists for is a SILENT ALLOW** — told tree clean, session tree carrying
68
+ * unrecorded work — because `didWork()` reads the clean tree, returns false, and nothing is printed.
69
+ * The first arm's naming sentences cannot reach it: they live inside the block path.
70
+ *
71
+ * `payload.cwd` is the datum. Measured on this host, a real Stop payload carries
72
+ * `session_id, transcript_path, cwd, prompt_id, permission_mode, effort, hook_event_name,
73
+ * stop_hook_active, last_assistant_message, background_tasks, session_crons` — recorded because the
74
+ * record held no measurement of this payload's shape at all, and the whole arm rests on one field.
75
+ *
76
+ * **THE SAME-REPOSITORY GUARD, which is the difference between a fix and a bypass.** `cwd` is the one
77
+ * input the gated agent can steer, so a naive rescope would let a session point the gate at a clean
78
+ * unrelated clone and be allowed in silence — the very defect, one remove away. A foreign tree's
79
+ * obligations are also unsatisfiable, which is the shape that gets a rail switched off rather than
80
+ * fixed. So the session tree answers only when it is the SAME repository, compared by
81
+ * `--git-common-dir` and through `realpathSync`: worktrees share their common dir with the origin
82
+ * repository, which is exactly the class this arm exists for, while a foreign clone does not.
83
+ * Compared resolved, because macOS symlinks `/tmp` to `/private/tmp` and raw strings would disagree
84
+ * about identical directories.
85
+ *
86
+ * **What the same-repository condition does NOT close, said plainly because the sentences around it
87
+ * could be read as claiming otherwise.** It stops a foreign tree answering; it does not stop steering
88
+ * *within* this repository. A session whose told root is dirty and unrecorded can name a clean,
89
+ * recorded sibling worktree in `cwd` and be allowed — measured. That is narrower than the hole this
90
+ * arm closes and is not closable from here: the gate is told where the session ENDED, never where it
91
+ * worked, and a `cwd` inside this repository is exactly the datum this arm exists to trust.
92
+ * **Pinned** — as a recorded gap, not a wanted property — by *THE KNOWN HOLE (#307)* in
93
+ * `./stop-gate.test.mjs`, whose comment carries the contract for closing it.
94
+ *
95
+ * Returns `{ root, workspace, origin, note }`. `note` is a sentence to print when the datum was
96
+ * offered and could not be used; it is **null when `cwd` is simply absent**, because a host that
97
+ * never offered the field has degraded nothing and deserves today's behaviour byte for byte.
98
+ */
99
+ function resolveSessionTree(cwd, told = { root: REPO, workspace: WORKSPACE }) {
100
+ const fell = (note) => ({ ...told, origin: "told", note });
101
+ if (typeof cwd !== "string" || cwd.trim() === "") return { ...told, origin: "told", note: null };
102
+
103
+ const git = (dir, args) => execFileSync("git", ["-C", dir, ...args], {
104
+ encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "pipe"],
105
+ }).trim();
106
+
107
+ let root;
108
+ try {
109
+ root = git(cwd, ["rev-parse", "--show-toplevel"]);
110
+ } catch {
111
+ // Gone, or not a repository. Both exit 128; neither is a tree this gate can ask about.
112
+ return fell(`the session's directory (${cwd}) is not inside a git repository`);
113
+ }
114
+ if (!root) return fell(`the session's directory (${cwd}) yielded no repository root`);
115
+
116
+ // Same repository? Worktrees share a common dir with their origin; a foreign clone does not.
117
+ // **`--path-format` needs git 2.31, so it is TRIED, not required.** Where it is missing the flag
118
+ // is fatal, and without a fallback this comparison would throw on every stop — sending a correct
119
+ // worktree down the degraded path with a stderr sentence, on an old git, forever. The arm would
120
+ // read as shipped and do nothing, which is the shape this repository has been bitten by before.
121
+ // The bare spelling answers too, but RELATIVELY at a repository root (measured on git 2.50.1:
122
+ // `.git` there, absolute from a subdirectory or a linked worktree), so it is resolved against the
123
+ // directory that was asked. Copilot, round 2.
124
+ const commonDir = (dir) => {
125
+ let raw;
126
+ try {
127
+ raw = git(dir, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
128
+ } catch {
129
+ raw = path.resolve(dir, git(dir, ["rev-parse", "--git-common-dir"]));
130
+ }
131
+ try {
132
+ return fs.realpathSync(raw);
133
+ } catch {
134
+ return raw;
135
+ }
136
+ };
137
+ let mine, theirs;
138
+ try {
139
+ mine = commonDir(told.root);
140
+ theirs = commonDir(root);
141
+ } catch {
142
+ return fell("this repository's identity could not be compared with the session's");
143
+ }
144
+ if (mine !== theirs) {
145
+ return fell(
146
+ `the session's directory (${cwd}) is in a different repository from the one this hook governs — ` +
147
+ "its obligations are not this repository's to enforce",
148
+ );
149
+ }
150
+
151
+ let resolved = root;
152
+ try {
153
+ resolved = fs.realpathSync(root);
154
+ } catch { /* the toplevel git just printed; keep it */ }
155
+ return {
156
+ root: resolved,
157
+ // Recomputed against the session tree, never reused from the told root, or the handoff
158
+ // question is asked correctly in the wrong directory. `path.resolve` leaves an ABSOLUTE
159
+ // `PORTULAN_WORKSPACE` absolute, so a workspace named absolutely deliberately does not travel.
160
+ workspace: path.resolve(resolved, process.env.PORTULAN_WORKSPACE || ".portulan"),
161
+ origin: "session",
162
+ note: null,
163
+ };
164
+ }
165
+
50
166
  // The independent reasons this gate refuses a stop for. Each keeps its own consecutive count.
51
167
  //
52
168
  // Named as data rather than left implicit in the code, because the counters, the reset rule and the
@@ -236,14 +352,14 @@ function defaultRecipe() {
236
352
  * handoff gate while local commits sat there unrecorded.** A fail-open in the gate, and a quiet one.
237
353
  * Found by review on the pull request.
238
354
  */
239
- function didWork() {
355
+ function didWork(root = REPO) {
240
356
  // `maxBuffer` raised from node's 1 MiB default **for `git cherry`, which cannot be bounded the way
241
357
  // the emptiness probe below can**: it must print a line per compared commit to be read at all,
242
358
  // where the probe only has to answer yes-or-no and so is asked with `--max-count=1`. An overflow
243
359
  // here throws into the outer catch and reads as *cannot tell* on precisely the branches that carry
244
360
  // the most commits. This raise moves that cliff rather than removing it — every buffer is finite —
245
361
  // which is why the probe is bounded instead of merely given more room.
246
- const git = (args) => execFileSync("git", args, { cwd: REPO, encoding: "utf8", timeout: 10_000, maxBuffer: 64 * 1024 * 1024 });
362
+ const git = (args) => execFileSync("git", args, { cwd: root, encoding: "utf8", timeout: 10_000, maxBuffer: 64 * 1024 * 1024 });
247
363
 
248
364
  // 1. Uncommitted changes. Portable, and the strongest signal there is.
249
365
  try {
@@ -387,9 +503,9 @@ function didWork() {
387
503
  }
388
504
 
389
505
  /** Is there a handoff dated today? The doctrine's checkable form: existence and a date, never length. */
390
- function handoffToday(stamp) {
506
+ function handoffToday(stamp, workspace = WORKSPACE) {
391
507
  try {
392
- return fs.readdirSync(path.join(WORKSPACE, "handoffs")).some((f) => f.startsWith(stamp) && f.endsWith(".md"));
508
+ return fs.readdirSync(path.join(workspace, "handoffs")).some((f) => f.startsWith(stamp) && f.endsWith(".md"));
393
509
  } catch {
394
510
  return false;
395
511
  }
@@ -405,16 +521,19 @@ function handoffToday(stamp) {
405
521
  * worktree had been removed had its cwd fall back to the origin repository, parked on an unrelated
406
522
  * branch 84 commits behind, and was told *"no handoff dated 2026-08-10"* while five sat on `main`.
407
523
  *
408
- * **This names the tree rather than rescoping the gate to it, and that is a choice with a cost.**
409
- * #220 offers rescoping as the alternative and it is the more thorough repair — but the tree a
410
- * removed worktree wrote to no longer exists to be read, so rescoping cannot answer the very
411
- * incident that produced the issue. What a reader needed, and did not get, was to see that the
412
- * sentence was true about a tree they were not thinking of.
524
+ * **Since #220's second arm the gate DOES rescope, and this names whichever tree answered.** An
525
+ * earlier version of this paragraph argued that naming was sufficient because a removed worktree
526
+ * cannot be read. That held for the one stop after the removal and failed as an argument about the
527
+ * arm: for the whole of that session's working phase both trees existed and differed, so every stop
528
+ * before the removal was already answering about the wrong one. The removal exposed the divergence
529
+ * rather than creating it. `resolveSessionTree` above now picks the session's tree from the Stop
530
+ * payload's `cwd` when it belongs to this repository, and falls back here — told root, named — only
531
+ * where it cannot.
413
532
  */
414
- function treeIdentity() {
533
+ function treeIdentity(root = REPO) {
415
534
  try {
416
535
  const run = (args) => execFileSync("git", args, {
417
- cwd: REPO, encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "pipe"],
536
+ cwd: root, encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "pipe"],
418
537
  }).trim();
419
538
  const branch = run(["rev-parse", "--abbrev-ref", "HEAD"]);
420
539
  // **A DETACHED HEAD is the common case here, not the exotic one.** `--abbrev-ref` answers the
@@ -422,10 +541,10 @@ function treeIdentity() {
422
541
  // detached worktrees at once — measured: four, on the machine this was written on. Printing
423
542
  // *"on `HEAD`"* would name a branch that does not exist, in the very sentence added so a reader
424
543
  // could identify the tree. The commit is what identifies a detached one. Copilot, round 3.
425
- if (branch === "HEAD") return `${REPO} (detached at \`${run(["rev-parse", "--short", "HEAD"])}\`)`;
426
- return `${REPO} (on \`${branch}\`)`;
544
+ if (branch === "HEAD") return `${root} (detached at \`${run(["rev-parse", "--short", "HEAD"])}\`)`;
545
+ return `${root} (on \`${branch}\`)`;
427
546
  } catch {
428
- return REPO;
547
+ return root;
429
548
  }
430
549
  }
431
550
 
@@ -438,17 +557,20 @@ function treeIdentity() {
438
557
  * remote-tracking ones. The sentence it feeds must not overclaim: *absent from this working tree* is
439
558
  * always true when it fires; *present at `<ref>`* is claimed only where some ref on disk shows one.
440
559
  */
441
- function handoffInHistory(stamp) {
560
+ function handoffInHistory(stamp, tree = { root: REPO, workspace: WORKSPACE }) {
561
+ // **Root and workspace arrive as ONE pair, never mixed.** A told root beside a session workspace
562
+ // makes the relative path below start with `..`, the guard returns null, and the elsewhere arm
563
+ // dies in silence — the same both-must-move-together rule the caller obeys, inside this function.
442
564
  try {
443
565
  // **Separators normalised to `/`, because this becomes a git PATHSPEC.** `path.relative` yields
444
566
  // `\` on Windows and git would match nothing — and the failure is silent: this returns null,
445
567
  // the refusal loses the half that tells a reader their handoff is already recorded, and nothing
446
568
  // says why. The same spelling is used wherever this repository hands git a derived path
447
569
  // (`./librarian.mjs`, `./compile.mjs`). Copilot, round 3.
448
- const dir = path.relative(REPO, path.join(WORKSPACE, "handoffs")).split(path.sep).join("/");
570
+ const dir = path.relative(tree.root, path.join(tree.workspace, "handoffs")).split(path.sep).join("/");
449
571
  // A workspace outside the repository is not a question git can answer about this history.
450
572
  if (dir === "" || dir.startsWith("..") || path.isAbsolute(dir)) return null;
451
- const git = (args) => execFileSync("git", args, { cwd: REPO, encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "pipe"] });
573
+ const git = (args) => execFileSync("git", args, { cwd: tree.root, encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "pipe"] });
452
574
  // `--all` is every ref already on disk, local and remote-tracking. `-1` because existence is
453
575
  // the whole question — this is the doctrine's checkable form, not a census.
454
576
  // **`:(glob)` magic, because a bare `*` is not reliably a glob.** With `GIT_NOGLOB_PATHSPECS`
@@ -477,7 +599,7 @@ function handoffInHistory(stamp) {
477
599
  * The two are reported separately because the consecutive-cap reset keys off the recipe alone, while
478
600
  * whether to block keys off the whole set.
479
601
  */
480
- function collectProblems() {
602
+ function collectProblems(tree = { root: REPO, workspace: WORKSPACE, origin: "told", note: null }) {
481
603
  /** Every problem carries the REASON it belongs to — the key its own counter is kept under. */
482
604
  const problems = [];
483
605
  let recipeGreen = false;
@@ -522,7 +644,14 @@ function collectProblems() {
522
644
  const outcome = CANNOT_RUN.has(code) || code === undefined || code === null
523
645
  ? `could not run (exit ${code ?? "no status"}) — the gate could not judge`
524
646
  : `RED (exit ${code})`;
525
- problems.push({ reason: "recipe", text: `verify recipe \`${recipe.id}\` — ${outcome}\n${output}` });
647
+ // The tree is named here too, now that one verdict can span two: the recipe is always run
648
+ // in the TOLD root (see the header) while the handoff question may have been answered
649
+ // about the session's. Without this, two problems in one refusal would read as though
650
+ // both concerned the same tree. Raised as optional at the session-open checkpoint; taken.
651
+ problems.push({
652
+ reason: "recipe",
653
+ text: `verify recipe \`${recipe.id}\` in ${REPO} — ${outcome}\n${output}`,
654
+ });
526
655
  }
527
656
  }
528
657
 
@@ -533,15 +662,38 @@ function collectProblems() {
533
662
  // `today()`'s own header records that this file has already produced one false red from a date
534
663
  // disagreement; two dates inside a single verdict is the same class. Copilot, round 3.
535
664
  const stamp = today();
536
- const handoffPresent = handoffToday(stamp);
665
+ const handoffPresent = handoffToday(stamp, tree.workspace);
537
666
  // **`!handoffPresent` first, and the order is load-bearing rather than stylistic.** `didWork()`
538
667
  // shells out to git several times and can print the could-not-compare sentence; ordered the other
539
668
  // way, a session that HAS written its handoff still paid that cost on every Stop event and could
540
669
  // be handed a degradation warning about an obligation it does not owe. Copilot, round 1.
541
- if (!handoffPresent && didWork()) {
670
+ if (!handoffPresent && didWork(tree.root)) {
542
671
  // The tree is NAMED, because the sentence was once true about a tree the reader was not
543
672
  // thinking of and read as a verdict about their session (#220).
544
- const elsewhere = handoffInHistory(stamp);
673
+ //
674
+ // **A REFUSAL names both trees where they differ**, because a reader looking at the hook's
675
+ // configured root would otherwise not find the tree this verdict came from. Only on a refusal:
676
+ // told ≠ session is the routine worktree case here, so a divergence line on every green stop
677
+ // would be a metronome, and this gate's sentences are worth reading precisely because they are
678
+ // rare (#220 second arm).
679
+ // **Both sides resolved through `realpathSync`, or this sentence invents a divergence.**
680
+ // `resolveSessionTree` returns a realpathed root, so comparing it against a merely
681
+ // `path.resolve`d `REPO` makes a symlinked told root — `/tmp` on macOS, or any convenience
682
+ // link — read as a second tree, and the refusal then names one directory twice as though the
683
+ // session had worked somewhere else. Measured. It is the same trap the same-repository guard
684
+ // above already defends against, left unapplied one comparison away, which is why the guard's
685
+ // own reasoning is repeated here rather than assumed to carry.
686
+ const real = (dir) => {
687
+ try {
688
+ return fs.realpathSync(dir);
689
+ } catch {
690
+ return path.resolve(dir);
691
+ }
692
+ };
693
+ const answeredElsewhere = tree.origin === "session" && real(tree.root) !== real(REPO)
694
+ ? ` — the tree this session worked in, not the one this hook was told (${REPO})`
695
+ : "";
696
+ const elsewhere = handoffInHistory(stamp, tree);
545
697
  const found = elsewhere
546
698
  ? ` One dated ${stamp} does exist elsewhere in this repository's refs, at ` +
547
699
  `${elsewhere.commit}${elsewhere.ref ? ` on \`${elsewhere.ref}\`` : ""} — so this working tree may not be ` +
@@ -551,7 +703,8 @@ function collectProblems() {
551
703
  problems.push({
552
704
  reason: "handoff",
553
705
  text:
554
- `no handoff dated ${stamp} in ${path.join(WORKSPACE, "handoffs")}, read from ${treeIdentity()}. ` +
706
+ `no handoff dated ${stamp} in ${path.join(tree.workspace, "handoffs")}, read from ` +
707
+ `${treeIdentity(tree.root)}${answeredElsewhere}. ` +
555
708
  "Every session ends with a dated handoff — five lines is enough, absent is not. The Session log " +
556
709
  "records what landed; the handoff records why, and the why is the part the next session cannot " +
557
710
  `reconstruct from the diff.${found}`,
@@ -622,7 +775,24 @@ function main() {
622
775
  }
623
776
 
624
777
  const sessionId = payload.session_id ?? "unknown";
625
- const { problems, recipeGreen, handoffPresent } = collectProblems();
778
+
779
+ // **Resolved ONCE, here, and threaded down** — so every question this stop asks is about the same
780
+ // tree, and the sentence that reports the answer names that tree rather than another one.
781
+ const tree = resolveSessionTree(payload.cwd);
782
+
783
+ // **Degradation is spoken when the datum was offered and could not be used, and silent when it was
784
+ // never offered.** A host that sends no `cwd` has degraded nothing: it gets today's behaviour byte
785
+ // for byte, which is also what makes every pre-existing case a control for this change. A host that
786
+ // sent one this gate could not use is a different fact, and one a reader needs — printed whatever
787
+ // the verdict turns out to be, because it explains an allow just as much as a refusal.
788
+ //
789
+ // **The honest residue: on this path the silent-allow gap of #220 REMAINS, by construction.** The
790
+ // told root answers, and if the session's real tree carries unrecorded work this gate cannot see
791
+ // it. Forcing a block instead would manufacture an obligation from no evidence and leave the
792
+ // removed-worktree case permanently blocking — the false red the first arm was built to remove.
793
+ if (tree.note) process.stderr.write(`portulan stop-gate: ${tree.note}; answering about ${REPO} instead.\n`);
794
+
795
+ const { problems, recipeGreen, handoffPresent } = collectProblems(tree);
626
796
 
627
797
  // A reason's condition clearing ends THAT reason's futile-retry episode, whether or not the stop
628
798
  // is allowed, and never any other reason's. Done before the bump so a cleared reason cannot be