@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/README.md +137 -50
- package/cli/README.md +91 -120
- package/cli/compile.mjs +242 -3
- package/cli/doctor.mjs +54 -1
- package/cli/index.mjs +92 -42
- package/cli/init.mjs +3 -2
- package/cli/inside.mjs +38 -0
- package/cli/pack-identity.mjs +180 -0
- package/cli/recipe-set.mjs +42 -1
- package/cli/skills-set.mjs +166 -4
- package/cli/stop-gate.mjs +401 -16
- package/cli/upgrade.mjs +2 -3
- package/cli/version-carriers.mjs +211 -0
- package/core/operating/memory.md +7 -8
- package/core/operating/verification.md +4 -12
- package/package.json +1 -1
- package/packs/README.md +11 -21
- package/packs/tools/README.md +1 -4
- package/spec/README.md +19 -22
- package/spec/migrations/README.md +2 -3
- package/spec/slots.md +4 -9
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
|
-
|
|
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
|
|
260
|
-
//
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
336
|
-
|
|
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 ${
|
|
341
|
-
|
|
342
|
-
"
|
|
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
|
-
|
|
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
|
|
12
|
-
//
|
|
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
|