Skip to content

Commit b0d1856

Browse files
committed
feat: a trigger may inject an operator-authored skills directory (issue #60)
`run.skillsDir` names a directory of skills on the WORKER host, in the same `<name>/SKILL.md` layout as `~/.pi/agent/skills`. Its contents are copied into that trigger's jobs and layered between the serviced repo's own `.pi/skills` and the deployment-wide overlay: repo > injected > overlay. Until now `run.flow` could only name a flow that already existed, committed to the target repo or baked into the overlay. So an operator could not run a flow against a repo that has not adopted `.pi/skills/`, A/B two versions of a flow across two triggers, or keep a private flow out of a public repo's history. The overlay was the only operator-side path and it is per DEPLOYMENT; this is the same capability at the granularity the decision actually has. COPIED, NOT MOUNTED, and the mount count is the weakest of the three reasons. The copy is the PIN. `:ro` bounds the container, not the host, and pi reads a skill's body on demand through the read tool, so under a live bind an operator editing their skills directory would change the instructions of a job already running. Copying gives the injected tier the property INT-CONTAINER-JOB-INPUTS cites for materialising `.pi/` at a fixed sha: the agent cannot be handed a moving target. It also answers symlinks once, on the side that can. loadSkillsFromDirInternal follows both file and directory links, so under a mount a directory symlink at `/` would turn skill discovery into a walk of the container filesystem, and one into `/workspace` would alias repo-controlled content into the operator-trusted tier. The host-side copier refuses links outright. And it adds no mount. CONST-ISOLATION-CONTAINER-PER-JOB's acceptance ENUMERATES them, and DES-OPERATOR-GLOBAL-OVERLAY already refused a mount for staged packages on exactly that trade, so this entry can borrow the argument the 2026-07-31 /session row explicitly could not. A job carrying injected skills has a docker argv byte-identical to one without, and there is a test that says so. Precedence is enforced twice, because once is not enough: by path order in additionalSkillPaths, and again in skillsOverride's protected roots, since pi puts a staged package's skill paths first no matter where we list the package. The exact deepEqual on PROTECTED_SKILL_ROOTS is updated rather than loosened. The middle tier's position is argued on specificity, not trust: "for THIS trigger" is narrower than "for this deployment", and both are the operator's own. Validated as a non-empty untrimmed string in triggers.mjs and NO FURTHER, which is two deliberate omissions. Existence is not checked there because both services parse the file and the receiver may run on another host (run.folder's split). Absoluteness is not either, and that one is subtler: path.isAbsolute is OS-dependent, so a shared check would let a Windows worker and a Linux receiver disagree about the same reviewed file. The worker enforces both where the answer is knowable, at boot for cron and pre-spend per job for every kind. The value never reaches /job/event.json. It rides at JOB level and never inside `trigger`, which is what the event subset is built from, because a worker-host path in an agent-readable file is what prepare-local's basename(folder) restraint already exists to prevent. It is in no log line either: the injection logs counts only. Gap 5 is recorded rather than built. An injected flow already fails closed at the ai-trigger gate, which reads the repo's object store at a pre-agent sha and finds no-skill. Two tests pin it, DES-AI-TRIGGER-FLOW-GATE states it, and doctor warns about the corollary an operator cannot otherwise discover: an injected SKILL.md carrying `ai-trigger: allow` is never read. Residual OQ-022. Doctor dry-runs the REAL copier against each named directory rather than reimplementing its rules, because a second agreeing-by-hand checker is how doctor comes to report green on a directory the worker then refuses. REQ-PER-TRIGGER-SKILLS and DES-TRIGGER-SKILLS-COPIED-NOT-MOUNTED are NEW, the latter recording four rejected alternatives including the per-trigger :ro bind the issue originally sketched and an env var naming the injected root. INT-TRIGGERS-FILE-CONTRACT, INT-CONTAINER-JOB-INPUTS, INT-SDK-SESSION-OPTIONS, INT-RUN-HISTORY-FILE-CONTRACT, REQ-GLOBAL-PI-OVERLAY, DES-AI-TRIGGER-FLOW-GATE: AMENDED. OQ-022 added. CONST-ISOLATION-CONTAINER-PER-JOB, INT-CONTAINER-RUNTIME-CONTRACT, CONST-NO-CONTEXT-FILES-MANDATORY, CONST-ISSUE-TEXT-IS-DATA, CONST-PERSONA-IN-CACHED-PREFIX, CONST-BUDGET-BEFORE-TOKENS, CONST-RETRY-INFRA-ONLY, CONST-TOKEN-SCOPED-PER-JOB, REQ-UPSTREAM-CONTRACT-TESTS, REQ-RESURRECTABLE-SANDBOX: UNCHANGED, checked. Mutation-checked: dropping the injected root from protectedSkillRoots turns the package-shadow test red, and dropping it from additionalSkillPaths turns both precedence tests red. 2053 tests, 0 failures, 0 skipped in the CI posture. Signed-off-by: Rob Boerman <robboerman@live.nl>
1 parent e307e68 commit b0d1856

36 files changed

Lines changed: 853 additions & 44 deletions

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,12 @@ or what it costs):
172172
([`docs/job-image.md`](docs/job-image.md)).
173173
- `"packages": false` opts one trigger out of the staged third-party pi packages, which is also how a
174174
workflow extension is withheld from one flow ([`docs/workflows.md`](docs/workflows.md)).
175+
- `"skillsDir"` points at a directory of skills on the worker host, in the same `<name>/SKILL.md` layout
176+
as your own `~/.pi/agent/skills`. They are copied into that trigger's jobs and layered under the repo's
177+
own `.pi/skills` and over the global overlay, so a repo skill of the same name still wins. Use it to run
178+
a flow against a repo that has not adopted `.pi/skills/` at all, to A/B two versions of a flow across two
179+
triggers, or to keep a private flow out of a public repo's history
180+
([`docs/global-pi-overlay.md`](docs/global-pi-overlay.md)).
175181
- `"replicas": 2` (GitHub only) races independent sandboxes on the same event and opens one PR per
176182
replica. Each replica spends its own budget slot ([`docs/replicas.md`](docs/replicas.md)).
177183
- `"resume": true` continues the session that opened the PR ([`docs/sessions.md`](docs/sessions.md)).

SECURITY.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -247,8 +247,9 @@ Stated openly rather than discovered later:
247247
flow.** A trigger's `run.image` names the container its jobs run in; absent, they run `PI_JOB_IMAGE`.
248248
Whichever it is, the *isolation* holds: `--cap-drop=ALL`, `--security-opt no-new-privileges`, the
249249
memory/CPU/pids limits, `/job` read-only, the closed env allowlist and the mount set (four mounts, five
250-
once `run.resume` is armed) are all built by the worker's `docker run` argv, so nothing an image contains
251-
can weaken them. **Non-root is not in that argv.** It is `USER pi` in the image itself, as the trust table
250+
once `run.resume` is armed, and `run.skillsDir` adds none: its skills are copied into the per-job dir and
251+
ride the `/job` mount that already exists) are all built by the worker's `docker run` argv, so nothing an
252+
image contains can weaken them. **Non-root is not in that argv.** It is `USER pi` in the image itself, as the trust table
252253
above says, and that is exactly why an unconformant image can lose it: `docs/job-image.md` requires a
253254
non-root runtime user with a writable agent dir, and nothing here verifies that the image you named
254255
honours it. What is **not** checked is the image's contents, and every way that can be wrong fails

admin/src/read-model.mjs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -692,6 +692,10 @@ export function normalizeTriggerForDisplay(entry) {
692692
const flow = typeof run.flow === "string" ? run.flow : null;
693693
const packages = run.packages !== false;
694694
const image = typeof run.image === "string" && run.image.trim() !== "" ? run.image : null;
695+
// The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS, issue #60). Carried on all four kinds like
696+
// `image`, and shown for the same reason: which skills a job loads IS what the agent can do. `null` is
697+
// the none sentinel, matching this function's own convention.
698+
const skillsDir = typeof run.skillsDir === "string" && run.skillsDir.trim() !== "" ? run.skillsDir : null;
695699
// An opt-IN, so `=== true` and not `!== false` -- the opposite test from `packages` directly above, and
696700
// the difference is the whole point. Getting this polarity wrong is the defect 0.1.4 shipped a fix for:
697701
// the riskiest triggers rendered with no badge and no warning, quiet exactly where the risk was.
@@ -716,12 +720,13 @@ export function normalizeTriggerForDisplay(entry) {
716720
model: typeof run.model === "string" ? run.model : null,
717721
packages,
718722
image,
723+
skillsDir,
719724
resume,
720725
};
721726
case "label":
722-
return { type: "label", any: normalizeSelector(on.any), all: normalizeSelector(on.all), none: normalizeSelector(on.none), flow, packages, image, resume, replicas, forge };
727+
return { type: "label", any: normalizeSelector(on.any), all: normalizeSelector(on.all), none: normalizeSelector(on.none), flow, packages, image, skillsDir, resume, replicas, forge };
723728
case "comment":
724-
return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, packages, image, resume, replicas, forge };
729+
return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, packages, image, skillsDir, resume, replicas, forge };
725730
case "pull_request":
726731
return {
727732
type: "pull_request",
@@ -732,6 +737,7 @@ export function normalizeTriggerForDisplay(entry) {
732737
flow,
733738
packages,
734739
image,
740+
skillsDir,
735741
resume,
736742
replicas,
737743
forge,

admin/src/render.mjs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -175,22 +175,28 @@ function triggerLine(t) {
175175
// A trigger that PERSISTS the agent's working history to disk says so. Without this badge it renders
176176
// identically to one that does not, which is the defect 0.1.4 fixed for [packages] arriving in a new
177177
// field -- and a transcript is a bigger disclosure than staged packages are.
178+
// A trigger whose jobs load operator-authored skills from the host says which directory (issue #60).
179+
// Same doctrine as [resume] and [image]: it must never render the same as one that does not, because
180+
// choosing the skills IS choosing what the agent can do. The BASENAME only, so the line stays skimmable;
181+
// the full path lives in the trigger detail view, where the panel is the operator's own session on their
182+
// own host and a path discloses nothing new.
183+
const skl = t?.skillsDir ? ` [skills ${String(t.skillsDir).split(/[\\/]/).filter(Boolean).pop()}]` : "";
178184
const res = t?.resume === true ? " [resume]" : "";
179185
// A trigger that turns one delivery into N paid runs says so (REQ-REPLICA-RUNS). Same class of badge as
180186
// [resume]: not a preference an operator can skim past, but the field that multiplies the bill. Absent on
181187
// an unreplicated trigger, appended last, so every existing line is byte-identical.
182188
const rep = t?.replicas > 1 ? ` [x${t.replicas}]` : "";
183189
switch (t?.type) {
184190
case "cron":
185-
return `cron ${t.id ?? "-"} ${t.pattern ?? "-"}${t.folder ?? "-"}/${flow}${forge}${pkgs}${img}${res}${rep}`;
191+
return `cron ${t.id ?? "-"} ${t.pattern ?? "-"}${t.folder ?? "-"}/${flow}${forge}${pkgs}${img}${skl}${res}${rep}`;
186192
case "label":
187-
return `label ${ruleClauses(t) || "(no selector)"}${flow}${forge}${pkgs}${img}${res}${rep}`;
193+
return `label ${ruleClauses(t) || "(no selector)"}${flow}${forge}${pkgs}${img}${skl}${res}${rep}`;
188194
case "comment":
189-
return `comment "${t.phrase ?? "-"}" → ${flow}${forge}${pkgs}${img}${res}${rep}`;
195+
return `comment "${t.phrase ?? "-"}" → ${flow}${forge}${pkgs}${img}${skl}${res}${rep}`;
190196
case "pull_request": {
191197
const clauses = ruleClauses(t);
192198
const action = `action[${(t.action ?? []).join(",")}]`;
193-
return `pull_request ${action}${clauses ? ` ${clauses}` : ""}${flow}${forge}${pkgs}${img}${res}${rep}`;
199+
return `pull_request ${action}${clauses ? ` ${clauses}` : ""}${flow}${forge}${pkgs}${img}${skl}${res}${rep}`;
194200
}
195201
default:
196202
return "(unknown trigger)";

admin/test/read-model.test.mjs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -448,10 +448,10 @@ test("readTriggers normalizes each on.type into its discriminated display record
448448
const res = readTriggers({ triggersPath: "/x/triggers.json", fs: fakeFs(files) });
449449
// Every entry omits `run.packages`, and packages is an OPT-OUT -- so all four normalize to `true`.
450450
assert.deepEqual(res.triggers, [
451-
{ type: "cron", id: "nightly", pattern: "0 3 * * *", folder: "/srv/p", flow: "tidy", model: null, packages: true, image: null, resume: false },
452-
{ type: "label", any: ["pi:frontend"], all: [], none: ["wontfix"], flow: "frontend-fix", packages: true, image: null, resume: false, replicas: null, forge: "github" },
453-
{ type: "comment", phrase: "@pi", flow: "fix", packages: true, image: null, resume: false, replicas: null, forge: "github" },
454-
{ type: "pull_request", action: ["labeled"], any: ["pi:review"], all: [], none: [], flow: "review", packages: true, image: null, resume: false, replicas: null, forge: "github" },
451+
{ type: "cron", id: "nightly", pattern: "0 3 * * *", folder: "/srv/p", flow: "tidy", model: null, packages: true, image: null, skillsDir: null, resume: false },
452+
{ type: "label", any: ["pi:frontend"], all: [], none: ["wontfix"], flow: "frontend-fix", packages: true, image: null, skillsDir: null, resume: false, replicas: null, forge: "github" },
453+
{ type: "comment", phrase: "@pi", flow: "fix", packages: true, image: null, skillsDir: null, resume: false, replicas: null, forge: "github" },
454+
{ type: "pull_request", action: ["labeled"], any: ["pi:review"], all: [], none: [], flow: "review", packages: true, image: null, skillsDir: null, resume: false, replicas: null, forge: "github" },
455455
]);
456456
});
457457

@@ -516,7 +516,7 @@ test("readTriggers skips an entry that is not a usable { on, run } object (viewe
516516
}),
517517
};
518518
const res = readTriggers({ triggersPath: "/x/triggers.json", fs: fakeFs(files) });
519-
assert.deepEqual(res.triggers, [{ type: "label", any: ["pi:frontend"], all: [], none: [], flow: "frontend-fix", packages: true, image: null, resume: false, replicas: null, forge: "github" }]);
519+
assert.deepEqual(res.triggers, [{ type: "label", any: ["pi:frontend"], all: [], none: [], flow: "frontend-fix", packages: true, image: null, skillsDir: null, resume: false, replicas: null, forge: "github" }]);
520520
});
521521

522522
test("readTriggers returns { invalid } when there is no triggers array", () => {

docs/global-pi-overlay.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,18 @@ Four tiers, most-trusted first; each refines but never removes the one above:
3434
| 1. Safety floor | baked `HARD_RULES.md` (from **whichever image the trigger names**`run.image`) | image, root-owned | no (immutable) |
3535
| 2a. **Global overlay** | `PI_GLOBAL_PI_DIR``/opt/pi-global:ro` | **operator, deploy-time** | re-run `import-pi` |
3636
| 2b. **Staged packages** | `<overlay>/packages/<dir>` — per-trigger opt-out | **third-party**, operator-pinned | re-run `import-pi --with-packages` |
37+
| 2c. **Injected skills** | a trigger's `run.skillsDir`, copied per job into `/job/trigger-skills` | **operator, per trigger** | edit `triggers.json` |
3738
| 3. Per-repo `.pi/` | repo's committed `.pi/` (default-branch SHA) | trusted-by-merge | per PR |
3839
| 3b. Repo `AGENTS.md` + `.pi/extensions` | the checkout, which is always the **default-branch SHA** | trusted-by-merge; **extensions execute** | per PR |
3940
| 4. Task/issue text | the webhook / CLI input | **adversarial — never instructions** ||
4041

4142
- **Skills**: repo skills are listed **first**, so a repo skill **overrides** a global one of the same name
4243
(pi is first-path-wins); names that don't collide all load.
44+
A trigger's own `run.skillsDir` sits **between** the two: repo beats injected beats global. "For this
45+
trigger" is a narrower statement than "for this deployment", so it refines the overlay, and the repo's
46+
own committed skills refine both. Those skills are **copied** per job rather than mounted, which is what
47+
stops an edit to your skills directory from changing a job that is already running, and it means the
48+
feature adds no mount to any container.
4349
- **Persona**: the assembled prompt is `guardrails → outbox protocol → global persona → repo persona`. The
4450
floor is always first and cannot be removed; global is your baseline; the repo's `.pi/APPEND_SYSTEM.md` is
4551
most specific. The outbox tier is **local jobs only**: it is read only when the `/outbox` mount exists, so

docs/workflows.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ skills, or a pi extension that orchestrates them.
1515
| **skill** | pi's unit of instruction; a flow is just the entry one. The whole directory travels, not only `SKILL.md` | the target repo, or the overlay |
1616
| **workflow extension** | a pi extension that chains skills into stages, with its own state and routing | a third party, staged by you |
1717
| **staged package** | the pinned directory a workflow extension lives in, inside the global overlay | `import-pi --with-packages` |
18+
| **injected skills** | a directory of skills on the worker host that one trigger's jobs load, via `run.skillsDir` | you, in a reviewed file |
1819

1920
## How a workflow gets triggered
2021

@@ -38,7 +39,9 @@ Four properties of that chain decide what is possible inside it.
3839

3940
**`run.flow` is the only entry point.** There is no `run.workflow` and there is not going to be one. Which
4041
stages run is a property of the repo's own skill, which the repo changes by merging; the trigger stays a
41-
reviewed pairing of an event with a flow name. That split is the same one the whole trigger schema rests
42+
reviewed pairing of an event with a flow name. `run.skillsDir` does not change that and is worth being
43+
precise about why: it supplies **where a flow comes from**, never **which stages run**. The trigger still
44+
names one flow, and what that flow does is still the skill's business. That split is the same one the whole trigger schema rests
4245
on: this service decides *when* and *in what box*, the repo decides *what*.
4346

4447
**A job is not an interactive session.** The runner assembles one prompt, calls pi once, and reads the exit

image/runner/run-job.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
SettingsManager,
88
} from "@earendil-works/pi-coding-agent";
99
import { assertPackagePathsExist, assertSessionMountReady, enforceOfflineMode, parseRunnerEnv } from "./src/config.mjs";
10-
import { buildLoadedResourceLoader, GLOBAL_PI_DIR, JOB_PI_DIR, WORKSPACE } from "./src/loader.mjs";
10+
import { buildLoadedResourceLoader, GLOBAL_PI_DIR, JOB_PI_DIR, TRIGGER_SKILLS_DIR, WORKSPACE } from "./src/loader.mjs";
1111
import {
1212
captureTerminal,
1313
classifyThrow,
@@ -127,7 +127,7 @@ async function main() {
127127
// The protected roots are derived from the loader's own constants rather than written out
128128
// again, so a change to where the worker materialises .pi/ cannot leave this check guarding
129129
// paths that no longer exist.
130-
const protectedRoots = [`${JOB_PI_DIR}/skills`, `${GLOBAL_PI_DIR}/skills`];
130+
const protectedRoots = [`${JOB_PI_DIR}/skills`, TRIGGER_SKILLS_DIR, `${GLOBAL_PI_DIR}/skills`];
131131
const shadowed = findShadowedSkills(diagnostics, { packageRoots: cfg.packages, protectedRoots });
132132
if (shadowed.length > 0) {
133133
// The winner is read off the LOADED skill, not off pi's pre-override diagnostic: the

image/runner/src/loader.mjs

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,21 @@ export const OUTBOX_MOUNT = "/outbox";
1414
/** Read-only mount the worker materialises the project's .pi/ into, from the default-branch SHA. */
1515
export const JOB_PI_DIR = "/job/pi";
1616

17+
/**
18+
* Where a trigger's INJECTED skills land (REQ-PER-TRIGGER-SKILLS, issue #60). Operator-authored, copied
19+
* per job by the worker from the host directory `run.skillsDir` names, and reaching us on the /job:ro
20+
* bind that already exists -- this feature adds NO mount, which is what keeps
21+
* CONST-ISOLATION-CONTAINER-PER-JOB's mount enumeration untouched.
22+
*
23+
* Spelled `trigger-skills` and NOT `skills`: `/job/skills` beside `/job/pi/skills` would be two roots one
24+
* letter apart in a precedence list, which is how the wrong one gets edited.
25+
*
26+
* The worker writes the same last segment from its own side (TRIGGER_SKILLS_SUBDIR in prepare.mjs); it is
27+
* not in this container and this file is not on that host, so the duplication is forced. CHANGE BOTH, IN
28+
* THE SAME COMMIT -- the same discipline ADMIN_EXTENSION_RE carries, and each side pins the literal.
29+
*/
30+
export const TRIGGER_SKILLS_DIR = "/job/trigger-skills";
31+
1732
/**
1833
* Read-only mount of the operator's global pi overlay (REQ-GLOBAL-PI-OVERLAY): custom models, global
1934
* skills, and a global persona from the operator's own ~/.pi/agent, present only when configured. It is
@@ -217,6 +232,7 @@ export function buildResourceLoader({
217232
guardrailsPath = GUARDRAILS_PATH,
218233
jobPiDir = JOB_PI_DIR,
219234
globalPiDir = GLOBAL_PI_DIR,
235+
triggerSkillsDir = TRIGGER_SKILLS_DIR,
220236
outboxMount = OUTBOX_MOUNT,
221237
outboxProtocolPath = OUTBOX_PROTOCOL_PATH,
222238
// ON, matching the runtime posture (REQ-GLOBAL-PI-OVERLAY): the operator staged that dir themselves,
@@ -243,7 +259,7 @@ export function buildResourceLoader({
243259
// The roots a staged package may never take a skill name from. Both are listed unconditionally: a
244260
// root that is not mounted contributes no skill to protect, so gating it would only add a way to
245261
// forget one.
246-
const protectedSkillRoots = [`${jobPiDir}/skills`, globalSkills];
262+
const protectedSkillRoots = [`${jobPiDir}/skills`, triggerSkillsDir, globalSkills];
247263
// Roots the recursion guard can NAME in its log line, most specific FIRST: a staged package sits
248264
// under the overlay, so an overlay-first list would report a package's extension as the operator's.
249265
// The workspace is last because it is the catch-all -- a discovered repo extension is anywhere in it.
@@ -263,7 +279,18 @@ export function buildResourceLoader({
263279
// second path and take the mount out of force on a first-path-wins collision. See the docstring.
264280
noSkills: true,
265281
// Repo path FIRST so a repo skill overrides a global one of the same name (first-path-wins).
266-
additionalSkillPaths: [`${jobPiDir}/skills`, ...(existsSync(globalSkills) ? [globalSkills] : [])],
282+
// then the trigger's INJECTED skills, then the deployment-wide overlay: repo > injected > overlay
283+
// (REQ-GLOBAL-PI-OVERLAY, REQ-PER-TRIGGER-SKILLS). The middle tier is the narrower operator statement,
284+
// "for THIS trigger", so it refines the overlay and is refined by the repo -- the same most-specific-
285+
// wins ordering the persona layers use. The order here is OURS: under noSkills, pi builds skillPaths as
286+
// mergePaths(cliEnabledSkills, additionalSkillPaths), mergePaths preserves order, and loadSkills keeps
287+
// the first of each name. existsSync-gated like the overlay, because an absent path becomes a permanent
288+
// "skill path does not exist" diagnostic, and an always-populated channel cannot detect anything.
289+
additionalSkillPaths: [
290+
`${jobPiDir}/skills`,
291+
...(existsSync(triggerSkillsDir) ? [triggerSkillsDir] : []),
292+
...(existsSync(globalSkills) ? [globalSkills] : []),
293+
],
267294
// Global overlay extensions load whenever the dir is present -- staging them IS the operator's
268295
// decision, so a second arming step was friction rather than safety. PI_GLOBAL_ALLOW_EXTENSIONS=0
269296
// is the opt-out, and any other value is refused at config load so a typo cannot silently mean

image/runner/src/packages.mjs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,14 @@
88
*/
99
import { basename, dirname } from "node:path";
1010

11-
/** Roots whose skills a staged package must never be able to replace. */
12-
export const PROTECTED_SKILL_ROOTS = ["/job/pi/skills", "/opt/pi-global/skills"];
11+
/**
12+
* Roots whose skills a staged package must never be able to replace, most specific FIRST.
13+
*
14+
* The middle entry is a trigger's injected skills (REQ-PER-TRIGGER-SKILLS, issue #60), and it sits
15+
* between the repo and the overlay for the same reason it does in additionalSkillPaths: "for THIS
16+
* trigger" is a narrower operator statement than "for this deployment", and narrower wins.
17+
*/
18+
export const PROTECTED_SKILL_ROOTS = ["/job/pi/skills", "/job/trigger-skills", "/opt/pi-global/skills"];
1319

1420
/**
1521
* The admin extension, BY NAME -- a deliberate duplicate of `ADMIN_RE` in worker/src/import-pi.mjs.

0 commit comments

Comments
 (0)