@edgehero/pi-dispatch-admin 1.2.0 → 1.4.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/dist/index.mjs CHANGED
@@ -7611,7 +7611,7 @@ function isForgeKind(kind) {
7611
7611
  function forgeSpec(kind) {
7612
7612
  return isForgeKind(kind) ? FORGES[kind] : void 0;
7613
7613
  }
7614
- var FORGES, FORGE_KINDS, RUN_KINDS, MINTED_TOKEN_VARS;
7614
+ var FORGES, FORGE_KINDS, RUN_KINDS, MINTED_TOKEN_VARS, FORGE_HOST_VARS;
7615
7615
  var init_forges = __esm({
7616
7616
  "../worker/src/forges.mjs"() {
7617
7617
  FORGES = {
@@ -7677,6 +7677,13 @@ var init_forges = __esm({
7677
7677
  FORGE_KINDS = Object.freeze(Object.keys(FORGES));
7678
7678
  RUN_KINDS = Object.freeze(["local", ...FORGE_KINDS]);
7679
7679
  MINTED_TOKEN_VARS = new Set(FORGE_KINDS.flatMap((kind) => FORGES[kind].tokenVars));
7680
+ FORGE_HOST_VARS = new Set(FORGE_KINDS.map((kind) => FORGES[kind].hostVar).filter((name) => typeof name === "string"));
7681
+ }
7682
+ });
7683
+
7684
+ // ../worker/src/secret-profiles.mjs
7685
+ var init_secret_profiles = __esm({
7686
+ "../worker/src/secret-profiles.mjs"() {
7680
7687
  }
7681
7688
  });
7682
7689
 
@@ -7701,13 +7708,16 @@ function defaultGraphDir(env = process.env) {
7701
7708
  function defaultSettingsFile() {
7702
7709
  return `${process.env.TMPDIR ?? process.env.TEMP ?? "/tmp"}/pi-dispatch/settings.json`.replace(/\\/g, "/");
7703
7710
  }
7704
- var CHAIN_DEPTH_MAX_DEFAULT, CHAIN_MAX_PER_JOB_DEFAULT;
7711
+ var CHAIN_DEPTH_MAX_DEFAULT, CHAIN_MAX_PER_JOB_DEFAULT, WORKER_ONLY_SECRET_VARS, EGRESS_ENV_VARS;
7705
7712
  var init_config2 = __esm({
7706
7713
  "../worker/src/config.mjs"() {
7707
7714
  init_egress();
7708
7715
  init_forges();
7716
+ init_secret_profiles();
7709
7717
  CHAIN_DEPTH_MAX_DEFAULT = 1;
7710
7718
  CHAIN_MAX_PER_JOB_DEFAULT = 2;
7719
+ WORKER_ONLY_SECRET_VARS = /* @__PURE__ */ new Set(["GITHUB_APP_PRIVATE_KEY"]);
7720
+ EGRESS_ENV_VARS = /* @__PURE__ */ new Set(["HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", "NODE_USE_ENV_PROXY"]);
7711
7721
  }
7712
7722
  });
7713
7723
 
@@ -7752,6 +7762,21 @@ function validateOverlay(candidate, log) {
7752
7762
  if (!isIntInRange(value, 1, 10)) return { invalid: "concurrency must be an integer 1-10" };
7753
7763
  overlay[key] = value;
7754
7764
  break;
7765
+ case "secretProfiles": {
7766
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
7767
+ return { invalid: "secretProfiles must be an object mapping profile names to resolver paths" };
7768
+ }
7769
+ for (const name of Object.keys(value)) {
7770
+ if (!/^[A-Za-z0-9._-]+$/.test(name)) {
7771
+ return { invalid: "secretProfiles names may use letters, digits, dot, dash and underscore only" };
7772
+ }
7773
+ if (typeof value[name] !== "string" || value[name].trim() === "") {
7774
+ return { invalid: `secretProfiles.${name} must be a non-empty absolute path` };
7775
+ }
7776
+ }
7777
+ overlay[key] = { ...value };
7778
+ break;
7779
+ }
7755
7780
  case "softHoldPct":
7756
7781
  if (!isIntInRange(value, 1, 99)) return { invalid: "softHoldPct must be an integer 1-99" };
7757
7782
  overlay[key] = value;
@@ -7861,10 +7886,399 @@ var init_budget = __esm({
7861
7886
  }
7862
7887
  });
7863
7888
 
7864
- // ../worker/src/triggers.mjs
7889
+ // ../worker/src/pause-windows.mjs
7865
7890
  function isNonEmptyString2(value) {
7866
7891
  return typeof value === "string" && value.trim() !== "";
7867
7892
  }
7893
+ function parseHHMM(value, label, at, path) {
7894
+ const m = isNonEmptyString2(value) ? /^([0-9]{1,2}):([0-9]{2})$/.exec(value.trim()) : null;
7895
+ if (!m) throw configError(`${at}: ${label} must be "HH:MM": ${JSON.stringify(value)} (${path})`);
7896
+ const h = Number(m[1]);
7897
+ const min = Number(m[2]);
7898
+ if (h > 23 || min > 59) throw configError(`${at}: ${label} out of range 00:00-23:59: ${JSON.stringify(value)} (${path})`);
7899
+ return h * 60 + min;
7900
+ }
7901
+ function assertDate(value, label, at, path) {
7902
+ const m = /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.exec(String(value).trim());
7903
+ const mo = m && Number(m[2]);
7904
+ const d = m && Number(m[3]);
7905
+ if (!m || mo < 1 || mo > 12 || d < 1 || d > 31) {
7906
+ throw configError(`${at}: ${label} must be "YYYY-MM-DD": ${JSON.stringify(value)} (${path})`);
7907
+ }
7908
+ }
7909
+ function validateTz(tz, at, path) {
7910
+ try {
7911
+ new Intl.DateTimeFormat("en-US", { timeZone: tz });
7912
+ return tz;
7913
+ } catch {
7914
+ throw configError(`${at}: tz is not a valid IANA timezone: ${JSON.stringify(tz)} (${path})`);
7915
+ }
7916
+ }
7917
+ function parsePauseWindows(text, path) {
7918
+ let parsed;
7919
+ try {
7920
+ parsed = JSON.parse(text);
7921
+ } catch (error) {
7922
+ throw configError(`pause-windows file is not valid JSON: ${path} (${error.message})`);
7923
+ }
7924
+ const windows = parsed?.windows;
7925
+ if (!Array.isArray(windows)) {
7926
+ throw configError(`pause-windows file must have a "windows" array: ${path}`);
7927
+ }
7928
+ return windows.map((w, index) => normalizeWindow(w, index, path));
7929
+ }
7930
+ function normalizeWindow(w, index, path) {
7931
+ const at = `pause window at index ${index}`;
7932
+ if (w === null || typeof w !== "object") throw configError(`${at}: must be an object: ${path}`);
7933
+ if (!isNonEmptyString2(w.scope)) throw configError(`${at}: scope must be a non-empty string: ${path}`);
7934
+ const fromMin = parseHHMM(w.from, "from", at, path);
7935
+ const toMin = parseHHMM(w.to, "to", at, path);
7936
+ if (fromMin === toMin) {
7937
+ throw configError(`${at}: from and to must differ (a 24h pause is not expressible; remove the trigger): ${path}`);
7938
+ }
7939
+ const tz = w.tz === void 0 ? "UTC" : validateTz(w.tz, at, path);
7940
+ let days = null;
7941
+ if (w.days !== void 0) {
7942
+ if (!Array.isArray(w.days) || w.days.length === 0) {
7943
+ throw configError(`${at}: days must be a non-empty array of weekday names (mon..sun): ${path}`);
7944
+ }
7945
+ days = w.days.map((d) => {
7946
+ const key = String(d).trim().toLowerCase().slice(0, 3);
7947
+ if (!DAY_SET.has(key)) throw configError(`${at}: unknown weekday ${JSON.stringify(d)} (use mon..sun): ${path}`);
7948
+ return key;
7949
+ });
7950
+ }
7951
+ let dateFrom = null;
7952
+ let dateTo = null;
7953
+ if (w.dateFrom !== void 0) {
7954
+ assertDate(w.dateFrom, "dateFrom", at, path);
7955
+ dateFrom = String(w.dateFrom).trim();
7956
+ }
7957
+ if (w.dateTo !== void 0) {
7958
+ assertDate(w.dateTo, "dateTo", at, path);
7959
+ dateTo = String(w.dateTo).trim();
7960
+ }
7961
+ if (dateFrom && dateTo && dateFrom > dateTo) throw configError(`${at}: dateFrom must be <= dateTo: ${path}`);
7962
+ const norm = { scope: w.scope.trim(), from: w.from.trim(), to: w.to.trim(), fromMin, toMin, tz };
7963
+ if (days) norm.days = days;
7964
+ if (dateFrom) norm.dateFrom = dateFrom;
7965
+ if (dateTo) norm.dateTo = dateTo;
7966
+ return norm;
7967
+ }
7968
+ function zonedParts(tz, ms) {
7969
+ const fmt2 = new Intl.DateTimeFormat("en-US", {
7970
+ timeZone: tz,
7971
+ hour12: false,
7972
+ year: "numeric",
7973
+ month: "2-digit",
7974
+ day: "2-digit",
7975
+ hour: "2-digit",
7976
+ minute: "2-digit",
7977
+ weekday: "short"
7978
+ });
7979
+ const p = {};
7980
+ for (const part of fmt2.formatToParts(new Date(ms))) p[part.type] = part.value;
7981
+ let hh = Number(p.hour);
7982
+ if (hh === 24) hh = 0;
7983
+ return { y: Number(p.year), mo: Number(p.month), d: Number(p.day), hh, mi: Number(p.minute), dow: WEEKDAY_INDEX[p.weekday] };
7984
+ }
7985
+ function wallToMs(tz, y, mo, d, hh, mi) {
7986
+ const guess = Date.UTC(y, mo - 1, d, hh, mi);
7987
+ const shown = zonedParts(tz, guess);
7988
+ const shownAsUtc = Date.UTC(shown.y, shown.mo - 1, shown.d, shown.hh, shown.mi);
7989
+ return guess - (shownAsUtc - guess);
7990
+ }
7991
+ function shiftDay(tz, parts, delta) {
7992
+ const noon = wallToMs(tz, parts.y, parts.mo, parts.d, 12, 0);
7993
+ return zonedParts(tz, noon + delta * 864e5);
7994
+ }
7995
+ function ymd(parts) {
7996
+ return `${String(parts.y).padStart(4, "0")}-${String(parts.mo).padStart(2, "0")}-${String(parts.d).padStart(2, "0")}`;
7997
+ }
7998
+ function qualifies(w, parts) {
7999
+ if (w.days && !w.days.includes(DAYS[parts.dow])) return false;
8000
+ const date = ymd(parts);
8001
+ if (w.dateFrom && date < w.dateFrom) return false;
8002
+ if (w.dateTo && date > w.dateTo) return false;
8003
+ return true;
8004
+ }
8005
+ function windowEndAt(w, nowMs) {
8006
+ const p = zonedParts(w.tz, nowMs);
8007
+ const nowMin = p.hh * 60 + p.mi;
8008
+ const endAt = (dateParts) => wallToMs(w.tz, dateParts.y, dateParts.mo, dateParts.d, Math.floor(w.toMin / 60), w.toMin % 60);
8009
+ if (w.fromMin < w.toMin) {
8010
+ if (nowMin >= w.fromMin && nowMin < w.toMin && qualifies(w, p)) return endAt(p);
8011
+ return null;
8012
+ }
8013
+ if (nowMin >= w.fromMin && qualifies(w, p)) return endAt(shiftDay(w.tz, p, 1));
8014
+ if (nowMin < w.toMin && qualifies(w, shiftDay(w.tz, p, -1))) return endAt(p);
8015
+ return null;
8016
+ }
8017
+ var DAYS, DAY_SET, WEEKDAY_INDEX;
8018
+ var init_pause_windows = __esm({
8019
+ "../worker/src/pause-windows.mjs"() {
8020
+ init_config2();
8021
+ DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
8022
+ DAY_SET = new Set(DAYS);
8023
+ WEEKDAY_INDEX = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
8024
+ }
8025
+ });
8026
+
8027
+ // ../worker/src/subscriptions.mjs
8028
+ function isNonEmptyString3(value) {
8029
+ return typeof value === "string" && value.trim() !== "";
8030
+ }
8031
+ function normalizeGlobs(value, label, at, path) {
8032
+ if (!Array.isArray(value) || value.length === 0) {
8033
+ throw configError(`${at}: ${label} must be a non-empty array of model globs: ${path}`);
8034
+ }
8035
+ return value.map((glob) => {
8036
+ if (typeof glob !== "string" || !MODEL_GLOB_RE.test(glob.trim())) {
8037
+ throw configError(`${at}: ${label} glob ${JSON.stringify(glob)} must match ${MODEL_GLOB_RE} (model-id charset plus "*", max 64 chars): ${path}`);
8038
+ }
8039
+ return glob.trim();
8040
+ });
8041
+ }
8042
+ function normalizeBool(value, label, at, path, fallback) {
8043
+ if (value === void 0) return fallback;
8044
+ if (typeof value !== "boolean") throw configError(`${at}: ${label} must be a boolean: ${path}`);
8045
+ return value;
8046
+ }
8047
+ function parseSubscriptions(text, path) {
8048
+ let parsed;
8049
+ try {
8050
+ parsed = JSON.parse(text);
8051
+ } catch (error) {
8052
+ throw configError(`subscriptions file is not valid JSON: ${path} (${error.message})`);
8053
+ }
8054
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
8055
+ throw configError(`subscriptions file must be a JSON object: ${path}`);
8056
+ }
8057
+ const version = parsed.version;
8058
+ if (!Number.isInteger(version) || version < 1) {
8059
+ throw configError(`subscriptions file must declare an integer "version" >= 1 (got ${JSON.stringify(version)}): ${path}`);
8060
+ }
8061
+ if (version > SUBSCRIPTIONS_VERSION) {
8062
+ throw configError(`subscriptions file written by a newer pi-dispatch (version ${version}; this build understands ${SUBSCRIPTIONS_VERSION}): ${path}`);
8063
+ }
8064
+ const entries = parsed.subscriptions;
8065
+ if (!Array.isArray(entries)) {
8066
+ throw configError(`subscriptions file must have a "subscriptions" array: ${path}`);
8067
+ }
8068
+ const seenIds = /* @__PURE__ */ new Map();
8069
+ return { version, subscriptions: entries.map((entry, index) => normalizeSubscription(entry, index, path, seenIds)) };
8070
+ }
8071
+ function normalizeSubscription(entry, index, path, seenIds) {
8072
+ const at = `subscription at index ${index}`;
8073
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
8074
+ throw configError(`${at}: must be an object: ${path}`);
8075
+ }
8076
+ if (!isNonEmptyString3(entry.id)) throw configError(`${at}: id must be a non-empty string: ${path}`);
8077
+ const id = entry.id.trim();
8078
+ if (seenIds.has(id)) {
8079
+ throw configError(`${at}: id ${JSON.stringify(id)} is already used by the subscription at index ${seenIds.get(id)}: ${path}`);
8080
+ }
8081
+ seenIds.set(id, index);
8082
+ if (!isNonEmptyString3(entry.vendor)) throw configError(`${at}: vendor must be a non-empty string: ${path}`);
8083
+ if (!isNonEmptyString3(entry.provider)) throw configError(`${at}: provider must be a non-empty string (the pi-ai provider id this plan covers): ${path}`);
8084
+ const models = entry.models === void 0 ? ["*"] : normalizeGlobs(entry.models, "models", at, path);
8085
+ const price = entry.price;
8086
+ if (price === null || typeof price !== "object" || Array.isArray(price)) {
8087
+ throw configError(`${at}: price must be an object { amount, currency, per }: ${path}`);
8088
+ }
8089
+ if (typeof price.amount !== "number" || !Number.isFinite(price.amount) || price.amount <= 0) {
8090
+ throw configError(`${at}: price.amount must be a number > 0: ${path}`);
8091
+ }
8092
+ if (typeof price.currency !== "string" || !/^[A-Z]{3}$/.test(price.currency)) {
8093
+ throw configError(`${at}: price.currency must be a 3-letter uppercase code (e.g. "USD"): ${path}`);
8094
+ }
8095
+ if (price.per !== "month") {
8096
+ throw configError(`${at}: price.per must be "month" (got ${JSON.stringify(price.per)}): ${path}`);
8097
+ }
8098
+ const sharedWithOtherProducts = normalizeBool(entry.sharedWithOtherProducts, "sharedWithOtherProducts", at, path, false);
8099
+ const hypothetical = normalizeBool(entry.hypothetical, "hypothetical", at, path, false);
8100
+ let counterfactualModel = null;
8101
+ if (entry.counterfactualModel !== void 0 && entry.counterfactualModel !== null) {
8102
+ const cm = entry.counterfactualModel;
8103
+ if (typeof cm !== "object" || Array.isArray(cm) || !isNonEmptyString3(cm.provider) || !isNonEmptyString3(cm.id)) {
8104
+ throw configError(`${at}: counterfactualModel must be { provider, id } with non-empty strings (a PRICED pi-ai model to re-price covered runs at API rates): ${path}`);
8105
+ }
8106
+ counterfactualModel = { provider: cm.provider.trim(), id: cm.id.trim() };
8107
+ }
8108
+ let windows = [];
8109
+ if (entry.windows !== void 0) {
8110
+ if (!Array.isArray(entry.windows)) throw configError(`${at}: windows must be an array: ${path}`);
8111
+ windows = entry.windows.map((w, windowIndex) => normalizeWindow2(w, windowIndex, index, path));
8112
+ }
8113
+ return {
8114
+ id,
8115
+ vendor: entry.vendor.trim(),
8116
+ provider: entry.provider.trim(),
8117
+ models,
8118
+ price: { amount: price.amount, currency: price.currency, per: "month" },
8119
+ sharedWithOtherProducts,
8120
+ hypothetical,
8121
+ counterfactualModel,
8122
+ windows
8123
+ };
8124
+ }
8125
+ function normalizeWindow2(w, windowIndex, subIndex, path) {
8126
+ const at = `window at index ${windowIndex} of subscription at index ${subIndex}`;
8127
+ if (w === null || typeof w !== "object" || Array.isArray(w)) {
8128
+ throw configError(`${at}: must be an object: ${path}`);
8129
+ }
8130
+ if (!WINDOW_PERS.has(w.per)) {
8131
+ throw configError(`${at}: per must be one of 5h|7d|30d|month (got ${JSON.stringify(w.per)}): ${path}`);
8132
+ }
8133
+ const rolling = normalizeBool(w.rolling, "rolling", at, path, false);
8134
+ let unit = null;
8135
+ if (w.unit !== void 0 && w.unit !== null) {
8136
+ if (!WINDOW_UNITS.has(w.unit)) {
8137
+ throw configError(`${at}: unit must be one of tokens|requests|prompts|credits, or null (vendor undisclosed): ${path}`);
8138
+ }
8139
+ unit = w.unit;
8140
+ }
8141
+ let limit = null;
8142
+ if (w.limit !== void 0 && w.limit !== null) {
8143
+ if (typeof w.limit === "number") {
8144
+ if (!Number.isFinite(w.limit) || w.limit <= 0) throw configError(`${at}: limit must be a number > 0, [min, max], or null (vendor undisclosed): ${path}`);
8145
+ limit = w.limit;
8146
+ } else if (Array.isArray(w.limit)) {
8147
+ const [min, max] = w.limit;
8148
+ if (w.limit.length !== 2 || typeof min !== "number" || typeof max !== "number" || !Number.isFinite(min) || !Number.isFinite(max) || min <= 0 || max <= 0 || min >= max) {
8149
+ throw configError(`${at}: limit range must be [min, max] with both > 0 and min < max: ${path}`);
8150
+ }
8151
+ limit = [min, max];
8152
+ } else {
8153
+ throw configError(`${at}: limit must be a number > 0, [min, max], or null (vendor undisclosed): ${path}`);
8154
+ }
8155
+ }
8156
+ const scope = w.scope === void 0 || w.scope === null ? null : normalizeGlobs(w.scope, "scope", at, path);
8157
+ return { per: w.per, rolling, unit, limit, scope };
8158
+ }
8159
+ var SUBSCRIPTIONS_VERSION, WINDOW_PERS, WINDOW_UNITS, MODEL_GLOB_RE;
8160
+ var init_subscriptions = __esm({
8161
+ "../worker/src/subscriptions.mjs"() {
8162
+ init_config2();
8163
+ SUBSCRIPTIONS_VERSION = 1;
8164
+ WINDOW_PERS = /* @__PURE__ */ new Set(["5h", "7d", "30d", "month"]);
8165
+ WINDOW_UNITS = /* @__PURE__ */ new Set(["tokens", "requests", "prompts", "credits"]);
8166
+ MODEL_GLOB_RE = /^[A-Za-z0-9*._:/-]{1,64}$/;
8167
+ }
8168
+ });
8169
+
8170
+ // ../worker/src/connection.mjs
8171
+ import { Redis } from "ioredis";
8172
+ function parseConnection(url, { failFast = false } = {}) {
8173
+ const u = new URL(url);
8174
+ return {
8175
+ host: u.hostname || "127.0.0.1",
8176
+ port: Number(u.port || 6379),
8177
+ ...u.password ? { password: u.password } : {},
8178
+ ...u.username ? { username: u.username } : {},
8179
+ ...u.pathname && u.pathname !== "/" ? { db: Number(u.pathname.slice(1)) } : {},
8180
+ maxRetriesPerRequest: null,
8181
+ // required for BullMQ blocking connections
8182
+ ...failFast ? {
8183
+ connectTimeout: 2e3,
8184
+ enableOfflineQueue: false,
8185
+ // don't buffer commands while disconnected -- error now
8186
+ retryStrategy: (attempts) => attempts > 2 ? null : 200
8187
+ // give up after ~2 tries
8188
+ } : {}
8189
+ };
8190
+ }
8191
+ function makeRedisClient(url) {
8192
+ return new Redis(url, { maxRetriesPerRequest: null });
8193
+ }
8194
+ var init_connection = __esm({
8195
+ "../worker/src/connection.mjs"() {
8196
+ }
8197
+ });
8198
+
8199
+ // ../worker/src/job-id.mjs
8200
+ import { createHash } from "node:crypto";
8201
+ function localJobId({ folder, flow, task, minute }) {
8202
+ const digest = createHash("sha256").update([folder, flow ?? "", task ?? "", minute].join("\0")).digest("hex");
8203
+ return `local-${digest.slice(0, 16)}`;
8204
+ }
8205
+ var init_job_id = __esm({
8206
+ "../worker/src/job-id.mjs"() {
8207
+ init_config2();
8208
+ init_forges();
8209
+ }
8210
+ });
8211
+
8212
+ // ../worker/src/flow-gate.mjs
8213
+ import { execFile } from "node:child_process";
8214
+ import { promisify } from "node:util";
8215
+ async function readFlowGate({ folder, flow, sha, git = defaultGit }) {
8216
+ if (typeof sha !== "string" || sha.trim() === "") return { gate: "deny" };
8217
+ if (typeof flow !== "string" || !SKILL_NAME_RE.test(flow)) return { gate: "deny" };
8218
+ const path = `.pi/skills/${flow}/SKILL.md`;
8219
+ try {
8220
+ const lsTreeZ = await git(folder, ["ls-tree", "-z", sha, path]);
8221
+ const record = lsTreeZ.split("\0").find((r) => r);
8222
+ if (!record) return { gate: "no-skill" };
8223
+ const tab = record.indexOf(" ");
8224
+ if (tab === -1) return { gate: "deny" };
8225
+ const [mode, type, oid] = record.slice(0, tab).split(/\s+/);
8226
+ if (mode !== "100644" || type !== "blob") return { gate: "deny" };
8227
+ const content = await git(folder, ["cat-file", "blob", oid], { raw: true });
8228
+ return { gate: aiTriggerAllows(content) ? "allow" : "deny" };
8229
+ } catch {
8230
+ return { gate: "deny" };
8231
+ }
8232
+ }
8233
+ function aiTriggerAllows(buf) {
8234
+ const text = buf.toString("utf8").replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
8235
+ const block = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(text);
8236
+ if (!block) return false;
8237
+ return /^ai-trigger:\s*("?)allow\1\s*$/m.test(block[1]);
8238
+ }
8239
+ async function defaultGit(gitDir, args, { raw = false } = {}) {
8240
+ const hardened = ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "--no-pager", "-C", gitDir, ...args];
8241
+ const { stdout } = await exec("git", hardened, {
8242
+ encoding: raw ? "buffer" : "utf8",
8243
+ maxBuffer: 16 * 1024 * 1024
8244
+ });
8245
+ return stdout;
8246
+ }
8247
+ var exec, SKILL_NAME_RE;
8248
+ var init_flow_gate = __esm({
8249
+ "../worker/src/flow-gate.mjs"() {
8250
+ exec = promisify(execFile);
8251
+ SKILL_NAME_RE = /^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/;
8252
+ }
8253
+ });
8254
+
8255
+ // ../worker/src/reserved-env.mjs
8256
+ var CONTAINER_ENV_NAMES;
8257
+ var init_reserved_env = __esm({
8258
+ "../worker/src/reserved-env.mjs"() {
8259
+ CONTAINER_ENV_NAMES = /* @__PURE__ */ new Set([
8260
+ "PI_PROVIDER",
8261
+ "PI_MODEL",
8262
+ "PI_MAX_TURNS",
8263
+ "PI_MAX_TOKENS",
8264
+ "PI_JOB_ID",
8265
+ "PI_GLOBAL_ALLOW_EXTENSIONS",
8266
+ "PI_PACKAGES",
8267
+ "PI_SESSION_FILE",
8268
+ "PI_FLOW",
8269
+ "PI_COMMAND",
8270
+ "PI_OFFLINE",
8271
+ "PLAYWRIGHT_BROWSERS_PATH",
8272
+ "PLAYWRIGHT_MCP_BROWSER",
8273
+ "PLAYWRIGHT_MCP_SANDBOX"
8274
+ ]);
8275
+ }
8276
+ });
8277
+
8278
+ // ../worker/src/triggers.mjs
8279
+ function isNonEmptyString4(value) {
8280
+ return typeof value === "string" && value.trim() !== "";
8281
+ }
7868
8282
  function parseTriggers(text, path) {
7869
8283
  let parsed;
7870
8284
  try {
@@ -7892,7 +8306,7 @@ function normalizeTrigger(entry, index, path, state) {
7892
8306
  throw configError(`${at}: "run" must be an object: ${path}`);
7893
8307
  }
7894
8308
  if (!ON_TYPES.has(on.type)) {
7895
- throw configError(`${at}: on.type must be one of cron|label|comment|pull_request (got ${JSON.stringify(on.type)}): ${path}`);
8309
+ throw configError(`${at}: on.type must be one of ${[...ON_TYPES].join("|")} (got ${JSON.stringify(on.type)}): ${path}`);
7896
8310
  }
7897
8311
  if (!RUN_KINDS.includes(run.kind)) {
7898
8312
  throw configError(`${at}: run.kind must be one of ${RUN_KINDS.join("|")} (got ${JSON.stringify(run.kind)}): ${path}`);
@@ -7906,14 +8320,18 @@ function normalizeTrigger(entry, index, path, state) {
7906
8320
  if (!isForgeKind(run.kind)) {
7907
8321
  throw configError(`${at}: a ${on.type} trigger is webhook-driven and produces a forge job; run.kind must be one of ${FORGE_KINDS.join("|")} (got ${JSON.stringify(run.kind)}): ${path}`);
7908
8322
  }
7909
- if (on.type === "label") return normalizeLabel(on, run, index, path);
7910
- if (on.type === "comment") return normalizeComment(on, run, index, path, state);
7911
- return normalizePullRequest(on, run, index, path);
8323
+ let normalized;
8324
+ if (on.type === "label") normalized = normalizeLabel(on, run, index, path);
8325
+ else if (on.type === "comment") normalized = normalizeComment(on, run, index, path, state);
8326
+ else if (on.type === "issue") normalized = normalizeIssue(on, run, index, path);
8327
+ else normalized = normalizePullRequest(on, run, index, path);
8328
+ if (on.disarmed !== void 0) return { on: { type: DISARMED_TYPE }, run: {} };
8329
+ return normalized;
7912
8330
  }
7913
8331
  function normalizeCron(on, run, index, path, state) {
7914
8332
  const at = `trigger at index ${index}`;
7915
8333
  const id = on.id;
7916
- if (!isNonEmptyString2(id)) {
8334
+ if (!isNonEmptyString4(id)) {
7917
8335
  throw configError(`${at}: cron on.id must be a non-empty string: ${path}`);
7918
8336
  }
7919
8337
  if (id.includes(":")) {
@@ -7927,7 +8345,7 @@ function normalizeCron(on, run, index, path, state) {
7927
8345
  }
7928
8346
  state.seenCronIds.add(id);
7929
8347
  const pattern = on.pattern;
7930
- if (!isNonEmptyString2(pattern)) {
8348
+ if (!isNonEmptyString4(pattern)) {
7931
8349
  throw configError(`cron trigger "${id}": on.pattern must be a non-empty string: ${path}`);
7932
8350
  }
7933
8351
  const fieldCount = pattern.trim().split(/\s+/).length;
@@ -7935,13 +8353,13 @@ function normalizeCron(on, run, index, path, state) {
7935
8353
  throw configError(`cron trigger "${id}": on.pattern must have 5 or 6 space-separated fields, got ${fieldCount}: ${path}`);
7936
8354
  }
7937
8355
  const command = validateCommand(run, `cron trigger "${id}"`, path, { onType: "cron" });
7938
- if (!isNonEmptyString2(run.folder)) {
8356
+ if (!isNonEmptyString4(run.folder)) {
7939
8357
  throw configError(`cron trigger "${id}": run.folder must be a non-empty string: ${path}`);
7940
8358
  }
7941
- if (command === void 0 && !isNonEmptyString2(run.flow)) {
8359
+ if (command === void 0 && !isNonEmptyString4(run.flow)) {
7942
8360
  throw configError(`cron trigger "${id}": run.flow must be a non-empty string (or use run.command): ${path}`);
7943
8361
  }
7944
- if (command === void 0 && !isNonEmptyString2(run.task)) {
8362
+ if (command === void 0 && !isNonEmptyString4(run.task)) {
7945
8363
  throw configError(`cron trigger "${id}": run.task must be a non-empty string: ${path}`);
7946
8364
  }
7947
8365
  if (run.github !== void 0 && typeof run.github !== "boolean") {
@@ -7953,9 +8371,14 @@ function normalizeCron(on, run, index, path, state) {
7953
8371
  validateInstructions(run, `cron trigger "${id}"`, path, { cron: true });
7954
8372
  const resume = validateResumeFlag(run, `cron trigger "${id}"`, path);
7955
8373
  validateReplicas(run, `cron trigger "${id}"`, path);
8374
+ validateNumber(on, `cron trigger "${id}"`, path, { onType: "cron" });
8375
+ validateOnce(on, run, `cron trigger "${id}"`, path, { onType: "cron" });
8376
+ validateDisarmed(on, `cron trigger "${id}"`, path, { onType: "cron" });
8377
+ const secrets = validateSecrets(run, `cron trigger "${id}"`, path);
8378
+ const secretsProfile = validateSecretsProfile(run, `cron trigger "${id}"`, path);
7956
8379
  return {
7957
8380
  on: { type: "cron", id, pattern },
7958
- 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 !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir } }
8381
+ 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 !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile } }
7959
8382
  };
7960
8383
  }
7961
8384
  function validatePackagesFlag(run, at, path) {
@@ -8020,7 +8443,7 @@ function validateCommand(run, at, path, { onType }) {
8020
8443
  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}`);
8021
8444
  }
8022
8445
  if (raw === void 0) return void 0;
8023
- if (!isNonEmptyString2(raw)) {
8446
+ if (!isNonEmptyString4(raw)) {
8024
8447
  throw configError(`${at}: run.command must be a non-empty string -- the registered command name, optionally followed by its arguments: ${path}`);
8025
8448
  }
8026
8449
  if (raw !== raw.trim()) {
@@ -8057,6 +8480,72 @@ function validatePredicate(on, index, path, requirePositive) {
8057
8480
  }
8058
8481
  return { any: on.any, all: on.all, none: on.none };
8059
8482
  }
8483
+ function validateNumber(on, at, path, { capable = false, onType } = {}) {
8484
+ const number = on.number;
8485
+ if (number === void 0) return void 0;
8486
+ if (!capable) {
8487
+ if (onType === "cron") {
8488
+ throw configError(`${at}: on.number is not available on a cron trigger -- a schedule fires on time, not on an item, so there is no delivery for a number to narrow: ${path}`);
8489
+ }
8490
+ throw configError(`${at}: on.number is not yet covered for ${onType} triggers here -- only the close routes read it, so on a rule they never serve it would sit in the file looking configured; narrowing labels, comments or non-close pull_request rules is a gap to close, not a limit: ${path}`);
8491
+ }
8492
+ if (!Number.isInteger(number) || number < 1) {
8493
+ throw configError(`${at}: on.number must be an integer >= 1 when present -- the item number the forge itself assigns (on GitLab, the iid) (got ${JSON.stringify(number)}): ${path}`);
8494
+ }
8495
+ return number;
8496
+ }
8497
+ function validateOnce(on, run, at, path, { capable = false, onType } = {}) {
8498
+ const once = on.once;
8499
+ if (once === void 0) return void 0;
8500
+ if (!capable) {
8501
+ if (onType === "cron") {
8502
+ throw configError(`${at}: on.once is not available on a cron trigger -- a one-shot that can re-arm is a schedule, and a cron job carries no matched delivery for the disarm to name; delete the entry when the work is done: ${path}`);
8503
+ }
8504
+ const closeWords = Object.entries(PR_CLOSE_ACTIONS).map(([k, w]) => `${k} has ${JSON.stringify(w)}`).join(", ");
8505
+ throw configError(`${at}: on.once is not yet covered here -- the disarm writes back to the one entry a delivery matched, and only a close delivery names the single item that spends it; use on.type "issue", or a pull_request rule whose only action is the close word (${closeWords}; azure has no close trigger yet): ${path}`);
8506
+ }
8507
+ if (typeof once !== "boolean") {
8508
+ throw configError(`${at}: on.once must be true or false when present -- a truthy string arming a one-shot is exactly the drift this validator exists to refuse (got ${JSON.stringify(once)}): ${path}`);
8509
+ }
8510
+ if (once === true && on.number === void 0) {
8511
+ throw configError(`${at}: on.once requires on.number -- a one-shot without a named item is spent by whichever close arrives first, and two different items closing inside one dedup window would race for it; the number is also the identity the disarm re-checks before it writes: ${path}`);
8512
+ }
8513
+ if (once === true && run.replicas !== void 0) {
8514
+ throw configError(`${at}: on.once and run.replicas cannot be combined -- "exactly one run" and "N sandboxes race" contradict, and with N run records the disarm no longer says which one spent the trigger: ${path}`);
8515
+ }
8516
+ return once;
8517
+ }
8518
+ function validateDisarmed(on, at, path, { capable = false, onType } = {}) {
8519
+ const disarmed = on.disarmed;
8520
+ if (disarmed === void 0) return void 0;
8521
+ if (!capable) {
8522
+ throw configError(`${at}: on.disarmed marks a spent one-shot, and on.once is not ${onType === "cron" ? "available on a cron trigger" : "yet covered here"}, so there is nothing this entry could have spent: ${path}`);
8523
+ }
8524
+ if (disarmed === null || typeof disarmed !== "object" || Array.isArray(disarmed)) {
8525
+ throw configError(`${at}: on.disarmed must be an object with a non-empty string "at" (and optionally a non-empty string "jobId") -- the worker writes it when the one-shot fires, and a hand-written one disarms the entry deliberately (got ${JSON.stringify(disarmed)}): ${path}`);
8526
+ }
8527
+ for (const key of Object.keys(disarmed)) {
8528
+ if (key !== "at" && key !== "jobId") {
8529
+ throw configError(`${at}: on.disarmed has an unsupported key ${JSON.stringify(key)} (expected at|jobId): ${path}`);
8530
+ }
8531
+ }
8532
+ if (!isNonEmptyString4(disarmed.at)) {
8533
+ throw configError(`${at}: on.disarmed.at must be a non-empty string (the time the one-shot fired): ${path}`);
8534
+ }
8535
+ if (disarmed.jobId !== void 0 && !isNonEmptyString4(disarmed.jobId)) {
8536
+ throw configError(`${at}: on.disarmed.jobId must be a non-empty string when present (the run record it joins to): ${path}`);
8537
+ }
8538
+ if (on.once !== true) {
8539
+ throw configError(`${at}: on.disarmed is only meaningful beside on.once: true -- an entry that was never a one-shot has nothing to spend: ${path}`);
8540
+ }
8541
+ return disarmed;
8542
+ }
8543
+ function validateFlowName(run, at, path) {
8544
+ if (run.flow === void 0) return;
8545
+ if (!SKILL_NAME_RE.test(run.flow)) {
8546
+ throw configError(`${at}: run.flow ${JSON.stringify(run.flow)} fails the skill-name charset (lowercase letters, digits, dash and underscore, 1-64 chars, starting and ending alphanumeric) -- a name outside it can never materialise, so this trigger could only ever fail after the budget slot was reserved: ${path}`);
8547
+ }
8548
+ }
8060
8549
  function validateRepository(run, onType, at, path) {
8061
8550
  const needed = run.kind === "azure" && (onType === "label" || onType === "comment");
8062
8551
  const raw = run.repository;
@@ -8067,7 +8556,7 @@ function validateRepository(run, onType, at, path) {
8067
8556
  if (!needed) {
8068
8557
  throw configError(`${at}: run.repository is only meaningful on an azure label or comment trigger (a pull request names its own repository): ${path}`);
8069
8558
  }
8070
- if (!isNonEmptyString2(raw) || raw !== raw.trim() || raw.includes("/")) {
8559
+ if (!isNonEmptyString4(raw) || raw !== raw.trim() || raw.includes("/")) {
8071
8560
  throw configError(`${at}: run.repository must be a non-empty repository NAME within the project, with no slashes and no surrounding whitespace (got ${JSON.stringify(raw)}): ${path}`);
8072
8561
  }
8073
8562
  return raw;
@@ -8086,13 +8575,67 @@ function validateReplicas(run, at, path) {
8086
8575
  }
8087
8576
  return replicas;
8088
8577
  }
8578
+ function validateSecrets(run, at, path) {
8579
+ const secrets = run.secrets;
8580
+ if (secrets === void 0) return void 0;
8581
+ if (secrets === null || typeof secrets !== "object" || Array.isArray(secrets)) {
8582
+ throw configError(`${at}: run.secrets must be an object mapping environment variable names to references when present (got ${JSON.stringify(secrets)}): ${path}`);
8583
+ }
8584
+ const names = Object.keys(secrets);
8585
+ if (names.length === 0) {
8586
+ throw configError(`${at}: run.secrets is empty -- an empty map is a field that does nothing, and this trigger reads as though it binds a secret: ${path}`);
8587
+ }
8588
+ if (names.length > SECRETS_MAX) {
8589
+ throw configError(`${at}: run.secrets names ${names.length} variables, over the ${SECRETS_MAX} cap -- each one is resolved before the container starts, holding a concurrency slot while it runs: ${path}`);
8590
+ }
8591
+ for (const name of names) {
8592
+ if (!ENV_NAME.test(name)) {
8593
+ throw configError(`${at}: run.secrets key ${JSON.stringify(name)} is not an environment variable name (letters, digits and underscore, not starting with a digit): ${path}`);
8594
+ }
8595
+ if (RESERVED_ENV_NAMES.has(name)) {
8596
+ throw configError(`${at}: run.secrets key ${JSON.stringify(name)} is a variable the worker sets itself -- the job container would receive the worker's value, not this trigger's, and the trigger would look like it worked: ${path}`);
8597
+ }
8598
+ const reference = secrets[name];
8599
+ if (!isNonEmptyString4(reference)) {
8600
+ throw configError(`${at}: run.secrets.${name} must be a non-empty string reference for your resolver to read (got ${JSON.stringify(reference)}): ${path}`);
8601
+ }
8602
+ if (reference !== reference.trim()) {
8603
+ throw configError(`${at}: run.secrets.${name} must not have leading or trailing whitespace -- the file is the reviewed artifact and silently trimming it would make the file disagree with what the resolver is asked for: ${path}`);
8604
+ }
8605
+ if (reference.startsWith("-")) {
8606
+ throw configError(`${at}: run.secrets.${name} must not start with "-" -- it is passed as the resolver's first argument, where a leading dash parses as a flag: ${path}`);
8607
+ }
8608
+ }
8609
+ if (run.resume === true) {
8610
+ throw configError(`${at}: run.secrets and run.resume cannot be combined -- a resumed job replays a transcript kept on host disk, and any command the agent ran that echoed a resolved value wrote it into that transcript, which is then prefilled into every later job on the same key: ${path}`);
8611
+ }
8612
+ return { ...secrets };
8613
+ }
8614
+ function validateSecretsProfile(run, at, path) {
8615
+ const profile = run.secretsProfile;
8616
+ if (profile === void 0) return void 0;
8617
+ if (!isNonEmptyString4(profile)) {
8618
+ throw configError(`${at}: run.secretsProfile must be a non-empty string naming one of the resolver profiles this deployment declares (got ${JSON.stringify(profile)}): ${path}`);
8619
+ }
8620
+ if (!ID_CHARSET.test(profile)) {
8621
+ throw configError(`${at}: run.secretsProfile ${JSON.stringify(profile)} may use letters, digits, dot, dash and underscore only -- PI_SECRET_PROFILES is a comma-separated list of name:path pairs, so a name carrying either separator cannot be declared: ${path}`);
8622
+ }
8623
+ if (run.secrets === void 0) {
8624
+ throw configError(`${at}: run.secretsProfile is set but run.secrets names nothing -- a profile that resolves no references is a field that does nothing: ${path}`);
8625
+ }
8626
+ return profile;
8627
+ }
8089
8628
  function normalizeLabel(on, run, index, path) {
8090
8629
  const at = `trigger at index ${index}`;
8091
8630
  const predicate = validatePredicate(on, index, path, true);
8631
+ validateNumber(on, at, path, { onType: "label" });
8632
+ validateOnce(on, run, at, path, { onType: "label" });
8633
+ validateDisarmed(on, at, path, { onType: "label" });
8092
8634
  const command = validateCommand(run, at, path, { onType: "label" });
8093
- if (command === void 0 && !isNonEmptyString2(run.flow)) {
8635
+ if (command === void 0 && !isNonEmptyString4(run.flow)) {
8094
8636
  throw configError(`${at}: label trigger run.flow must be a non-empty string (or use run.command): ${path}`);
8095
8637
  }
8638
+ validateFlowName(run, at, path);
8096
8639
  const packages = validatePackagesFlag(run, at, path);
8097
8640
  const image = validateImageRef(run, at, path);
8098
8641
  const skillsDir = validateSkillsDir(run, at, path);
@@ -8100,20 +8643,26 @@ function normalizeLabel(on, run, index, path) {
8100
8643
  const resume = validateResumeFlag(run, at, path);
8101
8644
  const repository = validateRepository(run, "label", at, path);
8102
8645
  const replicas = validateReplicas(run, at, path);
8646
+ const secrets = validateSecrets(run, at, path);
8647
+ const secretsProfile = validateSecretsProfile(run, at, path);
8103
8648
  return {
8104
8649
  on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none },
8105
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository } }
8650
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile } }
8106
8651
  };
8107
8652
  }
8108
8653
  function normalizeComment(on, run, index, path, state) {
8109
8654
  const at = `trigger at index ${index}`;
8110
- if (!isNonEmptyString2(on.phrase)) {
8655
+ if (!isNonEmptyString4(on.phrase)) {
8111
8656
  throw configError(`${at}: comment trigger on.phrase must be a non-empty string: ${path}`);
8112
8657
  }
8658
+ validateNumber(on, at, path, { onType: "comment" });
8659
+ validateOnce(on, run, at, path, { onType: "comment" });
8660
+ validateDisarmed(on, at, path, { onType: "comment" });
8113
8661
  const command = validateCommand(run, at, path, { onType: "comment" });
8114
- if (command === void 0 && !isNonEmptyString2(run.flow)) {
8662
+ if (command === void 0 && !isNonEmptyString4(run.flow)) {
8115
8663
  throw configError(`${at}: comment trigger run.flow (the default flow) must be a non-empty string (or use run.command): ${path}`);
8116
8664
  }
8665
+ validateFlowName(run, at, path);
8117
8666
  state.commentCounts[run.kind] = (state.commentCounts[run.kind] ?? 0) + 1;
8118
8667
  if (state.commentCounts[run.kind] > 1) {
8119
8668
  throw configError(`${at}: at most one ${run.kind} comment trigger is allowed: ${path}`);
@@ -8125,9 +8674,59 @@ function normalizeComment(on, run, index, path, state) {
8125
8674
  const resume = validateResumeFlag(run, at, path);
8126
8675
  const repository = validateRepository(run, "comment", at, path);
8127
8676
  const replicas = validateReplicas(run, at, path);
8677
+ const secrets = validateSecrets(run, at, path);
8678
+ const secretsProfile = validateSecretsProfile(run, at, path);
8128
8679
  return {
8129
8680
  on: { type: "comment", phrase: on.phrase },
8130
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository } }
8681
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile } }
8682
+ };
8683
+ }
8684
+ function normalizeIssue(on, run, index, path) {
8685
+ const at = `trigger at index ${index}`;
8686
+ if (run.kind === "azure") {
8687
+ throw configError(`${at}: an issue trigger is not yet covered for azure -- a work item's close is a System.State transition whose terminal names vary by process template (Agile "Closed", Scrum "Done"), and the projected subset carries only System.Tags, so nothing in the delivery says the item closed; widening the payload subset is the gap to close, not a limit: ${path}`);
8688
+ }
8689
+ const actions = on.action;
8690
+ if (!Array.isArray(actions) || actions.length === 0) {
8691
+ throw configError(`${at}: issue on.action must be a non-empty array: ${path}`);
8692
+ }
8693
+ const allowed = ISSUE_ACTIONS[run.kind];
8694
+ const expected = [...allowed].join("|");
8695
+ for (const a of actions) {
8696
+ if (!allowed.has(a)) {
8697
+ throw configError(`${at}: issue on.action has an unsupported ${run.kind} action ${JSON.stringify(a)} (expected ${expected}): ${path}`);
8698
+ }
8699
+ }
8700
+ if (on.any !== void 0 || on.all !== void 0 || on.none !== void 0) {
8701
+ throw configError(`${at}: an issue trigger cannot carry a label predicate -- it fires on a lifecycle action, not a label diff, so any/all/none could never match; a label-predicated rule is on.type "label": ${path}`);
8702
+ }
8703
+ const number = validateNumber(on, at, path, { capable: true, onType: "issue" });
8704
+ const once = validateOnce(on, run, at, path, { capable: true, onType: "issue" });
8705
+ validateDisarmed(on, at, path, { capable: true, onType: "issue" });
8706
+ const command = validateCommand(run, at, path, { onType: "issue" });
8707
+ if (command === void 0 && !isNonEmptyString4(run.flow)) {
8708
+ throw configError(`${at}: issue trigger run.flow must be a non-empty string (or use run.command): ${path}`);
8709
+ }
8710
+ validateFlowName(run, at, path);
8711
+ const packages = validatePackagesFlag(run, at, path);
8712
+ const image = validateImageRef(run, at, path);
8713
+ const skillsDir = validateSkillsDir(run, at, path);
8714
+ const instructions = validateInstructions(run, at, path);
8715
+ const resume = validateResumeFlag(run, at, path);
8716
+ validateRepository(run, "issue", at, path);
8717
+ const replicas = validateReplicas(run, at, path);
8718
+ const secrets = validateSecrets(run, at, path);
8719
+ const secretsProfile = validateSecretsProfile(run, at, path);
8720
+ return {
8721
+ on: {
8722
+ type: "issue",
8723
+ action: [...actions],
8724
+ // Absent rather than present-and-undefined, reviewState's rule below: an unnarrowed rule's
8725
+ // normalized shape must not grow keys.
8726
+ ...number !== void 0 && { number },
8727
+ ...once !== void 0 && { once }
8728
+ },
8729
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile } }
8131
8730
  };
8132
8731
  }
8133
8732
  function normalizePullRequest(on, run, index, path) {
@@ -8143,17 +8742,29 @@ function normalizePullRequest(on, run, index, path) {
8143
8742
  throw configError(`${at}: pull_request on.action has an unsupported ${run.kind} action ${JSON.stringify(a)} (expected ${expected}): ${path}`);
8144
8743
  }
8145
8744
  }
8745
+ const closeWord = PR_CLOSE_ACTIONS[run.kind];
8746
+ const isClose = closeWord !== void 0 && actions.includes(closeWord);
8747
+ if (isClose && actions.some((a) => a !== closeWord)) {
8748
+ throw configError(`${at}: pull_request on.action cannot mix ${JSON.stringify(closeWord)} with other actions -- a close rule is gated on the closer's write access and every other action on the author's, and one rule cannot gate on two different actors; split it into two entries: ${path}`);
8749
+ }
8146
8750
  const reviewState = validateReviewState(on, actions, run, at, path);
8147
8751
  const labelAction = forgeSpec(run.kind)?.prLabelAction;
8148
8752
  const requirePositive = typeof labelAction === "string" && actions.includes(labelAction);
8149
8753
  if (run.kind === "azure" && (on.any !== void 0 || on.all !== void 0 || on.none !== void 0)) {
8150
8754
  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}`);
8151
8755
  }
8756
+ if (isClose && (on.any !== void 0 || on.all !== void 0 || on.none !== void 0)) {
8757
+ throw configError(`${at}: a close pull_request rule cannot carry a label predicate -- the close route matches the action and on.number alone, so any/all/none would sit in the file looking configured and never match anything: ${path}`);
8758
+ }
8152
8759
  const predicate = validatePredicate(on, index, path, requirePositive);
8760
+ const number = validateNumber(on, at, path, { capable: isClose, onType: "pull_request" });
8761
+ const once = validateOnce(on, run, at, path, { capable: isClose, onType: "pull_request" });
8762
+ validateDisarmed(on, at, path, { capable: isClose, onType: "pull_request" });
8153
8763
  const command = validateCommand(run, at, path, { onType: "pull_request" });
8154
- if (command === void 0 && !isNonEmptyString2(run.flow)) {
8764
+ if (command === void 0 && !isNonEmptyString4(run.flow)) {
8155
8765
  throw configError(`${at}: pull_request trigger run.flow must be a non-empty string (or use run.command): ${path}`);
8156
8766
  }
8767
+ validateFlowName(run, at, path);
8157
8768
  const packages = validatePackagesFlag(run, at, path);
8158
8769
  const image = validateImageRef(run, at, path);
8159
8770
  const skillsDir = validateSkillsDir(run, at, path);
@@ -8161,6 +8772,8 @@ function normalizePullRequest(on, run, index, path) {
8161
8772
  const resume = validateResumeFlag(run, at, path);
8162
8773
  validateRepository(run, "pull_request", at, path);
8163
8774
  const replicas = validateReplicas(run, at, path);
8775
+ const secrets = validateSecrets(run, at, path);
8776
+ const secretsProfile = validateSecretsProfile(run, at, path);
8164
8777
  return {
8165
8778
  on: {
8166
8779
  type: "pull_request",
@@ -8170,9 +8783,12 @@ function normalizePullRequest(on, run, index, path) {
8170
8783
  ...reviewState !== void 0 && { reviewState },
8171
8784
  any: predicate.any,
8172
8785
  all: predicate.all,
8173
- none: predicate.none
8786
+ none: predicate.none,
8787
+ // Same rule for the close-narrowing pair (issue #231): only a close-only rule can carry them.
8788
+ ...number !== void 0 && { number },
8789
+ ...once !== void 0 && { once }
8174
8790
  },
8175
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions } }
8791
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile } }
8176
8792
  };
8177
8793
  }
8178
8794
  function validateReviewState(on, actions, run, at, path) {
@@ -8194,366 +8810,58 @@ function validateReviewState(on, actions, run, at, path) {
8194
8810
  }
8195
8811
  return [...on.reviewState];
8196
8812
  }
8197
- var ON_TYPES, PR_ACTIONS, REVIEW_STATES, REVIEW_ACTION, ID_CHARSET, REPLICAS_MAX, INSTRUCTIONS_MAX;
8813
+ var ON_TYPES, DISARMED_TYPE, PR_ACTIONS, PR_CLOSE_ACTIONS, ISSUE_ACTIONS, REVIEW_STATES, REVIEW_ACTION, ID_CHARSET, REPLICAS_MAX, SECRETS_MAX, ENV_NAME, RESERVED_ENV_NAMES, INSTRUCTIONS_MAX;
8198
8814
  var init_triggers = __esm({
8199
8815
  "../worker/src/triggers.mjs"() {
8200
8816
  init_config2();
8817
+ init_flow_gate();
8201
8818
  init_forges();
8202
- ON_TYPES = /* @__PURE__ */ new Set(["cron", "label", "comment", "pull_request"]);
8819
+ init_reserved_env();
8820
+ ON_TYPES = /* @__PURE__ */ new Set(["cron", "label", "comment", "pull_request", "issue"]);
8821
+ DISARMED_TYPE = "disarmed";
8203
8822
  PR_ACTIONS = {
8204
- github: /* @__PURE__ */ new Set(["labeled", "opened", "synchronize", "reopened", "review_submitted"]),
8823
+ github: /* @__PURE__ */ new Set(["labeled", "opened", "synchronize", "reopened", "review_submitted", "closed"]),
8205
8824
  // GitLab's `approved` is its review gate (a member approved the MR). It is NOT github's
8206
8825
  // `review_submitted` renamed: `approved` is one verdict, `review_submitted` is every verdict, which is
8207
- // what `on.reviewState` below exists to narrow.
8208
- gitlab: /* @__PURE__ */ new Set(["open", "update", "reopen", "approved"]),
8826
+ // what `on.reviewState` below exists to narrow. `close` is GitLab's own spelling of the close action.
8827
+ gitlab: /* @__PURE__ */ new Set(["open", "update", "reopen", "approved", "close"]),
8209
8828
  // Forgejo's own spellings. `label_updated` is its `labeled` and `synchronized` its `synchronize` -- a
8210
8829
  // one-letter difference that an operator would otherwise discover as a trigger that loads clean and
8211
8830
  // never fires. `label_cleared` is deliberately ABSENT and always will be: REMOVING a label must never
8212
8831
  // start a paid run, and it has no GitHub counterpart to inherit that rule from.
8213
- forgejo: /* @__PURE__ */ new Set(["label_updated", "opened", "synchronized", "reopened"]),
8832
+ forgejo: /* @__PURE__ */ new Set(["label_updated", "opened", "synchronized", "reopened", "closed"]),
8214
8833
  // Azure's Service Hook events reduced to the two that leave something to act on. `git.pullrequest.merged`
8215
- // is omitted for the same reason GitLab's `merge` and `close` are: a job started by a merge has nothing
8216
- // left to do. There is no label action at all -- Azure attaches tags to WORK ITEMS, never to pull
8217
- // requests -- which is why azure's `prLabelAction` is null and a predicated PR rule is refused below.
8834
+ // is omitted for the reason the close words' own comment above records, and there is no close word here at
8835
+ // all: an abandon arrives as `git.pullrequest.updated` with nothing in the projected subset to tell it from
8836
+ // any other update, so an azure close rule could only ever fire on the wrong events or never. Widening
8837
+ // INT-AZURE-PAYLOAD-SUBSET (a PR status field) is the gap to close before this set can grow. There is no
8838
+ // label action either -- Azure attaches tags to WORK ITEMS, never to pull requests -- which is why azure's
8839
+ // `prLabelAction` is null and a predicated PR rule is refused below.
8218
8840
  azure: /* @__PURE__ */ new Set(["created", "updated"])
8219
8841
  };
8842
+ PR_CLOSE_ACTIONS = { github: "closed", gitlab: "close", forgejo: "closed" };
8843
+ ISSUE_ACTIONS = {
8844
+ github: /* @__PURE__ */ new Set(["closed"]),
8845
+ gitlab: /* @__PURE__ */ new Set(["close"]),
8846
+ forgejo: /* @__PURE__ */ new Set(["closed"])
8847
+ };
8220
8848
  REVIEW_STATES = /* @__PURE__ */ new Set(["approved", "changes_requested", "commented"]);
8221
8849
  REVIEW_ACTION = "review_submitted";
8222
8850
  ID_CHARSET = /^[A-Za-z0-9._-]+$/;
8223
8851
  REPLICAS_MAX = 3;
8852
+ SECRETS_MAX = 16;
8853
+ ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
8854
+ RESERVED_ENV_NAMES = /* @__PURE__ */ new Set([...MINTED_TOKEN_VARS, ...FORGE_HOST_VARS, ...WORKER_ONLY_SECRET_VARS, ...EGRESS_ENV_VARS, ...CONTAINER_ENV_NAMES]);
8224
8855
  INSTRUCTIONS_MAX = 2e3;
8225
8856
  }
8226
8857
  });
8227
8858
 
8228
- // ../worker/src/pause-windows.mjs
8229
- function isNonEmptyString3(value) {
8230
- return typeof value === "string" && value.trim() !== "";
8231
- }
8232
- function parseHHMM(value, label, at, path) {
8233
- const m = isNonEmptyString3(value) ? /^([0-9]{1,2}):([0-9]{2})$/.exec(value.trim()) : null;
8234
- if (!m) throw configError(`${at}: ${label} must be "HH:MM": ${JSON.stringify(value)} (${path})`);
8235
- const h = Number(m[1]);
8236
- const min = Number(m[2]);
8237
- if (h > 23 || min > 59) throw configError(`${at}: ${label} out of range 00:00-23:59: ${JSON.stringify(value)} (${path})`);
8238
- return h * 60 + min;
8239
- }
8240
- function assertDate(value, label, at, path) {
8241
- const m = /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.exec(String(value).trim());
8242
- const mo = m && Number(m[2]);
8243
- const d = m && Number(m[3]);
8244
- if (!m || mo < 1 || mo > 12 || d < 1 || d > 31) {
8245
- throw configError(`${at}: ${label} must be "YYYY-MM-DD": ${JSON.stringify(value)} (${path})`);
8246
- }
8247
- }
8248
- function validateTz(tz, at, path) {
8249
- try {
8250
- new Intl.DateTimeFormat("en-US", { timeZone: tz });
8251
- return tz;
8252
- } catch {
8253
- throw configError(`${at}: tz is not a valid IANA timezone: ${JSON.stringify(tz)} (${path})`);
8254
- }
8255
- }
8256
- function parsePauseWindows(text, path) {
8257
- let parsed;
8258
- try {
8259
- parsed = JSON.parse(text);
8260
- } catch (error) {
8261
- throw configError(`pause-windows file is not valid JSON: ${path} (${error.message})`);
8262
- }
8263
- const windows = parsed?.windows;
8264
- if (!Array.isArray(windows)) {
8265
- throw configError(`pause-windows file must have a "windows" array: ${path}`);
8266
- }
8267
- return windows.map((w, index) => normalizeWindow(w, index, path));
8268
- }
8269
- function normalizeWindow(w, index, path) {
8270
- const at = `pause window at index ${index}`;
8271
- if (w === null || typeof w !== "object") throw configError(`${at}: must be an object: ${path}`);
8272
- if (!isNonEmptyString3(w.scope)) throw configError(`${at}: scope must be a non-empty string: ${path}`);
8273
- const fromMin = parseHHMM(w.from, "from", at, path);
8274
- const toMin = parseHHMM(w.to, "to", at, path);
8275
- if (fromMin === toMin) {
8276
- throw configError(`${at}: from and to must differ (a 24h pause is not expressible; remove the trigger): ${path}`);
8277
- }
8278
- const tz = w.tz === void 0 ? "UTC" : validateTz(w.tz, at, path);
8279
- let days = null;
8280
- if (w.days !== void 0) {
8281
- if (!Array.isArray(w.days) || w.days.length === 0) {
8282
- throw configError(`${at}: days must be a non-empty array of weekday names (mon..sun): ${path}`);
8283
- }
8284
- days = w.days.map((d) => {
8285
- const key = String(d).trim().toLowerCase().slice(0, 3);
8286
- if (!DAY_SET.has(key)) throw configError(`${at}: unknown weekday ${JSON.stringify(d)} (use mon..sun): ${path}`);
8287
- return key;
8288
- });
8289
- }
8290
- let dateFrom = null;
8291
- let dateTo = null;
8292
- if (w.dateFrom !== void 0) {
8293
- assertDate(w.dateFrom, "dateFrom", at, path);
8294
- dateFrom = String(w.dateFrom).trim();
8295
- }
8296
- if (w.dateTo !== void 0) {
8297
- assertDate(w.dateTo, "dateTo", at, path);
8298
- dateTo = String(w.dateTo).trim();
8299
- }
8300
- if (dateFrom && dateTo && dateFrom > dateTo) throw configError(`${at}: dateFrom must be <= dateTo: ${path}`);
8301
- const norm = { scope: w.scope.trim(), from: w.from.trim(), to: w.to.trim(), fromMin, toMin, tz };
8302
- if (days) norm.days = days;
8303
- if (dateFrom) norm.dateFrom = dateFrom;
8304
- if (dateTo) norm.dateTo = dateTo;
8305
- return norm;
8306
- }
8307
- function zonedParts(tz, ms) {
8308
- const fmt2 = new Intl.DateTimeFormat("en-US", {
8309
- timeZone: tz,
8310
- hour12: false,
8311
- year: "numeric",
8312
- month: "2-digit",
8313
- day: "2-digit",
8314
- hour: "2-digit",
8315
- minute: "2-digit",
8316
- weekday: "short"
8317
- });
8318
- const p = {};
8319
- for (const part of fmt2.formatToParts(new Date(ms))) p[part.type] = part.value;
8320
- let hh = Number(p.hour);
8321
- if (hh === 24) hh = 0;
8322
- return { y: Number(p.year), mo: Number(p.month), d: Number(p.day), hh, mi: Number(p.minute), dow: WEEKDAY_INDEX[p.weekday] };
8323
- }
8324
- function wallToMs(tz, y, mo, d, hh, mi) {
8325
- const guess = Date.UTC(y, mo - 1, d, hh, mi);
8326
- const shown = zonedParts(tz, guess);
8327
- const shownAsUtc = Date.UTC(shown.y, shown.mo - 1, shown.d, shown.hh, shown.mi);
8328
- return guess - (shownAsUtc - guess);
8329
- }
8330
- function shiftDay(tz, parts, delta) {
8331
- const noon = wallToMs(tz, parts.y, parts.mo, parts.d, 12, 0);
8332
- return zonedParts(tz, noon + delta * 864e5);
8333
- }
8334
- function ymd(parts) {
8335
- return `${String(parts.y).padStart(4, "0")}-${String(parts.mo).padStart(2, "0")}-${String(parts.d).padStart(2, "0")}`;
8336
- }
8337
- function qualifies(w, parts) {
8338
- if (w.days && !w.days.includes(DAYS[parts.dow])) return false;
8339
- const date = ymd(parts);
8340
- if (w.dateFrom && date < w.dateFrom) return false;
8341
- if (w.dateTo && date > w.dateTo) return false;
8342
- return true;
8343
- }
8344
- function windowEndAt(w, nowMs) {
8345
- const p = zonedParts(w.tz, nowMs);
8346
- const nowMin = p.hh * 60 + p.mi;
8347
- const endAt = (dateParts) => wallToMs(w.tz, dateParts.y, dateParts.mo, dateParts.d, Math.floor(w.toMin / 60), w.toMin % 60);
8348
- if (w.fromMin < w.toMin) {
8349
- if (nowMin >= w.fromMin && nowMin < w.toMin && qualifies(w, p)) return endAt(p);
8350
- return null;
8351
- }
8352
- if (nowMin >= w.fromMin && qualifies(w, p)) return endAt(shiftDay(w.tz, p, 1));
8353
- if (nowMin < w.toMin && qualifies(w, shiftDay(w.tz, p, -1))) return endAt(p);
8354
- return null;
8355
- }
8356
- var DAYS, DAY_SET, WEEKDAY_INDEX;
8357
- var init_pause_windows = __esm({
8358
- "../worker/src/pause-windows.mjs"() {
8359
- init_config2();
8360
- DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
8361
- DAY_SET = new Set(DAYS);
8362
- WEEKDAY_INDEX = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
8363
- }
8364
- });
8365
-
8366
- // ../worker/src/subscriptions.mjs
8367
- function isNonEmptyString4(value) {
8368
- return typeof value === "string" && value.trim() !== "";
8369
- }
8370
- function normalizeGlobs(value, label, at, path) {
8371
- if (!Array.isArray(value) || value.length === 0) {
8372
- throw configError(`${at}: ${label} must be a non-empty array of model globs: ${path}`);
8373
- }
8374
- return value.map((glob) => {
8375
- if (typeof glob !== "string" || !MODEL_GLOB_RE.test(glob.trim())) {
8376
- throw configError(`${at}: ${label} glob ${JSON.stringify(glob)} must match ${MODEL_GLOB_RE} (model-id charset plus "*", max 64 chars): ${path}`);
8377
- }
8378
- return glob.trim();
8379
- });
8380
- }
8381
- function normalizeBool(value, label, at, path, fallback) {
8382
- if (value === void 0) return fallback;
8383
- if (typeof value !== "boolean") throw configError(`${at}: ${label} must be a boolean: ${path}`);
8384
- return value;
8385
- }
8386
- function parseSubscriptions(text, path) {
8387
- let parsed;
8388
- try {
8389
- parsed = JSON.parse(text);
8390
- } catch (error) {
8391
- throw configError(`subscriptions file is not valid JSON: ${path} (${error.message})`);
8392
- }
8393
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
8394
- throw configError(`subscriptions file must be a JSON object: ${path}`);
8395
- }
8396
- const version = parsed.version;
8397
- if (!Number.isInteger(version) || version < 1) {
8398
- throw configError(`subscriptions file must declare an integer "version" >= 1 (got ${JSON.stringify(version)}): ${path}`);
8399
- }
8400
- if (version > SUBSCRIPTIONS_VERSION) {
8401
- throw configError(`subscriptions file written by a newer pi-dispatch (version ${version}; this build understands ${SUBSCRIPTIONS_VERSION}): ${path}`);
8402
- }
8403
- const entries = parsed.subscriptions;
8404
- if (!Array.isArray(entries)) {
8405
- throw configError(`subscriptions file must have a "subscriptions" array: ${path}`);
8406
- }
8407
- const seenIds = /* @__PURE__ */ new Map();
8408
- return { version, subscriptions: entries.map((entry, index) => normalizeSubscription(entry, index, path, seenIds)) };
8409
- }
8410
- function normalizeSubscription(entry, index, path, seenIds) {
8411
- const at = `subscription at index ${index}`;
8412
- if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
8413
- throw configError(`${at}: must be an object: ${path}`);
8414
- }
8415
- if (!isNonEmptyString4(entry.id)) throw configError(`${at}: id must be a non-empty string: ${path}`);
8416
- const id = entry.id.trim();
8417
- if (seenIds.has(id)) {
8418
- throw configError(`${at}: id ${JSON.stringify(id)} is already used by the subscription at index ${seenIds.get(id)}: ${path}`);
8419
- }
8420
- seenIds.set(id, index);
8421
- if (!isNonEmptyString4(entry.vendor)) throw configError(`${at}: vendor must be a non-empty string: ${path}`);
8422
- if (!isNonEmptyString4(entry.provider)) throw configError(`${at}: provider must be a non-empty string (the pi-ai provider id this plan covers): ${path}`);
8423
- const models = entry.models === void 0 ? ["*"] : normalizeGlobs(entry.models, "models", at, path);
8424
- const price = entry.price;
8425
- if (price === null || typeof price !== "object" || Array.isArray(price)) {
8426
- throw configError(`${at}: price must be an object { amount, currency, per }: ${path}`);
8427
- }
8428
- if (typeof price.amount !== "number" || !Number.isFinite(price.amount) || price.amount <= 0) {
8429
- throw configError(`${at}: price.amount must be a number > 0: ${path}`);
8430
- }
8431
- if (typeof price.currency !== "string" || !/^[A-Z]{3}$/.test(price.currency)) {
8432
- throw configError(`${at}: price.currency must be a 3-letter uppercase code (e.g. "USD"): ${path}`);
8433
- }
8434
- if (price.per !== "month") {
8435
- throw configError(`${at}: price.per must be "month" (got ${JSON.stringify(price.per)}): ${path}`);
8436
- }
8437
- const sharedWithOtherProducts = normalizeBool(entry.sharedWithOtherProducts, "sharedWithOtherProducts", at, path, false);
8438
- const hypothetical = normalizeBool(entry.hypothetical, "hypothetical", at, path, false);
8439
- let counterfactualModel = null;
8440
- if (entry.counterfactualModel !== void 0 && entry.counterfactualModel !== null) {
8441
- const cm = entry.counterfactualModel;
8442
- if (typeof cm !== "object" || Array.isArray(cm) || !isNonEmptyString4(cm.provider) || !isNonEmptyString4(cm.id)) {
8443
- throw configError(`${at}: counterfactualModel must be { provider, id } with non-empty strings (a PRICED pi-ai model to re-price covered runs at API rates): ${path}`);
8444
- }
8445
- counterfactualModel = { provider: cm.provider.trim(), id: cm.id.trim() };
8446
- }
8447
- let windows = [];
8448
- if (entry.windows !== void 0) {
8449
- if (!Array.isArray(entry.windows)) throw configError(`${at}: windows must be an array: ${path}`);
8450
- windows = entry.windows.map((w, windowIndex) => normalizeWindow2(w, windowIndex, index, path));
8451
- }
8452
- return {
8453
- id,
8454
- vendor: entry.vendor.trim(),
8455
- provider: entry.provider.trim(),
8456
- models,
8457
- price: { amount: price.amount, currency: price.currency, per: "month" },
8458
- sharedWithOtherProducts,
8459
- hypothetical,
8460
- counterfactualModel,
8461
- windows
8462
- };
8463
- }
8464
- function normalizeWindow2(w, windowIndex, subIndex, path) {
8465
- const at = `window at index ${windowIndex} of subscription at index ${subIndex}`;
8466
- if (w === null || typeof w !== "object" || Array.isArray(w)) {
8467
- throw configError(`${at}: must be an object: ${path}`);
8468
- }
8469
- if (!WINDOW_PERS.has(w.per)) {
8470
- throw configError(`${at}: per must be one of 5h|7d|30d|month (got ${JSON.stringify(w.per)}): ${path}`);
8471
- }
8472
- const rolling = normalizeBool(w.rolling, "rolling", at, path, false);
8473
- let unit = null;
8474
- if (w.unit !== void 0 && w.unit !== null) {
8475
- if (!WINDOW_UNITS.has(w.unit)) {
8476
- throw configError(`${at}: unit must be one of tokens|requests|prompts|credits, or null (vendor undisclosed): ${path}`);
8477
- }
8478
- unit = w.unit;
8479
- }
8480
- let limit = null;
8481
- if (w.limit !== void 0 && w.limit !== null) {
8482
- if (typeof w.limit === "number") {
8483
- if (!Number.isFinite(w.limit) || w.limit <= 0) throw configError(`${at}: limit must be a number > 0, [min, max], or null (vendor undisclosed): ${path}`);
8484
- limit = w.limit;
8485
- } else if (Array.isArray(w.limit)) {
8486
- const [min, max] = w.limit;
8487
- if (w.limit.length !== 2 || typeof min !== "number" || typeof max !== "number" || !Number.isFinite(min) || !Number.isFinite(max) || min <= 0 || max <= 0 || min >= max) {
8488
- throw configError(`${at}: limit range must be [min, max] with both > 0 and min < max: ${path}`);
8489
- }
8490
- limit = [min, max];
8491
- } else {
8492
- throw configError(`${at}: limit must be a number > 0, [min, max], or null (vendor undisclosed): ${path}`);
8493
- }
8494
- }
8495
- const scope = w.scope === void 0 || w.scope === null ? null : normalizeGlobs(w.scope, "scope", at, path);
8496
- return { per: w.per, rolling, unit, limit, scope };
8497
- }
8498
- var SUBSCRIPTIONS_VERSION, WINDOW_PERS, WINDOW_UNITS, MODEL_GLOB_RE;
8499
- var init_subscriptions = __esm({
8500
- "../worker/src/subscriptions.mjs"() {
8501
- init_config2();
8502
- SUBSCRIPTIONS_VERSION = 1;
8503
- WINDOW_PERS = /* @__PURE__ */ new Set(["5h", "7d", "30d", "month"]);
8504
- WINDOW_UNITS = /* @__PURE__ */ new Set(["tokens", "requests", "prompts", "credits"]);
8505
- MODEL_GLOB_RE = /^[A-Za-z0-9*._:/-]{1,64}$/;
8506
- }
8507
- });
8508
-
8509
- // ../worker/src/connection.mjs
8510
- import { Redis } from "ioredis";
8511
- function parseConnection(url, { failFast = false } = {}) {
8512
- const u = new URL(url);
8513
- return {
8514
- host: u.hostname || "127.0.0.1",
8515
- port: Number(u.port || 6379),
8516
- ...u.password ? { password: u.password } : {},
8517
- ...u.username ? { username: u.username } : {},
8518
- ...u.pathname && u.pathname !== "/" ? { db: Number(u.pathname.slice(1)) } : {},
8519
- maxRetriesPerRequest: null,
8520
- // required for BullMQ blocking connections
8521
- ...failFast ? {
8522
- connectTimeout: 2e3,
8523
- enableOfflineQueue: false,
8524
- // don't buffer commands while disconnected -- error now
8525
- retryStrategy: (attempts) => attempts > 2 ? null : 200
8526
- // give up after ~2 tries
8527
- } : {}
8528
- };
8529
- }
8530
- function makeRedisClient(url) {
8531
- return new Redis(url, { maxRetriesPerRequest: null });
8532
- }
8533
- var init_connection = __esm({
8534
- "../worker/src/connection.mjs"() {
8535
- }
8536
- });
8537
-
8538
- // ../worker/src/job-id.mjs
8539
- import { createHash } from "node:crypto";
8540
- function localJobId({ folder, flow, task, minute }) {
8541
- const digest = createHash("sha256").update([folder, flow ?? "", task ?? "", minute].join("\0")).digest("hex");
8542
- return `local-${digest.slice(0, 16)}`;
8543
- }
8544
- var init_job_id = __esm({
8545
- "../worker/src/job-id.mjs"() {
8546
- init_config2();
8547
- init_forges();
8548
- }
8549
- });
8550
-
8551
8859
  // ../worker/src/queue.mjs
8552
8860
  import { Queue } from "bullmq";
8553
8861
  function makeQueue(connection) {
8554
8862
  return new Queue(QUEUE, { connection });
8555
8863
  }
8556
- async function enqueueLocalJob(queue, { folder, flow, task, command, provider, model, maxTurns, image, skillsDir, chainDepth, parentJobId, jobId, now = /* @__PURE__ */ new Date() }) {
8864
+ async function enqueueLocalJob(queue, { folder, flow, task, command, provider, model, maxTurns, image, skillsDir, secrets, secretsProfile, chainDepth, parentJobId, jobId, now = /* @__PURE__ */ new Date() }) {
8557
8865
  const minute = now.toISOString().slice(0, 16);
8558
8866
  const id = jobId ?? localJobId({ folder, flow: command !== void 0 ? `cmd:${command}` : flow, task, minute });
8559
8867
  const data = {
@@ -8574,6 +8882,11 @@ async function enqueueLocalJob(queue, { folder, flow, task, command, provider, m
8574
8882
  // than inside `trigger` because a worker-host path is an execution knob, not a fact about the
8575
8883
  // delivery -- and `trigger` is the object copied into /job/event.json.
8576
8884
  ...skillsDir !== void 0 && { skillsDir },
8885
+ // REQ-TRIGGER-SECRETS, on the forge path's terms: references only, resolved by the worker at job
8886
+ // start. A cron trigger may bind secrets (a nightly deploy is the obvious user), which is where
8887
+ // this differs from `replicas` -- that one is refused on a local job and this one is not.
8888
+ ...secrets !== void 0 && { secrets },
8889
+ ...secretsProfile !== void 0 && { secretsProfile },
8577
8890
  ...chainDepth !== void 0 && { chainDepth },
8578
8891
  ...parentJobId !== void 0 && { parentJobId }
8579
8892
  };
@@ -8586,59 +8899,18 @@ async function enqueueLocalJob(queue, { folder, flow, task, command, provider, m
8586
8899
  });
8587
8900
  return id;
8588
8901
  }
8589
- var QUEUE, SEMANTIC_WINDOW_MS;
8902
+ var PR_CLOSE_WORDS, QUEUE, SEMANTIC_WINDOW_MS;
8590
8903
  var init_queue = __esm({
8591
8904
  "../worker/src/queue.mjs"() {
8592
8905
  init_job_id();
8593
8906
  init_forges();
8907
+ init_triggers();
8908
+ PR_CLOSE_WORDS = new Set(Object.values(PR_CLOSE_ACTIONS));
8594
8909
  QUEUE = "pi-jobs";
8595
8910
  SEMANTIC_WINDOW_MS = 10 * 60 * 1e3;
8596
8911
  }
8597
8912
  });
8598
8913
 
8599
- // ../worker/src/flow-gate.mjs
8600
- import { execFile } from "node:child_process";
8601
- import { promisify } from "node:util";
8602
- async function readFlowGate({ folder, flow, sha, git = defaultGit }) {
8603
- if (typeof sha !== "string" || sha.trim() === "") return { gate: "deny" };
8604
- if (typeof flow !== "string" || !SKILL_NAME_RE.test(flow)) return { gate: "deny" };
8605
- const path = `.pi/skills/${flow}/SKILL.md`;
8606
- try {
8607
- const lsTreeZ = await git(folder, ["ls-tree", "-z", sha, path]);
8608
- const record = lsTreeZ.split("\0").find((r) => r);
8609
- if (!record) return { gate: "no-skill" };
8610
- const tab = record.indexOf(" ");
8611
- if (tab === -1) return { gate: "deny" };
8612
- const [mode, type, oid] = record.slice(0, tab).split(/\s+/);
8613
- if (mode !== "100644" || type !== "blob") return { gate: "deny" };
8614
- const content = await git(folder, ["cat-file", "blob", oid], { raw: true });
8615
- return { gate: aiTriggerAllows(content) ? "allow" : "deny" };
8616
- } catch {
8617
- return { gate: "deny" };
8618
- }
8619
- }
8620
- function aiTriggerAllows(buf) {
8621
- const text = buf.toString("utf8").replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
8622
- const block = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(text);
8623
- if (!block) return false;
8624
- return /^ai-trigger:\s*("?)allow\1\s*$/m.test(block[1]);
8625
- }
8626
- async function defaultGit(gitDir, args, { raw = false } = {}) {
8627
- const hardened = ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "--no-pager", "-C", gitDir, ...args];
8628
- const { stdout } = await exec("git", hardened, {
8629
- encoding: raw ? "buffer" : "utf8",
8630
- maxBuffer: 16 * 1024 * 1024
8631
- });
8632
- return stdout;
8633
- }
8634
- var exec, SKILL_NAME_RE;
8635
- var init_flow_gate = __esm({
8636
- "../worker/src/flow-gate.mjs"() {
8637
- exec = promisify(execFile);
8638
- SKILL_NAME_RE = /^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/;
8639
- }
8640
- });
8641
-
8642
8914
  // ../worker/src/git-dirty.mjs
8643
8915
  import { execFileSync } from "node:child_process";
8644
8916
  function gitDirty(folder, { exec: exec4 = execFileSync } = {}) {
@@ -9216,6 +9488,16 @@ function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, o
9216
9488
  // (guarded on `typeof t.flow === "string"`) correctly draws nothing for it.
9217
9489
  command: t.command ?? null,
9218
9490
  replicas: t.replicas ?? null,
9491
+ // The one-shot facts (issue #231), carried the way `cost` is: node FACTS, never new vocabulary
9492
+ // -- the #188 rule, and GRAPH_FLAGS/GRAPH_NODE_KINDS are byte-unchanged by this issue too. A
9493
+ // spent entry still arrives here because the display normalizer reads the RAW file (the spent
9494
+ // row must stay visible), so the two states a renderer has to keep apart ride as one boolean
9495
+ // and one mark: `once` is true only while ARMED -- a spent one-shot is not "a rule that will
9496
+ // fire once", and letting both read true would ask every consumer to re-derive the difference
9497
+ // -- and `disarmed` is the worker's mark verbatim ({ at, jobId? }), null like every absent
9498
+ // fact on this literal.
9499
+ once: t.once === true && !t.disarmed,
9500
+ disarmed: t.disarmed !== null && typeof t.disarmed === "object" ? t.disarmed : null,
9219
9501
  folderKey: group?.key ?? null,
9220
9502
  runs: Number.isInteger(stats?.runs) ? stats.runs : 0,
9221
9503
  lastOutcome: stats?.lastOutcome ?? null,
@@ -9353,8 +9635,16 @@ function triggerMatchLabel(t) {
9353
9635
  return selectorLabel(t);
9354
9636
  case "comment":
9355
9637
  return `"${t.phrase ?? "-"}"`;
9356
- case "pull_request":
9357
- return `action[${(Array.isArray(t.action) ? t.action : []).join(",")}]`;
9638
+ case "pull_request": {
9639
+ const action = `action[${(Array.isArray(t.action) ? t.action : []).join(",")}]`;
9640
+ const num = Number.isInteger(t.number) ? ` #${t.number}` : "";
9641
+ return t.disarmed ? `${action}${num} (spent)` : `${action}${num}`;
9642
+ }
9643
+ case "issue": {
9644
+ const action = `action[${(Array.isArray(t.action) ? t.action : []).join(",")}]`;
9645
+ const base = Number.isInteger(t.number) ? `${action} #${t.number}` : action;
9646
+ return t.disarmed ? `${base} (spent)` : base;
9647
+ }
9358
9648
  default:
9359
9649
  return "(unknown)";
9360
9650
  }
@@ -9961,8 +10251,110 @@ var init_costs = __esm({
9961
10251
  }
9962
10252
  });
9963
10253
 
10254
+ // ../worker/src/triggers-file.mjs
10255
+ import nodeFs2 from "node:fs";
10256
+ function lockPathFor(triggersPath) {
10257
+ return `${triggersPath}.lock`;
10258
+ }
10259
+ function takeLock(triggersPath, fs, log) {
10260
+ const lock = lockPathFor(triggersPath);
10261
+ let sweptAgeMs = null;
10262
+ for (let attempt = 0; attempt < 2; attempt++) {
10263
+ try {
10264
+ const fd = fs.openSync(lock, "wx");
10265
+ if (sweptAgeMs !== null) log("triggers_lock_stale_taken", { ageMs: sweptAgeMs });
10266
+ return fd;
10267
+ } catch (err) {
10268
+ if (err?.code !== "EEXIST") throw err;
10269
+ let mtimeMs;
10270
+ try {
10271
+ mtimeMs = fs.statSync(lock).mtimeMs;
10272
+ } catch {
10273
+ continue;
10274
+ }
10275
+ if (Date.now() - mtimeMs <= LOCK_STALE_MS) return null;
10276
+ try {
10277
+ fs.unlinkSync(lock);
10278
+ } catch {
10279
+ }
10280
+ sweptAgeMs = Math.round(Date.now() - mtimeMs);
10281
+ }
10282
+ }
10283
+ return null;
10284
+ }
10285
+ function releaseLock(fd, triggersPath, fs, log) {
10286
+ fs.closeSync(fd);
10287
+ try {
10288
+ fs.unlinkSync(lockPathFor(triggersPath));
10289
+ } catch {
10290
+ log("triggers_lock_stuck", {});
10291
+ }
10292
+ }
10293
+ function serialize(triggersArray) {
10294
+ return `${JSON.stringify({ triggers: triggersArray }, null, 2)}
10295
+ `;
10296
+ }
10297
+ function renameIntoPlace(fs, tmp, dest) {
10298
+ try {
10299
+ fs.renameSync(tmp, dest);
10300
+ } catch (err) {
10301
+ if (err?.code !== "EPERM") throw err;
10302
+ fs.renameSync(tmp, dest);
10303
+ }
10304
+ }
10305
+ function tmpPathFor(triggersPath) {
10306
+ return `${triggersPath}.${process.pid}.${tmpSeq++}.tmp`;
10307
+ }
10308
+ function discardTmp(fs, tmp) {
10309
+ try {
10310
+ fs.unlinkSync(tmp);
10311
+ } catch {
10312
+ }
10313
+ }
10314
+ function writeTriggers({ triggersPath, mutate, fs = nodeFs2, log = () => {
10315
+ } }) {
10316
+ const fd = takeLock(triggersPath, fs, log);
10317
+ if (fd === null) {
10318
+ return { invalid: `triggers file locked (another write in progress): ${lockPathFor(triggersPath)}` };
10319
+ }
10320
+ try {
10321
+ let current = [];
10322
+ try {
10323
+ const raw = JSON.parse(fs.readFileSync(triggersPath, "utf8"));
10324
+ if (Array.isArray(raw?.triggers)) current = raw.triggers;
10325
+ } catch {
10326
+ }
10327
+ const next = mutate(current.map((t) => ({ ...t })));
10328
+ const text = serialize(next);
10329
+ try {
10330
+ parseTriggers(text, triggersPath);
10331
+ } catch (e) {
10332
+ return { invalid: e?.message ?? String(e) };
10333
+ }
10334
+ const tmp = tmpPathFor(triggersPath);
10335
+ try {
10336
+ fs.writeFileSync(tmp, text, { mode: 420 });
10337
+ renameIntoPlace(fs, tmp, triggersPath);
10338
+ } catch (err) {
10339
+ discardTmp(fs, tmp);
10340
+ throw err;
10341
+ }
10342
+ return { ok: true };
10343
+ } finally {
10344
+ releaseLock(fd, triggersPath, fs, log);
10345
+ }
10346
+ }
10347
+ var LOCK_STALE_MS, tmpSeq;
10348
+ var init_triggers_file = __esm({
10349
+ "../worker/src/triggers-file.mjs"() {
10350
+ init_triggers();
10351
+ LOCK_STALE_MS = 1e4;
10352
+ tmpSeq = 0;
10353
+ }
10354
+ });
10355
+
9964
10356
  // src/read-model.mjs
9965
- import * as nodeFs2 from "node:fs";
10357
+ import * as nodeFs3 from "node:fs";
9966
10358
  import { join as join2, delimiter, sep } from "node:path";
9967
10359
  import { execFileSync as execFileSync2 } from "node:child_process";
9968
10360
  function resolvePaths(env = process.env) {
@@ -10135,14 +10527,14 @@ function folderUnderRoots(folder, roots) {
10135
10527
  if (!Array.isArray(roots) || roots.length === 0) return false;
10136
10528
  let realTarget;
10137
10529
  try {
10138
- realTarget = nodeFs2.realpathSync(folder);
10530
+ realTarget = nodeFs3.realpathSync(folder);
10139
10531
  } catch {
10140
10532
  return false;
10141
10533
  }
10142
10534
  for (const root of roots) {
10143
10535
  let realRoot;
10144
10536
  try {
10145
- realRoot = nodeFs2.realpathSync(root);
10537
+ realRoot = nodeFs3.realpathSync(root);
10146
10538
  } catch {
10147
10539
  continue;
10148
10540
  }
@@ -10153,7 +10545,7 @@ function folderUnderRoots(folder, roots) {
10153
10545
  function hourKey(nowMs) {
10154
10546
  return `dispatch-run:${new Date(nowMs).toISOString().slice(0, 13).replace("T", "-")}`;
10155
10547
  }
10156
- function writeSettings({ settingsFile, mutate, fs = nodeFs2 }) {
10548
+ function writeSettings({ settingsFile, mutate, fs = nodeFs3 }) {
10157
10549
  const res = readOverlay(settingsFile, { fs });
10158
10550
  const base = res.overlay ?? {};
10159
10551
  const next = mutate({ ...base });
@@ -10161,27 +10553,7 @@ function writeSettings({ settingsFile, mutate, fs = nodeFs2 }) {
10161
10553
  if (w.invalid) return { invalid: w.invalid };
10162
10554
  return res.invalid ? { ok: true, overlay: next, rebuiltFrom: res.invalid } : { ok: true, overlay: next };
10163
10555
  }
10164
- function writeTriggers({ triggersPath, mutate, fs = nodeFs2 }) {
10165
- let current = [];
10166
- try {
10167
- const raw = JSON.parse(fs.readFileSync(triggersPath, "utf8"));
10168
- if (Array.isArray(raw?.triggers)) current = raw.triggers;
10169
- } catch {
10170
- }
10171
- const next = mutate(current.map((t) => ({ ...t })));
10172
- const text = `${JSON.stringify({ triggers: next }, null, 2)}
10173
- `;
10174
- try {
10175
- parseTriggers(text, triggersPath);
10176
- } catch (e) {
10177
- return { invalid: e?.message ?? String(e) };
10178
- }
10179
- const tmp = `${triggersPath}.tmp`;
10180
- fs.writeFileSync(tmp, text, { mode: 420 });
10181
- fs.renameSync(tmp, triggersPath);
10182
- return { ok: true };
10183
- }
10184
- function readPauseWindows({ pauseWindowsPath, fs = nodeFs2 }) {
10556
+ function readPauseWindows({ pauseWindowsPath, fs = nodeFs3 }) {
10185
10557
  let text;
10186
10558
  try {
10187
10559
  text = fs.readFileSync(pauseWindowsPath, "utf8");
@@ -10194,7 +10566,7 @@ function readPauseWindows({ pauseWindowsPath, fs = nodeFs2 }) {
10194
10566
  return { invalid: e?.message ?? String(e) };
10195
10567
  }
10196
10568
  }
10197
- function writePauseWindows({ pauseWindowsPath, mutate, fs = nodeFs2 }) {
10569
+ function writePauseWindows({ pauseWindowsPath, mutate, fs = nodeFs3 }) {
10198
10570
  let current = [];
10199
10571
  try {
10200
10572
  const raw = JSON.parse(fs.readFileSync(pauseWindowsPath, "utf8"));
@@ -10214,7 +10586,7 @@ function writePauseWindows({ pauseWindowsPath, mutate, fs = nodeFs2 }) {
10214
10586
  fs.renameSync(tmp, pauseWindowsPath);
10215
10587
  return { ok: true };
10216
10588
  }
10217
- function readSubscriptions({ subscriptionsPath, fs = nodeFs2 }) {
10589
+ function readSubscriptions({ subscriptionsPath, fs = nodeFs3 }) {
10218
10590
  if (subscriptionsPath === null || subscriptionsPath === void 0) return { missing: true };
10219
10591
  let text;
10220
10592
  try {
@@ -10296,7 +10668,7 @@ function withTimeout(promise, ms, fallback) {
10296
10668
  });
10297
10669
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
10298
10670
  }
10299
- function listRuns({ logsDir, limit = 10, fs = nodeFs2 }) {
10671
+ function listRuns({ logsDir, limit = 10, fs = nodeFs3 }) {
10300
10672
  const cap = clampLimit(limit);
10301
10673
  let names;
10302
10674
  try {
@@ -10314,7 +10686,7 @@ function listRuns({ logsDir, limit = 10, fs = nodeFs2 }) {
10314
10686
  records.sort(byEndedAtDesc);
10315
10687
  return records.slice(0, cap);
10316
10688
  }
10317
- function scanRunRecords({ logsDir, sinceMs, nowMs = Date.now(), fs = nodeFs2 }) {
10689
+ function scanRunRecords({ logsDir, sinceMs, nowMs = Date.now(), fs = nodeFs3 }) {
10318
10690
  const oldestMs = nowMs - SCAN_WINDOW_MAX_DAYS * 24 * 60 * 60 * 1e3;
10319
10691
  const cutoffMs = Math.max(Number.isFinite(sinceMs) ? sinceMs : oldestMs, oldestMs);
10320
10692
  let names;
@@ -10335,27 +10707,26 @@ function scanRunRecords({ logsDir, sinceMs, nowMs = Date.now(), fs = nodeFs2 })
10335
10707
  }
10336
10708
  return records;
10337
10709
  }
10338
- function readRun({ logsDir, jobId, fs = nodeFs2 }) {
10710
+ function readRun({ logsDir, jobId, fs = nodeFs3 }) {
10339
10711
  return readJsonFile(join2(logsDir, `${sanitizeJobId(jobId)}.json`), fs);
10340
10712
  }
10341
- function readLogTail({ logsDir, jobId, lines = 200, fs = nodeFs2 }) {
10713
+ function readLogTail({ logsDir, jobId, lines = 200, fs = nodeFs3 }) {
10342
10714
  let text;
10343
10715
  try {
10344
10716
  text = fs.readFileSync(join2(logsDir, `${sanitizeJobId(jobId)}.log`), "utf8");
10345
10717
  } catch {
10346
10718
  return { missing: true };
10347
10719
  }
10348
- const all = text.split("\n");
10349
- if (all.length > 0 && all[all.length - 1] === "") all.pop();
10720
+ const all = text.split("\n").filter((line) => line !== "");
10350
10721
  const cap = clampLines(lines);
10351
10722
  return { lines: all.slice(Math.max(0, all.length - cap)) };
10352
10723
  }
10353
- function readSettingsView({ settingsFile, fs = nodeFs2 }) {
10724
+ function readSettingsView({ settingsFile, fs = nodeFs3 }) {
10354
10725
  const result = readOverlay(settingsFile, { fs });
10355
10726
  if (result.invalid) return { path: settingsFile, invalid: result.invalid };
10356
10727
  return { path: settingsFile, overlay: result.overlay };
10357
10728
  }
10358
- function readTriggers({ triggersPath, fs = nodeFs2 }) {
10729
+ function readTriggers({ triggersPath, fs = nodeFs3 }) {
10359
10730
  let text;
10360
10731
  try {
10361
10732
  text = fs.readFileSync(triggersPath, "utf8");
@@ -10393,6 +10764,8 @@ function normalizeTriggerForDisplay(entry) {
10393
10764
  const resume = run.resume === true;
10394
10765
  const forge = typeof run.kind === "string" && run.kind.trim() !== "" ? run.kind : null;
10395
10766
  const replicas = Number.isInteger(run.replicas) && run.replicas > 1 ? run.replicas : null;
10767
+ const secrets = run.secrets !== null && typeof run.secrets === "object" && !Array.isArray(run.secrets) ? Object.keys(run.secrets).length : 0;
10768
+ const secretsProfile = typeof run.secretsProfile === "string" && run.secretsProfile.trim() !== "" ? run.secretsProfile : null;
10396
10769
  switch (on.type) {
10397
10770
  case "cron":
10398
10771
  return {
@@ -10409,19 +10782,25 @@ function normalizeTriggerForDisplay(entry) {
10409
10782
  image,
10410
10783
  skillsDir,
10411
10784
  instructions,
10412
- resume
10785
+ resume,
10786
+ secrets,
10787
+ secretsProfile
10413
10788
  };
10414
10789
  case "label":
10415
- return { type: "label", any: normalizeSelector(on.any), all: normalizeSelector(on.all), none: normalizeSelector(on.none), flow, command, packages, image, skillsDir, instructions, resume, replicas, forge };
10790
+ return { type: "label", any: normalizeSelector(on.any), all: normalizeSelector(on.all), none: normalizeSelector(on.none), flow, command, packages, image, skillsDir, instructions, resume, secrets, secretsProfile, replicas, forge };
10416
10791
  case "comment":
10417
- return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, command, packages, image, skillsDir, instructions, resume, replicas, forge };
10418
- case "pull_request":
10792
+ return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, command, packages, image, skillsDir, instructions, resume, secrets, secretsProfile, replicas, forge };
10793
+ case "pull_request": {
10794
+ const prDisarmed = on.disarmed !== null && typeof on.disarmed === "object" && !Array.isArray(on.disarmed) ? on.disarmed : null;
10419
10795
  return {
10420
10796
  type: "pull_request",
10421
10797
  action: normalizeSelector(on.action),
10422
10798
  any: normalizeSelector(on.any),
10423
10799
  all: normalizeSelector(on.all),
10424
10800
  none: normalizeSelector(on.none),
10801
+ ...Number.isInteger(on.number) && on.number >= 1 && { number: on.number },
10802
+ ...on.once === true && { once: true },
10803
+ ...prDisarmed !== null && { disarmed: prDisarmed },
10425
10804
  flow,
10426
10805
  command,
10427
10806
  packages,
@@ -10429,9 +10808,39 @@ function normalizeTriggerForDisplay(entry) {
10429
10808
  skillsDir,
10430
10809
  instructions,
10431
10810
  resume,
10811
+ secrets,
10812
+ secretsProfile,
10432
10813
  replicas,
10433
10814
  forge
10434
10815
  };
10816
+ }
10817
+ case "issue": {
10818
+ const disarmed = on.disarmed !== null && typeof on.disarmed === "object" && !Array.isArray(on.disarmed) ? on.disarmed : null;
10819
+ return {
10820
+ type: "issue",
10821
+ action: normalizeSelector(on.action),
10822
+ // `null` is the every-item sentinel, this function's own flow/model/phrase convention; the loader
10823
+ // refused anything but an integer >= 1, so the display mirrors rather than re-validates.
10824
+ number: Number.isInteger(on.number) && on.number >= 1 ? on.number : null,
10825
+ // An opt-IN, `=== true` like `resume` above and NOT `!== false`: the rendering mistake that costs
10826
+ // is a one-shot with no marker, never a standing rule with a spurious one.
10827
+ once: on.once === true,
10828
+ // Absent, not null, when unspent: `disarmed` is a mark the worker adds, and a key that is usually
10829
+ // missing reads truer than a fifth null sentinel -- consumers test presence, not value.
10830
+ ...disarmed !== null && { disarmed },
10831
+ flow,
10832
+ command,
10833
+ packages,
10834
+ image,
10835
+ skillsDir,
10836
+ instructions,
10837
+ resume,
10838
+ secrets,
10839
+ secretsProfile,
10840
+ replicas,
10841
+ forge
10842
+ };
10843
+ }
10435
10844
  default:
10436
10845
  return null;
10437
10846
  }
@@ -10440,7 +10849,7 @@ function normalizeSelector(value) {
10440
10849
  if (!Array.isArray(value)) return [];
10441
10850
  return value.filter((member) => typeof member === "string");
10442
10851
  }
10443
- function readStagedPackages({ globalPiDir, fs = nodeFs2, readManifest: readManifest2 = readStageManifest } = {}) {
10852
+ function readStagedPackages({ globalPiDir, fs = nodeFs3, readManifest: readManifest2 = readStageManifest } = {}) {
10444
10853
  const empty = { stagedAt: null, packages: [] };
10445
10854
  if (typeof globalPiDir !== "string" || globalPiDir === "") return empty;
10446
10855
  let manifest;
@@ -10648,7 +11057,7 @@ function readFolderSkills({ folder, exec: exec4 = execFileSync2 } = {}) {
10648
11057
  }
10649
11058
  return { head, skills, truncated: truncated || anyUnread, unreachable: null };
10650
11059
  }
10651
- function readInjectedSkills({ skillsDir, fs = nodeFs2 } = {}) {
11060
+ function readInjectedSkills({ skillsDir, fs = nodeFs3 } = {}) {
10652
11061
  if (typeof skillsDir !== "string" || skillsDir === "") return { skills: [], truncated: false, unreachable: null };
10653
11062
  let names;
10654
11063
  try {
@@ -10671,7 +11080,7 @@ function readInjectedSkills({ skillsDir, fs = nodeFs2 } = {}) {
10671
11080
  }
10672
11081
  return { skills, truncated, unreachable: null };
10673
11082
  }
10674
- function readOverlaySkills({ globalPiDir, fs = nodeFs2 } = {}) {
11083
+ function readOverlaySkills({ globalPiDir, fs = nodeFs3 } = {}) {
10675
11084
  if (typeof globalPiDir !== "string" || globalPiDir === "") return { skills: [], truncated: false, unreachable: null };
10676
11085
  const dir = join2(globalPiDir, "skills");
10677
11086
  let names;
@@ -10695,7 +11104,7 @@ function readOverlaySkills({ globalPiDir, fs = nodeFs2 } = {}) {
10695
11104
  }
10696
11105
  return { skills, truncated, unreachable: null };
10697
11106
  }
10698
- function readStagedSkillsList({ globalPiDir, fs = nodeFs2, readStaged = readStagedSkills } = {}) {
11107
+ function readStagedSkillsList({ globalPiDir, fs = nodeFs3, readStaged = readStagedSkills } = {}) {
10699
11108
  const empty = { skills: [], unenumerable: [], truncated: false };
10700
11109
  if (typeof globalPiDir !== "string" || globalPiDir === "") return empty;
10701
11110
  let result;
@@ -10770,7 +11179,7 @@ function forgeRepoTargets({ records } = {}) {
10770
11179
  function escapeRegExp2(s) {
10771
11180
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
10772
11181
  }
10773
- function listRunIds({ logsDir, fs = nodeFs2 }) {
11182
+ function listRunIds({ logsDir, fs = nodeFs3 }) {
10774
11183
  let names;
10775
11184
  try {
10776
11185
  names = fs.readdirSync(logsDir);
@@ -10815,7 +11224,6 @@ var init_read_model = __esm({
10815
11224
  init_runtime_settings();
10816
11225
  init_run_history();
10817
11226
  init_budget();
10818
- init_triggers();
10819
11227
  init_pause_windows();
10820
11228
  init_subscriptions();
10821
11229
  init_connection();
@@ -10827,6 +11235,7 @@ var init_read_model = __esm({
10827
11235
  init_forges();
10828
11236
  init_graph_model();
10829
11237
  init_costs();
11238
+ init_triggers_file();
10830
11239
  DISPATCH_RUN_TTL_SECONDS = 2 * 60 * 60;
10831
11240
  SCAN_WINDOW_MAX_DAYS = 92;
10832
11241
  GRAPH_LIMITS = Object.freeze({
@@ -10945,6 +11354,12 @@ function normalizeModel(model) {
10945
11354
  package: typeof n.package === "string" ? clip(n.package, 80) : null,
10946
11355
  tiersUnknown: Array.isArray(n.tiersUnknown) ? [...new Set(n.tiersUnknown.filter((t) => typeof t === "string").map((t) => clip(t, 40)))].sort().slice(0, 4) : [],
10947
11356
  replicas: intOr(n.replicas, null),
11357
+ // One-shot facts (#231): `once` is the armed state, and the disarm mark is narrowed to its
11358
+ // instant alone. The mark's jobId stays server-side on purpose: the page answers "spent, and
11359
+ // when", and provenance beyond that belongs to the operator's own console -- a shareable
11360
+ // file:// page should carry no more of a deployment's run history than the question needs.
11361
+ once: n.once === true,
11362
+ disarmed: n.disarmed !== null && n.disarmed !== void 0 && typeof n.disarmed === "object" && !Array.isArray(n.disarmed) ? { at: typeof n.disarmed.at === "string" ? clip(n.disarmed.at, 40) : Number.isFinite(n.disarmed.at) ? n.disarmed.at : null } : null,
10948
11363
  folderKey: typeof n.folderKey === "string" ? n.folderKey : null,
10949
11364
  runs: intOr(n.runs, 0),
10950
11365
  lastOutcome: typeof n.lastOutcome === "string" ? n.lastOutcome : null,
@@ -11389,6 +11804,8 @@ function buildTip(n, flags, groupLabel, nowMs) {
11389
11804
  const nextMs = typeof n.next === "number" ? n.next : typeof n.next === "string" ? Date.parse(n.next) : NaN;
11390
11805
  if (Number.isFinite(nextMs) && Number.isFinite(nowMs) && nextMs > nowMs) lines.push(`next ${relSpan(nextMs - nowMs)}`);
11391
11806
  }
11807
+ if (n.disarmed !== null) lines.push(`one-shot, spent${n.disarmed.at !== null ? ` ${n.disarmed.at}` : ""}`);
11808
+ else if (n.once) lines.push("one-shot (armed)");
11392
11809
  }
11393
11810
  if (n.aiTrigger) lines.push("chainable: ai-trigger allow");
11394
11811
  if (n.kind === "skill" && n.loops.length > 0) {
@@ -11419,6 +11836,7 @@ function nodeState(n, flagNames) {
11419
11836
  }
11420
11837
  if (n.kind === "skill-unverified") return { stroke: PAGE_DIM, dash: "10,4", faded: false };
11421
11838
  if (n.kind === "skill-not-at-head") return { stroke: PAGE_AMBER, dash: "10,4", faded: false };
11839
+ if (n.kind === "trigger" && n.disarmed !== null) return { stroke: CHIP_STROKE, dash: "8,3", faded: true };
11422
11840
  if (flagNames.has("orphan")) return { stroke: CHIP_STROKE, dash: "8,3", faded: true };
11423
11841
  return { stroke: CHIP_STROKE, dash: null, faded: false };
11424
11842
  }
@@ -11663,6 +12081,7 @@ var init_graph_html = __esm({
11663
12081
  label: "#d8bfd8",
11664
12082
  comment: "#e7e7ae",
11665
12083
  pull_request: "#c0deed",
12084
+ issue: "#c7e9c0",
11666
12085
  skill: "#fdd0a2"
11667
12086
  });
11668
12087
  STATUS_COMPLETED = "#3fb950";
@@ -11692,6 +12111,7 @@ var init_graph_html = __esm({
11692
12111
  label: "\u25C8",
11693
12112
  comment: "\u275D",
11694
12113
  pull_request: "\u21C4",
12114
+ issue: "\u25C9",
11695
12115
  skill: "\u0192",
11696
12116
  "skill-missing": "!",
11697
12117
  "skill-unverified": "?",
@@ -13106,7 +13526,7 @@ var init_open_browser = __esm({
13106
13526
  });
13107
13527
 
13108
13528
  // src/deployment-pointer.mjs
13109
- import * as nodeFs3 from "node:fs";
13529
+ import * as nodeFs4 from "node:fs";
13110
13530
  import { homedir } from "node:os";
13111
13531
  import { join as join3, isAbsolute } from "node:path";
13112
13532
  function pointerPath(env = process.env) {
@@ -13138,7 +13558,7 @@ function normalizePointer(raw) {
13138
13558
  }
13139
13559
  return { pointer: { version: raw.version, deploymentDir: raw.deploymentDir, env } };
13140
13560
  }
13141
- function readPointer({ path = pointerPath(), fs = nodeFs3 } = {}) {
13561
+ function readPointer({ path = pointerPath(), fs = nodeFs4 } = {}) {
13142
13562
  let text;
13143
13563
  try {
13144
13564
  text = fs.readFileSync(path, "utf8");
@@ -13171,12 +13591,12 @@ function layerPointer(env, fs) {
13171
13591
  }
13172
13592
  return { applied };
13173
13593
  }
13174
- function applyDeploymentPointer(env = process.env, { fs = nodeFs3 } = {}) {
13594
+ function applyDeploymentPointer(env = process.env, { fs = nodeFs4 } = {}) {
13175
13595
  if (appliedOnce) return { applied: [] };
13176
13596
  appliedOnce = true;
13177
13597
  return layerPointer(env, fs);
13178
13598
  }
13179
- function reapplyDeploymentPointer(env = process.env, { fs = nodeFs3 } = {}) {
13599
+ function reapplyDeploymentPointer(env = process.env, { fs = nodeFs4 } = {}) {
13180
13600
  appliedOnce = true;
13181
13601
  return layerPointer(env, fs);
13182
13602
  }
@@ -13185,7 +13605,7 @@ function takePointerNotice() {
13185
13605
  notice = void 0;
13186
13606
  return n;
13187
13607
  }
13188
- function writePointer({ path = pointerPath(), pointer, fs = nodeFs3 } = {}) {
13608
+ function writePointer({ path = pointerPath(), pointer, fs = nodeFs4 } = {}) {
13189
13609
  const res = normalizePointer(pointer);
13190
13610
  if (res.ignored) return { invalid: res.ignored };
13191
13611
  const tmp = `${path}.tmp`;
@@ -38917,17 +39337,25 @@ function triggerLine(t) {
38917
39337
  const ins = t?.instructions === true ? " [instructions]" : "";
38918
39338
  const res = t?.resume === true ? " [resume]" : "";
38919
39339
  const rep = t?.replicas > 1 ? ` [x${t.replicas}]` : "";
39340
+ const sec = t?.secrets > 0 ? ` [secrets ${t.secrets}${t.secretsProfile ? ` via ${t.secretsProfile}` : ""}]` : "";
39341
+ const shot = t?.disarmed ? " [spent]" : t?.once === true ? " [once]" : "";
38920
39342
  switch (t?.type) {
38921
39343
  case "cron":
38922
- return `cron ${t.id ?? "-"} ${t.pattern ?? "-"} \u2192 ${t.folder ?? "-"}/${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}`;
39344
+ return `cron ${t.id ?? "-"} ${t.pattern ?? "-"} \u2192 ${t.folder ?? "-"}/${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}${sec}`;
38923
39345
  case "label":
38924
- return `label ${ruleClauses(t) || "(no selector)"} \u2192 ${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}`;
39346
+ return `label ${ruleClauses(t) || "(no selector)"} \u2192 ${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}${sec}`;
38925
39347
  case "comment":
38926
- return `comment "${t.phrase ?? "-"}" \u2192 ${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}`;
39348
+ return `comment "${t.phrase ?? "-"}" \u2192 ${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}${sec}`;
38927
39349
  case "pull_request": {
38928
39350
  const clauses = ruleClauses(t);
38929
39351
  const action = `action[${(t.action ?? []).join(",")}]`;
38930
- return `pull_request ${action}${clauses ? ` ${clauses}` : ""} \u2192 ${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}`;
39352
+ const num = Number.isInteger(t.number) ? ` #${t.number}` : "";
39353
+ return `pull_request ${action}${num}${clauses ? ` ${clauses}` : ""} \u2192 ${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}${sec}${shot}`;
39354
+ }
39355
+ case "issue": {
39356
+ const action = `action[${(t.action ?? []).join(",")}]`;
39357
+ const num = Number.isInteger(t.number) ? ` #${t.number}` : "";
39358
+ return `issue ${action}${num} \u2192 ${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}${sec}${shot}`;
38931
39359
  }
38932
39360
  default:
38933
39361
  return "(unknown trigger)";
@@ -39856,8 +40284,9 @@ function triggerRow(t, sel, inner, styler, sched = null) {
39856
40284
  const img = t?.image ? " " + styler.fg("accent", `[${t.image}]`) : "";
39857
40285
  const res = t?.resume === true ? " " + styler.fg("warning", "[resume]") : "";
39858
40286
  const rep = t?.replicas > 1 ? " " + styler.fg("warning", `[x${t.replicas}]`) : "";
40287
+ const shot = t?.disarmed ? " " + styler.fg("dim", "[spent]") : t?.once === true ? " " + styler.fg("accent", "[once]") : "";
39859
40288
  const health = sched && (sched.overdueMs || sched.stallMax > 0 && sched.stalls >= sched.stallMax) ? " " + styler.fg("warning", sched.overdueMs ? "\u26A0 overdue" : "\u26A0 stalled") : "";
39860
- return fitLine(`${cursor} ${badge} ${matchColored(t, styler)} ${targetColored(t, styler)}${pkgs}${img}${res}${rep}${health}`, inner, styler);
40289
+ return fitLine(`${cursor} ${badge} ${matchColored(t, styler)} ${targetColored(t, styler)}${pkgs}${img}${res}${rep}${shot}${health}`, inner, styler);
39861
40290
  }
39862
40291
  function matchColored(t, styler) {
39863
40292
  switch (t?.type) {
@@ -39865,10 +40294,15 @@ function matchColored(t, styler) {
39865
40294
  return styler.fg("text", `${t.id ?? "-"} ${t.pattern ?? "-"}`);
39866
40295
  case "comment":
39867
40296
  return styler.fg("text", `"${t.phrase ?? "-"}"`);
40297
+ case "issue": {
40298
+ const num = Number.isInteger(t.number) ? " " + styler.fg("success", `#${t.number}`) : "";
40299
+ return styler.fg("muted", `[${(t.action ?? []).join(",")}]`) + num;
40300
+ }
39868
40301
  case "label":
39869
40302
  case "pull_request": {
39870
40303
  const parts = [];
39871
40304
  if (t.type === "pull_request") parts.push(styler.fg("muted", `[${(t.action ?? []).join(",")}]`));
40305
+ if (t.type === "pull_request" && Number.isInteger(t.number)) parts.push(styler.fg("success", `#${t.number}`));
39872
40306
  for (const x of t.any ?? []) parts.push(styler.fg("success", x));
39873
40307
  for (const x of t.all ?? []) parts.push(styler.fg("success", `+${x}`));
39874
40308
  for (const x of t.none ?? []) parts.push(styler.fg("error", `!${x}`));
@@ -40003,11 +40437,17 @@ function renderTriggerDetail(t, inner, styler, sched = null, staged = null) {
40003
40437
  }
40004
40438
  } else if (t.type === "label" || t.type === "pull_request") {
40005
40439
  if (t.type === "pull_request") out.push(kv("PR actions", (t.action ?? []).join(", ") || "-"));
40440
+ if (t.type === "pull_request" && Number.isInteger(t.number)) out.push(kv("item", `#${t.number}`, "success"));
40441
+ if (t.once === true) out.push(kv("one-shot", t.disarmed ? `spent ${t.disarmed.at ?? "-"}${t.disarmed.jobId ? ` by ${t.disarmed.jobId}` : ""}` : "armed", t.disarmed ? "dim" : "accent"));
40006
40442
  out.push(kv("any of", (t.any ?? []).join(" \xB7 ") || "-", "success"));
40007
40443
  out.push(kv("all of", (t.all ?? []).join(" \xB7 ") || "-", "success"));
40008
40444
  out.push(kv("none of", (t.none ?? []).join(" \xB7 ") || "-", "error"));
40009
40445
  } else if (t.type === "comment") {
40010
40446
  out.push(kv("phrase", `"${t.phrase ?? "-"}"`));
40447
+ } else if (t.type === "issue") {
40448
+ out.push(kv("issue actions", (t.action ?? []).join(", ") || "-"));
40449
+ if (Number.isInteger(t.number)) out.push(kv("item", `#${t.number}`, "success"));
40450
+ if (t.once === true) out.push(kv("one-shot", t.disarmed ? `spent ${t.disarmed.at ?? "-"}${t.disarmed.jobId ? ` by ${t.disarmed.jobId}` : ""}` : "armed", t.disarmed ? "dim" : "accent"));
40011
40451
  }
40012
40452
  blank();
40013
40453
  section("runs");
@@ -40038,6 +40478,12 @@ function renderTriggerDetail(t, inner, styler, sched = null, staged = null) {
40038
40478
  keeps("issue text, file contents, tool output, the agent's own reasoning");
40039
40479
  keeps("replayed into the next job on the same branch; forks never resume");
40040
40480
  }
40481
+ if (t.secrets > 0) {
40482
+ const holds = (text) => out.push(fitLine(styler.fg("border", "\xB7 ") + styler.fg("warning", text), inner, styler));
40483
+ holds(`${t.secrets} vault secret(s) injected${t.secretsProfile ? ` via profile ${t.secretsProfile}` : ""}`);
40484
+ holds("resolved on the host before the container; the job holds no vault credential");
40485
+ holds("the agent can read them, and the forge is on the egress allowlist");
40486
+ }
40041
40487
  if (t.replicas > 1) {
40042
40488
  const races = (text) => out.push(fitLine(styler.fg("border", "\xB7 ") + styler.fg("warning", text), inner, styler));
40043
40489
  races(`each delivery starts ${t.replicas} independent jobs`);
@@ -40065,33 +40511,38 @@ function trustModel(t) {
40065
40511
  }
40066
40512
  const subject = t?.type === "comment" ? "adversarial comment text" : "adversarial issue/PR text";
40067
40513
  const where = `lives in the receiver \xB7 task is ${subject}`;
40514
+ const closeCapable = t?.type === "issue" || t?.type === "pull_request" && (t?.action ?? []).some((a) => a === "closed" || a === "close");
40515
+ const oneShot = (lines) => closeCapable && (t?.once === true || t?.disarmed) ? [...lines, "one-shot: the WORKER writes on.disarmed into triggers.json, strictly after the run record \u2014 spent always has its run"] : lines;
40068
40516
  switch (t?.forge) {
40069
40517
  case "gitlab":
40070
- return [
40518
+ return oneShot([
40071
40519
  "authorized by the actor's resolved project access level (>= Developer) \u2014 a label is NOT approval here",
40072
40520
  "HMAC over the body (19.0+) or a bare X-Gitlab-Token \xB7 dedup by webhook-id",
40073
40521
  where
40074
- ];
40522
+ ]);
40075
40523
  case "forgejo":
40076
- return [
40524
+ return oneShot([
40077
40525
  "authorized by the actor's resolved repository permission (admin|write), on every trigger type",
40078
40526
  "HMAC over the body (GitHub-compatible) \xB7 dedup by X-GitHub-Delivery GUID",
40079
40527
  where
40080
- ];
40528
+ ]);
40081
40529
  case "azure":
40082
- return [
40530
+ return oneShot([
40083
40531
  "authorized by the actor's resolved project membership \u2014 work items name the actor only by email",
40084
40532
  "NO HMAC: a shared secret that covers no bytes \xB7 dedup by the payload's own id (OQ-015)",
40085
40533
  where
40086
- ];
40534
+ ]);
40087
40535
  default:
40088
- return [
40536
+ return oneShot([
40089
40537
  // A review trigger reads the REVIEWER's author_association, not the PR author's (issue #66), so it
40090
40538
  // cannot share the generic line: an operator reading "author gate" would picture the wrong person.
40091
- 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",
40539
+ // A close rule reads the CLOSER's resolved permission (issue #231), for the inversion's sharper
40540
+ // sibling: GitHub sends NO association for a close at all, so the collaborator-label line would
40541
+ // name a gate this rule never runs — and a wrong trust model is worse than an absent one.
40542
+ closeCapable ? "authorized by the CLOSER's resolved repository permission (admin|write) + HMAC \u2014 a close names no author_association" : 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",
40092
40543
  "dedup by X-GitHub-Delivery GUID (redelivery-safe)",
40093
40544
  where
40094
- ];
40545
+ ]);
40095
40546
  }
40096
40547
  }
40097
40548
  function triggerDetailHints(inner, styler, pendingDelete = false) {
@@ -40332,7 +40783,7 @@ var init_dashboard = __esm({
40332
40783
  TAIL_VIEWPORT = 20;
40333
40784
  MIN_WIDTH2 = 8;
40334
40785
  DRILL_WIDTH = 70;
40335
- KIND_COLOR = { cron: "accent", label: "syntaxType", comment: "syntaxKeyword", pull_request: "syntaxFunction" };
40786
+ KIND_COLOR = { cron: "accent", label: "syntaxType", comment: "syntaxKeyword", pull_request: "syntaxFunction", issue: "syntaxString" };
40336
40787
  KIND_WIDTH = 13;
40337
40788
  FRAME_CHROME_ROWS = 4;
40338
40789
  }
@@ -40357,7 +40808,7 @@ __export(setup_wizard_exports, {
40357
40808
  runSetupWizard: () => runSetupWizard,
40358
40809
  runtimeDirFor: () => runtimeDirFor
40359
40810
  });
40360
- import * as nodeFs4 from "node:fs";
40811
+ import * as nodeFs5 from "node:fs";
40361
40812
  import { execFileSync as execFileSync3, spawn as nodeSpawn2 } from "node:child_process";
40362
40813
  import { homedir as homedir2 } from "node:os";
40363
40814
  import { join as join6 } from "node:path";
@@ -40373,7 +40824,7 @@ async function defaultProbeQueue(url) {
40373
40824
  async function detectDeployment({
40374
40825
  env = process.env,
40375
40826
  cwd = process.cwd(),
40376
- fs = nodeFs4,
40827
+ fs = nodeFs5,
40377
40828
  probeQueue = defaultProbeQueue
40378
40829
  } = {}) {
40379
40830
  const pointerRes = readPointer({ path: pointerPath(env), fs });
@@ -40500,7 +40951,7 @@ async function ensureDocker(ui, notify, { platform, probeDockerFn }) {
40500
40951
  function npmSpawnOptions(platform, dir) {
40501
40952
  return platform === "win32" ? { bin: "npm.cmd", options: { cwd: dir, shell: true } } : { bin: "npm", options: { cwd: dir } };
40502
40953
  }
40503
- function listRepoSkills(cwd, fs = nodeFs4) {
40954
+ function listRepoSkills(cwd, fs = nodeFs5) {
40504
40955
  try {
40505
40956
  const root = join6(cwd, ".pi", "skills");
40506
40957
  return fs.readdirSync(root).filter((name) => SKILL_NAME_RE.test(name) && fs.existsSync(join6(root, name, "SKILL.md")));
@@ -40528,7 +40979,7 @@ function ensureDeploymentPackageJson(fs, dir) {
40528
40979
  }
40529
40980
  async function runSetupWizard(paths, ctx, notify, deps = {}) {
40530
40981
  const {
40531
- fs = nodeFs4,
40982
+ fs = nodeFs5,
40532
40983
  env = process.env,
40533
40984
  platform = process.platform,
40534
40985
  execPath = process.execPath,
@@ -40842,7 +41293,7 @@ async function offerFirstTrigger(repoCwd, deployDir, ui, notify, { fs, listRepoS
40842
41293
  );
40843
41294
  }
40844
41295
  function registerNudge(pi, deps = {}) {
40845
- const { fs = nodeFs4, env = process.env, homedirFn = homedir2 } = deps;
41296
+ const { fs = nodeFs5, env = process.env, homedirFn = homedir2 } = deps;
40846
41297
  pi.on("session_start", (event, ctx) => {
40847
41298
  try {
40848
41299
  if (event?.reason !== "startup" || !ctx?.hasUI) return;
@@ -40867,8 +41318,8 @@ var init_setup_wizard = __esm({
40867
41318
  init_deployment_pointer();
40868
41319
  init_read_model();
40869
41320
  init_index();
40870
- RUNTIME_VERSION = "1.2.0";
40871
- RECEIVER_VERSION = "1.1.0";
41321
+ RUNTIME_VERSION = "1.4.0";
41322
+ RECEIVER_VERSION = "1.3.0";
40872
41323
  RUNTIME_PKG = "@edgehero/pi-dispatch";
40873
41324
  RECEIVER_PKG = "@edgehero/pi-dispatch-receiver";
40874
41325
  NUDGE_MARKER_BASENAME = "pi-dispatch-setup.nudged";
@@ -40883,10 +41334,108 @@ var init_setup_wizard = __esm({
40883
41334
  }
40884
41335
  });
40885
41336
 
41337
+ // src/secrets-command.ts
41338
+ var secrets_command_exports = {};
41339
+ __export(secrets_command_exports, {
41340
+ runSecretsCommand: () => runSecretsCommand
41341
+ });
41342
+ function renderProfiles(profiles) {
41343
+ const names = Object.keys(profiles).sort();
41344
+ if (names.length === 0) return "No resolver profiles are declared in the settings overlay.";
41345
+ return ["Resolver profiles (settings overlay):", ...names.map((n) => ` ${n} -> ${profiles[n]}`)].join("\n");
41346
+ }
41347
+ async function runSecretsCommand(paths, ctx, notify, tokens, deps = {}) {
41348
+ const ui = ctx?.ui;
41349
+ const fsSeam = deps.fs ? { fs: deps.fs } : {};
41350
+ const view = readSettingsView({ settingsFile: paths.settingsFile, ...fsSeam });
41351
+ if (view.invalid) {
41352
+ notify?.(`settings overlay is unreadable (${view.invalid}) \u2014 fix it before declaring a profile`, "error");
41353
+ return;
41354
+ }
41355
+ const profiles = { ...view.overlay?.secretProfiles ?? {} };
41356
+ const action = tokens[0] ?? "list";
41357
+ if (action === "list") {
41358
+ notify?.(renderProfiles(profiles), "info");
41359
+ return;
41360
+ }
41361
+ if (typeof ui?.input !== "function" || typeof ui?.confirm !== "function") {
41362
+ notify?.("declaring a profile needs dialogs (newer pi) \u2014 no input/confirm available", "warning");
41363
+ return;
41364
+ }
41365
+ if (action === "add") {
41366
+ const nameAnswer = await ui.input("profile name \u2014 what a trigger's run.secretsProfile will say (e.g. prod)", "default");
41367
+ const name = (nameAnswer ?? "").trim();
41368
+ if (name === "") return;
41369
+ if (!PROFILE_NAME.test(name)) {
41370
+ notify?.("a profile name may use letters, digits, dot, dash and underscore only \u2014 PI_SECRET_PROFILES is a comma-separated list of name:path pairs", "error");
41371
+ return;
41372
+ }
41373
+ const pathAnswer = await ui.input("resolver \u2014 ABSOLUTE path to the script that reads one reference", "/opt/pi/resolve.sh");
41374
+ const path = (pathAnswer ?? "").trim();
41375
+ if (path === "") return;
41376
+ if (!(path.startsWith("/") || /^([A-Za-z]:[\\/]|\\\\)/.test(path))) {
41377
+ notify?.("the resolver needs an ABSOLUTE path: a service manager's working directory is not your shell's", "error");
41378
+ return;
41379
+ }
41380
+ const replacing = profiles[name] ? `
41381
+
41382
+ This REPLACES the current ${name} -> ${profiles[name]}` : "";
41383
+ const ok = await ui.confirm(
41384
+ "Declare a secret resolver",
41385
+ `${paths.settingsFile} gets:
41386
+ ${JSON.stringify({ secretProfiles: { ...profiles, [name]: path } }, null, 2)}${replacing}
41387
+
41388
+ The worker EXECUTES this script, as the account it runs as, once per reference. It must also sit inside PI_SECRET_RESOLVER_ROOTS or the worker will refuse it.`
41389
+ );
41390
+ if (!ok) {
41391
+ notify?.("not declared", "info");
41392
+ return;
41393
+ }
41394
+ const res = writeSettings({ settingsFile: paths.settingsFile, mutate: (o) => ({ ...o, secretProfiles: { ...profiles, [name]: path } }), ...fsSeam });
41395
+ if (res.invalid) {
41396
+ notify?.(`rejected: ${res.invalid}`, "error");
41397
+ return;
41398
+ }
41399
+ notify?.(`declared ${name} -> ${path}. A trigger reaches it with "secretsProfile": "${name}" beside its "secrets" map, which is an edit to triggers.json.`, "info");
41400
+ return;
41401
+ }
41402
+ if (action === "remove") {
41403
+ const name = (tokens[1] ?? "").trim();
41404
+ if (!name || !(name in profiles)) {
41405
+ notify?.(`no such profile. ${renderProfiles(profiles)}`, "warning");
41406
+ return;
41407
+ }
41408
+ const ok = await ui.confirm("Remove a secret resolver", `${name} -> ${profiles[name]}
41409
+
41410
+ Every trigger naming it refuses PRE-SPEND after this, rather than running without its secrets.`);
41411
+ if (!ok) {
41412
+ notify?.("not removed", "info");
41413
+ return;
41414
+ }
41415
+ const next = { ...profiles };
41416
+ delete next[name];
41417
+ const res = writeSettings({ settingsFile: paths.settingsFile, mutate: (o) => ({ ...o, secretProfiles: next }), ...fsSeam });
41418
+ if (res.invalid) {
41419
+ notify?.(`rejected: ${res.invalid}`, "error");
41420
+ return;
41421
+ }
41422
+ notify?.(`removed ${name}`, "info");
41423
+ return;
41424
+ }
41425
+ notify?.("usage: /dispatch secrets <list|add|remove <name>>", "warning");
41426
+ }
41427
+ var PROFILE_NAME;
41428
+ var init_secrets_command = __esm({
41429
+ "src/secrets-command.ts"() {
41430
+ init_read_model();
41431
+ PROFILE_NAME = /^[A-Za-z0-9._-]+$/;
41432
+ }
41433
+ });
41434
+
40886
41435
  // src/index.ts
40887
41436
  import { VERSION } from "@earendil-works/pi-coding-agent";
40888
41437
  import { fileURLToPath as fileURLToPath2, pathToFileURL } from "node:url";
40889
- import * as nodeFs5 from "node:fs";
41438
+ import * as nodeFs6 from "node:fs";
40890
41439
  function computePiVersionAdvisory(version, supported) {
40891
41440
  if (version === supported || version === "0.0.0") return void 0;
40892
41441
  return `pi-dispatch admin: running on pi ${version}, tested with pi ${supported} \u2014 things should work; report anything odd, and check for a newer @edgehero/pi-dispatch-admin`;
@@ -41028,7 +41577,7 @@ function registerTools(pi) {
41028
41577
  pi.registerTool({
41029
41578
  name: "dispatch_triggers",
41030
41579
  label: "pi-dispatch triggers",
41031
- description: "Read-only. Lists the configured triggers as `{ index, ...trigger }` entries. Use the `index` to target a specific trigger with dispatch_trigger_edit or dispatch_trigger_delete.",
41580
+ description: "Read-only. Lists the configured triggers as `{ index, ...trigger }` entries. Use the `index` to target a specific trigger with dispatch_trigger_edit or dispatch_trigger_delete. A close-capable entry (kind issue, or a pull_request on the forge's close word) may carry `on.number` (narrowed to one item), `on.once` (a one-shot), and `on.disarmed` { at, jobId? } \u2014 the worker's mark that the one-shot fired; a disarmed entry still lists here but matches nothing until an operator deletes the key to re-arm it.",
41032
41581
  parameters: typebox_exports.Object({}),
41033
41582
  async execute() {
41034
41583
  const paths = resolvePaths(process.env);
@@ -41073,7 +41622,7 @@ function registerTools(pi) {
41073
41622
  pi.registerTool({
41074
41623
  name: "dispatch_trigger_add",
41075
41624
  label: "pi-dispatch add trigger",
41076
- 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 | forgejo | azure, which also decides which action words pull_request accepts: github is labeled|opened|synchronize|reopened|review_submitted, gitlab is open|update|reopen|approved, forgejo is label_updated|opened|synchronized|reopened, azure is created|updated. An azure label or comment trigger must also set `repository` (a work item belongs to a project, not a repository), and an azure pull_request trigger may not carry labels[] at all. 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.",
41625
+ 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|issue. 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. issue fires when an ISSUE closes: it needs flow, its action[] defaults to the forge's close word (github/forgejo closed, gitlab close; azure has no close trigger), and it may set `number` (the forge's own item number, narrowing the rule to that one issue) and `once: true` (a one-shot: it fires once, the run is recorded, then the worker disarms the entry by writing on.disarmed; once requires number). A close-only pull_request rule accepts the same number/once narrowing. Webhook triggers take an optional `forge` = github (default) | gitlab | forgejo | azure, which also decides which action words pull_request accepts: github is labeled|opened|synchronize|reopened|review_submitted|closed, gitlab is open|update|reopen|approved|close, forgejo is label_updated|opened|synchronized|reopened|closed, azure is created|updated (no close word). The close word rides alone: it cannot be mixed with other actions in one entry. An azure label or comment trigger must also set `repository` (a work item belongs to a project, not a repository), and an azure pull_request trigger may not carry labels[] at all. 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.",
41077
41626
  executionMode: "sequential",
41078
41627
  parameters: typebox_exports.Object({
41079
41628
  kind: typebox_exports.String(),
@@ -41090,13 +41639,18 @@ function registerTools(pi) {
41090
41639
  phrase: typebox_exports.Optional(typebox_exports.String()),
41091
41640
  labels: typebox_exports.Optional(typebox_exports.Array(typebox_exports.String())),
41092
41641
  action: typebox_exports.Optional(typebox_exports.Array(typebox_exports.String())),
41642
+ // The close-trigger narrowings (issue #231), read only by the close-capable arms of
41643
+ // buildTriggerEntry and refused fail-loud by the shared validator everywhere else -- the
41644
+ // `repository` posture above, restated for the two fields whose whole point is narrowing spend.
41645
+ number: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 })),
41646
+ once: typebox_exports.Optional(typebox_exports.Boolean()),
41093
41647
  model: typebox_exports.Optional(typebox_exports.String()),
41094
41648
  provider: typebox_exports.Optional(typebox_exports.String()),
41095
41649
  maxTurns: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 }))
41096
41650
  }),
41097
41651
  async execute(_id, params, _signal, _onUpdate, ctx) {
41098
41652
  const entry = buildTriggerEntry(params.kind, params);
41099
- if (!entry) throw new Error(`unknown trigger kind '${params.kind}' (cron|label|comment|pull_request)`);
41653
+ if (!entry) throw new Error(`unknown trigger kind '${params.kind}' (cron|label|comment|pull_request|issue)`);
41100
41654
  const result = await confirmedWrite(
41101
41655
  ctx,
41102
41656
  { title: `Add ${params.kind} trigger`, message: `Add to triggers.json:
@@ -41307,6 +41861,17 @@ function buildTriggerEntry(kind, f) {
41307
41861
  const on = { type: "pull_request", action: asWords(f.action) };
41308
41862
  const any = asWords(f.labels ?? f.any);
41309
41863
  if (any.length > 0) on.any = any;
41864
+ const prNumber = optInt(f.number);
41865
+ if (prNumber !== void 0) on.number = prNumber;
41866
+ if (typeof f.once === "boolean") on.once = f.once;
41867
+ return { on, run: { kind: forge, flow: f.flow } };
41868
+ }
41869
+ if (kind === "issue") {
41870
+ const action = asWords(f.action);
41871
+ const on = { type: "issue", action: action.length > 0 ? action : [ISSUE_CLOSE_WORD[forge] ?? "closed"] };
41872
+ const number = optInt(f.number);
41873
+ if (number !== void 0) on.number = number;
41874
+ if (typeof f.once === "boolean") on.once = f.once;
41310
41875
  return { on, run: { kind: forge, flow: f.flow } };
41311
41876
  }
41312
41877
  return null;
@@ -41358,7 +41923,7 @@ async function dispatch(pi, args, ctx) {
41358
41923
  if (det.state === "pointer" && !runtimeSkewNoticed) {
41359
41924
  const ptrRes = readPointer({ path: pointerPath(process.env) });
41360
41925
  const deploymentDir = ptrRes.pointer?.deploymentDir;
41361
- const installed = deploymentDir ? readInstalledVersion2(nodeFs5, runtimeDirFor2(deploymentDir)) : void 0;
41926
+ const installed = deploymentDir ? readInstalledVersion2(nodeFs6, runtimeDirFor2(deploymentDir)) : void 0;
41362
41927
  if (typeof installed === "string" && installed !== RUNTIME_VERSION2) {
41363
41928
  runtimeSkewNoticed = true;
41364
41929
  notify?.(
@@ -41458,6 +42023,11 @@ ${renderBudget({ budget, settings: settings2 })}`);
41458
42023
  applyUnset(paths.settingsFile, tokens, notify);
41459
42024
  return;
41460
42025
  }
42026
+ case "secrets": {
42027
+ const { runSecretsCommand: runSecretsCommand2 } = await Promise.resolve().then(() => (init_secrets_command(), secrets_command_exports));
42028
+ await runSecretsCommand2(paths, ctx, notify, tokens.slice(1));
42029
+ return;
42030
+ }
41461
42031
  case "setup": {
41462
42032
  const { runSetupWizard: runSetupWizard2 } = await Promise.resolve().then(() => (init_setup_wizard(), setup_wizard_exports));
41463
42033
  await runSetupWizard2(paths, ctx, notify, { openDashboardFn: openDashboard });
@@ -41512,7 +42082,7 @@ async function assembleGraph(paths) {
41512
42082
  });
41513
42083
  }
41514
42084
  function realArtifactDeps() {
41515
- return { fs: nodeFs5, openBrowser, env: process.env, now: () => Date.now(), platform: process.platform };
42085
+ return { fs: nodeFs6, openBrowser, env: process.env, now: () => Date.now(), platform: process.platform };
41516
42086
  }
41517
42087
  function isHeadlessEnv(env, platform) {
41518
42088
  if (env?.SSH_CONNECTION || env?.SSH_TTY) return "SSH session";
@@ -42038,7 +42608,7 @@ function completeArguments(prefix) {
42038
42608
  }
42039
42609
  return null;
42040
42610
  }
42041
- var USED_API, SUPPORTED_PI_VERSION, piVersionAdvisory, piVersionCheckDone, runtimeSkewNoticed, CHANNEL, REBUILT_NOTICE, USAGE, KNOWN_SUBCOMMANDS, FORGE_PROMPT, PR_ACTION_VOCAB, PRICING, INSIGHTS_USAGE, VIEWPORT_LINES;
42611
+ var USED_API, SUPPORTED_PI_VERSION, piVersionAdvisory, piVersionCheckDone, runtimeSkewNoticed, CHANNEL, REBUILT_NOTICE, USAGE, KNOWN_SUBCOMMANDS, FORGE_PROMPT, ISSUE_CLOSE_WORD, PR_ACTION_VOCAB, PRICING, INSIGHTS_USAGE, VIEWPORT_LINES;
42042
42612
  var init_index = __esm({
42043
42613
  "src/index.ts"() {
42044
42614
  init_build();
@@ -42063,7 +42633,7 @@ var init_index = __esm({
42063
42633
  runtimeSkewNoticed = false;
42064
42634
  CHANNEL = "pi-dispatch-admin";
42065
42635
  REBUILT_NOTICE = (reason) => `replaced invalid settings file (${reason}) \u2014 other keys were lost`;
42066
- USAGE = "usage: /dispatch <status|pause|resume|run|runs|logs|budget|insights|triggers|settings|set|unset|setup>";
42636
+ USAGE = "usage: /dispatch <status|pause|resume|run|runs|logs|budget|insights|triggers|settings|set|unset|setup|secrets>";
42067
42637
  KNOWN_SUBCOMMANDS = [
42068
42638
  "status",
42069
42639
  "pause",
@@ -42077,13 +42647,18 @@ var init_index = __esm({
42077
42647
  "settings",
42078
42648
  "set",
42079
42649
  "unset",
42080
- "setup"
42650
+ "setup",
42651
+ "secrets"
42081
42652
  ];
42082
42653
  FORGE_PROMPT = "forge \u2014 github, gitlab, forgejo or azure";
42654
+ ISSUE_CLOSE_WORD = { github: "closed", gitlab: "close", forgejo: "closed" };
42083
42655
  PR_ACTION_VOCAB = {
42084
- github: { hint: "labeled opened synchronize reopened review_submitted", dflt: "labeled" },
42085
- gitlab: { hint: "open update reopen approved", dflt: "update" },
42086
- forgejo: { hint: "label_updated opened synchronized reopened", dflt: "label_updated" },
42656
+ // The close words ride the hint too (issue #231): the dialog passes whatever is typed through the
42657
+ // shared validator, so a close-only rule IS authorable here, and a hint that omits the word reads
42658
+ // as the word not existing. The loader refuses a list mixing a close word with any other action.
42659
+ github: { hint: "labeled opened synchronize reopened review_submitted closed", dflt: "labeled" },
42660
+ gitlab: { hint: "open update reopen approved close", dflt: "update" },
42661
+ forgejo: { hint: "label_updated opened synchronized reopened closed", dflt: "label_updated" },
42087
42662
  azure: { hint: "created updated", dflt: "updated" }
42088
42663
  };
42089
42664
  PRICING = { listPricedModels, getPricedModel, isZeroRated, reprice, piAiVersion };