@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.
- package/README.md +15 -18
- package/cli/README.md +88 -125
- package/cli/compile.mjs +242 -3
- package/cli/doctor.mjs +54 -1
- package/cli/index.mjs +92 -42
- package/cli/inside.mjs +38 -0
- package/cli/recipe-set.mjs +42 -1
- package/cli/skills-set.mjs +166 -4
- package/cli/stop-gate.mjs +194 -24
- 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/recipe-set.mjs
CHANGED
|
@@ -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
|
-
|
|
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 —
|
package/cli/skills-set.mjs
CHANGED
|
@@ -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.
|
|
549
|
-
* its reader. No production caller passes
|
|
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:
|
|
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(
|
|
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
|
-
* **
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
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:
|
|
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 `${
|
|
426
|
-
return `${
|
|
544
|
+
if (branch === "HEAD") return `${root} (detached at \`${run(["rev-parse", "--short", "HEAD"])}\`)`;
|
|
545
|
+
return `${root} (on \`${branch}\`)`;
|
|
427
546
|
} catch {
|
|
428
|
-
return
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|