@edgehero/pi-dispatch-admin 0.5.0 → 0.6.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/README.md CHANGED
@@ -46,7 +46,7 @@ Every trigger is one `{ on, run }` entry in a single `triggers.json`, read live
46
46
 
47
47
  Four forges: GitHub, GitLab, Forgejo (and Gitea), Azure DevOps. **Who may fire a trigger is your forge's decision, not this service's**: on GitHub the label *is* the approval, because only collaborators can apply one, while GitLab, Forgejo and Azure resolve the actor's permission through their APIs. Each forge's action vocabulary is validated when the file loads, so a word from the wrong forge is refused instead of silently never matching.
48
48
 
49
- **Flows, and workflows.** `run.flow` names a skill committed to the target repo at `.pi/skills/<flow>/SKILL.md`, read from the **default branch**, so the repo owns the prompt and merging it is the repo's consent. A skill may call other skills, which is already a workflow. For typed multi-stage ones, a pi extension such as `@juicesharp/rpiv-workflow` can be staged into the deployment: pinned to an exact version, installed on your host (never at job time, since jobs run offline), loaded in every container, and declinable per trigger.
49
+ **Flows, and workflows.** `run.flow` names a skill committed to the target repo at `.pi/skills/<flow>/SKILL.md`, read from the **default branch**, so the repo owns the prompt and merging it is the repo's consent. A skill may call other skills, which is already a workflow. For typed multi-stage ones, a pi extension such as `@juicesharp/rpiv-workflow` can be staged into the deployment: pinned to an exact version, installed on your host (never at job time, since jobs run offline), loaded in every container, and declinable per trigger. Anything you already installed with `pi install` is staged automatically, at the version your host has, so there is nothing to declare twice.
50
50
 
51
51
  **How a workflow gets triggered, in one line:** `label / comment / PR / cron` fires **one job in one container**, that job runs `run.flow`, and the flow drives whatever stages follow. Four consequences worth knowing before you build on it. A trigger names a **flow, never a workflow**, so which stages run is decided inside the job by that skill. A job is **not an interactive session** (the container hands pi one assembled prompt and reads the exit line), so a workflow extension's slash command has nobody to type it: in a job the flow's instructions drive the workflow, or a small extension you also staged calls its API from a lifecycle hook. One trigger is **one job, one budget slot and one turn budget**, so ten stages share the same `PI_MAX_TURNS` and per-job token budget, and exhausting either ends the job as a policy refusal that is never retried. And whether the workflow's own state survives depends on the trigger kind: a cron or CLI job has your folder mounted read-write so state persists between runs, while a forge job gets a fresh clone that is discarded with the container. Full reference: [`docs/workflows.md`](https://github.com/edgehero/pi-dispatch/blob/main/docs/workflows.md).
52
52
 
package/dist/index.mjs CHANGED
@@ -7935,11 +7935,13 @@ function normalizeCron(on, run, index, path, state) {
7935
7935
  }
7936
7936
  const packages = validatePackagesFlag(run, `cron trigger "${id}"`, path);
7937
7937
  const image = validateImageRef(run, `cron trigger "${id}"`, path);
7938
+ const skillsDir = validateSkillsDir(run, `cron trigger "${id}"`, path);
7939
+ validateInstructions(run, `cron trigger "${id}"`, path, { cron: true });
7938
7940
  const resume = validateResumeFlag(run, `cron trigger "${id}"`, path);
7939
7941
  validateReplicas(run, `cron trigger "${id}"`, path);
7940
7942
  return {
7941
7943
  on: { type: "cron", id, pattern },
7942
- 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 }
7944
+ 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 !== void 0 && { skillsDir } }
7943
7945
  };
7944
7946
  }
7945
7947
  function validatePackagesFlag(run, at, path) {
@@ -7973,6 +7975,31 @@ function validateImageRef(run, at, path) {
7973
7975
  }
7974
7976
  return image;
7975
7977
  }
7978
+ function validateSkillsDir(run, at, path) {
7979
+ const dir = run.skillsDir;
7980
+ if (dir === void 0) return void 0;
7981
+ if (typeof dir !== "string" || dir.trim() === "") {
7982
+ throw configError(`${at}: run.skillsDir must be a non-empty string when present: ${path}`);
7983
+ }
7984
+ if (dir !== dir.trim()) {
7985
+ throw configError(`${at}: run.skillsDir must not have leading or trailing whitespace (got ${JSON.stringify(dir)}): ${path}`);
7986
+ }
7987
+ return dir;
7988
+ }
7989
+ function validateInstructions(run, at, path, { cron = false } = {}) {
7990
+ const text = run.instructions;
7991
+ if (text === void 0) return void 0;
7992
+ if (cron) {
7993
+ throw configError(`${at}: run.instructions is not accepted on a cron trigger -- a local job's prompt IS run.task, the same operator-authored text in the same place. Put the standing instruction at the top of run.task: ${path}`);
7994
+ }
7995
+ if (typeof text !== "string" || text.trim() === "") {
7996
+ throw configError(`${at}: run.instructions must be a non-empty string when present: ${path}`);
7997
+ }
7998
+ if (text.length > INSTRUCTIONS_MAX) {
7999
+ throw configError(`${at}: run.instructions is ${text.length} characters, over the ${INSTRUCTIONS_MAX} cap -- a standing instruction is a sentence or two. Anything longer belongs in the flow's own SKILL.md, or in the global overlay's APPEND_SYSTEM.md if it applies to every job: ${path}`);
8000
+ }
8001
+ return text;
8002
+ }
7976
8003
  function validatePredicate(on, index, path, requirePositive) {
7977
8004
  const at = `trigger at index ${index}`;
7978
8005
  for (const key of ["any", "all", "none"]) {
@@ -8027,12 +8054,14 @@ function normalizeLabel(on, run, index, path) {
8027
8054
  }
8028
8055
  const packages = validatePackagesFlag(run, at, path);
8029
8056
  const image = validateImageRef(run, at, path);
8057
+ const skillsDir = validateSkillsDir(run, at, path);
8058
+ const instructions = validateInstructions(run, at, path);
8030
8059
  const resume = validateResumeFlag(run, at, path);
8031
8060
  const repository = validateRepository(run, "label", at, path);
8032
8061
  const replicas = validateReplicas(run, at, path);
8033
8062
  return {
8034
8063
  on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none },
8035
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...repository !== void 0 && { repository } }
8064
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository } }
8036
8065
  };
8037
8066
  }
8038
8067
  function normalizeComment(on, run, index, path, state) {
@@ -8049,12 +8078,14 @@ function normalizeComment(on, run, index, path, state) {
8049
8078
  }
8050
8079
  const packages = validatePackagesFlag(run, at, path);
8051
8080
  const image = validateImageRef(run, at, path);
8081
+ const skillsDir = validateSkillsDir(run, at, path);
8082
+ const instructions = validateInstructions(run, at, path);
8052
8083
  const resume = validateResumeFlag(run, at, path);
8053
8084
  const repository = validateRepository(run, "comment", at, path);
8054
8085
  const replicas = validateReplicas(run, at, path);
8055
8086
  return {
8056
8087
  on: { type: "comment", phrase: on.phrase },
8057
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...repository !== void 0 && { repository } }
8088
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository } }
8058
8089
  };
8059
8090
  }
8060
8091
  function normalizePullRequest(on, run, index, path) {
@@ -8070,6 +8101,7 @@ function normalizePullRequest(on, run, index, path) {
8070
8101
  throw configError(`${at}: pull_request on.action has an unsupported ${run.kind} action ${JSON.stringify(a)} (expected ${expected}): ${path}`);
8071
8102
  }
8072
8103
  }
8104
+ const reviewState = validateReviewState(on, actions, run, at, path);
8073
8105
  const labelAction = forgeSpec(run.kind)?.prLabelAction;
8074
8106
  const requirePositive = typeof labelAction === "string" && actions.includes(labelAction);
8075
8107
  if (run.kind === "azure" && (on.any !== void 0 || on.all !== void 0 || on.none !== void 0)) {
@@ -8081,22 +8113,55 @@ function normalizePullRequest(on, run, index, path) {
8081
8113
  }
8082
8114
  const packages = validatePackagesFlag(run, at, path);
8083
8115
  const image = validateImageRef(run, at, path);
8116
+ const skillsDir = validateSkillsDir(run, at, path);
8117
+ const instructions = validateInstructions(run, at, path);
8084
8118
  const resume = validateResumeFlag(run, at, path);
8085
8119
  validateRepository(run, "pull_request", at, path);
8086
8120
  const replicas = validateReplicas(run, at, path);
8087
8121
  return {
8088
- on: { type: "pull_request", action: [...actions], any: predicate.any, all: predicate.all, none: predicate.none },
8089
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas }
8122
+ on: {
8123
+ type: "pull_request",
8124
+ action: [...actions],
8125
+ // Absent rather than present-and-undefined: an unnarrowed rule's normalized shape must stay
8126
+ // byte-identical to the one every pre-#66 trigger file produces.
8127
+ ...reviewState !== void 0 && { reviewState },
8128
+ any: predicate.any,
8129
+ all: predicate.all,
8130
+ none: predicate.none
8131
+ },
8132
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions } }
8090
8133
  };
8091
8134
  }
8092
- var ON_TYPES, PR_ACTIONS, ID_CHARSET, REPLICAS_MAX;
8135
+ function validateReviewState(on, actions, run, at, path) {
8136
+ if (on.reviewState === void 0) return void 0;
8137
+ if (run.kind !== "github") {
8138
+ throw configError(`${at}: on.reviewState is github-only (got ${run.kind}), no other forge reports a review verdict: ${path}`);
8139
+ }
8140
+ if (!actions.includes(REVIEW_ACTION)) {
8141
+ throw configError(`${at}: on.reviewState requires on.action to include ${JSON.stringify(REVIEW_ACTION)}, otherwise it narrows nothing: ${path}`);
8142
+ }
8143
+ if (!Array.isArray(on.reviewState) || on.reviewState.length === 0) {
8144
+ throw configError(`${at}: on.reviewState must be a non-empty array: ${path}`);
8145
+ }
8146
+ const expected = [...REVIEW_STATES].join("|");
8147
+ for (const s of on.reviewState) {
8148
+ if (!REVIEW_STATES.has(s)) {
8149
+ throw configError(`${at}: on.reviewState has an unsupported review state ${JSON.stringify(s)} (expected ${expected}): ${path}`);
8150
+ }
8151
+ }
8152
+ return [...on.reviewState];
8153
+ }
8154
+ var ON_TYPES, PR_ACTIONS, REVIEW_STATES, REVIEW_ACTION, ID_CHARSET, REPLICAS_MAX, INSTRUCTIONS_MAX;
8093
8155
  var init_triggers = __esm({
8094
8156
  "../worker/src/triggers.mjs"() {
8095
8157
  init_config2();
8096
8158
  init_forges();
8097
8159
  ON_TYPES = /* @__PURE__ */ new Set(["cron", "label", "comment", "pull_request"]);
8098
8160
  PR_ACTIONS = {
8099
- github: /* @__PURE__ */ new Set(["labeled", "opened", "synchronize", "reopened"]),
8161
+ github: /* @__PURE__ */ new Set(["labeled", "opened", "synchronize", "reopened", "review_submitted"]),
8162
+ // GitLab's `approved` is its review gate (a member approved the MR). It is NOT github's
8163
+ // `review_submitted` renamed: `approved` is one verdict, `review_submitted` is every verdict, which is
8164
+ // what `on.reviewState` below exists to narrow.
8100
8165
  gitlab: /* @__PURE__ */ new Set(["open", "update", "reopen", "approved"]),
8101
8166
  // Forgejo's own spellings. `label_updated` is its `labeled` and `synchronized` its `synchronize` -- a
8102
8167
  // one-letter difference that an operator would otherwise discover as a trigger that loads clean and
@@ -8109,8 +8174,11 @@ var init_triggers = __esm({
8109
8174
  // requests -- which is why azure's `prLabelAction` is null and a predicated PR rule is refused below.
8110
8175
  azure: /* @__PURE__ */ new Set(["created", "updated"])
8111
8176
  };
8177
+ REVIEW_STATES = /* @__PURE__ */ new Set(["approved", "changes_requested", "commented"]);
8178
+ REVIEW_ACTION = "review_submitted";
8112
8179
  ID_CHARSET = /^[A-Za-z0-9._-]+$/;
8113
8180
  REPLICAS_MAX = 3;
8181
+ INSTRUCTIONS_MAX = 2e3;
8114
8182
  }
8115
8183
  });
8116
8184
 
@@ -8442,7 +8510,7 @@ import { Queue } from "bullmq";
8442
8510
  function makeQueue(connection) {
8443
8511
  return new Queue(QUEUE, { connection });
8444
8512
  }
8445
- async function enqueueLocalJob(queue, { folder, flow, task, provider, model, maxTurns, image, chainDepth, parentJobId, jobId, now = /* @__PURE__ */ new Date() }) {
8513
+ async function enqueueLocalJob(queue, { folder, flow, task, provider, model, maxTurns, image, skillsDir, chainDepth, parentJobId, jobId, now = /* @__PURE__ */ new Date() }) {
8446
8514
  const minute = now.toISOString().slice(0, 16);
8447
8515
  const id = jobId ?? localJobId({ folder, flow, task, minute });
8448
8516
  const data = {
@@ -8454,6 +8522,11 @@ async function enqueueLocalJob(queue, { folder, flow, task, provider, model, max
8454
8522
  model,
8455
8523
  maxTurns,
8456
8524
  ...image !== void 0 && { image },
8525
+ // The host directory of operator-authored skills this trigger injects (REQ-PER-TRIGGER-SKILLS).
8526
+ // Conditional like `image`, so an unflagged job's data stays byte-identical, and at JOB level rather
8527
+ // than inside `trigger` because a worker-host path is an execution knob, not a fact about the
8528
+ // delivery -- and `trigger` is the object copied into /job/event.json.
8529
+ ...skillsDir !== void 0 && { skillsDir },
8457
8530
  ...chainDepth !== void 0 && { chainDepth },
8458
8531
  ...parentJobId !== void 0 && { parentJobId }
8459
8532
  };
@@ -8534,14 +8607,40 @@ var init_git_dirty = __esm({
8534
8607
  }
8535
8608
  });
8536
8609
 
8610
+ // ../worker/src/host-pi.mjs
8611
+ var init_host_pi = __esm({
8612
+ "../worker/src/host-pi.mjs"() {
8613
+ init_packages();
8614
+ }
8615
+ });
8616
+
8617
+ // ../worker/src/copy-tree.mjs
8618
+ var ENTRY_NAME_RE, INJECT_LIMITS;
8619
+ var init_copy_tree = __esm({
8620
+ "../worker/src/copy-tree.mjs"() {
8621
+ ENTRY_NAME_RE = /^[a-z0-9](?:[a-z0-9_.-]{0,62}[a-z0-9])?$/i;
8622
+ INJECT_LIMITS = Object.freeze({
8623
+ // The binding one, and its reason is NOT disk. Every loaded skill contributes a <name> and a
8624
+ // description (spec-capped at 1024 chars) to the SYSTEM PROMPT of every job of that trigger, so
8625
+ // this bounds the cached prefix rather than the filesystem. UNVERIFIED figure, in the sense
8626
+ // ISOLATION_FLAGS' --pids-limit=512 is: a ceiling on absurdity, not a measured budget.
8627
+ maxDirs: 64,
8628
+ maxFiles: 512,
8629
+ maxBytes: 4 << 20,
8630
+ maxDepth: 8
8631
+ });
8632
+ }
8633
+ });
8634
+
8537
8635
  // ../worker/src/import-pi.mjs
8538
8636
  import { execFile as execFile2 } from "node:child_process";
8539
8637
  import { promisify as promisify2 } from "node:util";
8540
- var ENTRY_NAME_RE, execFileAsync;
8638
+ var execFileAsync;
8541
8639
  var init_import_pi = __esm({
8542
8640
  "../worker/src/import-pi.mjs"() {
8543
8641
  init_packages();
8544
- ENTRY_NAME_RE = /^[a-z0-9](?:[a-z0-9_.-]{0,62}[a-z0-9])?$/i;
8642
+ init_host_pi();
8643
+ init_copy_tree();
8545
8644
  execFileAsync = promisify2(execFile2);
8546
8645
  }
8547
8646
  });
@@ -8627,14 +8726,14 @@ function readStageManifest({ globalPiDir, readFile = readFileSync, fileExists =
8627
8726
  const { name, version, dir } = entry;
8628
8727
  if (!isNonEmptyString5(name) || !isNonEmptyString5(version) || !isNonEmptyString5(dir)) return null;
8629
8728
  if (!ENTRY_NAME_RE.test(dir) || dir.length > MAX_DIR_LENGTH) return null;
8630
- packages.push({ name, version, dir });
8729
+ packages.push({ name, version, dir, from: entry.from === FROM_HOST ? FROM_HOST : FROM_DECLARED });
8631
8730
  }
8632
8731
  return { stagedAt: typeof parsed.stagedAt === "string" ? parsed.stagedAt : null, packages };
8633
8732
  } catch {
8634
8733
  return null;
8635
8734
  }
8636
8735
  }
8637
- var PACKAGES_SUBDIR, STAGE_MANIFEST, CONTAINER_PACKAGES_ROOT, MAX_DIR_LENGTH;
8736
+ var PACKAGES_SUBDIR, STAGE_MANIFEST, CONTAINER_PACKAGES_ROOT, FROM_DECLARED, FROM_HOST, MAX_DIR_LENGTH;
8638
8737
  var init_packages = __esm({
8639
8738
  "../worker/src/packages.mjs"() {
8640
8739
  init_config2();
@@ -8643,6 +8742,8 @@ var init_packages = __esm({
8643
8742
  PACKAGES_SUBDIR = "packages";
8644
8743
  STAGE_MANIFEST = "packages.json";
8645
8744
  CONTAINER_PACKAGES_ROOT = `${CONTAINER_GLOBAL_PI_DIR}/${PACKAGES_SUBDIR}`;
8745
+ FROM_DECLARED = "pi-packages";
8746
+ FROM_HOST = "host";
8646
8747
  MAX_DIR_LENGTH = 64;
8647
8748
  }
8648
8749
  });
@@ -9056,6 +9157,8 @@ function normalizeTriggerForDisplay(entry) {
9056
9157
  const flow = typeof run.flow === "string" ? run.flow : null;
9057
9158
  const packages = run.packages !== false;
9058
9159
  const image = typeof run.image === "string" && run.image.trim() !== "" ? run.image : null;
9160
+ const skillsDir = typeof run.skillsDir === "string" && run.skillsDir.trim() !== "" ? run.skillsDir : null;
9161
+ const instructions = typeof run.instructions === "string" && run.instructions.trim() !== "";
9059
9162
  const resume = run.resume === true;
9060
9163
  const forge = typeof run.kind === "string" && run.kind.trim() !== "" ? run.kind : null;
9061
9164
  const replicas = Number.isInteger(run.replicas) && run.replicas > 1 ? run.replicas : null;
@@ -9072,12 +9175,14 @@ function normalizeTriggerForDisplay(entry) {
9072
9175
  model: typeof run.model === "string" ? run.model : null,
9073
9176
  packages,
9074
9177
  image,
9178
+ skillsDir,
9179
+ instructions,
9075
9180
  resume
9076
9181
  };
9077
9182
  case "label":
9078
- return { type: "label", any: normalizeSelector(on.any), all: normalizeSelector(on.all), none: normalizeSelector(on.none), flow, packages, image, resume, replicas, forge };
9183
+ return { type: "label", any: normalizeSelector(on.any), all: normalizeSelector(on.all), none: normalizeSelector(on.none), flow, packages, image, skillsDir, instructions, resume, replicas, forge };
9079
9184
  case "comment":
9080
- return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, packages, image, resume, replicas, forge };
9185
+ return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, packages, image, skillsDir, instructions, resume, replicas, forge };
9081
9186
  case "pull_request":
9082
9187
  return {
9083
9188
  type: "pull_request",
@@ -9088,6 +9193,8 @@ function normalizeTriggerForDisplay(entry) {
9088
9193
  flow,
9089
9194
  packages,
9090
9195
  image,
9196
+ skillsDir,
9197
+ instructions,
9091
9198
  resume,
9092
9199
  replicas,
9093
9200
  forge
@@ -35570,19 +35677,21 @@ function triggerLine(t) {
35570
35677
  const forge = t?.forge && t.forge !== "github" ? ` [${t.forge}]` : "";
35571
35678
  const pkgs = t?.packages === true ? " [packages]" : "";
35572
35679
  const img = t?.image ? ` [image ${t.image}]` : "";
35680
+ const skl = t?.skillsDir ? ` [skills ${String(t.skillsDir).split(/[\\/]/).filter(Boolean).pop()}]` : "";
35681
+ const ins = t?.instructions === true ? " [instructions]" : "";
35573
35682
  const res = t?.resume === true ? " [resume]" : "";
35574
35683
  const rep = t?.replicas > 1 ? ` [x${t.replicas}]` : "";
35575
35684
  switch (t?.type) {
35576
35685
  case "cron":
35577
- return `cron ${t.id ?? "-"} ${t.pattern ?? "-"} \u2192 ${t.folder ?? "-"}/${flow}${forge}${pkgs}${img}${res}${rep}`;
35686
+ return `cron ${t.id ?? "-"} ${t.pattern ?? "-"} \u2192 ${t.folder ?? "-"}/${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}`;
35578
35687
  case "label":
35579
- return `label ${ruleClauses(t) || "(no selector)"} \u2192 ${flow}${forge}${pkgs}${img}${res}${rep}`;
35688
+ return `label ${ruleClauses(t) || "(no selector)"} \u2192 ${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}`;
35580
35689
  case "comment":
35581
- return `comment "${t.phrase ?? "-"}" \u2192 ${flow}${forge}${pkgs}${img}${res}${rep}`;
35690
+ return `comment "${t.phrase ?? "-"}" \u2192 ${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}`;
35582
35691
  case "pull_request": {
35583
35692
  const clauses = ruleClauses(t);
35584
35693
  const action = `action[${(t.action ?? []).join(",")}]`;
35585
- return `pull_request ${action}${clauses ? ` ${clauses}` : ""} \u2192 ${flow}${forge}${pkgs}${img}${res}${rep}`;
35694
+ return `pull_request ${action}${clauses ? ` ${clauses}` : ""} \u2192 ${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}`;
35586
35695
  }
35587
35696
  default:
35588
35697
  return "(unknown trigger)";
@@ -36968,7 +37077,9 @@ function trustModel(t) {
36968
37077
  ];
36969
37078
  default:
36970
37079
  return [
36971
- t?.type === "comment" ? "authorized by a collaborator comment (author_association) + HMAC" : "authorized by a collaborator's label + HMAC webhook + author gate",
37080
+ // A review trigger reads the REVIEWER's author_association, not the PR author's (issue #66), so it
37081
+ // cannot share the generic line: an operator reading "author gate" would picture the wrong person.
37082
+ t?.type === "comment" ? "authorized by a collaborator comment (author_association) + HMAC" : t?.type === "pull_request" && (t?.action ?? []).includes("review_submitted") ? "authorized by the REVIEWER's author_association (not the PR author's) + HMAC" : "authorized by a collaborator's label + HMAC webhook + author gate",
36972
37083
  "dedup by X-GitHub-Delivery GUID (redelivery-safe)",
36973
37084
  where
36974
37085
  ];
@@ -37987,8 +38098,8 @@ var init_setup_wizard = __esm({
37987
38098
  init_deployment_pointer();
37988
38099
  init_read_model();
37989
38100
  init_index();
37990
- RUNTIME_VERSION = "0.1.2";
37991
- RECEIVER_VERSION = "0.1.1";
38101
+ RUNTIME_VERSION = "0.2.0";
38102
+ RECEIVER_VERSION = "0.2.0";
37992
38103
  RUNTIME_PKG = "@edgehero/pi-dispatch";
37993
38104
  RECEIVER_PKG = "@edgehero/pi-dispatch-receiver";
37994
38105
  NUDGE_MARKER_BASENAME = "pi-dispatch-setup.nudged";
@@ -38190,7 +38301,7 @@ function registerTools(pi) {
38190
38301
  pi.registerTool({
38191
38302
  name: "dispatch_trigger_add",
38192
38303
  label: "pi-dispatch add trigger",
38193
- description: "Adds a trigger to triggers.json and applies it live. The operator MUST approve a confirm dialog showing the entry; with no interactive operator it is refused. `flow` is the .pi/skills/<name> skill the job runs (its SKILL.md is the agent's instructions). `kind` = cron|label|comment|pull_request. cron (local) needs id, pattern, `folder` (absolute host path the job runs in), `flow`, and `task` (the prompt text handed to the agent), and may set optional model/provider/maxTurns for that schedule (omit = deployment default). label needs labels[]+flow; comment needs phrase+flow; pull_request needs action[] (+ optional labels[]) + flow. Webhook triggers take an optional `forge` = github (default) | gitlab, which also decides which action words pull_request accepts: github is labeled|opened|synchronize|reopened, gitlab is open|update|reopen|approved. For webhook triggers the repo and the task come from the triggering issue/PR event \u2014 set only the match + flow \u2014 and they run under the deployment default model.",
38304
+ description: "Adds a trigger to triggers.json and applies it live. The operator MUST approve a confirm dialog showing the entry; with no interactive operator it is refused. `flow` is the .pi/skills/<name> skill the job runs (its SKILL.md is the agent's instructions). `kind` = cron|label|comment|pull_request. cron (local) needs id, pattern, `folder` (absolute host path the job runs in), `flow`, and `task` (the prompt text handed to the agent), and may set optional model/provider/maxTurns for that schedule (omit = deployment default). label needs labels[]+flow; comment needs phrase+flow; pull_request needs action[] (+ optional labels[]) + flow. Webhook triggers take an optional `forge` = github (default) | gitlab, which also decides which action words pull_request accepts: github is labeled|opened|synchronize|reopened|review_submitted, gitlab is open|update|reopen|approved. A github review_submitted trigger may also set reviewState[] (approved|changes_requested|commented) to narrow which verdicts fire; omitted, all three do. For webhook triggers the repo and the task come from the triggering issue/PR event \u2014 set only the match + flow \u2014 and they run under the deployment default model.",
38194
38305
  executionMode: "sequential",
38195
38306
  parameters: typebox_exports.Object({
38196
38307
  kind: typebox_exports.String(),
@@ -39086,7 +39197,7 @@ var init_index = __esm({
39086
39197
  ];
39087
39198
  FORGE_PROMPT = "forge \u2014 github, gitlab, forgejo or azure";
39088
39199
  PR_ACTION_VOCAB = {
39089
- github: { hint: "labeled opened synchronize reopened", dflt: "labeled" },
39200
+ github: { hint: "labeled opened synchronize reopened review_submitted", dflt: "labeled" },
39090
39201
  gitlab: { hint: "open update reopen approved", dflt: "update" },
39091
39202
  forgejo: { hint: "label_updated opened synchronized reopened", dflt: "label_updated" },
39092
39203
  azure: { hint: "created updated", dflt: "updated" }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch-admin",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Operator console (a pi extension) + skill for pi-dispatch: run the pi coding agent as a self-hosted service. A /dispatch TUI for the queue, spend caps, run history, editable GitHub, GitLab, Forgejo and Azure DevOps triggers (cron/label/comment/PR/MR/work item), and scheduled pause windows — plus AI-operable, human-confirmed controls. Runs against a live pi-dispatch deployment.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -128,9 +128,14 @@ trigger is the same — the `on.type`, the `{any, all, none}` label predicate, `
128
128
  Two things are NOT the same, and both refuse at load rather than misbehaving quietly:
129
129
 
130
130
  - **`pull_request` actions are the forge's own words.** GitHub takes
131
- `labeled | opened | synchronize | reopened`; GitLab takes `open | update | reopen | approved`. A word
131
+ `labeled | opened | synchronize | reopened | review_submitted`; GitLab takes
132
+ `open | update | reopen | approved`. A word
132
133
  from the wrong forge is refused when the file is written. It would not break anything otherwise — it
133
134
  would simply never match an event, and the trigger would look configured while doing nothing.
135
+ `review_submitted` is GitHub's `pull_request_review` event: it fires on **every** submitted review, so
136
+ add `reviewState` (`approved | changes_requested | commented`) to narrow which verdicts are worth paying
137
+ for. That field is github-only and legal only beside `review_submitted`; anywhere else it refuses at
138
+ load, because a narrowing that cannot apply reads as one that does.
134
139
  - **One `comment` trigger per forge.** Two GitHub comment triggers are refused; one GitHub and one GitLab
135
140
  are fine.
136
141