@edgehero/pi-dispatch-admin 1.3.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
@@ -7886,6 +7886,372 @@ var init_budget = __esm({
7886
7886
  }
7887
7887
  });
7888
7888
 
7889
+ // ../worker/src/pause-windows.mjs
7890
+ function isNonEmptyString2(value) {
7891
+ return typeof value === "string" && value.trim() !== "";
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
+
7889
8255
  // ../worker/src/reserved-env.mjs
7890
8256
  var CONTAINER_ENV_NAMES;
7891
8257
  var init_reserved_env = __esm({
@@ -7910,7 +8276,7 @@ var init_reserved_env = __esm({
7910
8276
  });
7911
8277
 
7912
8278
  // ../worker/src/triggers.mjs
7913
- function isNonEmptyString2(value) {
8279
+ function isNonEmptyString4(value) {
7914
8280
  return typeof value === "string" && value.trim() !== "";
7915
8281
  }
7916
8282
  function parseTriggers(text, path) {
@@ -7940,7 +8306,7 @@ function normalizeTrigger(entry, index, path, state) {
7940
8306
  throw configError(`${at}: "run" must be an object: ${path}`);
7941
8307
  }
7942
8308
  if (!ON_TYPES.has(on.type)) {
7943
- 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}`);
7944
8310
  }
7945
8311
  if (!RUN_KINDS.includes(run.kind)) {
7946
8312
  throw configError(`${at}: run.kind must be one of ${RUN_KINDS.join("|")} (got ${JSON.stringify(run.kind)}): ${path}`);
@@ -7954,14 +8320,18 @@ function normalizeTrigger(entry, index, path, state) {
7954
8320
  if (!isForgeKind(run.kind)) {
7955
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}`);
7956
8322
  }
7957
- if (on.type === "label") return normalizeLabel(on, run, index, path);
7958
- if (on.type === "comment") return normalizeComment(on, run, index, path, state);
7959
- 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;
7960
8330
  }
7961
8331
  function normalizeCron(on, run, index, path, state) {
7962
8332
  const at = `trigger at index ${index}`;
7963
8333
  const id = on.id;
7964
- if (!isNonEmptyString2(id)) {
8334
+ if (!isNonEmptyString4(id)) {
7965
8335
  throw configError(`${at}: cron on.id must be a non-empty string: ${path}`);
7966
8336
  }
7967
8337
  if (id.includes(":")) {
@@ -7975,7 +8345,7 @@ function normalizeCron(on, run, index, path, state) {
7975
8345
  }
7976
8346
  state.seenCronIds.add(id);
7977
8347
  const pattern = on.pattern;
7978
- if (!isNonEmptyString2(pattern)) {
8348
+ if (!isNonEmptyString4(pattern)) {
7979
8349
  throw configError(`cron trigger "${id}": on.pattern must be a non-empty string: ${path}`);
7980
8350
  }
7981
8351
  const fieldCount = pattern.trim().split(/\s+/).length;
@@ -7983,13 +8353,13 @@ function normalizeCron(on, run, index, path, state) {
7983
8353
  throw configError(`cron trigger "${id}": on.pattern must have 5 or 6 space-separated fields, got ${fieldCount}: ${path}`);
7984
8354
  }
7985
8355
  const command = validateCommand(run, `cron trigger "${id}"`, path, { onType: "cron" });
7986
- if (!isNonEmptyString2(run.folder)) {
8356
+ if (!isNonEmptyString4(run.folder)) {
7987
8357
  throw configError(`cron trigger "${id}": run.folder must be a non-empty string: ${path}`);
7988
8358
  }
7989
- if (command === void 0 && !isNonEmptyString2(run.flow)) {
8359
+ if (command === void 0 && !isNonEmptyString4(run.flow)) {
7990
8360
  throw configError(`cron trigger "${id}": run.flow must be a non-empty string (or use run.command): ${path}`);
7991
8361
  }
7992
- if (command === void 0 && !isNonEmptyString2(run.task)) {
8362
+ if (command === void 0 && !isNonEmptyString4(run.task)) {
7993
8363
  throw configError(`cron trigger "${id}": run.task must be a non-empty string: ${path}`);
7994
8364
  }
7995
8365
  if (run.github !== void 0 && typeof run.github !== "boolean") {
@@ -8001,6 +8371,9 @@ function normalizeCron(on, run, index, path, state) {
8001
8371
  validateInstructions(run, `cron trigger "${id}"`, path, { cron: true });
8002
8372
  const resume = validateResumeFlag(run, `cron trigger "${id}"`, path);
8003
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" });
8004
8377
  const secrets = validateSecrets(run, `cron trigger "${id}"`, path);
8005
8378
  const secretsProfile = validateSecretsProfile(run, `cron trigger "${id}"`, path);
8006
8379
  return {
@@ -8070,7 +8443,7 @@ function validateCommand(run, at, path, { onType }) {
8070
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}`);
8071
8444
  }
8072
8445
  if (raw === void 0) return void 0;
8073
- if (!isNonEmptyString2(raw)) {
8446
+ if (!isNonEmptyString4(raw)) {
8074
8447
  throw configError(`${at}: run.command must be a non-empty string -- the registered command name, optionally followed by its arguments: ${path}`);
8075
8448
  }
8076
8449
  if (raw !== raw.trim()) {
@@ -8107,6 +8480,72 @@ function validatePredicate(on, index, path, requirePositive) {
8107
8480
  }
8108
8481
  return { any: on.any, all: on.all, none: on.none };
8109
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
+ }
8110
8549
  function validateRepository(run, onType, at, path) {
8111
8550
  const needed = run.kind === "azure" && (onType === "label" || onType === "comment");
8112
8551
  const raw = run.repository;
@@ -8117,7 +8556,7 @@ function validateRepository(run, onType, at, path) {
8117
8556
  if (!needed) {
8118
8557
  throw configError(`${at}: run.repository is only meaningful on an azure label or comment trigger (a pull request names its own repository): ${path}`);
8119
8558
  }
8120
- if (!isNonEmptyString2(raw) || raw !== raw.trim() || raw.includes("/")) {
8559
+ if (!isNonEmptyString4(raw) || raw !== raw.trim() || raw.includes("/")) {
8121
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}`);
8122
8561
  }
8123
8562
  return raw;
@@ -8157,7 +8596,7 @@ function validateSecrets(run, at, path) {
8157
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}`);
8158
8597
  }
8159
8598
  const reference = secrets[name];
8160
- if (!isNonEmptyString2(reference)) {
8599
+ if (!isNonEmptyString4(reference)) {
8161
8600
  throw configError(`${at}: run.secrets.${name} must be a non-empty string reference for your resolver to read (got ${JSON.stringify(reference)}): ${path}`);
8162
8601
  }
8163
8602
  if (reference !== reference.trim()) {
@@ -8175,7 +8614,7 @@ function validateSecrets(run, at, path) {
8175
8614
  function validateSecretsProfile(run, at, path) {
8176
8615
  const profile = run.secretsProfile;
8177
8616
  if (profile === void 0) return void 0;
8178
- if (!isNonEmptyString2(profile)) {
8617
+ if (!isNonEmptyString4(profile)) {
8179
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}`);
8180
8619
  }
8181
8620
  if (!ID_CHARSET.test(profile)) {
@@ -8189,10 +8628,14 @@ function validateSecretsProfile(run, at, path) {
8189
8628
  function normalizeLabel(on, run, index, path) {
8190
8629
  const at = `trigger at index ${index}`;
8191
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" });
8192
8634
  const command = validateCommand(run, at, path, { onType: "label" });
8193
- if (command === void 0 && !isNonEmptyString2(run.flow)) {
8635
+ if (command === void 0 && !isNonEmptyString4(run.flow)) {
8194
8636
  throw configError(`${at}: label trigger run.flow must be a non-empty string (or use run.command): ${path}`);
8195
8637
  }
8638
+ validateFlowName(run, at, path);
8196
8639
  const packages = validatePackagesFlag(run, at, path);
8197
8640
  const image = validateImageRef(run, at, path);
8198
8641
  const skillsDir = validateSkillsDir(run, at, path);
@@ -8209,13 +8652,17 @@ function normalizeLabel(on, run, index, path) {
8209
8652
  }
8210
8653
  function normalizeComment(on, run, index, path, state) {
8211
8654
  const at = `trigger at index ${index}`;
8212
- if (!isNonEmptyString2(on.phrase)) {
8655
+ if (!isNonEmptyString4(on.phrase)) {
8213
8656
  throw configError(`${at}: comment trigger on.phrase must be a non-empty string: ${path}`);
8214
8657
  }
8658
+ validateNumber(on, at, path, { onType: "comment" });
8659
+ validateOnce(on, run, at, path, { onType: "comment" });
8660
+ validateDisarmed(on, at, path, { onType: "comment" });
8215
8661
  const command = validateCommand(run, at, path, { onType: "comment" });
8216
- if (command === void 0 && !isNonEmptyString2(run.flow)) {
8662
+ if (command === void 0 && !isNonEmptyString4(run.flow)) {
8217
8663
  throw configError(`${at}: comment trigger run.flow (the default flow) must be a non-empty string (or use run.command): ${path}`);
8218
8664
  }
8665
+ validateFlowName(run, at, path);
8219
8666
  state.commentCounts[run.kind] = (state.commentCounts[run.kind] ?? 0) + 1;
8220
8667
  if (state.commentCounts[run.kind] > 1) {
8221
8668
  throw configError(`${at}: at most one ${run.kind} comment trigger is allowed: ${path}`);
@@ -8234,6 +8681,54 @@ function normalizeComment(on, run, index, path, state) {
8234
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 } }
8235
8682
  };
8236
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 } }
8730
+ };
8731
+ }
8237
8732
  function normalizePullRequest(on, run, index, path) {
8238
8733
  const at = `trigger at index ${index}`;
8239
8734
  const actions = on.action;
@@ -8247,17 +8742,29 @@ function normalizePullRequest(on, run, index, path) {
8247
8742
  throw configError(`${at}: pull_request on.action has an unsupported ${run.kind} action ${JSON.stringify(a)} (expected ${expected}): ${path}`);
8248
8743
  }
8249
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
+ }
8250
8750
  const reviewState = validateReviewState(on, actions, run, at, path);
8251
8751
  const labelAction = forgeSpec(run.kind)?.prLabelAction;
8252
8752
  const requirePositive = typeof labelAction === "string" && actions.includes(labelAction);
8253
8753
  if (run.kind === "azure" && (on.any !== void 0 || on.all !== void 0 || on.none !== void 0)) {
8254
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}`);
8255
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
+ }
8256
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" });
8257
8763
  const command = validateCommand(run, at, path, { onType: "pull_request" });
8258
- if (command === void 0 && !isNonEmptyString2(run.flow)) {
8764
+ if (command === void 0 && !isNonEmptyString4(run.flow)) {
8259
8765
  throw configError(`${at}: pull_request trigger run.flow must be a non-empty string (or use run.command): ${path}`);
8260
8766
  }
8767
+ validateFlowName(run, at, path);
8261
8768
  const packages = validatePackagesFlag(run, at, path);
8262
8769
  const image = validateImageRef(run, at, path);
8263
8770
  const skillsDir = validateSkillsDir(run, at, path);
@@ -8276,7 +8783,10 @@ function normalizePullRequest(on, run, index, path) {
8276
8783
  ...reviewState !== void 0 && { reviewState },
8277
8784
  any: predicate.any,
8278
8785
  all: predicate.all,
8279
- 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 }
8280
8790
  },
8281
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 } }
8282
8792
  };
@@ -8300,30 +8810,41 @@ function validateReviewState(on, actions, run, at, path) {
8300
8810
  }
8301
8811
  return [...on.reviewState];
8302
8812
  }
8303
- var ON_TYPES, PR_ACTIONS, REVIEW_STATES, REVIEW_ACTION, ID_CHARSET, REPLICAS_MAX, SECRETS_MAX, ENV_NAME, RESERVED_ENV_NAMES, 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;
8304
8814
  var init_triggers = __esm({
8305
8815
  "../worker/src/triggers.mjs"() {
8306
8816
  init_config2();
8817
+ init_flow_gate();
8307
8818
  init_forges();
8308
8819
  init_reserved_env();
8309
- ON_TYPES = /* @__PURE__ */ new Set(["cron", "label", "comment", "pull_request"]);
8820
+ ON_TYPES = /* @__PURE__ */ new Set(["cron", "label", "comment", "pull_request", "issue"]);
8821
+ DISARMED_TYPE = "disarmed";
8310
8822
  PR_ACTIONS = {
8311
- github: /* @__PURE__ */ new Set(["labeled", "opened", "synchronize", "reopened", "review_submitted"]),
8823
+ github: /* @__PURE__ */ new Set(["labeled", "opened", "synchronize", "reopened", "review_submitted", "closed"]),
8312
8824
  // GitLab's `approved` is its review gate (a member approved the MR). It is NOT github's
8313
8825
  // `review_submitted` renamed: `approved` is one verdict, `review_submitted` is every verdict, which is
8314
- // what `on.reviewState` below exists to narrow.
8315
- 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"]),
8316
8828
  // Forgejo's own spellings. `label_updated` is its `labeled` and `synchronized` its `synchronize` -- a
8317
8829
  // one-letter difference that an operator would otherwise discover as a trigger that loads clean and
8318
8830
  // never fires. `label_cleared` is deliberately ABSENT and always will be: REMOVING a label must never
8319
8831
  // start a paid run, and it has no GitHub counterpart to inherit that rule from.
8320
- forgejo: /* @__PURE__ */ new Set(["label_updated", "opened", "synchronized", "reopened"]),
8832
+ forgejo: /* @__PURE__ */ new Set(["label_updated", "opened", "synchronized", "reopened", "closed"]),
8321
8833
  // Azure's Service Hook events reduced to the two that leave something to act on. `git.pullrequest.merged`
8322
- // is omitted for the same reason GitLab's `merge` and `close` are: a job started by a merge has nothing
8323
- // left to do. There is no label action at all -- Azure attaches tags to WORK ITEMS, never to pull
8324
- // 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.
8325
8840
  azure: /* @__PURE__ */ new Set(["created", "updated"])
8326
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
+ };
8327
8848
  REVIEW_STATES = /* @__PURE__ */ new Set(["approved", "changes_requested", "commented"]);
8328
8849
  REVIEW_ACTION = "review_submitted";
8329
8850
  ID_CHARSET = /^[A-Za-z0-9._-]+$/;
@@ -8335,329 +8856,6 @@ var init_triggers = __esm({
8335
8856
  }
8336
8857
  });
8337
8858
 
8338
- // ../worker/src/pause-windows.mjs
8339
- function isNonEmptyString3(value) {
8340
- return typeof value === "string" && value.trim() !== "";
8341
- }
8342
- function parseHHMM(value, label, at, path) {
8343
- const m = isNonEmptyString3(value) ? /^([0-9]{1,2}):([0-9]{2})$/.exec(value.trim()) : null;
8344
- if (!m) throw configError(`${at}: ${label} must be "HH:MM": ${JSON.stringify(value)} (${path})`);
8345
- const h = Number(m[1]);
8346
- const min = Number(m[2]);
8347
- if (h > 23 || min > 59) throw configError(`${at}: ${label} out of range 00:00-23:59: ${JSON.stringify(value)} (${path})`);
8348
- return h * 60 + min;
8349
- }
8350
- function assertDate(value, label, at, path) {
8351
- const m = /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.exec(String(value).trim());
8352
- const mo = m && Number(m[2]);
8353
- const d = m && Number(m[3]);
8354
- if (!m || mo < 1 || mo > 12 || d < 1 || d > 31) {
8355
- throw configError(`${at}: ${label} must be "YYYY-MM-DD": ${JSON.stringify(value)} (${path})`);
8356
- }
8357
- }
8358
- function validateTz(tz, at, path) {
8359
- try {
8360
- new Intl.DateTimeFormat("en-US", { timeZone: tz });
8361
- return tz;
8362
- } catch {
8363
- throw configError(`${at}: tz is not a valid IANA timezone: ${JSON.stringify(tz)} (${path})`);
8364
- }
8365
- }
8366
- function parsePauseWindows(text, path) {
8367
- let parsed;
8368
- try {
8369
- parsed = JSON.parse(text);
8370
- } catch (error) {
8371
- throw configError(`pause-windows file is not valid JSON: ${path} (${error.message})`);
8372
- }
8373
- const windows = parsed?.windows;
8374
- if (!Array.isArray(windows)) {
8375
- throw configError(`pause-windows file must have a "windows" array: ${path}`);
8376
- }
8377
- return windows.map((w, index) => normalizeWindow(w, index, path));
8378
- }
8379
- function normalizeWindow(w, index, path) {
8380
- const at = `pause window at index ${index}`;
8381
- if (w === null || typeof w !== "object") throw configError(`${at}: must be an object: ${path}`);
8382
- if (!isNonEmptyString3(w.scope)) throw configError(`${at}: scope must be a non-empty string: ${path}`);
8383
- const fromMin = parseHHMM(w.from, "from", at, path);
8384
- const toMin = parseHHMM(w.to, "to", at, path);
8385
- if (fromMin === toMin) {
8386
- throw configError(`${at}: from and to must differ (a 24h pause is not expressible; remove the trigger): ${path}`);
8387
- }
8388
- const tz = w.tz === void 0 ? "UTC" : validateTz(w.tz, at, path);
8389
- let days = null;
8390
- if (w.days !== void 0) {
8391
- if (!Array.isArray(w.days) || w.days.length === 0) {
8392
- throw configError(`${at}: days must be a non-empty array of weekday names (mon..sun): ${path}`);
8393
- }
8394
- days = w.days.map((d) => {
8395
- const key = String(d).trim().toLowerCase().slice(0, 3);
8396
- if (!DAY_SET.has(key)) throw configError(`${at}: unknown weekday ${JSON.stringify(d)} (use mon..sun): ${path}`);
8397
- return key;
8398
- });
8399
- }
8400
- let dateFrom = null;
8401
- let dateTo = null;
8402
- if (w.dateFrom !== void 0) {
8403
- assertDate(w.dateFrom, "dateFrom", at, path);
8404
- dateFrom = String(w.dateFrom).trim();
8405
- }
8406
- if (w.dateTo !== void 0) {
8407
- assertDate(w.dateTo, "dateTo", at, path);
8408
- dateTo = String(w.dateTo).trim();
8409
- }
8410
- if (dateFrom && dateTo && dateFrom > dateTo) throw configError(`${at}: dateFrom must be <= dateTo: ${path}`);
8411
- const norm = { scope: w.scope.trim(), from: w.from.trim(), to: w.to.trim(), fromMin, toMin, tz };
8412
- if (days) norm.days = days;
8413
- if (dateFrom) norm.dateFrom = dateFrom;
8414
- if (dateTo) norm.dateTo = dateTo;
8415
- return norm;
8416
- }
8417
- function zonedParts(tz, ms) {
8418
- const fmt2 = new Intl.DateTimeFormat("en-US", {
8419
- timeZone: tz,
8420
- hour12: false,
8421
- year: "numeric",
8422
- month: "2-digit",
8423
- day: "2-digit",
8424
- hour: "2-digit",
8425
- minute: "2-digit",
8426
- weekday: "short"
8427
- });
8428
- const p = {};
8429
- for (const part of fmt2.formatToParts(new Date(ms))) p[part.type] = part.value;
8430
- let hh = Number(p.hour);
8431
- if (hh === 24) hh = 0;
8432
- return { y: Number(p.year), mo: Number(p.month), d: Number(p.day), hh, mi: Number(p.minute), dow: WEEKDAY_INDEX[p.weekday] };
8433
- }
8434
- function wallToMs(tz, y, mo, d, hh, mi) {
8435
- const guess = Date.UTC(y, mo - 1, d, hh, mi);
8436
- const shown = zonedParts(tz, guess);
8437
- const shownAsUtc = Date.UTC(shown.y, shown.mo - 1, shown.d, shown.hh, shown.mi);
8438
- return guess - (shownAsUtc - guess);
8439
- }
8440
- function shiftDay(tz, parts, delta) {
8441
- const noon = wallToMs(tz, parts.y, parts.mo, parts.d, 12, 0);
8442
- return zonedParts(tz, noon + delta * 864e5);
8443
- }
8444
- function ymd(parts) {
8445
- return `${String(parts.y).padStart(4, "0")}-${String(parts.mo).padStart(2, "0")}-${String(parts.d).padStart(2, "0")}`;
8446
- }
8447
- function qualifies(w, parts) {
8448
- if (w.days && !w.days.includes(DAYS[parts.dow])) return false;
8449
- const date = ymd(parts);
8450
- if (w.dateFrom && date < w.dateFrom) return false;
8451
- if (w.dateTo && date > w.dateTo) return false;
8452
- return true;
8453
- }
8454
- function windowEndAt(w, nowMs) {
8455
- const p = zonedParts(w.tz, nowMs);
8456
- const nowMin = p.hh * 60 + p.mi;
8457
- const endAt = (dateParts) => wallToMs(w.tz, dateParts.y, dateParts.mo, dateParts.d, Math.floor(w.toMin / 60), w.toMin % 60);
8458
- if (w.fromMin < w.toMin) {
8459
- if (nowMin >= w.fromMin && nowMin < w.toMin && qualifies(w, p)) return endAt(p);
8460
- return null;
8461
- }
8462
- if (nowMin >= w.fromMin && qualifies(w, p)) return endAt(shiftDay(w.tz, p, 1));
8463
- if (nowMin < w.toMin && qualifies(w, shiftDay(w.tz, p, -1))) return endAt(p);
8464
- return null;
8465
- }
8466
- var DAYS, DAY_SET, WEEKDAY_INDEX;
8467
- var init_pause_windows = __esm({
8468
- "../worker/src/pause-windows.mjs"() {
8469
- init_config2();
8470
- DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
8471
- DAY_SET = new Set(DAYS);
8472
- WEEKDAY_INDEX = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
8473
- }
8474
- });
8475
-
8476
- // ../worker/src/subscriptions.mjs
8477
- function isNonEmptyString4(value) {
8478
- return typeof value === "string" && value.trim() !== "";
8479
- }
8480
- function normalizeGlobs(value, label, at, path) {
8481
- if (!Array.isArray(value) || value.length === 0) {
8482
- throw configError(`${at}: ${label} must be a non-empty array of model globs: ${path}`);
8483
- }
8484
- return value.map((glob) => {
8485
- if (typeof glob !== "string" || !MODEL_GLOB_RE.test(glob.trim())) {
8486
- throw configError(`${at}: ${label} glob ${JSON.stringify(glob)} must match ${MODEL_GLOB_RE} (model-id charset plus "*", max 64 chars): ${path}`);
8487
- }
8488
- return glob.trim();
8489
- });
8490
- }
8491
- function normalizeBool(value, label, at, path, fallback) {
8492
- if (value === void 0) return fallback;
8493
- if (typeof value !== "boolean") throw configError(`${at}: ${label} must be a boolean: ${path}`);
8494
- return value;
8495
- }
8496
- function parseSubscriptions(text, path) {
8497
- let parsed;
8498
- try {
8499
- parsed = JSON.parse(text);
8500
- } catch (error) {
8501
- throw configError(`subscriptions file is not valid JSON: ${path} (${error.message})`);
8502
- }
8503
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
8504
- throw configError(`subscriptions file must be a JSON object: ${path}`);
8505
- }
8506
- const version = parsed.version;
8507
- if (!Number.isInteger(version) || version < 1) {
8508
- throw configError(`subscriptions file must declare an integer "version" >= 1 (got ${JSON.stringify(version)}): ${path}`);
8509
- }
8510
- if (version > SUBSCRIPTIONS_VERSION) {
8511
- throw configError(`subscriptions file written by a newer pi-dispatch (version ${version}; this build understands ${SUBSCRIPTIONS_VERSION}): ${path}`);
8512
- }
8513
- const entries = parsed.subscriptions;
8514
- if (!Array.isArray(entries)) {
8515
- throw configError(`subscriptions file must have a "subscriptions" array: ${path}`);
8516
- }
8517
- const seenIds = /* @__PURE__ */ new Map();
8518
- return { version, subscriptions: entries.map((entry, index) => normalizeSubscription(entry, index, path, seenIds)) };
8519
- }
8520
- function normalizeSubscription(entry, index, path, seenIds) {
8521
- const at = `subscription at index ${index}`;
8522
- if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
8523
- throw configError(`${at}: must be an object: ${path}`);
8524
- }
8525
- if (!isNonEmptyString4(entry.id)) throw configError(`${at}: id must be a non-empty string: ${path}`);
8526
- const id = entry.id.trim();
8527
- if (seenIds.has(id)) {
8528
- throw configError(`${at}: id ${JSON.stringify(id)} is already used by the subscription at index ${seenIds.get(id)}: ${path}`);
8529
- }
8530
- seenIds.set(id, index);
8531
- if (!isNonEmptyString4(entry.vendor)) throw configError(`${at}: vendor must be a non-empty string: ${path}`);
8532
- if (!isNonEmptyString4(entry.provider)) throw configError(`${at}: provider must be a non-empty string (the pi-ai provider id this plan covers): ${path}`);
8533
- const models = entry.models === void 0 ? ["*"] : normalizeGlobs(entry.models, "models", at, path);
8534
- const price = entry.price;
8535
- if (price === null || typeof price !== "object" || Array.isArray(price)) {
8536
- throw configError(`${at}: price must be an object { amount, currency, per }: ${path}`);
8537
- }
8538
- if (typeof price.amount !== "number" || !Number.isFinite(price.amount) || price.amount <= 0) {
8539
- throw configError(`${at}: price.amount must be a number > 0: ${path}`);
8540
- }
8541
- if (typeof price.currency !== "string" || !/^[A-Z]{3}$/.test(price.currency)) {
8542
- throw configError(`${at}: price.currency must be a 3-letter uppercase code (e.g. "USD"): ${path}`);
8543
- }
8544
- if (price.per !== "month") {
8545
- throw configError(`${at}: price.per must be "month" (got ${JSON.stringify(price.per)}): ${path}`);
8546
- }
8547
- const sharedWithOtherProducts = normalizeBool(entry.sharedWithOtherProducts, "sharedWithOtherProducts", at, path, false);
8548
- const hypothetical = normalizeBool(entry.hypothetical, "hypothetical", at, path, false);
8549
- let counterfactualModel = null;
8550
- if (entry.counterfactualModel !== void 0 && entry.counterfactualModel !== null) {
8551
- const cm = entry.counterfactualModel;
8552
- if (typeof cm !== "object" || Array.isArray(cm) || !isNonEmptyString4(cm.provider) || !isNonEmptyString4(cm.id)) {
8553
- 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}`);
8554
- }
8555
- counterfactualModel = { provider: cm.provider.trim(), id: cm.id.trim() };
8556
- }
8557
- let windows = [];
8558
- if (entry.windows !== void 0) {
8559
- if (!Array.isArray(entry.windows)) throw configError(`${at}: windows must be an array: ${path}`);
8560
- windows = entry.windows.map((w, windowIndex) => normalizeWindow2(w, windowIndex, index, path));
8561
- }
8562
- return {
8563
- id,
8564
- vendor: entry.vendor.trim(),
8565
- provider: entry.provider.trim(),
8566
- models,
8567
- price: { amount: price.amount, currency: price.currency, per: "month" },
8568
- sharedWithOtherProducts,
8569
- hypothetical,
8570
- counterfactualModel,
8571
- windows
8572
- };
8573
- }
8574
- function normalizeWindow2(w, windowIndex, subIndex, path) {
8575
- const at = `window at index ${windowIndex} of subscription at index ${subIndex}`;
8576
- if (w === null || typeof w !== "object" || Array.isArray(w)) {
8577
- throw configError(`${at}: must be an object: ${path}`);
8578
- }
8579
- if (!WINDOW_PERS.has(w.per)) {
8580
- throw configError(`${at}: per must be one of 5h|7d|30d|month (got ${JSON.stringify(w.per)}): ${path}`);
8581
- }
8582
- const rolling = normalizeBool(w.rolling, "rolling", at, path, false);
8583
- let unit = null;
8584
- if (w.unit !== void 0 && w.unit !== null) {
8585
- if (!WINDOW_UNITS.has(w.unit)) {
8586
- throw configError(`${at}: unit must be one of tokens|requests|prompts|credits, or null (vendor undisclosed): ${path}`);
8587
- }
8588
- unit = w.unit;
8589
- }
8590
- let limit = null;
8591
- if (w.limit !== void 0 && w.limit !== null) {
8592
- if (typeof w.limit === "number") {
8593
- if (!Number.isFinite(w.limit) || w.limit <= 0) throw configError(`${at}: limit must be a number > 0, [min, max], or null (vendor undisclosed): ${path}`);
8594
- limit = w.limit;
8595
- } else if (Array.isArray(w.limit)) {
8596
- const [min, max] = w.limit;
8597
- if (w.limit.length !== 2 || typeof min !== "number" || typeof max !== "number" || !Number.isFinite(min) || !Number.isFinite(max) || min <= 0 || max <= 0 || min >= max) {
8598
- throw configError(`${at}: limit range must be [min, max] with both > 0 and min < max: ${path}`);
8599
- }
8600
- limit = [min, max];
8601
- } else {
8602
- throw configError(`${at}: limit must be a number > 0, [min, max], or null (vendor undisclosed): ${path}`);
8603
- }
8604
- }
8605
- const scope = w.scope === void 0 || w.scope === null ? null : normalizeGlobs(w.scope, "scope", at, path);
8606
- return { per: w.per, rolling, unit, limit, scope };
8607
- }
8608
- var SUBSCRIPTIONS_VERSION, WINDOW_PERS, WINDOW_UNITS, MODEL_GLOB_RE;
8609
- var init_subscriptions = __esm({
8610
- "../worker/src/subscriptions.mjs"() {
8611
- init_config2();
8612
- SUBSCRIPTIONS_VERSION = 1;
8613
- WINDOW_PERS = /* @__PURE__ */ new Set(["5h", "7d", "30d", "month"]);
8614
- WINDOW_UNITS = /* @__PURE__ */ new Set(["tokens", "requests", "prompts", "credits"]);
8615
- MODEL_GLOB_RE = /^[A-Za-z0-9*._:/-]{1,64}$/;
8616
- }
8617
- });
8618
-
8619
- // ../worker/src/connection.mjs
8620
- import { Redis } from "ioredis";
8621
- function parseConnection(url, { failFast = false } = {}) {
8622
- const u = new URL(url);
8623
- return {
8624
- host: u.hostname || "127.0.0.1",
8625
- port: Number(u.port || 6379),
8626
- ...u.password ? { password: u.password } : {},
8627
- ...u.username ? { username: u.username } : {},
8628
- ...u.pathname && u.pathname !== "/" ? { db: Number(u.pathname.slice(1)) } : {},
8629
- maxRetriesPerRequest: null,
8630
- // required for BullMQ blocking connections
8631
- ...failFast ? {
8632
- connectTimeout: 2e3,
8633
- enableOfflineQueue: false,
8634
- // don't buffer commands while disconnected -- error now
8635
- retryStrategy: (attempts) => attempts > 2 ? null : 200
8636
- // give up after ~2 tries
8637
- } : {}
8638
- };
8639
- }
8640
- function makeRedisClient(url) {
8641
- return new Redis(url, { maxRetriesPerRequest: null });
8642
- }
8643
- var init_connection = __esm({
8644
- "../worker/src/connection.mjs"() {
8645
- }
8646
- });
8647
-
8648
- // ../worker/src/job-id.mjs
8649
- import { createHash } from "node:crypto";
8650
- function localJobId({ folder, flow, task, minute }) {
8651
- const digest = createHash("sha256").update([folder, flow ?? "", task ?? "", minute].join("\0")).digest("hex");
8652
- return `local-${digest.slice(0, 16)}`;
8653
- }
8654
- var init_job_id = __esm({
8655
- "../worker/src/job-id.mjs"() {
8656
- init_config2();
8657
- init_forges();
8658
- }
8659
- });
8660
-
8661
8859
  // ../worker/src/queue.mjs
8662
8860
  import { Queue } from "bullmq";
8663
8861
  function makeQueue(connection) {
@@ -8701,59 +8899,18 @@ async function enqueueLocalJob(queue, { folder, flow, task, command, provider, m
8701
8899
  });
8702
8900
  return id;
8703
8901
  }
8704
- var QUEUE, SEMANTIC_WINDOW_MS;
8902
+ var PR_CLOSE_WORDS, QUEUE, SEMANTIC_WINDOW_MS;
8705
8903
  var init_queue = __esm({
8706
8904
  "../worker/src/queue.mjs"() {
8707
8905
  init_job_id();
8708
8906
  init_forges();
8907
+ init_triggers();
8908
+ PR_CLOSE_WORDS = new Set(Object.values(PR_CLOSE_ACTIONS));
8709
8909
  QUEUE = "pi-jobs";
8710
8910
  SEMANTIC_WINDOW_MS = 10 * 60 * 1e3;
8711
8911
  }
8712
8912
  });
8713
8913
 
8714
- // ../worker/src/flow-gate.mjs
8715
- import { execFile } from "node:child_process";
8716
- import { promisify } from "node:util";
8717
- async function readFlowGate({ folder, flow, sha, git = defaultGit }) {
8718
- if (typeof sha !== "string" || sha.trim() === "") return { gate: "deny" };
8719
- if (typeof flow !== "string" || !SKILL_NAME_RE.test(flow)) return { gate: "deny" };
8720
- const path = `.pi/skills/${flow}/SKILL.md`;
8721
- try {
8722
- const lsTreeZ = await git(folder, ["ls-tree", "-z", sha, path]);
8723
- const record = lsTreeZ.split("\0").find((r) => r);
8724
- if (!record) return { gate: "no-skill" };
8725
- const tab = record.indexOf(" ");
8726
- if (tab === -1) return { gate: "deny" };
8727
- const [mode, type, oid] = record.slice(0, tab).split(/\s+/);
8728
- if (mode !== "100644" || type !== "blob") return { gate: "deny" };
8729
- const content = await git(folder, ["cat-file", "blob", oid], { raw: true });
8730
- return { gate: aiTriggerAllows(content) ? "allow" : "deny" };
8731
- } catch {
8732
- return { gate: "deny" };
8733
- }
8734
- }
8735
- function aiTriggerAllows(buf) {
8736
- const text = buf.toString("utf8").replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
8737
- const block = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(text);
8738
- if (!block) return false;
8739
- return /^ai-trigger:\s*("?)allow\1\s*$/m.test(block[1]);
8740
- }
8741
- async function defaultGit(gitDir, args, { raw = false } = {}) {
8742
- const hardened = ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "--no-pager", "-C", gitDir, ...args];
8743
- const { stdout } = await exec("git", hardened, {
8744
- encoding: raw ? "buffer" : "utf8",
8745
- maxBuffer: 16 * 1024 * 1024
8746
- });
8747
- return stdout;
8748
- }
8749
- var exec, SKILL_NAME_RE;
8750
- var init_flow_gate = __esm({
8751
- "../worker/src/flow-gate.mjs"() {
8752
- exec = promisify(execFile);
8753
- SKILL_NAME_RE = /^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/;
8754
- }
8755
- });
8756
-
8757
8914
  // ../worker/src/git-dirty.mjs
8758
8915
  import { execFileSync } from "node:child_process";
8759
8916
  function gitDirty(folder, { exec: exec4 = execFileSync } = {}) {
@@ -9331,6 +9488,16 @@ function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, o
9331
9488
  // (guarded on `typeof t.flow === "string"`) correctly draws nothing for it.
9332
9489
  command: t.command ?? null,
9333
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,
9334
9501
  folderKey: group?.key ?? null,
9335
9502
  runs: Number.isInteger(stats?.runs) ? stats.runs : 0,
9336
9503
  lastOutcome: stats?.lastOutcome ?? null,
@@ -9468,8 +9635,16 @@ function triggerMatchLabel(t) {
9468
9635
  return selectorLabel(t);
9469
9636
  case "comment":
9470
9637
  return `"${t.phrase ?? "-"}"`;
9471
- case "pull_request":
9472
- 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
+ }
9473
9648
  default:
9474
9649
  return "(unknown)";
9475
9650
  }
@@ -10076,8 +10251,110 @@ var init_costs = __esm({
10076
10251
  }
10077
10252
  });
10078
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
+
10079
10356
  // src/read-model.mjs
10080
- import * as nodeFs2 from "node:fs";
10357
+ import * as nodeFs3 from "node:fs";
10081
10358
  import { join as join2, delimiter, sep } from "node:path";
10082
10359
  import { execFileSync as execFileSync2 } from "node:child_process";
10083
10360
  function resolvePaths(env = process.env) {
@@ -10250,14 +10527,14 @@ function folderUnderRoots(folder, roots) {
10250
10527
  if (!Array.isArray(roots) || roots.length === 0) return false;
10251
10528
  let realTarget;
10252
10529
  try {
10253
- realTarget = nodeFs2.realpathSync(folder);
10530
+ realTarget = nodeFs3.realpathSync(folder);
10254
10531
  } catch {
10255
10532
  return false;
10256
10533
  }
10257
10534
  for (const root of roots) {
10258
10535
  let realRoot;
10259
10536
  try {
10260
- realRoot = nodeFs2.realpathSync(root);
10537
+ realRoot = nodeFs3.realpathSync(root);
10261
10538
  } catch {
10262
10539
  continue;
10263
10540
  }
@@ -10268,7 +10545,7 @@ function folderUnderRoots(folder, roots) {
10268
10545
  function hourKey(nowMs) {
10269
10546
  return `dispatch-run:${new Date(nowMs).toISOString().slice(0, 13).replace("T", "-")}`;
10270
10547
  }
10271
- function writeSettings({ settingsFile, mutate, fs = nodeFs2 }) {
10548
+ function writeSettings({ settingsFile, mutate, fs = nodeFs3 }) {
10272
10549
  const res = readOverlay(settingsFile, { fs });
10273
10550
  const base = res.overlay ?? {};
10274
10551
  const next = mutate({ ...base });
@@ -10276,27 +10553,7 @@ function writeSettings({ settingsFile, mutate, fs = nodeFs2 }) {
10276
10553
  if (w.invalid) return { invalid: w.invalid };
10277
10554
  return res.invalid ? { ok: true, overlay: next, rebuiltFrom: res.invalid } : { ok: true, overlay: next };
10278
10555
  }
10279
- function writeTriggers({ triggersPath, mutate, fs = nodeFs2 }) {
10280
- let current = [];
10281
- try {
10282
- const raw = JSON.parse(fs.readFileSync(triggersPath, "utf8"));
10283
- if (Array.isArray(raw?.triggers)) current = raw.triggers;
10284
- } catch {
10285
- }
10286
- const next = mutate(current.map((t) => ({ ...t })));
10287
- const text = `${JSON.stringify({ triggers: next }, null, 2)}
10288
- `;
10289
- try {
10290
- parseTriggers(text, triggersPath);
10291
- } catch (e) {
10292
- return { invalid: e?.message ?? String(e) };
10293
- }
10294
- const tmp = `${triggersPath}.tmp`;
10295
- fs.writeFileSync(tmp, text, { mode: 420 });
10296
- fs.renameSync(tmp, triggersPath);
10297
- return { ok: true };
10298
- }
10299
- function readPauseWindows({ pauseWindowsPath, fs = nodeFs2 }) {
10556
+ function readPauseWindows({ pauseWindowsPath, fs = nodeFs3 }) {
10300
10557
  let text;
10301
10558
  try {
10302
10559
  text = fs.readFileSync(pauseWindowsPath, "utf8");
@@ -10309,7 +10566,7 @@ function readPauseWindows({ pauseWindowsPath, fs = nodeFs2 }) {
10309
10566
  return { invalid: e?.message ?? String(e) };
10310
10567
  }
10311
10568
  }
10312
- function writePauseWindows({ pauseWindowsPath, mutate, fs = nodeFs2 }) {
10569
+ function writePauseWindows({ pauseWindowsPath, mutate, fs = nodeFs3 }) {
10313
10570
  let current = [];
10314
10571
  try {
10315
10572
  const raw = JSON.parse(fs.readFileSync(pauseWindowsPath, "utf8"));
@@ -10329,7 +10586,7 @@ function writePauseWindows({ pauseWindowsPath, mutate, fs = nodeFs2 }) {
10329
10586
  fs.renameSync(tmp, pauseWindowsPath);
10330
10587
  return { ok: true };
10331
10588
  }
10332
- function readSubscriptions({ subscriptionsPath, fs = nodeFs2 }) {
10589
+ function readSubscriptions({ subscriptionsPath, fs = nodeFs3 }) {
10333
10590
  if (subscriptionsPath === null || subscriptionsPath === void 0) return { missing: true };
10334
10591
  let text;
10335
10592
  try {
@@ -10411,7 +10668,7 @@ function withTimeout(promise, ms, fallback) {
10411
10668
  });
10412
10669
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
10413
10670
  }
10414
- function listRuns({ logsDir, limit = 10, fs = nodeFs2 }) {
10671
+ function listRuns({ logsDir, limit = 10, fs = nodeFs3 }) {
10415
10672
  const cap = clampLimit(limit);
10416
10673
  let names;
10417
10674
  try {
@@ -10429,7 +10686,7 @@ function listRuns({ logsDir, limit = 10, fs = nodeFs2 }) {
10429
10686
  records.sort(byEndedAtDesc);
10430
10687
  return records.slice(0, cap);
10431
10688
  }
10432
- function scanRunRecords({ logsDir, sinceMs, nowMs = Date.now(), fs = nodeFs2 }) {
10689
+ function scanRunRecords({ logsDir, sinceMs, nowMs = Date.now(), fs = nodeFs3 }) {
10433
10690
  const oldestMs = nowMs - SCAN_WINDOW_MAX_DAYS * 24 * 60 * 60 * 1e3;
10434
10691
  const cutoffMs = Math.max(Number.isFinite(sinceMs) ? sinceMs : oldestMs, oldestMs);
10435
10692
  let names;
@@ -10450,27 +10707,26 @@ function scanRunRecords({ logsDir, sinceMs, nowMs = Date.now(), fs = nodeFs2 })
10450
10707
  }
10451
10708
  return records;
10452
10709
  }
10453
- function readRun({ logsDir, jobId, fs = nodeFs2 }) {
10710
+ function readRun({ logsDir, jobId, fs = nodeFs3 }) {
10454
10711
  return readJsonFile(join2(logsDir, `${sanitizeJobId(jobId)}.json`), fs);
10455
10712
  }
10456
- function readLogTail({ logsDir, jobId, lines = 200, fs = nodeFs2 }) {
10713
+ function readLogTail({ logsDir, jobId, lines = 200, fs = nodeFs3 }) {
10457
10714
  let text;
10458
10715
  try {
10459
10716
  text = fs.readFileSync(join2(logsDir, `${sanitizeJobId(jobId)}.log`), "utf8");
10460
10717
  } catch {
10461
10718
  return { missing: true };
10462
10719
  }
10463
- const all = text.split("\n");
10464
- if (all.length > 0 && all[all.length - 1] === "") all.pop();
10720
+ const all = text.split("\n").filter((line) => line !== "");
10465
10721
  const cap = clampLines(lines);
10466
10722
  return { lines: all.slice(Math.max(0, all.length - cap)) };
10467
10723
  }
10468
- function readSettingsView({ settingsFile, fs = nodeFs2 }) {
10724
+ function readSettingsView({ settingsFile, fs = nodeFs3 }) {
10469
10725
  const result = readOverlay(settingsFile, { fs });
10470
10726
  if (result.invalid) return { path: settingsFile, invalid: result.invalid };
10471
10727
  return { path: settingsFile, overlay: result.overlay };
10472
10728
  }
10473
- function readTriggers({ triggersPath, fs = nodeFs2 }) {
10729
+ function readTriggers({ triggersPath, fs = nodeFs3 }) {
10474
10730
  let text;
10475
10731
  try {
10476
10732
  text = fs.readFileSync(triggersPath, "utf8");
@@ -10534,13 +10790,44 @@ function normalizeTriggerForDisplay(entry) {
10534
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 };
10535
10791
  case "comment":
10536
10792
  return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, command, packages, image, skillsDir, instructions, resume, secrets, secretsProfile, replicas, forge };
10537
- case "pull_request":
10793
+ case "pull_request": {
10794
+ const prDisarmed = on.disarmed !== null && typeof on.disarmed === "object" && !Array.isArray(on.disarmed) ? on.disarmed : null;
10538
10795
  return {
10539
10796
  type: "pull_request",
10540
10797
  action: normalizeSelector(on.action),
10541
10798
  any: normalizeSelector(on.any),
10542
10799
  all: normalizeSelector(on.all),
10543
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 },
10804
+ flow,
10805
+ command,
10806
+ packages,
10807
+ image,
10808
+ skillsDir,
10809
+ instructions,
10810
+ resume,
10811
+ secrets,
10812
+ secretsProfile,
10813
+ replicas,
10814
+ forge
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 },
10544
10831
  flow,
10545
10832
  command,
10546
10833
  packages,
@@ -10553,6 +10840,7 @@ function normalizeTriggerForDisplay(entry) {
10553
10840
  replicas,
10554
10841
  forge
10555
10842
  };
10843
+ }
10556
10844
  default:
10557
10845
  return null;
10558
10846
  }
@@ -10561,7 +10849,7 @@ function normalizeSelector(value) {
10561
10849
  if (!Array.isArray(value)) return [];
10562
10850
  return value.filter((member) => typeof member === "string");
10563
10851
  }
10564
- function readStagedPackages({ globalPiDir, fs = nodeFs2, readManifest: readManifest2 = readStageManifest } = {}) {
10852
+ function readStagedPackages({ globalPiDir, fs = nodeFs3, readManifest: readManifest2 = readStageManifest } = {}) {
10565
10853
  const empty = { stagedAt: null, packages: [] };
10566
10854
  if (typeof globalPiDir !== "string" || globalPiDir === "") return empty;
10567
10855
  let manifest;
@@ -10769,7 +11057,7 @@ function readFolderSkills({ folder, exec: exec4 = execFileSync2 } = {}) {
10769
11057
  }
10770
11058
  return { head, skills, truncated: truncated || anyUnread, unreachable: null };
10771
11059
  }
10772
- function readInjectedSkills({ skillsDir, fs = nodeFs2 } = {}) {
11060
+ function readInjectedSkills({ skillsDir, fs = nodeFs3 } = {}) {
10773
11061
  if (typeof skillsDir !== "string" || skillsDir === "") return { skills: [], truncated: false, unreachable: null };
10774
11062
  let names;
10775
11063
  try {
@@ -10792,7 +11080,7 @@ function readInjectedSkills({ skillsDir, fs = nodeFs2 } = {}) {
10792
11080
  }
10793
11081
  return { skills, truncated, unreachable: null };
10794
11082
  }
10795
- function readOverlaySkills({ globalPiDir, fs = nodeFs2 } = {}) {
11083
+ function readOverlaySkills({ globalPiDir, fs = nodeFs3 } = {}) {
10796
11084
  if (typeof globalPiDir !== "string" || globalPiDir === "") return { skills: [], truncated: false, unreachable: null };
10797
11085
  const dir = join2(globalPiDir, "skills");
10798
11086
  let names;
@@ -10816,7 +11104,7 @@ function readOverlaySkills({ globalPiDir, fs = nodeFs2 } = {}) {
10816
11104
  }
10817
11105
  return { skills, truncated, unreachable: null };
10818
11106
  }
10819
- function readStagedSkillsList({ globalPiDir, fs = nodeFs2, readStaged = readStagedSkills } = {}) {
11107
+ function readStagedSkillsList({ globalPiDir, fs = nodeFs3, readStaged = readStagedSkills } = {}) {
10820
11108
  const empty = { skills: [], unenumerable: [], truncated: false };
10821
11109
  if (typeof globalPiDir !== "string" || globalPiDir === "") return empty;
10822
11110
  let result;
@@ -10891,7 +11179,7 @@ function forgeRepoTargets({ records } = {}) {
10891
11179
  function escapeRegExp2(s) {
10892
11180
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
10893
11181
  }
10894
- function listRunIds({ logsDir, fs = nodeFs2 }) {
11182
+ function listRunIds({ logsDir, fs = nodeFs3 }) {
10895
11183
  let names;
10896
11184
  try {
10897
11185
  names = fs.readdirSync(logsDir);
@@ -10936,7 +11224,6 @@ var init_read_model = __esm({
10936
11224
  init_runtime_settings();
10937
11225
  init_run_history();
10938
11226
  init_budget();
10939
- init_triggers();
10940
11227
  init_pause_windows();
10941
11228
  init_subscriptions();
10942
11229
  init_connection();
@@ -10948,6 +11235,7 @@ var init_read_model = __esm({
10948
11235
  init_forges();
10949
11236
  init_graph_model();
10950
11237
  init_costs();
11238
+ init_triggers_file();
10951
11239
  DISPATCH_RUN_TTL_SECONDS = 2 * 60 * 60;
10952
11240
  SCAN_WINDOW_MAX_DAYS = 92;
10953
11241
  GRAPH_LIMITS = Object.freeze({
@@ -11066,6 +11354,12 @@ function normalizeModel(model) {
11066
11354
  package: typeof n.package === "string" ? clip(n.package, 80) : null,
11067
11355
  tiersUnknown: Array.isArray(n.tiersUnknown) ? [...new Set(n.tiersUnknown.filter((t) => typeof t === "string").map((t) => clip(t, 40)))].sort().slice(0, 4) : [],
11068
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,
11069
11363
  folderKey: typeof n.folderKey === "string" ? n.folderKey : null,
11070
11364
  runs: intOr(n.runs, 0),
11071
11365
  lastOutcome: typeof n.lastOutcome === "string" ? n.lastOutcome : null,
@@ -11510,6 +11804,8 @@ function buildTip(n, flags, groupLabel, nowMs) {
11510
11804
  const nextMs = typeof n.next === "number" ? n.next : typeof n.next === "string" ? Date.parse(n.next) : NaN;
11511
11805
  if (Number.isFinite(nextMs) && Number.isFinite(nowMs) && nextMs > nowMs) lines.push(`next ${relSpan(nextMs - nowMs)}`);
11512
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)");
11513
11809
  }
11514
11810
  if (n.aiTrigger) lines.push("chainable: ai-trigger allow");
11515
11811
  if (n.kind === "skill" && n.loops.length > 0) {
@@ -11540,6 +11836,7 @@ function nodeState(n, flagNames) {
11540
11836
  }
11541
11837
  if (n.kind === "skill-unverified") return { stroke: PAGE_DIM, dash: "10,4", faded: false };
11542
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 };
11543
11840
  if (flagNames.has("orphan")) return { stroke: CHIP_STROKE, dash: "8,3", faded: true };
11544
11841
  return { stroke: CHIP_STROKE, dash: null, faded: false };
11545
11842
  }
@@ -11784,6 +12081,7 @@ var init_graph_html = __esm({
11784
12081
  label: "#d8bfd8",
11785
12082
  comment: "#e7e7ae",
11786
12083
  pull_request: "#c0deed",
12084
+ issue: "#c7e9c0",
11787
12085
  skill: "#fdd0a2"
11788
12086
  });
11789
12087
  STATUS_COMPLETED = "#3fb950";
@@ -11813,6 +12111,7 @@ var init_graph_html = __esm({
11813
12111
  label: "\u25C8",
11814
12112
  comment: "\u275D",
11815
12113
  pull_request: "\u21C4",
12114
+ issue: "\u25C9",
11816
12115
  skill: "\u0192",
11817
12116
  "skill-missing": "!",
11818
12117
  "skill-unverified": "?",
@@ -13227,7 +13526,7 @@ var init_open_browser = __esm({
13227
13526
  });
13228
13527
 
13229
13528
  // src/deployment-pointer.mjs
13230
- import * as nodeFs3 from "node:fs";
13529
+ import * as nodeFs4 from "node:fs";
13231
13530
  import { homedir } from "node:os";
13232
13531
  import { join as join3, isAbsolute } from "node:path";
13233
13532
  function pointerPath(env = process.env) {
@@ -13259,7 +13558,7 @@ function normalizePointer(raw) {
13259
13558
  }
13260
13559
  return { pointer: { version: raw.version, deploymentDir: raw.deploymentDir, env } };
13261
13560
  }
13262
- function readPointer({ path = pointerPath(), fs = nodeFs3 } = {}) {
13561
+ function readPointer({ path = pointerPath(), fs = nodeFs4 } = {}) {
13263
13562
  let text;
13264
13563
  try {
13265
13564
  text = fs.readFileSync(path, "utf8");
@@ -13292,12 +13591,12 @@ function layerPointer(env, fs) {
13292
13591
  }
13293
13592
  return { applied };
13294
13593
  }
13295
- function applyDeploymentPointer(env = process.env, { fs = nodeFs3 } = {}) {
13594
+ function applyDeploymentPointer(env = process.env, { fs = nodeFs4 } = {}) {
13296
13595
  if (appliedOnce) return { applied: [] };
13297
13596
  appliedOnce = true;
13298
13597
  return layerPointer(env, fs);
13299
13598
  }
13300
- function reapplyDeploymentPointer(env = process.env, { fs = nodeFs3 } = {}) {
13599
+ function reapplyDeploymentPointer(env = process.env, { fs = nodeFs4 } = {}) {
13301
13600
  appliedOnce = true;
13302
13601
  return layerPointer(env, fs);
13303
13602
  }
@@ -13306,7 +13605,7 @@ function takePointerNotice() {
13306
13605
  notice = void 0;
13307
13606
  return n;
13308
13607
  }
13309
- function writePointer({ path = pointerPath(), pointer, fs = nodeFs3 } = {}) {
13608
+ function writePointer({ path = pointerPath(), pointer, fs = nodeFs4 } = {}) {
13310
13609
  const res = normalizePointer(pointer);
13311
13610
  if (res.ignored) return { invalid: res.ignored };
13312
13611
  const tmp = `${path}.tmp`;
@@ -39039,6 +39338,7 @@ function triggerLine(t) {
39039
39338
  const res = t?.resume === true ? " [resume]" : "";
39040
39339
  const rep = t?.replicas > 1 ? ` [x${t.replicas}]` : "";
39041
39340
  const sec = t?.secrets > 0 ? ` [secrets ${t.secrets}${t.secretsProfile ? ` via ${t.secretsProfile}` : ""}]` : "";
39341
+ const shot = t?.disarmed ? " [spent]" : t?.once === true ? " [once]" : "";
39042
39342
  switch (t?.type) {
39043
39343
  case "cron":
39044
39344
  return `cron ${t.id ?? "-"} ${t.pattern ?? "-"} \u2192 ${t.folder ?? "-"}/${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}${sec}`;
@@ -39049,7 +39349,13 @@ function triggerLine(t) {
39049
39349
  case "pull_request": {
39050
39350
  const clauses = ruleClauses(t);
39051
39351
  const action = `action[${(t.action ?? []).join(",")}]`;
39052
- return `pull_request ${action}${clauses ? ` ${clauses}` : ""} \u2192 ${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}${sec}`;
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}`;
39053
39359
  }
39054
39360
  default:
39055
39361
  return "(unknown trigger)";
@@ -39978,8 +40284,9 @@ function triggerRow(t, sel, inner, styler, sched = null) {
39978
40284
  const img = t?.image ? " " + styler.fg("accent", `[${t.image}]`) : "";
39979
40285
  const res = t?.resume === true ? " " + styler.fg("warning", "[resume]") : "";
39980
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]") : "";
39981
40288
  const health = sched && (sched.overdueMs || sched.stallMax > 0 && sched.stalls >= sched.stallMax) ? " " + styler.fg("warning", sched.overdueMs ? "\u26A0 overdue" : "\u26A0 stalled") : "";
39982
- 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);
39983
40290
  }
39984
40291
  function matchColored(t, styler) {
39985
40292
  switch (t?.type) {
@@ -39987,10 +40294,15 @@ function matchColored(t, styler) {
39987
40294
  return styler.fg("text", `${t.id ?? "-"} ${t.pattern ?? "-"}`);
39988
40295
  case "comment":
39989
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
+ }
39990
40301
  case "label":
39991
40302
  case "pull_request": {
39992
40303
  const parts = [];
39993
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}`));
39994
40306
  for (const x of t.any ?? []) parts.push(styler.fg("success", x));
39995
40307
  for (const x of t.all ?? []) parts.push(styler.fg("success", `+${x}`));
39996
40308
  for (const x of t.none ?? []) parts.push(styler.fg("error", `!${x}`));
@@ -40125,11 +40437,17 @@ function renderTriggerDetail(t, inner, styler, sched = null, staged = null) {
40125
40437
  }
40126
40438
  } else if (t.type === "label" || t.type === "pull_request") {
40127
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"));
40128
40442
  out.push(kv("any of", (t.any ?? []).join(" \xB7 ") || "-", "success"));
40129
40443
  out.push(kv("all of", (t.all ?? []).join(" \xB7 ") || "-", "success"));
40130
40444
  out.push(kv("none of", (t.none ?? []).join(" \xB7 ") || "-", "error"));
40131
40445
  } else if (t.type === "comment") {
40132
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"));
40133
40451
  }
40134
40452
  blank();
40135
40453
  section("runs");
@@ -40193,33 +40511,38 @@ function trustModel(t) {
40193
40511
  }
40194
40512
  const subject = t?.type === "comment" ? "adversarial comment text" : "adversarial issue/PR text";
40195
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;
40196
40516
  switch (t?.forge) {
40197
40517
  case "gitlab":
40198
- return [
40518
+ return oneShot([
40199
40519
  "authorized by the actor's resolved project access level (>= Developer) \u2014 a label is NOT approval here",
40200
40520
  "HMAC over the body (19.0+) or a bare X-Gitlab-Token \xB7 dedup by webhook-id",
40201
40521
  where
40202
- ];
40522
+ ]);
40203
40523
  case "forgejo":
40204
- return [
40524
+ return oneShot([
40205
40525
  "authorized by the actor's resolved repository permission (admin|write), on every trigger type",
40206
40526
  "HMAC over the body (GitHub-compatible) \xB7 dedup by X-GitHub-Delivery GUID",
40207
40527
  where
40208
- ];
40528
+ ]);
40209
40529
  case "azure":
40210
- return [
40530
+ return oneShot([
40211
40531
  "authorized by the actor's resolved project membership \u2014 work items name the actor only by email",
40212
40532
  "NO HMAC: a shared secret that covers no bytes \xB7 dedup by the payload's own id (OQ-015)",
40213
40533
  where
40214
- ];
40534
+ ]);
40215
40535
  default:
40216
- return [
40536
+ return oneShot([
40217
40537
  // A review trigger reads the REVIEWER's author_association, not the PR author's (issue #66), so it
40218
40538
  // cannot share the generic line: an operator reading "author gate" would picture the wrong person.
40219
- 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",
40220
40543
  "dedup by X-GitHub-Delivery GUID (redelivery-safe)",
40221
40544
  where
40222
- ];
40545
+ ]);
40223
40546
  }
40224
40547
  }
40225
40548
  function triggerDetailHints(inner, styler, pendingDelete = false) {
@@ -40460,7 +40783,7 @@ var init_dashboard = __esm({
40460
40783
  TAIL_VIEWPORT = 20;
40461
40784
  MIN_WIDTH2 = 8;
40462
40785
  DRILL_WIDTH = 70;
40463
- 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" };
40464
40787
  KIND_WIDTH = 13;
40465
40788
  FRAME_CHROME_ROWS = 4;
40466
40789
  }
@@ -40485,7 +40808,7 @@ __export(setup_wizard_exports, {
40485
40808
  runSetupWizard: () => runSetupWizard,
40486
40809
  runtimeDirFor: () => runtimeDirFor
40487
40810
  });
40488
- import * as nodeFs4 from "node:fs";
40811
+ import * as nodeFs5 from "node:fs";
40489
40812
  import { execFileSync as execFileSync3, spawn as nodeSpawn2 } from "node:child_process";
40490
40813
  import { homedir as homedir2 } from "node:os";
40491
40814
  import { join as join6 } from "node:path";
@@ -40501,7 +40824,7 @@ async function defaultProbeQueue(url) {
40501
40824
  async function detectDeployment({
40502
40825
  env = process.env,
40503
40826
  cwd = process.cwd(),
40504
- fs = nodeFs4,
40827
+ fs = nodeFs5,
40505
40828
  probeQueue = defaultProbeQueue
40506
40829
  } = {}) {
40507
40830
  const pointerRes = readPointer({ path: pointerPath(env), fs });
@@ -40628,7 +40951,7 @@ async function ensureDocker(ui, notify, { platform, probeDockerFn }) {
40628
40951
  function npmSpawnOptions(platform, dir) {
40629
40952
  return platform === "win32" ? { bin: "npm.cmd", options: { cwd: dir, shell: true } } : { bin: "npm", options: { cwd: dir } };
40630
40953
  }
40631
- function listRepoSkills(cwd, fs = nodeFs4) {
40954
+ function listRepoSkills(cwd, fs = nodeFs5) {
40632
40955
  try {
40633
40956
  const root = join6(cwd, ".pi", "skills");
40634
40957
  return fs.readdirSync(root).filter((name) => SKILL_NAME_RE.test(name) && fs.existsSync(join6(root, name, "SKILL.md")));
@@ -40656,7 +40979,7 @@ function ensureDeploymentPackageJson(fs, dir) {
40656
40979
  }
40657
40980
  async function runSetupWizard(paths, ctx, notify, deps = {}) {
40658
40981
  const {
40659
- fs = nodeFs4,
40982
+ fs = nodeFs5,
40660
40983
  env = process.env,
40661
40984
  platform = process.platform,
40662
40985
  execPath = process.execPath,
@@ -40970,7 +41293,7 @@ async function offerFirstTrigger(repoCwd, deployDir, ui, notify, { fs, listRepoS
40970
41293
  );
40971
41294
  }
40972
41295
  function registerNudge(pi, deps = {}) {
40973
- const { fs = nodeFs4, env = process.env, homedirFn = homedir2 } = deps;
41296
+ const { fs = nodeFs5, env = process.env, homedirFn = homedir2 } = deps;
40974
41297
  pi.on("session_start", (event, ctx) => {
40975
41298
  try {
40976
41299
  if (event?.reason !== "startup" || !ctx?.hasUI) return;
@@ -40995,8 +41318,8 @@ var init_setup_wizard = __esm({
40995
41318
  init_deployment_pointer();
40996
41319
  init_read_model();
40997
41320
  init_index();
40998
- RUNTIME_VERSION = "1.3.0";
40999
- RECEIVER_VERSION = "1.2.0";
41321
+ RUNTIME_VERSION = "1.4.0";
41322
+ RECEIVER_VERSION = "1.3.0";
41000
41323
  RUNTIME_PKG = "@edgehero/pi-dispatch";
41001
41324
  RECEIVER_PKG = "@edgehero/pi-dispatch-receiver";
41002
41325
  NUDGE_MARKER_BASENAME = "pi-dispatch-setup.nudged";
@@ -41112,7 +41435,7 @@ var init_secrets_command = __esm({
41112
41435
  // src/index.ts
41113
41436
  import { VERSION } from "@earendil-works/pi-coding-agent";
41114
41437
  import { fileURLToPath as fileURLToPath2, pathToFileURL } from "node:url";
41115
- import * as nodeFs5 from "node:fs";
41438
+ import * as nodeFs6 from "node:fs";
41116
41439
  function computePiVersionAdvisory(version, supported) {
41117
41440
  if (version === supported || version === "0.0.0") return void 0;
41118
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`;
@@ -41254,7 +41577,7 @@ function registerTools(pi) {
41254
41577
  pi.registerTool({
41255
41578
  name: "dispatch_triggers",
41256
41579
  label: "pi-dispatch triggers",
41257
- 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.",
41258
41581
  parameters: typebox_exports.Object({}),
41259
41582
  async execute() {
41260
41583
  const paths = resolvePaths(process.env);
@@ -41299,7 +41622,7 @@ function registerTools(pi) {
41299
41622
  pi.registerTool({
41300
41623
  name: "dispatch_trigger_add",
41301
41624
  label: "pi-dispatch add trigger",
41302
- 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.",
41303
41626
  executionMode: "sequential",
41304
41627
  parameters: typebox_exports.Object({
41305
41628
  kind: typebox_exports.String(),
@@ -41316,13 +41639,18 @@ function registerTools(pi) {
41316
41639
  phrase: typebox_exports.Optional(typebox_exports.String()),
41317
41640
  labels: typebox_exports.Optional(typebox_exports.Array(typebox_exports.String())),
41318
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()),
41319
41647
  model: typebox_exports.Optional(typebox_exports.String()),
41320
41648
  provider: typebox_exports.Optional(typebox_exports.String()),
41321
41649
  maxTurns: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 }))
41322
41650
  }),
41323
41651
  async execute(_id, params, _signal, _onUpdate, ctx) {
41324
41652
  const entry = buildTriggerEntry(params.kind, params);
41325
- 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)`);
41326
41654
  const result = await confirmedWrite(
41327
41655
  ctx,
41328
41656
  { title: `Add ${params.kind} trigger`, message: `Add to triggers.json:
@@ -41533,6 +41861,17 @@ function buildTriggerEntry(kind, f) {
41533
41861
  const on = { type: "pull_request", action: asWords(f.action) };
41534
41862
  const any = asWords(f.labels ?? f.any);
41535
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;
41536
41875
  return { on, run: { kind: forge, flow: f.flow } };
41537
41876
  }
41538
41877
  return null;
@@ -41584,7 +41923,7 @@ async function dispatch(pi, args, ctx) {
41584
41923
  if (det.state === "pointer" && !runtimeSkewNoticed) {
41585
41924
  const ptrRes = readPointer({ path: pointerPath(process.env) });
41586
41925
  const deploymentDir = ptrRes.pointer?.deploymentDir;
41587
- const installed = deploymentDir ? readInstalledVersion2(nodeFs5, runtimeDirFor2(deploymentDir)) : void 0;
41926
+ const installed = deploymentDir ? readInstalledVersion2(nodeFs6, runtimeDirFor2(deploymentDir)) : void 0;
41588
41927
  if (typeof installed === "string" && installed !== RUNTIME_VERSION2) {
41589
41928
  runtimeSkewNoticed = true;
41590
41929
  notify?.(
@@ -41743,7 +42082,7 @@ async function assembleGraph(paths) {
41743
42082
  });
41744
42083
  }
41745
42084
  function realArtifactDeps() {
41746
- 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 };
41747
42086
  }
41748
42087
  function isHeadlessEnv(env, platform) {
41749
42088
  if (env?.SSH_CONNECTION || env?.SSH_TTY) return "SSH session";
@@ -42269,7 +42608,7 @@ function completeArguments(prefix) {
42269
42608
  }
42270
42609
  return null;
42271
42610
  }
42272
- 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;
42273
42612
  var init_index = __esm({
42274
42613
  "src/index.ts"() {
42275
42614
  init_build();
@@ -42312,10 +42651,14 @@ var init_index = __esm({
42312
42651
  "secrets"
42313
42652
  ];
42314
42653
  FORGE_PROMPT = "forge \u2014 github, gitlab, forgejo or azure";
42654
+ ISSUE_CLOSE_WORD = { github: "closed", gitlab: "close", forgejo: "closed" };
42315
42655
  PR_ACTION_VOCAB = {
42316
- github: { hint: "labeled opened synchronize reopened review_submitted", dflt: "labeled" },
42317
- gitlab: { hint: "open update reopen approved", dflt: "update" },
42318
- 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" },
42319
42662
  azure: { hint: "created updated", dflt: "updated" }
42320
42663
  };
42321
42664
  PRICING = { listPricedModels, getPricedModel, isZeroRated, reprice, piAiVersion };