You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
| 3b. Repo `AGENTS.md` + `.pi/extensions`| the checkout, which is always the **default-branch SHA**| trusted-by-merge; **extensions execute**| per PR |
39
40
| 4. Task/issue text | the webhook / CLI input |**adversarial — never instructions**| — |
40
41
41
42
-**Skills**: repo skills are listed **first**, so a repo skill **overrides** a global one of the same name
42
43
(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.
43
49
-**Persona**: the assembled prompt is `guardrails → outbox protocol → global persona → repo persona`. The
44
50
floor is always first and cannot be removed; global is your baseline; the repo's `.pi/APPEND_SYSTEM.md` is
45
51
most specific. The outbox tier is **local jobs only**: it is read only when the `/outbox` mount exists, so
Copy file name to clipboardExpand all lines: docs/workflows.md
+4-1Lines changed: 4 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -15,6 +15,7 @@ skills, or a pi extension that orchestrates them.
15
15
|**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 |
16
16
|**workflow extension**| a pi extension that chains skills into stages, with its own state and routing | a third party, staged by you |
17
17
|**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 |
18
19
19
20
## How a workflow gets triggered
20
21
@@ -38,7 +39,9 @@ Four properties of that chain decide what is possible inside it.
38
39
39
40
**`run.flow` is the only entry point.** There is no `run.workflow` and there is not going to be one. Which
40
41
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
42
45
on: this service decides *when* and *in what box*, the repo decides *what*.
43
46
44
47
**A job is not an interactive session.** The runner assembles one prompt, calls pi once, and reads the exit
0 commit comments