@sleepy_panda_srl/portulan 0.1.0 → 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.
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,8 +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() {
240
- const git = (args) => execFileSync("git", args, { cwd: REPO, encoding: "utf8", timeout: 10_000 });
355
+ function didWork(root = REPO) {
356
+ // `maxBuffer` raised from node's 1 MiB default **for `git cherry`, which cannot be bounded the way
357
+ // the emptiness probe below can**: it must print a line per compared commit to be read at all,
358
+ // where the probe only has to answer yes-or-no and so is asked with `--max-count=1`. An overflow
359
+ // here throws into the outer catch and reads as *cannot tell* on precisely the branches that carry
360
+ // the most commits. This raise moves that cliff rather than removing it — every buffer is finite —
361
+ // which is why the probe is bounded instead of merely given more room.
362
+ const git = (args) => execFileSync("git", args, { cwd: root, encoding: "utf8", timeout: 10_000, maxBuffer: 64 * 1024 * 1024 });
241
363
 
242
364
  // 1. Uncommitted changes. Portable, and the strongest signal there is.
243
365
  try {
@@ -256,10 +378,122 @@ function didWork() {
256
378
  // No upstream configured. Ordinary for a branch that has never been pushed — fall through.
257
379
  }
258
380
 
259
- // 3. Commits that are on no remote at all. Works with no upstream, and with no remote either —
260
- // in a repository with no remotes every commit qualifies, which is the correct reading there.
381
+ // 3. Commits whose PATCHES are on no remote — compared by patch-id, never by reachability.
382
+ //
383
+ // **This repository rebase-merges, and that makes reachability the wrong question** (#220). A
384
+ // rebase-merge rewrites commits, so a merged branch's originals appear on no remote; once the
385
+ // remote branch is deleted on merge they never will. `HEAD --not --remotes` therefore answers
386
+ // *yes, there is unrecorded work* **permanently**, for every commit of every merged-and-deleted
387
+ // branch — and any checkout left on such a branch demanded a handoff from a session that had
388
+ // done nothing. Measured 2026-08-10: the gate blocked twice over a handoff that was already on
389
+ // `main`, on the strength of four commits `git cherry` showed as fully upstream.
390
+ //
391
+ // The class is named in this repository for a different tool —
392
+ // `../.portulan/memory/a-branch-syncs-with-main-before-it-merges.md`, *"a rebase-merge leaves the
393
+ // branch tip an ancestor of nothing"* — and the sanctioned test there is `git cherry`:
394
+ // comparison by patch-id. This is that record applied at the site that asked the same wrong
395
+ // question.
261
396
  try {
262
- return git(["log", "--oneline", "HEAD", "--not", "--remotes"]).trim() !== "";
397
+ // The coarse reading first, because it is cheap and it BOUNDS the expensive one: nothing on
398
+ // no remote by reachability means nothing on no remote by patch either, since a rewritten
399
+ // commit is still a commit that reachability would have found.
400
+ //
401
+ // **`rev-list --max-count=1`, not `log --oneline`, and the difference is a fail-open.** This
402
+ // asks only whether the set is EMPTY, but `log --oneline` answers by printing one line per
403
+ // commit — and a long-lived checkout on a rebase-merged branch is exactly where that set gets
404
+ // large, which is the situation this whole signal exists for. Past whatever `maxBuffer` is in
405
+ // force, `execFileSync` throws `ENOBUFS` (measured against node's 1 MiB default, which is what
406
+ // this ran on when the defect was found), the outer catch below turns that into `return false`,
407
+ // and the handoff check disables itself on the repositories most likely to need it.
408
+ //
409
+ // **The raised `maxBuffer` above does not make this redundant, and the order matters.** That
410
+ // raise moves the cliff; it does not remove one, because every buffer is finite and the set
411
+ // this probe asks about has no bound. Bounding the QUESTION is what removes the failure: one
412
+ // hash cannot outgrow any buffer. The raise is defence in depth for `git cherry`, which must
413
+ // print a line per compared commit and so cannot be bounded the same way. Copilot, round 3;
414
+ // the wording corrected in the same round after the raise made the original rationale stale.
415
+ if (git(["rev-list", "--max-count=1", "HEAD", "--not", "--remotes"]).trim() === "") return false;
416
+
417
+ // No remote at all: every commit qualifies, which is the correct reading there and is what
418
+ // this signal has always said. Documented rather than degraded, so it reports nothing.
419
+ const remotes = git(["remote"]).trim();
420
+ if (remotes === "") return true;
421
+
422
+ // A base to compare patches against: the remote's own recorded default head, never a branch
423
+ // picked by name. `./init.mjs`'s `delete-a-remote-branch` reason settles that — *"a wrong base
424
+ // answers no-unique-work about a question nobody asked"* — and `main` is not a name this file
425
+ // may assume, for the same reason signal 2 above stopped hard-coding `origin/main`.
426
+ // **ONE ordering, used by both the base selection and the sentence that reports its failure.**
427
+ // `git remote` lists alphabetically, so a repository carrying `backup` beside `origin` offered
428
+ // `backup/HEAD` first — and this loop takes the first that resolves, which means comparing
429
+ // patch-ids against a non-canonical remote and reaching a wrong allow/block, not merely a
430
+ // clumsy message. `origin` is the conventional canonical remote and is preferred where it
431
+ // exists. Written as one list rather than two matching literals because the first version of
432
+ // this preferred `origin` in the DEGRADED SENTENCE only, leaving the verdict itself on the
433
+ // alphabetical remote — a fix that repaired the report of a defect and not the defect.
434
+ // Copilot, round 3.
435
+ let base = null;
436
+ const listed = remotes.split("\n").filter(Boolean);
437
+ const named = listed.includes("origin") ? ["origin", ...listed.filter((r) => r !== "origin")] : listed;
438
+ for (const remote of named) {
439
+ try {
440
+ base = git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", `${remote}/HEAD`]).trim() || null;
441
+ if (base) break;
442
+ } catch {
443
+ // This remote records no default head. Try the next one.
444
+ }
445
+ }
446
+
447
+ // **The refinement failed, so the coarse reading stands — and it stands as TRUE.**
448
+ //
449
+ // This is the fail-CLOSED direction and it is chosen rather than defaulted into. Commits on
450
+ // no remote is an established fact by the time we are here; only the attempt to excuse them
451
+ // as already-upstream could not be made. Reading that as *no work* would newly convert a
452
+ // case that blocks today into a pass — a relaxation, and this gate's own refusal message
453
+ // says relaxing a check is the change to scrutinise hardest. The asymmetry decides it: a
454
+ // wrong block is capped at MAX_BLOCKS and speaks, a wrong pass is unbounded and silent.
455
+ // `../.portulan/memory/verify-preconditions-fail-closed.md` is cited in this file's header
456
+ // for exactly this direction.
457
+ // The advice NAMES A COMMAND THE READER CAN RUN. The first draft always said
458
+ // `git cherry <remote>/HEAD HEAD` — but the commonest way into this branch is that
459
+ // `<remote>/HEAD` does not resolve, so the suggested check failed for the very reason the
460
+ // refinement did. Telling someone to re-run the thing that just broke is a diagnosis-shaped
461
+ // sentence carrying no diagnosis. Copilot, round 1.
462
+ const degraded = (why, check) => {
463
+ process.stderr.write(
464
+ `portulan stop-gate: could not compare by patch-id (${why}), so commits on no remote are ` +
465
+ "being read as work. If this branch was rebase-merged the handoff demand may be spurious — " +
466
+ `${check} and say so rather than working around the gate.\n`,
467
+ );
468
+ return true;
469
+ };
470
+ if (!base) {
471
+ // **The remotes are NAMED, because this runner already knows them** — it just tried every
472
+ // one. A `<remote>` placeholder in a multi-remote repository invites the reader to run the
473
+ // repair against whichever they think of first, which is how a diagnosis sends someone to
474
+ // the wrong ref. Copilot, round 3. `named` is already ordered `origin`-first above, so the
475
+ // remote this repairs is the same one the loop would have compared against — the two
476
+ // cannot disagree, which is why the ordering is computed once.
477
+ const which = named[0];
478
+ return degraded(
479
+ `no remote records a default head (tried \`${named.join("`, `")}\`)`,
480
+ `record one with \`git remote set-head ${which} -a\`, then check with ` +
481
+ `\`git cherry ${which}/HEAD HEAD\``,
482
+ );
483
+ }
484
+
485
+ // **Exit 0 AND zero `+` lines, both required.** `git cherry` against an unknown ref exits 128
486
+ // and prints NOTHING, so counting `+` alone reads a failed command as *nothing unmerged* —
487
+ // measured, and already recorded in `../.portulan/gate-map.md` and `./init.mjs`'s
488
+ // `delete-a-remote-branch` reason as a fail-open closed once before. The exit half is carried
489
+ // by `git()` itself, which throws on non-zero; the catch below is that half, not decoration.
490
+ let cherry;
491
+ try {
492
+ cherry = git(["cherry", base, "HEAD"]);
493
+ } catch {
494
+ return degraded(`\`git cherry ${base} HEAD\` could not run`, `check with \`git cherry ${base} HEAD\``);
495
+ }
496
+ return cherry.split("\n").some((line) => line.startsWith("+"));
263
497
  } catch {
264
498
  // Genuinely cannot tell. Say so rather than inventing an obligation OR silently dropping one:
265
499
  // a gate that disables itself without a word is the failure this whole runner exists against.
@@ -269,21 +503,103 @@ function didWork() {
269
503
  }
270
504
 
271
505
  /** Is there a handoff dated today? The doctrine's checkable form: existence and a date, never length. */
272
- function handoffToday() {
273
- const stamp = today();
506
+ function handoffToday(stamp, workspace = WORKSPACE) {
274
507
  try {
275
- 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"));
276
509
  } catch {
277
510
  return false;
278
511
  }
279
512
  }
280
513
 
514
+ /**
515
+ * WHICH TREE this gate just answered about — the working copy and the branch it is on.
516
+ *
517
+ * `handoffToday()` reads the directory under `WORKSPACE`, which is derived from `PROJECT`, which is
518
+ * whatever the host told this hook or whatever the cwd happens to be. That is deliberate — this file
519
+ * is TOLD its root rather than deriving one, for the reason its header gives — but it means the gate
520
+ * can ask a question about one tree and answer it from another. Measured 2026-08-10: a session whose
521
+ * worktree had been removed had its cwd fall back to the origin repository, parked on an unrelated
522
+ * branch 84 commits behind, and was told *"no handoff dated 2026-08-10"* while five sat on `main`.
523
+ *
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.
532
+ */
533
+ function treeIdentity(root = REPO) {
534
+ try {
535
+ const run = (args) => execFileSync("git", args, {
536
+ cwd: root, encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "pipe"],
537
+ }).trim();
538
+ const branch = run(["rev-parse", "--abbrev-ref", "HEAD"]);
539
+ // **A DETACHED HEAD is the common case here, not the exotic one.** `--abbrev-ref` answers the
540
+ // literal string `HEAD` when no branch is checked out, and this repository routinely has several
541
+ // detached worktrees at once — measured: four, on the machine this was written on. Printing
542
+ // *"on `HEAD`"* would name a branch that does not exist, in the very sentence added so a reader
543
+ // could identify the tree. The commit is what identifies a detached one. Copilot, round 3.
544
+ if (branch === "HEAD") return `${root} (detached at \`${run(["rev-parse", "--short", "HEAD"])}\`)`;
545
+ return `${root} (on \`${branch}\`)`;
546
+ } catch {
547
+ return root;
548
+ }
549
+ }
550
+
551
+ /**
552
+ * A handoff dated today somewhere in this repository's history — every ref already on disk — or null.
553
+ *
554
+ * **No network, deliberately.** A Stop hook runs on every attempt to end a turn, and a fetch would
555
+ * put a remote round trip — and an offline host's timeout — inside the gate. So this reads refs that
556
+ * are already on disk — `--all` is every ref this repository carries, local branches included, not only
557
+ * remote-tracking ones. The sentence it feeds must not overclaim: *absent from this working tree* is
558
+ * always true when it fires; *present at `<ref>`* is claimed only where some ref on disk shows one.
559
+ */
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.
564
+ try {
565
+ // **Separators normalised to `/`, because this becomes a git PATHSPEC.** `path.relative` yields
566
+ // `\` on Windows and git would match nothing — and the failure is silent: this returns null,
567
+ // the refusal loses the half that tells a reader their handoff is already recorded, and nothing
568
+ // says why. The same spelling is used wherever this repository hands git a derived path
569
+ // (`./librarian.mjs`, `./compile.mjs`). Copilot, round 3.
570
+ const dir = path.relative(tree.root, path.join(tree.workspace, "handoffs")).split(path.sep).join("/");
571
+ // A workspace outside the repository is not a question git can answer about this history.
572
+ if (dir === "" || dir.startsWith("..") || path.isAbsolute(dir)) return null;
573
+ const git = (args) => execFileSync("git", args, { cwd: tree.root, encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "pipe"] });
574
+ // `--all` is every ref already on disk, local and remote-tracking. `-1` because existence is
575
+ // the whole question — this is the doctrine's checkable form, not a census.
576
+ // **`:(glob)` magic, because a bare `*` is not reliably a glob.** With `GIT_NOGLOB_PATHSPECS`
577
+ // set, git reads `*` literally, this matches nothing, and the function returns null — silently
578
+ // reintroducing the exact "a handoff exists elsewhere and the gate does not say so" gap this
579
+ // arm exists to close. Measured on git 2.50.1: bare pattern matches 1 normally and **0** under
580
+ // `GIT_NOGLOB_PATHSPECS=1`; with `:(glob)` it matches 1 under both. _(The neighbouring lever
581
+ // `core.globPathspec=false` does NOT reproduce it — measured — so the magic is what makes this
582
+ // deterministic, not a config default.)_ Explicit pathspec magic is the same discipline
583
+ // `../.portulan/verify/plugin.sh` documents. Copilot, round 3.
584
+ const commit = git(["log", "--all", "-1", "--format=%H", "--", `:(glob)${dir}/${stamp}*`]).trim();
585
+ if (!commit) return null;
586
+ const refs = git(["branch", "--all", "--contains", commit, "--format=%(refname:short)"])
587
+ .split("\n").map((r) => r.trim()).filter(Boolean);
588
+ return { commit: commit.slice(0, 7), ref: refs[0] ?? null };
589
+ } catch {
590
+ // Cannot look. Say nothing rather than claim an absence — the refusal below is already
591
+ // correct without this sentence, and a false "nowhere in history either" would be worse
592
+ // than silence.
593
+ return null;
594
+ }
595
+ }
596
+
281
597
  /**
282
598
  * Everything this gate refuses a stop for, plus whether the governing recipe was observed GREEN.
283
599
  * The two are reported separately because the consecutive-cap reset keys off the recipe alone, while
284
600
  * whether to block keys off the whole set.
285
601
  */
286
- function collectProblems() {
602
+ function collectProblems(tree = { root: REPO, workspace: WORKSPACE, origin: "told", note: null }) {
287
603
  /** Every problem carries the REASON it belongs to — the key its own counter is kept under. */
288
604
  const problems = [];
289
605
  let recipeGreen = false;
@@ -328,18 +644,70 @@ function collectProblems() {
328
644
  const outcome = CANNOT_RUN.has(code) || code === undefined || code === null
329
645
  ? `could not run (exit ${code ?? "no status"}) — the gate could not judge`
330
646
  : `RED (exit ${code})`;
331
- 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
+ });
332
655
  }
333
656
  }
334
657
 
335
- const handoffPresent = handoffToday();
336
- if (didWork() && !handoffPresent) {
658
+ // **ONE date per Stop event, captured once and threaded through every use.** There were four
659
+ // independent `today()` calls on this path — the tree check, the history query, and both halves of
660
+ // the sentence. A stop spanning local midnight (or a host clock that jumps) could therefore CHECK
661
+ // one date and REPORT another, and worse, search history for a date nobody checked the tree for.
662
+ // `today()`'s own header records that this file has already produced one false red from a date
663
+ // disagreement; two dates inside a single verdict is the same class. Copilot, round 3.
664
+ const stamp = today();
665
+ const handoffPresent = handoffToday(stamp, tree.workspace);
666
+ // **`!handoffPresent` first, and the order is load-bearing rather than stylistic.** `didWork()`
667
+ // shells out to git several times and can print the could-not-compare sentence; ordered the other
668
+ // way, a session that HAS written its handoff still paid that cost on every Stop event and could
669
+ // be handed a degradation warning about an obligation it does not owe. Copilot, round 1.
670
+ if (!handoffPresent && didWork(tree.root)) {
671
+ // The tree is NAMED, because the sentence was once true about a tree the reader was not
672
+ // thinking of and read as a verdict about their session (#220).
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);
697
+ const found = elsewhere
698
+ ? ` One dated ${stamp} does exist elsewhere in this repository's refs, at ` +
699
+ `${elsewhere.commit}${elsewhere.ref ? ` on \`${elsewhere.ref}\`` : ""} — so this working tree may not be ` +
700
+ "the tree that did the work. Check before writing a second one: a duplicate handoff reds `docs.sh`'s " +
701
+ "record check, which is the trap the bare sentence used to set."
702
+ : "";
337
703
  problems.push({
338
704
  reason: "handoff",
339
705
  text:
340
- `no handoff dated ${today()} in .portulan/handoffs/. Every session ends with a dated handoff — ` +
341
- "five lines is enough, absent is not. The Session log records what landed; the handoff records why, " +
342
- "and the why is the part the next session cannot reconstruct from the diff.",
706
+ `no handoff dated ${stamp} in ${path.join(tree.workspace, "handoffs")}, read from ` +
707
+ `${treeIdentity(tree.root)}${answeredElsewhere}. ` +
708
+ "Every session ends with a dated handoff — five lines is enough, absent is not. The Session log " +
709
+ "records what landed; the handoff records why, and the why is the part the next session cannot " +
710
+ `reconstruct from the diff.${found}`,
343
711
  });
344
712
  }
345
713
 
@@ -407,7 +775,24 @@ function main() {
407
775
  }
408
776
 
409
777
  const sessionId = payload.session_id ?? "unknown";
410
- 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);
411
796
 
412
797
  // A reason's condition clearing ends THAT reason's futile-retry episode, whether or not the stop
413
798
  // is allowed, and never any other reason's. Done before the bump so a cleared reason cannot be
package/cli/upgrade.mjs CHANGED
@@ -8,9 +8,8 @@
8
8
  //
9
9
  // Not here. `spec/migrations/` is the contract — a directory of steps, each a module — because a
10
10
  // migration is part of the **Workspace Definition** rather than part of a tool, and `spec/` ships in
11
- // `package.json`'s `files` so it would reach an `npx` user if the package were published — publishing
12
- // is Gated and has not happened, so there is no such user today, and whether there ever is remains
13
- // open (#242). See `../.portulan/identity.md`. This file is the runner: it decides which
11
+ // `package.json`'s `files` so it reaches an `npx` user — the package was published 2026-08-18 at
12
+ // `0.1.0`, closing #242, so that user now exists. See `../.portulan/identity.md`. This file is the runner: it decides which
14
13
  // steps a workspace owes, applies them, and grades the result with the real validator.
15
14
  //
16
15
  // **Two kinds of step**, by the maintainer's ruling of 2026-08-12: a `version` step migrates a