@edgehero/pi-dispatch 0.3.0 → 1.0.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch",
3
- "version": "0.3.0",
3
+ "version": "1.0.0",
4
4
  "type": "module",
5
5
  "description": "Self-hosted job harness for the pi coding agent: a BullMQ worker that drains the queue, mints scoped forge tokens, and runs one container per job — plus the pi-dispatch CLI (init, up, doctor, service).",
6
6
  "keywords": [
package/src/doctor.mjs CHANGED
@@ -53,8 +53,9 @@ import { defaultSandboxDir, globalExtensionsEnabled } from "./config.mjs";
53
53
  import { isForgeKind } from "./forges.mjs";
54
54
  import { findLiteralSecret, ADMIN_RE } from "./import-pi.mjs";
55
55
  import { agentDirFrom, readHostPi } from "./host-pi.mjs";
56
- import { PACKAGES_SUBDIR, readStageManifest } from "./packages.mjs";
56
+ import { PACKAGES_SUBDIR, readStagedSkills, readStageManifest } from "./packages.mjs";
57
57
  import { copySkillTree } from "./copy-tree.mjs";
58
+ import { SKILL_NAME_RE } from "./flow-gate.mjs";
58
59
  import { parseTriggers } from "./triggers.mjs";
59
60
 
60
61
  const NODE_FLOOR = [22, 19]; // pi's engine floor (22.19.0)
@@ -251,7 +252,7 @@ export async function collectChecks(env, seams) {
251
252
  // image checks just below, and `optingOut`/`requiring` colour the staged-packages lines further down.
252
253
  // `optingOut` counts the only value that withholds the staged set; `requiring` counts an explicit
253
254
  // run.packages: true, which arms nothing any more but is still an operator statement of intent.
254
- const { requiring, optingOut, resuming, replicating, instructing, images, skillsDirs, forges, repositories } = readTriggerFacts(env, fileExists, cwd);
255
+ const { requiring, optingOut, resuming, replicating, instructing, commands, images, skillsDirs, forges, repositories, flows } = readTriggerFacts(env, fileExists, cwd);
255
256
 
256
257
  // Only meaningful if docker itself responds; otherwise the image check is noise on top of a down daemon.
257
258
  const imageCode = dockerCode === 0 ? await runCmd(spawn, "docker", ["image", "inspect", jobImage]) : null;
@@ -325,6 +326,91 @@ export async function collectChecks(env, seams) {
325
326
  }
326
327
  }
327
328
 
329
+ // REQ-PER-TRIGGER-SKILLS (issue #189). One line per distinct (flow, folder, skillsDir, packages)
330
+ // question: does the flow this trigger names resolve in ANY tier this host can see? Probed in the
331
+ // loader's own precedence order (repo > injected > overlay > staged packages), first hit wins the
332
+ // line. ⚠ and NEVER ✗ when nothing resolves -- a forge trigger's repo is not on this host and
333
+ // mid-setup is legal -- and no fixAction (triggers content is the never tier). The runner's
334
+ // flow_not_loaded line is the exact, in-container half of the same answer
335
+ // (DES-FLOW-RESOLUTION-TWO-ADVISORY-LAYERS): these probes read dir names, and a frontmatter
336
+ // `name:` rename is invisible to them, so a ⚠ here can be wrong in only the loud direction.
337
+ // A deployment with no triggers adds no lines at all, so its output is byte-identical.
338
+ if (flows.length > 0) {
339
+ // One stage read for the whole run; each tuple's staged probe is a lookup on its result.
340
+ const staged = readStagedSkills({ globalPiDir: env.PI_GLOBAL_PI_DIR, readFile: (p) => readFileSync(p, "utf8"), fileExists });
341
+ const groups = new Map();
342
+ for (const f of flows) {
343
+ const key = JSON.stringify([f.flow, f.folder, f.skillsDir, f.packages]);
344
+ if (!groups.has(key)) groups.set(key, { ...f, labels: [] });
345
+ groups.get(key).labels.push(f.label);
346
+ }
347
+ for (const g of groups.values()) {
348
+ const at = g.labels.join(", ");
349
+ // The charset pre-check doubles as the interpolation guard for the git probe below, and it is
350
+ // a finding of its own: a name the skill charset refuses can never materialise in ANY tier
351
+ // (materialize and copy-tree enforce the same RE on the way in).
352
+ if (!SKILL_NAME_RE.test(g.flow)) {
353
+ checks.push({
354
+ ok: false,
355
+ warn: true,
356
+ label: `Trigger flow ${JSON.stringify(g.flow)} fails the skill name charset (${at})`,
357
+ fix: "a flow name must match the skill charset (lowercase alphanumerics, - and _, 64 max), or no tier can ever hold it -- fix run.flow",
358
+ });
359
+ continue;
360
+ }
361
+ let resolved = null;
362
+ const checked = [];
363
+ const unknown = [];
364
+ if (g.folder) {
365
+ const state = await repoFlowAtHead(spawn, g.folder, g.flow);
366
+ if (state === "present") resolved = `repo .pi/skills at HEAD of ${g.folder}`;
367
+ else if (state === "absent") checked.push("repo .pi/skills at HEAD");
368
+ else unknown.push(`repo (${g.folder} is not readable as a git repo here)`);
369
+ } else {
370
+ unknown.push("repo (a forge clone, not on this host)");
371
+ }
372
+ if (!resolved && g.skillsDir) {
373
+ if (fileExists(join(g.skillsDir, g.flow, "SKILL.md"))) resolved = `injected run.skillsDir ${g.skillsDir}`;
374
+ else checked.push("injected run.skillsDir");
375
+ }
376
+ if (!resolved && env.PI_GLOBAL_PI_DIR) {
377
+ if (fileExists(join(env.PI_GLOBAL_PI_DIR, "skills", g.flow, "SKILL.md"))) resolved = "the overlay skills/";
378
+ else checked.push("overlay skills/");
379
+ }
380
+ if (!resolved) {
381
+ if (!g.packages) {
382
+ checked.push("staged packages (withheld: run.packages false)");
383
+ } else {
384
+ const hit = staged.skills.find((s) => s.name === g.flow);
385
+ if (hit) resolved = `staged package ${hit.package}`;
386
+ else if (staged.unenumerable.length > 0) unknown.push(`staged package(s) ${staged.unenumerable.join(", ")} (manifest patterns, not enumerable here)`);
387
+ else checked.push("staged packages");
388
+ }
389
+ }
390
+ if (resolved) {
391
+ checks.push({ ok: true, label: `Trigger flow "${g.flow}" resolves (${at}: ${resolved})` });
392
+ } else {
393
+ checks.push({
394
+ ok: false,
395
+ warn: true,
396
+ label: `Trigger flow "${g.flow}" resolves in NO tier visible here (${at})`,
397
+ fix: `checked: ${checked.join(", ") || "nothing checkable"}${unknown.length > 0 ? `; not checkable here: ${unknown.join(", ")}` : ""} -- commit .pi/skills/${g.flow}/SKILL.md, add the skill to run.skillsDir or the overlay skills/, or stage a package shipping it; a job of this trigger runs without the flow it names (the runner logs flow_not_loaded) and still exits 0`,
398
+ });
399
+ }
400
+ }
401
+ }
402
+
403
+ // run.command triggers (issue #189): ONE advisory line, deliberately WITHOUT the per-tier probes the
404
+ // flow block above runs. A command is registered by extension CODE at pi startup -- repo .pi/, the
405
+ // overlay and staged packages all contribute, and none is enumerable host-side without executing the
406
+ // extension, which doctor must never do. The honest line names where the real check lives instead;
407
+ // unlike a missing flow, the failure there is LOUD (a refusal, not a clean exit 0), which is why this
408
+ // is advisory and carries no fixAction (triggers content is the never tier). A deployment with no
409
+ // command triggers adds no line at all, so its output is byte-identical.
410
+ if (commands > 0) {
411
+ checks.push({ ok: true, label: `${commands} command trigger(s): a command is only verifiable in-container -- the runner refuses an unregistered one pre-spend (command-unregistered)` });
412
+ }
413
+
328
414
  // Issue #41: every DISTINCT image a trigger names in run.image, minus the deployment default already
329
415
  // checked above. Two silent-failure modes, and both used to be impossible because there was one image.
330
416
  // 1. the image was never built -- a job that refuses pre-spend at 03:00 in a log nobody is reading, and
@@ -1191,8 +1277,37 @@ function aiTriggerNames(dir) {
1191
1277
  return names;
1192
1278
  }
1193
1279
 
1280
+ /**
1281
+ * Does `.pi/skills/<flow>/SKILL.md` exist at HEAD of a local folder? "present" | "absent" | "unknown".
1282
+ *
1283
+ * Deliberately NOT readFlowGate: that module answers WHO may fire a flow (the ai-trigger frontmatter,
1284
+ * at a caller-pinned sha) and its catch collapses ANY git failure into deny -- fail-closed is right
1285
+ * for a gate and exactly wrong here, where deny-because-git-broke would print a confident wrong
1286
+ * answer on an advisory line. Doctor resolving HEAD itself is also fine: the gate's no-ref rule
1287
+ * defends against an agent self-authorizing mid-run, and a host-side preflight has no agent. What IS
1288
+ * the gate's, verbatim, is the ls-tree read, the 100644-blob requirement and the hardening flags --
1289
+ * copied so the two readers cannot disagree about what "a committed skill file" means, and so a
1290
+ * hostile repo config cannot run code during the read (flow-gate.mjs's defaultGit, restated).
1291
+ */
1292
+ const GIT_READ_FLAGS = ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "--no-pager"];
1293
+ async function repoFlowAtHead(spawn, folder, flow) {
1294
+ if (!SKILL_NAME_RE.test(flow)) return "unknown"; // the caller pre-checks; belt against interpolation
1295
+ const head = await runCmdCapture(spawn, "git", [...GIT_READ_FLAGS, "-C", folder, "rev-parse", "HEAD"]);
1296
+ const sha = head.code === 0 ? head.output.trim() : null;
1297
+ if (!sha || !/^[0-9a-f]{40,64}$/.test(sha)) return "unknown";
1298
+ const tree = await runCmdCapture(spawn, "git", [...GIT_READ_FLAGS, "-C", folder, "ls-tree", "-z", sha, `.pi/skills/${flow}/SKILL.md`]);
1299
+ if (tree.code !== 0) return "unknown";
1300
+ const record = tree.output.split("\0").find((r) => r);
1301
+ if (!record) return "absent"; // valid sha, path absent at that commit
1302
+ const tab = record.indexOf("\t");
1303
+ const [mode, type] = tab === -1 ? [] : record.slice(0, tab).split(/\s+/);
1304
+ // A symlink/gitlink entry is "absent" for this question too: the gate would refuse it, and the
1305
+ // materialiser never copies it, so nothing downstream treats it as a skill file.
1306
+ return mode === "100644" && type === "blob" ? "present" : "absent";
1307
+ }
1308
+
1194
1309
  function readTriggerFacts(env, fileExists, cwd) {
1195
- const none = { requiring: 0, optingOut: 0, resuming: 0, replicating: 0, instructing: 0, images: [], skillsDirs: [], forges: [], repositories: [] };
1310
+ const none = { requiring: 0, optingOut: 0, resuming: 0, replicating: 0, instructing: 0, commands: 0, images: [], skillsDirs: [], forges: [], repositories: [], flows: [] };
1196
1311
  try {
1197
1312
  // Unset falls back to ./triggers.json in cwd, MIRRORING the receiver's own default
1198
1313
  // (receiver/src/config.mjs) -- the two must read the same file, or doctor preflights a deployment
@@ -1209,6 +1324,10 @@ function readTriggerFacts(env, fileExists, cwd) {
1209
1324
  // REQ-REPLICA-RUNS. `> 1` rather than `!== undefined` because the loader already refuses anything
1210
1325
  // else -- this counts triggers that will actually multiply spend, which is the only reason to say so.
1211
1326
  replicating: triggers.filter((t) => t.run.replicas > 1).length,
1327
+ // run.command triggers (issue #189), counted for the one advisory line below. The `flows`
1328
+ // tuple list already filters to `typeof f.flow === "string"`, so a command trigger drops out
1329
+ // of the flow-tier probes naturally -- no exclusion needed there.
1330
+ commands: triggers.filter((t) => typeof t.run.command === "string").length,
1212
1331
  optingOut: triggers.filter((t) => t.run.packages === false).length,
1213
1332
  images: [...new Set(triggers.map((t) => t.run.image).filter((i) => typeof i === "string"))].sort(),
1214
1333
  // REQ-PER-TRIGGER-SKILLS. The distinct host directories the file names, deduped like `images`,
@@ -1224,6 +1343,21 @@ function readTriggerFacts(env, fileExists, cwd) {
1224
1343
  // would report all-green and never mention that the credential it needs was never looked for.
1225
1344
  forges: [...new Set(triggers.map((t) => t.run.kind).filter(isForgeKind))].sort(),
1226
1345
  repositories: [...new Set(triggers.filter((t) => t.run.kind === "github" && typeof t.run.repository === "string").map((t) => t.run.repository))].sort(),
1346
+ // REQ-PER-TRIGGER-SKILLS (issue #189). Per-trigger TUPLES, unlike every deduped set above,
1347
+ // because a flow-resolution answer depends on the trigger's own folder/skillsDir/packages --
1348
+ // two triggers naming the same flow with different skillsDirs are two different questions.
1349
+ // The label is how a line names its trigger: cron entries by their id, id-less webhook
1350
+ // entries by raw file position (the admin's trigger:<index> identity).
1351
+ flows: triggers
1352
+ .map((t, index) => ({
1353
+ label: t.on.type === "cron" ? `cron "${t.on.id}"` : `${t.on.type} trigger #${index}`,
1354
+ flow: t.run.flow,
1355
+ kind: t.run.kind,
1356
+ folder: typeof t.run.folder === "string" ? t.run.folder : null,
1357
+ skillsDir: typeof t.run.skillsDir === "string" ? t.run.skillsDir : null,
1358
+ packages: t.run.packages !== false,
1359
+ }))
1360
+ .filter((f) => typeof f.flow === "string"),
1227
1361
  };
1228
1362
  } catch {
1229
1363
  return none;
@@ -110,7 +110,7 @@ function resolveEnvName(provider, cred) {
110
110
  * `allowGlobalExtensions` defaults to TRUE here, matching loadConfig's default (REQ-GLOBAL-PI-OVERLAY): a
111
111
  * caller that says nothing gets the operator's staged setup, and only an explicit `false` withholds it.
112
112
  */
113
- export function buildContainerEnv({ provider, model, maxTurns, maxTokens, jobId, githubToken, forgeKind, forgeHosts = {}, hostEnv, allowGlobalExtensions = true, packagePaths = [], forwardEnv = [], sessionFile = null, authFromPi = false, agentDir, readFile = readFileSync }) {
113
+ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, jobId, githubToken, forgeKind, forgeHosts = {}, hostEnv, allowGlobalExtensions = true, packagePaths = [], forwardEnv = [], sessionFile = null, flow = null, command = null, authFromPi = false, agentDir, readFile = readFileSync }) {
114
114
  // The provider credential(s), by pi's expected variable name(s) -- from the worker env, or (when
115
115
  // PI_AUTH_FROM_PI is set and the env has none) host-side from pi's auth.json. Throws (config) if
116
116
  // neither source yields one, which the processor turns into a pre-spend refusal.
@@ -146,6 +146,22 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, jobId,
146
146
  // an empty string, for PI_PACKAGES' reason: an empty value is a third state neither side reads the
147
147
  // same way, and the one reading a container must not have to infer which was meant.
148
148
  PI_SESSION_FILE: sessionFile || undefined,
149
+ // The trigger's run.flow, STRUCTURALLY (issue #189). The flow already reaches the container as
150
+ // prompt prose ("Use the X skill"), but pi never matches prose against loaded skill names, so a
151
+ // flow that resolves in no tier runs to a clean exit 0 -- the silent no-op this repo brands the
152
+ // worst outcome available. This variable is what lets the runner compare the name against what
153
+ // actually loaded. It rides env and NOT event.json because an execution knob is not a fact about
154
+ // the delivery (see prepare-github.mjs on replicas). Absent means "no flow to verify" (a bare
155
+ // run.task cron job), never an empty string, for PI_PACKAGES' reason.
156
+ PI_FLOW: flow || undefined,
157
+ // The trigger's run.command, STRUCTURALLY (issue #189) -- PI_FLOW's twin: the runner compares it
158
+ // against the commands that actually registered and refuses an unregistered one before any spend,
159
+ // where the prompt's bare `/name` would otherwise read as prose and run to a clean exit 0. It
160
+ // rides env and NOT event.json for the same reason PI_FLOW does. Absent means "not a command
161
+ // job", never an empty string, for PI_PACKAGES' reason. PI_FLOW and PI_COMMAND are mutually
162
+ // exclusive by parse (command XOR flow); that is deliberately NOT re-enforced here -- a second
163
+ // validator is a second place to disagree with the first.
164
+ PI_COMMAND: command || undefined,
149
165
  // Kill switch for job-time package installation, UNCONDITIONAL for every job. pi's resolver shells out
150
166
  // to a REAL `npm install` for any npm:/git: source unless offline mode is on, and `~/.pi/agent` IS
151
167
  // writable in the container. We emit only local paths, so nothing should reach that branch -- this
@@ -33,6 +33,7 @@ export function resolveJobImage(job, defaultImage) {
33
33
  * { unavailable: image} -- docker itself did not answer => INFRA, retry
34
34
  * { forgeUnsupported } -- present, but declares it cannot serve this job's forge => POLICY, refuse
35
35
  * { replicaUnsupported }-- present, but does not declare replica support for a replica job => POLICY
36
+ * { commandUnsupported }-- present, but does not declare command support for a command job => POLICY
36
37
  *
37
38
  * A non-zero `docker image inspect` is AMBIGUOUS -- an absent image and an unreachable daemon both exit 1 --
38
39
  * so the failure path disambiguates POSITIVELY with `docker info` rather than by matching docker's stderr.
@@ -87,6 +88,14 @@ export function makeImagePreflight({ image, spawnFn = spawn }) {
87
88
  if (job?.replica !== undefined && !(capabilities ?? []).includes("replicas")) {
88
89
  return { replicaUnsupported: wanted, declared: capabilities ?? [] };
89
90
  }
91
+ // Same inclusion-list polarity as `replicas` directly above, and the same class of stale-image
92
+ // failure it guards (issue #189): a runner that predates run.command reads no PI_COMMAND, so
93
+ // the bare `/name args` prompt reaches the model as PROSE -- no handler runs, the agent
94
+ // improvises, and the queue records a clean exit 0. Unreachable for a commandless job, so the
95
+ // existing fleet pays nothing for it.
96
+ if (job?.command !== undefined && !(capabilities ?? []).includes("commands")) {
97
+ return { commandUnsupported: wanted, declared: capabilities ?? [] };
98
+ }
90
99
  return { ok: true, image: wanted, piVersion };
91
100
  }
92
101
  if ((await runDocker(spawnFn, ["info"])).code === 0) return { missing: wanted };
package/src/outbox.mjs CHANGED
@@ -119,6 +119,20 @@ export function makeCollectChain({ queue, enqueue = enqueueLocalJob, readFlowGat
119
119
  continue;
120
120
  }
121
121
 
122
+ // Commands are NEVER AI-reachable (issue #189): a request naming one refuses outright,
123
+ // with no opt-in to widen. The flow gate below reads a COMMITTED artifact -- the target
124
+ // repo's SKILL.md frontmatter at the pinned sha, merge-gated and reviewable -- but a
125
+ // command is an operator-staged pi extension with no committed artifact a gate could
126
+ // read, so there is nothing to gate ON and fail-closed is the only honest answer. First
127
+ // in the semantic ladder, before the charset check: which field the request used is
128
+ // decided before any opinion about its spelling. A completed command job's OWN /outbox
129
+ // may still chain INTO flows through the unchanged gate below; nothing chains into a
130
+ // command.
131
+ if (req.command !== undefined) {
132
+ refuse("chain-command-refused", i);
133
+ continue;
134
+ }
135
+
122
136
  // Explicit property reads ONLY -- `req` is never spread into job data.
123
137
  const flow = req.flow;
124
138
  const task = req.task;
package/src/packages.mjs CHANGED
@@ -24,8 +24,8 @@
24
24
  * manifest must degrade to "no staged packages", not crash the worker mid-queue.
25
25
  */
26
26
 
27
- import { existsSync, readFileSync } from "node:fs";
28
- import { join } from "node:path";
27
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
28
+ import { basename, dirname, join, resolve } from "node:path";
29
29
  import { configError } from "./config.mjs";
30
30
  // ENTRY_NAME_RE / ADMIN_RE are import-pi's -- imported rather than re-declared so the staged dir charset
31
31
  // and the admin block cannot drift between the stager and this validator (doctor.mjs sets the precedent
@@ -269,6 +269,89 @@ export function readStageManifest({ globalPiDir, readFile = readFileSync, fileEx
269
269
  }
270
270
  }
271
271
 
272
+ /**
273
+ * Enumerate the skills the staged packages would contribute to a job (issue #189). Returns
274
+ * `{ skills: [{ name, package, dir }], unenumerable: [packageName...] }`, and NEVER throws --
275
+ * readStageManifest's policy, because the consumers are advisory (doctor's per-trigger flow lines,
276
+ * and issue #188's topology) and a half-staged tree must degrade to "nothing visible", not a crash.
277
+ *
278
+ * The semantics mirror pi's collectPackageResources at the 0.80.7 pin EXACTLY, because an enumerator
279
+ * that agrees with pi by hand is how doctor comes to report a tier pi then ignores:
280
+ * - a `pi` manifest object means its `skills` entries are the ONLY sources -- a manifest WITHOUT a
281
+ * `skills` key contributes NO skills and gets NO convention fallback (readPiManifest short-circuits
282
+ * before the dir walk);
283
+ * - no `pi` key at all falls through to the convention `skills/` dir (RESOURCE_DIRS);
284
+ * - a manifest entry may be a SKILL.md file, a skill dir, or a dir of skill dirs -- pi walks
285
+ * recursively, so this walks the same shape (bounded, it is host-advisory);
286
+ * - a glob (`*`/`?`) or override (`!`/`+`/`-` prefix) entry makes the whole package UNENUMERABLE
287
+ * rather than guessed at: patterns can also DISABLE files, and a wrong ✓ (a skill reported that pi
288
+ * filters out) is the direction an advisory line must never err in. Reported, not silently skipped.
289
+ *
290
+ * Names are DIR basenames (or the SKILL.md's parent dir), which is pi's fallback naming rule; a
291
+ * frontmatter `name:` rename is invisible here. That approximation is deliberate and one-directional:
292
+ * it can only turn a would-be ✓ into a ⚠, and the runner's flow_not_loaded check compares against the
293
+ * names pi actually loaded (DES-FLOW-RESOLUTION-TWO-ADVISORY-LAYERS).
294
+ */
295
+ export function readStagedSkills({ globalPiDir, readFile = readFileSync, fileExists = existsSync, readDir = readdirSync } = {}) {
296
+ const out = { skills: [], unenumerable: [] };
297
+ const manifest = readStageManifest({ globalPiDir, readFile, fileExists });
298
+ if (!manifest) return out;
299
+
300
+ for (const pkg of manifest.packages) {
301
+ const root = join(globalPiDir, PACKAGES_SUBDIR, pkg.dir);
302
+ let sources;
303
+ try {
304
+ const parsed = JSON.parse(readFile(join(root, "package.json"), "utf8"));
305
+ const pi = parsed !== null && typeof parsed === "object" && parsed.pi !== null && typeof parsed.pi === "object" ? parsed.pi : null;
306
+ if (pi) {
307
+ const entries = Array.isArray(pi.skills) ? pi.skills.filter((e) => typeof e === "string") : [];
308
+ if (entries.some((e) => e.startsWith("!") || e.startsWith("+") || e.startsWith("-") || e.includes("*") || e.includes("?"))) {
309
+ out.unenumerable.push(pkg.name);
310
+ continue;
311
+ }
312
+ // `..`-carrying entries are dropped rather than resolved: the stager never writes one, and
313
+ // following one would make an advisory reader walk outside the staged tree. A segment
314
+ // test, not a prefix test, so it holds on Windows separators too (parsePackagePaths' rule).
315
+ sources = entries.filter((e) => !e.split(/[\\/]/).includes("..")).map((e) => resolve(root, e));
316
+ } else {
317
+ sources = [join(root, "skills")];
318
+ }
319
+ } catch {
320
+ continue; // no readable package.json: pi would skip it too
321
+ }
322
+ for (const source of sources) {
323
+ for (const skillFile of walkSkillFiles(source, fileExists, readDir)) {
324
+ out.skills.push({ name: basename(dirname(skillFile)), package: pkg.name, dir: pkg.dir });
325
+ }
326
+ }
327
+ }
328
+ return out;
329
+ }
330
+
331
+ /**
332
+ * The SKILL.md files under one manifest source, the shapes pi's collectFilesFromPaths accepts: the file
333
+ * itself, a dir holding SKILL.md, or a tree of skill dirs (walked to a small fixed depth -- pi recurses
334
+ * unbounded, but a host-advisory reader stops where real layouts stop). Never throws.
335
+ */
336
+ function walkSkillFiles(source, fileExists, readDir, depth = 3) {
337
+ if (basename(source) === "SKILL.md") return fileExists(source) ? [source] : [];
338
+ const found = [];
339
+ const own = join(source, "SKILL.md");
340
+ if (fileExists(own)) found.push(own);
341
+ if (depth === 0) return found;
342
+ let children;
343
+ try {
344
+ children = readDir(source, { withFileTypes: true });
345
+ } catch {
346
+ return found; // absent or unreadable: nothing visible here
347
+ }
348
+ for (const child of children) {
349
+ if (!child.isDirectory?.()) continue;
350
+ found.push(...walkSkillFiles(join(source, child.name), fileExists, readDir, depth - 1));
351
+ }
352
+ return found;
353
+ }
354
+
272
355
  /**
273
356
  * The CONTAINER paths of the staged packages, in manifest order -- what gets handed to pi as local package
274
357
  * specs. Built with template literals and never `path.join`: the worker may run on Windows, where `join`
@@ -190,6 +190,15 @@ export async function prepareGithubWorkspace(
190
190
  const session = job.resume === true ? resolveSession(job, { jobDir, resolved, piVersion }) : null;
191
191
 
192
192
  // Issue text is DATA: it enters the USER prompt (buildGithubPrompt), never a system prompt.
193
+ //
194
+ // A command job (issue #189) skips the per-forge envelope entirely: the prompt is the slash
195
+ // invocation `/${job.command}` and nothing else -- no data heading, no quoted issue text, and NO
196
+ // trailing newline, because pi hands everything after the first space to the handler as its
197
+ // argument string verbatim. CONST-ISSUE-TEXT-IS-DATA is preserved and arguably STRENGTHENED:
198
+ // payload text reaches a command job only as event.json below, a file the handler chooses to
199
+ // parse, never interpolated into prompt prose at all. The envelope's never-merge discipline is
200
+ // not lost either -- that discipline addresses MODEL prose, and a command handler is
201
+ // operator-staged code, the same trust tier extensions hold generally.
193
202
  writeFile(
194
203
  join(jobDir, "prompt.md"),
195
204
  // `replica`/`replicas` (REQ-REPLICA-RUNS) are host-assigned integers off job.data, so they are safe
@@ -198,7 +207,9 @@ export async function prepareGithubWorkspace(
198
207
  // them away -- harmless, and always undefined while replicas are github-only.
199
208
  // `review` rides beside `comment` and, like `replica`/`replicas`, is destructured away by the
200
209
  // gitlab/forgejo/azure builders -- harmless, and always undefined while reviews are github-only.
201
- buildPrompt({ flow: job.flow, target: job.target, comment: job.trigger?.comment, resumed: session?.resume === true, replica: job.replica, replicas: job.replicas, review: job.trigger?.review, instructions: job.instructions }),
210
+ job.command
211
+ ? `/${job.command}`
212
+ : buildPrompt({ flow: job.flow, target: job.target, comment: job.trigger?.comment, resumed: session?.resume === true, replica: job.replica, replicas: job.replicas, review: job.trigger?.review, instructions: job.instructions }),
202
213
  { mode: 0o444 },
203
214
  );
204
215
 
package/src/prepare.mjs CHANGED
@@ -93,10 +93,19 @@ export function makePrepareWorkspace({
93
93
  // flow can discover the trigger context (mirroring the github prompt, which names the same
94
94
  // file); nothing in-container reads it otherwise. The pointer sits AFTER the flow hint and
95
95
  // BEFORE the operator's task, which stays verbatim (CONST-ISSUE-TEXT-IS-DATA).
96
+ //
97
+ // A command job (issue #189) bypasses ALL of that: the prompt is the slash invocation itself,
98
+ // `/name args`, and nothing else -- no pointer line, no task, and critically NO trailing
99
+ // newline, because pi hands everything after the first space to the handler as its argument
100
+ // string verbatim, so a newline appended here would land inside the args. The pointer is
101
+ // prose addressed to a MODEL reading instructions; a command handler is code, and its context
102
+ // channel is /job/event.json itself, which prepare-local already writes for every local job.
96
103
  const pointer = "Context about this run -- its trigger and schedule -- is in /job/event.json.\n\n";
97
- const task = job.flow
98
- ? `Use the "${job.flow}" skill for this task.\n\n${pointer}${job.task ?? ""}`
99
- : `${pointer}${job.task ?? ""}`;
104
+ const task = job.command
105
+ ? `/${job.command}`
106
+ : job.flow
107
+ ? `Use the "${job.flow}" skill for this task.\n\n${pointer}${job.task ?? ""}`
108
+ : `${pointer}${job.task ?? ""}`;
100
109
  const event = localEventContext(job, queueJobId, findPreviousRun);
101
110
  return discardOnPolicy(stampSandbox(await prepareLocal({ folder: job.folder, task, jobDir, event }), sandbox), jobDir);
102
111
  }
package/src/processor.mjs CHANGED
@@ -159,6 +159,23 @@ export async function runJob(job, deps) {
159
159
  log("refused_image_replicas_unsupported", { image: img.replicaUnsupported, declared: img.declared });
160
160
  return { outcome: "policy", reason: "job-image-replicas-unsupported", exitCode: null, turns: null, tokens: null, provider: job.provider ?? null, model: job.model ?? null, budgetReserved: false }; // return => not retried
161
161
  }
162
+ if (img.commandUnsupported) {
163
+ // The image is present and does not declare command support (issue #189), so its runner
164
+ // predates run.command: it reads no PI_COMMAND, and the bare `/name args` prompt reaches the
165
+ // model as PROSE -- no handler runs, the agent improvises, and the queue records a clean exit
166
+ // 0. The in-container half of the gate (the runner's own command-unregistered refusal) does
167
+ // not exist on such an image, which is exactly why the host must refuse first.
168
+ //
169
+ // Determinate, so a refusal rather than a retry, and pre-spend, because no version of this
170
+ // gets better by running. Like the replica branch above, the message names the FIX rather
171
+ // than the label that noticed it.
172
+ await comment(
173
+ job,
174
+ `Refused: the job image "${img.commandUnsupported}" does not declare command support (\`dev.pi-dispatch.capabilities\` ${img.declared.length > 0 ? `declares: ${img.declared.join(", ")}` : "is absent"}), so its runner would not dispatch \`run.command\`. Rebuild the image from a version that has this feature. Not run.`,
175
+ );
176
+ log("refused_image_commands_unsupported", { image: img.commandUnsupported, declared: img.declared });
177
+ return { outcome: "policy", reason: "job-image-commands-unsupported", exitCode: null, turns: null, tokens: null, provider: job.provider ?? null, model: job.model ?? null, budgetReserved: false }; // return => not retried
178
+ }
162
179
  if (img.unavailable) {
163
180
  // docker itself did not answer -- transient infra, NOT a determinate refusal. THROWN so BullMQ
164
181
  // retries (CONST-RETRY-INFRA-ONLY). `container-never-started` is literally true here, and it reuses
package/src/queue.mjs CHANGED
@@ -16,11 +16,16 @@ export function makeQueue(connection) {
16
16
  * removeOnComplete keeps the dedup window ~= the retention. Unlike webhooks, local jobs are not
17
17
  * redelivered, so a modest window is enough.
18
18
  */
19
- export async function enqueueLocalJob(queue, { folder, flow, task, provider, model, maxTurns, image, skillsDir, chainDepth, parentJobId, jobId, now = new Date() }) {
19
+ export async function enqueueLocalJob(queue, { folder, flow, task, command, provider, model, maxTurns, image, skillsDir, chainDepth, parentJobId, jobId, now = new Date() }) {
20
20
  const minute = now.toISOString().slice(0, 16); // YYYY-MM-DDTHH:MM -- the dedup window
21
21
  // A caller-supplied jobId (the outbox collector's retry-idempotent chainedJobId) wins; otherwise the
22
- // minute-windowed localJobId is the dedup key.
23
- const id = jobId ?? localJobId({ folder, flow, task, minute });
22
+ // minute-windowed localJobId is the dedup key. A command job (issue #189) fills the flow slot with
23
+ // `cmd:<command>` rather than leaving it empty: a command trigger carries no flow/task, so without it
24
+ // two DIFFERENT commands on one folder in one minute would hash identically and the second would
25
+ // vanish silently -- and the `cmd:` prefix keeps a command named X from colliding with a flow named X
26
+ // (`:` is outside the skill-name charset, so no real flow can spell the prefixed form). A flow job's
27
+ // key is byte-identical to before the feature.
28
+ const id = jobId ?? localJobId({ folder, flow: command !== undefined ? `cmd:${command}` : flow, task, minute });
24
29
  // image/chainDepth/parentJobId land on `data` only when present, so a plain non-chained job's data is
25
30
  // byte-identical. `image` is the container image this job runs in (INT-TRIGGERS-FILE-CONTRACT); absent
26
31
  // resolves the deployment default at job start, never a value frozen here.
@@ -29,6 +34,10 @@ export async function enqueueLocalJob(queue, { folder, flow, task, provider, mod
29
34
  folder,
30
35
  flow,
31
36
  task,
37
+ // The registered pi command this job dispatches instead of a flow (issue #189). Conditional like
38
+ // `image`, so a flow job's data keeps exactly the keys it has today; the parse-level XOR means a
39
+ // job carrying it has flow/task undefined, which JSON serialization drops.
40
+ ...(command !== undefined && { command }),
32
41
  provider,
33
42
  model,
34
43
  maxTurns,
@@ -115,7 +124,7 @@ export async function enqueueGitLabJob(queue, fields) {
115
124
  * window, replicas never coalesce against each other, and an unflagged job's dedup id is the same string it
116
125
  * has always been.
117
126
  */
118
- export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, target, flow, trigger, provider, model, maxTurns, packages, image, skillsDir, instructions, resume, replica, replicas }) {
127
+ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, target, flow, command, trigger, provider, model, maxTurns, packages, image, skillsDir, instructions, resume, replica, replicas }) {
119
128
  const jobId = forgeDeliveryJobId(kind, trigger?.deliveryId, replica);
120
129
  // `packages` (whether to load the operator-staged pi packages) and `image` (which container image to run)
121
130
  // come off the MATCHED trigger (INT-TRIGGERS-FILE-CONTRACT / REQ-GLOBAL-PI-OVERLAY) and land on `data`
@@ -132,6 +141,11 @@ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, tar
132
141
  ...(azure !== undefined && { azure }),
133
142
  target,
134
143
  flow,
144
+ // The registered pi command this trigger dispatches instead of a flow (issue #189). Conditional
145
+ // like `packages`/`image` below, so an unflagged trigger's job data is byte-identical -- and at
146
+ // JOB level, never inside `trigger`, for their reason too: an execution knob is not a fact about
147
+ // the delivery, and `trigger` is copied verbatim into /job/event.json.
148
+ ...(command !== undefined && { command }),
135
149
  trigger,
136
150
  provider,
137
151
  model,
@@ -156,7 +170,13 @@ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, tar
156
170
  };
157
171
  await queue.add(kind, data, {
158
172
  jobId,
159
- deduplication: { id: `${repo}${targetSeparator(kind, target?.type)}${target.number}:${flow}${replica !== undefined ? `:r${replica}` : ""}`, ttl: SEMANTIC_WINDOW_MS }, // ttl in ms
173
+ // A command job (issue #189) fills the semantic key's flow slot with `cmd:<command>`: a command
174
+ // trigger carries no flow, so the slot would otherwise read `undefined` for every command and one
175
+ // command's 10-minute window would swallow a different command's delivery on the same target. The
176
+ // `cmd:` prefix keeps a command named X from coalescing against a flow named X -- `:` is outside
177
+ // the skill-name charset, so no real flow can spell the prefixed form -- and a flow job's key
178
+ // stays byte-identical to before the feature.
179
+ deduplication: { id: `${repo}${targetSeparator(kind, target?.type)}${target.number}:${command !== undefined ? `cmd:${command}` : flow}${replica !== undefined ? `:r${replica}` : ""}`, ttl: SEMANTIC_WINDOW_MS }, // ttl in ms
160
180
  attempts: 2,
161
181
  backoff: { type: "exponential", delay: 60_000 },
162
182
  removeOnComplete: { age: 31 * 24 * 3600 }, // age in seconds -- do not cross units with the ms ttl above
@@ -74,6 +74,13 @@ export function makeRunContainer({
74
74
  // The constant is imported rather than re-typed so the mount below and this variable name one
75
75
  // path -- two literals is how they drift with both suites green.
76
76
  sessionFile: prepared.session ? CONTAINER_SESSION_FILE : undefined,
77
+ // Issue #189: the flow name, structurally, so the runner can verify it against the loaded
78
+ // skill set. Off `job` like maxTurns; absent (a bare run.task cron job) emits no variable.
79
+ flow: typeof job.flow === "string" && job.flow.trim() !== "" ? job.flow : undefined,
80
+ // Issue #189: the command name, structurally, so the runner can refuse an unregistered one
81
+ // before any spend (command-unregistered). Same guard shape as `flow` directly above, and
82
+ // mutually exclusive with it by parse -- a job carries one or the other, never both.
83
+ command: typeof job.command === "string" && job.command.trim() !== "" ? job.command : undefined,
77
84
  authFromPi, // source the provider key from pi's auth.json when the env has none
78
85
  });
79
86
 
package/src/schedules.mjs CHANGED
@@ -68,7 +68,13 @@ function normalizeCronSchedule({ on, run }, path, existsSync) {
68
68
  // cron-only field: it is carried into the local `/job/event.json` (INT-CONTAINER-JOB-INPUTS) so a
69
69
  // scheduled job can name its own trigger; the INT-TRIGGERS-FILE-CONTRACT byte-match acceptance is
70
70
  // amended for exactly this field.
71
- const data = { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages: run.packages, image: run.image, ...(run.skillsDir !== undefined && { skillsDir: run.skillsDir }), resume: run.resume, trigger: { id: on.id, pattern: on.pattern } };
71
+ //
72
+ // `command` (issue #189) is conditional like `skillsDir`, not present-and-undefined like flow/task,
73
+ // and both spellings serve the same byte-identity: a flow trigger's stored repeatable must not grow a
74
+ // key. A command trigger carries no flow/task at all (the validator enforces the XOR), so those two
75
+ // keys hold undefined here and drop at JSON serialization -- the command schedule's data is exactly
76
+ // kind/folder/command plus the shared fields.
77
+ const data = { kind: "local", folder: run.folder, flow: run.flow, task: run.task, ...(run.command !== undefined && { command: run.command }), provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages: run.packages, image: run.image, ...(run.skillsDir !== undefined && { skillsDir: run.skillsDir }), resume: run.resume, trigger: { id: on.id, pattern: on.pattern } };
72
78
  // Retention only; the deterministic repeat:<id>:<millis> jobId supplies dedup, so no jobId here, and
73
79
  // scheduler jobs are not retried (DES-CRON-VIA-BULLMQ-SCHEDULER) so no attempts/backoff.
74
80
  const opts = { removeOnComplete: { age: 24 * 3600 }, removeOnFail: { age: 7 * 24 * 3600 } };
package/src/triggers.mjs CHANGED
@@ -191,13 +191,20 @@ function normalizeCron(on, run, index, path, state) {
191
191
  throw configError(`cron trigger "${id}": on.pattern must have 5 or 6 space-separated fields, got ${fieldCount}: ${path}`);
192
192
  }
193
193
 
194
+ // FIRST among the run checks (all four normalizers do this), so a command-only entry is never told to
195
+ // add the flow it deliberately does not have, and a flow+command entry gets the exclusion message
196
+ // rather than whichever single-field check happens to run first.
197
+ const command = validateCommand(run, `cron trigger "${id}"`, path, { onType: "cron" });
198
+
194
199
  if (!isNonEmptyString(run.folder)) {
195
200
  throw configError(`cron trigger "${id}": run.folder must be a non-empty string: ${path}`);
196
201
  }
197
- if (!isNonEmptyString(run.flow)) {
198
- throw configError(`cron trigger "${id}": run.flow must be a non-empty string: ${path}`);
202
+ if (command === undefined && !isNonEmptyString(run.flow)) {
203
+ throw configError(`cron trigger "${id}": run.flow must be a non-empty string (or use run.command): ${path}`);
199
204
  }
200
- if (!isNonEmptyString(run.task)) {
205
+ // Gated on the flow path only: a command job's prompt IS the command line, and validateCommand has
206
+ // already refused any run.task written beside one.
207
+ if (command === undefined && !isNonEmptyString(run.task)) {
201
208
  throw configError(`cron trigger "${id}": run.task must be a non-empty string: ${path}`);
202
209
  }
203
210
 
@@ -229,7 +236,7 @@ function normalizeCron(on, run, index, path, state) {
229
236
  // freeze today's default into every stored repeatable.
230
237
  return {
231
238
  on: { type: "cron", id, pattern },
232
- run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages, image, resume, ...(skillsDir !== undefined && { skillsDir }) },
239
+ run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages, image, resume, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }) },
233
240
  };
234
241
  }
235
242
 
@@ -450,6 +457,85 @@ function validateInstructions(run, at, path, { cron = false } = {}) {
450
457
  return text;
451
458
  }
452
459
 
460
+ /**
461
+ * `run.command` (issue #189): dispatch a REGISTERED pi extension command headlessly, instead of a flow.
462
+ * The runner half is already merged: it exports the name via PI_COMMAND, refuses pre-spend when
463
+ * `getCommand` does not know it, and prompts with the exact `/name args` line and nothing else, so the
464
+ * arguments reach the command handler precisely as written here. Shared by all four normalizers so both
465
+ * services refuse the same file identically; the runner re-validates at the paid boundary regardless.
466
+ *
467
+ * EXACTLY ONE of run.flow / run.command, and the exclusion is checked BEFORE every normalizer's
468
+ * flow-required check on purpose: the two mistakes need their own messages. A flow+command entry no
469
+ * longer says which one runs and must hear that, not whichever single-field complaint fires first; a
470
+ * command-only entry must never be told to add the flow it deliberately does not have.
471
+ *
472
+ * The value rules each refuse something specific:
473
+ * - no leading "/": the runner PREPENDS the slash when it builds the prompt, so a written one would
474
+ * dispatch "//name" -- a command no registry holds, refused only after review already passed it.
475
+ * - surrounding whitespace is REFUSED, never trimmed (validateImageRef's rule): the arguments pass to
476
+ * the handler verbatim, so a silent trim is the reviewed file disagreeing with what runs.
477
+ * - no control characters. A newline would smuggle a SECOND line into what the operator reviewed as
478
+ * one command line, and the whole class is refused rather than the newline alone because every
479
+ * member is invisible in review, which is the hazard. The regex is spelled in \u escapes for the
480
+ * same reason: a literal ESC in this source would be exactly the unreviewable byte it refuses.
481
+ *
482
+ * Cross-field refusals, validateReplicas' posture (a field accepted where it does nothing is one an
483
+ * operator sets and then trusts):
484
+ * - run.task on cron: a command job's prompt IS the `/name args` line, so there is no task text for
485
+ * the runner to render -- two prompts written for one job, with only one ever sent.
486
+ * - run.instructions on the webhook kinds: instructions land in the prompt ENVELOPE, which a command
487
+ * job bypasses entirely, so nothing would render them. (Cron refuses instructions already, with its
488
+ * own run.task message, and that refusal stays the one a cron entry gets.)
489
+ * - run.resume: true on any kind: what a resumed session should do with a re-dispatched command is
490
+ * UNDESIGNED -- "not yet covered", validateResumeFlag's own vocabulary, because it is a gap to
491
+ * close and not a limit. Only `true` is refused; `false` is the documented default and refusing it
492
+ * would refuse an operator for writing down the behaviour they already have.
493
+ *
494
+ * Everything else stays orthogonal on purpose -- replicas, image, packages, skillsDir, repository,
495
+ * github. Those gate the CONTAINER a job runs in, and a command job runs in the same container a flow
496
+ * job does.
497
+ *
498
+ * A COMMENT command trigger has no default flow, which leaves the receiver's `<phrase> <flow>` comment
499
+ * override with nothing to override. That token is made inert by the receiver's FILTER, not refused
500
+ * here: the override lives in adversarial comment text, which this file-shape validator never sees.
501
+ *
502
+ * `at` is the caller's message prefix, `onType` selects the cross-field set. Returns the command,
503
+ * undefined when absent, and the callers spread it conditionally: a flow trigger must not grow the key
504
+ * at all, so an unflagged file normalizes byte-identically to today's (deepEqual pins depend on it).
505
+ */
506
+ function validateCommand(run, at, path, { onType }) {
507
+ const raw = run.command;
508
+ if (run.flow !== undefined && raw !== undefined) {
509
+ throw configError(`${at}: exactly one of run.flow or run.command must be set -- a trigger dispatches either a flow or a registered command, and with both present the file does not say which one runs: ${path}`);
510
+ }
511
+ if (raw === undefined) return undefined;
512
+ if (!isNonEmptyString(raw)) {
513
+ throw configError(`${at}: run.command must be a non-empty string -- the registered command name, optionally followed by its arguments: ${path}`);
514
+ }
515
+ if (raw !== raw.trim()) {
516
+ throw configError(`${at}: run.command must not have leading or trailing whitespace -- the arguments reach the command handler verbatim, so trimming here would make the reviewed file disagree with what runs (got ${JSON.stringify(raw)}): ${path}`);
517
+ }
518
+ if (raw.startsWith("/")) {
519
+ throw configError(`${at}: run.command must not start with "/" -- the runner prepends the slash when it builds the /name args prompt, so a written one would dispatch "//name" (got ${JSON.stringify(raw)}): ${path}`);
520
+ }
521
+ // The class is the RUNNER's (parseCommand, image/runner/src/config.mjs), DEL included: the two
522
+ // must refuse identically, or a value that loads here refuses in-container with the budget
523
+ // slot already burned -- the drift INT-TRIGGERS-FILE-CONTRACT promises cannot happen.
524
+ if (/[\u0000-\u001F\u007F]/.test(raw)) {
525
+ throw configError(`${at}: run.command must not contain control characters -- a newline would smuggle a second line into what the operator reviewed as one command line (got ${JSON.stringify(raw)}): ${path}`);
526
+ }
527
+ if (onType === "cron" && run.task !== undefined) {
528
+ throw configError(`${at}: run.command and run.task cannot be combined -- a command job's prompt IS the /name args line, so there is no task text for the runner to render; put the arguments in run.command: ${path}`);
529
+ }
530
+ if (onType !== "cron" && run.instructions !== undefined) {
531
+ throw configError(`${at}: run.command and run.instructions cannot be combined -- instructions render into the prompt envelope, and a command job's prompt is the exact /name args line with no envelope, so nothing would render them: ${path}`);
532
+ }
533
+ if (run.resume === true) {
534
+ throw configError(`${at}: combining run.command and run.resume is not yet covered -- what a resumed session should do with a re-dispatched command is undesigned, so this is a gap to close, not a limit: ${path}`);
535
+ }
536
+ return raw;
537
+ }
538
+
453
539
  /**
454
540
  * Validate an `{any, all, none}` label predicate. Selectors are validated as arrays of non-empty strings
455
541
  * BEFORE the positive-selector count, because `.length` is truthy on a string too -- a string selector
@@ -553,8 +639,10 @@ function validateReplicas(run, at, path) {
553
639
  function normalizeLabel(on, run, index, path) {
554
640
  const at = `trigger at index ${index}`;
555
641
  const predicate = validatePredicate(on, index, path, true);
556
- if (!isNonEmptyString(run.flow)) {
557
- throw configError(`${at}: label trigger run.flow must be a non-empty string: ${path}`);
642
+ // First among the run checks, before the flow-required check -- validateCommand says why.
643
+ const command = validateCommand(run, at, path, { onType: "label" });
644
+ if (command === undefined && !isNonEmptyString(run.flow)) {
645
+ throw configError(`${at}: label trigger run.flow must be a non-empty string (or use run.command): ${path}`);
558
646
  }
559
647
  const packages = validatePackagesFlag(run, at, path);
560
648
  const image = validateImageRef(run, at, path);
@@ -565,7 +653,7 @@ function normalizeLabel(on, run, index, path) {
565
653
  const replicas = validateReplicas(run, at, path);
566
654
  return {
567
655
  on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none },
568
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }) },
656
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }) },
569
657
  };
570
658
  }
571
659
 
@@ -574,8 +662,12 @@ function normalizeComment(on, run, index, path, state) {
574
662
  if (!isNonEmptyString(on.phrase)) {
575
663
  throw configError(`${at}: comment trigger on.phrase must be a non-empty string: ${path}`);
576
664
  }
577
- if (!isNonEmptyString(run.flow)) {
578
- throw configError(`${at}: comment trigger run.flow (the default flow) must be a non-empty string: ${path}`);
665
+ // First among the run checks, before the flow-required check -- validateCommand says why. A command
666
+ // trigger has NO default flow for the `<phrase> <flow>` comment override to replace; making that
667
+ // token inert is the receiver filter's job, not a shape this validator can see.
668
+ const command = validateCommand(run, at, path, { onType: "comment" });
669
+ if (command === undefined && !isNonEmptyString(run.flow)) {
670
+ throw configError(`${at}: comment trigger run.flow (the default flow) must be a non-empty string (or use run.command): ${path}`);
579
671
  }
580
672
  // At most one comment trigger PER FORGE. The cap exists because the receiver holds one comment rule
581
673
  // per forge and a second would be silently unreachable -- so it is a cap on ambiguity, not on count,
@@ -593,7 +685,7 @@ function normalizeComment(on, run, index, path, state) {
593
685
  const replicas = validateReplicas(run, at, path);
594
686
  return {
595
687
  on: { type: "comment", phrase: on.phrase },
596
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }) },
688
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }) },
597
689
  };
598
690
  }
599
691
 
@@ -634,8 +726,10 @@ function normalizePullRequest(on, run, index, path) {
634
726
  throw configError(`${at}: an azure pull_request trigger cannot carry a label predicate -- Azure DevOps attaches tags to work items, never to pull requests, so any/all/none could never match: ${path}`);
635
727
  }
636
728
  const predicate = validatePredicate(on, index, path, requirePositive);
637
- if (!isNonEmptyString(run.flow)) {
638
- throw configError(`${at}: pull_request trigger run.flow must be a non-empty string: ${path}`);
729
+ // First among the run checks, before the flow-required check -- validateCommand says why.
730
+ const command = validateCommand(run, at, path, { onType: "pull_request" });
731
+ if (command === undefined && !isNonEmptyString(run.flow)) {
732
+ throw configError(`${at}: pull_request trigger run.flow must be a non-empty string (or use run.command): ${path}`);
639
733
  }
640
734
  const packages = validatePackagesFlag(run, at, path);
641
735
  const image = validateImageRef(run, at, path);
@@ -655,7 +749,7 @@ function normalizePullRequest(on, run, index, path) {
655
749
  all: predicate.all,
656
750
  none: predicate.none,
657
751
  },
658
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }) },
752
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }) },
659
753
  };
660
754
  }
661
755