@edgehero/pi-dispatch-admin 0.5.0 → 0.7.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
@@ -7689,12 +7689,18 @@ function defaultSandboxDir(env = process.env) {
7689
7689
  function defaultLogsDir() {
7690
7690
  return `${process.env.TMPDIR ?? process.env.TEMP ?? "/tmp"}/pi-dispatch/logs`.replace(/\\/g, "/");
7691
7691
  }
7692
+ function defaultGraphDir(env = process.env) {
7693
+ return `${env.TMPDIR ?? env.TEMP ?? "/tmp"}/pi-dispatch/graph`.replace(/\\/g, "/");
7694
+ }
7692
7695
  function defaultSettingsFile() {
7693
7696
  return `${process.env.TMPDIR ?? process.env.TEMP ?? "/tmp"}/pi-dispatch/settings.json`.replace(/\\/g, "/");
7694
7697
  }
7698
+ var CHAIN_DEPTH_MAX_DEFAULT, CHAIN_MAX_PER_JOB_DEFAULT;
7695
7699
  var init_config2 = __esm({
7696
7700
  "../worker/src/config.mjs"() {
7697
7701
  init_forges();
7702
+ CHAIN_DEPTH_MAX_DEFAULT = 1;
7703
+ CHAIN_MAX_PER_JOB_DEFAULT = 2;
7698
7704
  }
7699
7705
  });
7700
7706
 
@@ -7935,11 +7941,13 @@ function normalizeCron(on, run, index, path, state) {
7935
7941
  }
7936
7942
  const packages = validatePackagesFlag(run, `cron trigger "${id}"`, path);
7937
7943
  const image = validateImageRef(run, `cron trigger "${id}"`, path);
7944
+ const skillsDir = validateSkillsDir(run, `cron trigger "${id}"`, path);
7945
+ validateInstructions(run, `cron trigger "${id}"`, path, { cron: true });
7938
7946
  const resume = validateResumeFlag(run, `cron trigger "${id}"`, path);
7939
7947
  validateReplicas(run, `cron trigger "${id}"`, path);
7940
7948
  return {
7941
7949
  on: { type: "cron", id, pattern },
7942
- run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages, image, resume }
7950
+ run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages, image, resume, ...skillsDir !== void 0 && { skillsDir } }
7943
7951
  };
7944
7952
  }
7945
7953
  function validatePackagesFlag(run, at, path) {
@@ -7973,6 +7981,31 @@ function validateImageRef(run, at, path) {
7973
7981
  }
7974
7982
  return image;
7975
7983
  }
7984
+ function validateSkillsDir(run, at, path) {
7985
+ const dir = run.skillsDir;
7986
+ if (dir === void 0) return void 0;
7987
+ if (typeof dir !== "string" || dir.trim() === "") {
7988
+ throw configError(`${at}: run.skillsDir must be a non-empty string when present: ${path}`);
7989
+ }
7990
+ if (dir !== dir.trim()) {
7991
+ throw configError(`${at}: run.skillsDir must not have leading or trailing whitespace (got ${JSON.stringify(dir)}): ${path}`);
7992
+ }
7993
+ return dir;
7994
+ }
7995
+ function validateInstructions(run, at, path, { cron = false } = {}) {
7996
+ const text = run.instructions;
7997
+ if (text === void 0) return void 0;
7998
+ if (cron) {
7999
+ throw configError(`${at}: run.instructions is not accepted on a cron trigger -- a local job's prompt IS run.task, the same operator-authored text in the same place. Put the standing instruction at the top of run.task: ${path}`);
8000
+ }
8001
+ if (typeof text !== "string" || text.trim() === "") {
8002
+ throw configError(`${at}: run.instructions must be a non-empty string when present: ${path}`);
8003
+ }
8004
+ if (text.length > INSTRUCTIONS_MAX) {
8005
+ throw configError(`${at}: run.instructions is ${text.length} characters, over the ${INSTRUCTIONS_MAX} cap -- a standing instruction is a sentence or two. Anything longer belongs in the flow's own SKILL.md, or in the global overlay's APPEND_SYSTEM.md if it applies to every job: ${path}`);
8006
+ }
8007
+ return text;
8008
+ }
7976
8009
  function validatePredicate(on, index, path, requirePositive) {
7977
8010
  const at = `trigger at index ${index}`;
7978
8011
  for (const key of ["any", "all", "none"]) {
@@ -8027,12 +8060,14 @@ function normalizeLabel(on, run, index, path) {
8027
8060
  }
8028
8061
  const packages = validatePackagesFlag(run, at, path);
8029
8062
  const image = validateImageRef(run, at, path);
8063
+ const skillsDir = validateSkillsDir(run, at, path);
8064
+ const instructions = validateInstructions(run, at, path);
8030
8065
  const resume = validateResumeFlag(run, at, path);
8031
8066
  const repository = validateRepository(run, "label", at, path);
8032
8067
  const replicas = validateReplicas(run, at, path);
8033
8068
  return {
8034
8069
  on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none },
8035
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...repository !== void 0 && { repository } }
8070
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository } }
8036
8071
  };
8037
8072
  }
8038
8073
  function normalizeComment(on, run, index, path, state) {
@@ -8049,12 +8084,14 @@ function normalizeComment(on, run, index, path, state) {
8049
8084
  }
8050
8085
  const packages = validatePackagesFlag(run, at, path);
8051
8086
  const image = validateImageRef(run, at, path);
8087
+ const skillsDir = validateSkillsDir(run, at, path);
8088
+ const instructions = validateInstructions(run, at, path);
8052
8089
  const resume = validateResumeFlag(run, at, path);
8053
8090
  const repository = validateRepository(run, "comment", at, path);
8054
8091
  const replicas = validateReplicas(run, at, path);
8055
8092
  return {
8056
8093
  on: { type: "comment", phrase: on.phrase },
8057
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...repository !== void 0 && { repository } }
8094
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository } }
8058
8095
  };
8059
8096
  }
8060
8097
  function normalizePullRequest(on, run, index, path) {
@@ -8070,6 +8107,7 @@ function normalizePullRequest(on, run, index, path) {
8070
8107
  throw configError(`${at}: pull_request on.action has an unsupported ${run.kind} action ${JSON.stringify(a)} (expected ${expected}): ${path}`);
8071
8108
  }
8072
8109
  }
8110
+ const reviewState = validateReviewState(on, actions, run, at, path);
8073
8111
  const labelAction = forgeSpec(run.kind)?.prLabelAction;
8074
8112
  const requirePositive = typeof labelAction === "string" && actions.includes(labelAction);
8075
8113
  if (run.kind === "azure" && (on.any !== void 0 || on.all !== void 0 || on.none !== void 0)) {
@@ -8081,22 +8119,55 @@ function normalizePullRequest(on, run, index, path) {
8081
8119
  }
8082
8120
  const packages = validatePackagesFlag(run, at, path);
8083
8121
  const image = validateImageRef(run, at, path);
8122
+ const skillsDir = validateSkillsDir(run, at, path);
8123
+ const instructions = validateInstructions(run, at, path);
8084
8124
  const resume = validateResumeFlag(run, at, path);
8085
8125
  validateRepository(run, "pull_request", at, path);
8086
8126
  const replicas = validateReplicas(run, at, path);
8087
8127
  return {
8088
- on: { type: "pull_request", action: [...actions], any: predicate.any, all: predicate.all, none: predicate.none },
8089
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas }
8128
+ on: {
8129
+ type: "pull_request",
8130
+ action: [...actions],
8131
+ // Absent rather than present-and-undefined: an unnarrowed rule's normalized shape must stay
8132
+ // byte-identical to the one every pre-#66 trigger file produces.
8133
+ ...reviewState !== void 0 && { reviewState },
8134
+ any: predicate.any,
8135
+ all: predicate.all,
8136
+ none: predicate.none
8137
+ },
8138
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions } }
8090
8139
  };
8091
8140
  }
8092
- var ON_TYPES, PR_ACTIONS, ID_CHARSET, REPLICAS_MAX;
8141
+ function validateReviewState(on, actions, run, at, path) {
8142
+ if (on.reviewState === void 0) return void 0;
8143
+ if (run.kind !== "github") {
8144
+ throw configError(`${at}: on.reviewState is github-only (got ${run.kind}), no other forge reports a review verdict: ${path}`);
8145
+ }
8146
+ if (!actions.includes(REVIEW_ACTION)) {
8147
+ throw configError(`${at}: on.reviewState requires on.action to include ${JSON.stringify(REVIEW_ACTION)}, otherwise it narrows nothing: ${path}`);
8148
+ }
8149
+ if (!Array.isArray(on.reviewState) || on.reviewState.length === 0) {
8150
+ throw configError(`${at}: on.reviewState must be a non-empty array: ${path}`);
8151
+ }
8152
+ const expected = [...REVIEW_STATES].join("|");
8153
+ for (const s of on.reviewState) {
8154
+ if (!REVIEW_STATES.has(s)) {
8155
+ throw configError(`${at}: on.reviewState has an unsupported review state ${JSON.stringify(s)} (expected ${expected}): ${path}`);
8156
+ }
8157
+ }
8158
+ return [...on.reviewState];
8159
+ }
8160
+ var ON_TYPES, PR_ACTIONS, REVIEW_STATES, REVIEW_ACTION, ID_CHARSET, REPLICAS_MAX, INSTRUCTIONS_MAX;
8093
8161
  var init_triggers = __esm({
8094
8162
  "../worker/src/triggers.mjs"() {
8095
8163
  init_config2();
8096
8164
  init_forges();
8097
8165
  ON_TYPES = /* @__PURE__ */ new Set(["cron", "label", "comment", "pull_request"]);
8098
8166
  PR_ACTIONS = {
8099
- github: /* @__PURE__ */ new Set(["labeled", "opened", "synchronize", "reopened"]),
8167
+ github: /* @__PURE__ */ new Set(["labeled", "opened", "synchronize", "reopened", "review_submitted"]),
8168
+ // GitLab's `approved` is its review gate (a member approved the MR). It is NOT github's
8169
+ // `review_submitted` renamed: `approved` is one verdict, `review_submitted` is every verdict, which is
8170
+ // what `on.reviewState` below exists to narrow.
8100
8171
  gitlab: /* @__PURE__ */ new Set(["open", "update", "reopen", "approved"]),
8101
8172
  // Forgejo's own spellings. `label_updated` is its `labeled` and `synchronized` its `synchronize` -- a
8102
8173
  // one-letter difference that an operator would otherwise discover as a trigger that loads clean and
@@ -8109,8 +8180,11 @@ var init_triggers = __esm({
8109
8180
  // requests -- which is why azure's `prLabelAction` is null and a predicated PR rule is refused below.
8110
8181
  azure: /* @__PURE__ */ new Set(["created", "updated"])
8111
8182
  };
8183
+ REVIEW_STATES = /* @__PURE__ */ new Set(["approved", "changes_requested", "commented"]);
8184
+ REVIEW_ACTION = "review_submitted";
8112
8185
  ID_CHARSET = /^[A-Za-z0-9._-]+$/;
8113
8186
  REPLICAS_MAX = 3;
8187
+ INSTRUCTIONS_MAX = 2e3;
8114
8188
  }
8115
8189
  });
8116
8190
 
@@ -8194,7 +8268,7 @@ function normalizeWindow(w, index, path) {
8194
8268
  return norm;
8195
8269
  }
8196
8270
  function zonedParts(tz, ms) {
8197
- const fmt = new Intl.DateTimeFormat("en-US", {
8271
+ const fmt2 = new Intl.DateTimeFormat("en-US", {
8198
8272
  timeZone: tz,
8199
8273
  hour12: false,
8200
8274
  year: "numeric",
@@ -8205,7 +8279,7 @@ function zonedParts(tz, ms) {
8205
8279
  weekday: "short"
8206
8280
  });
8207
8281
  const p = {};
8208
- for (const part of fmt.formatToParts(new Date(ms))) p[part.type] = part.value;
8282
+ for (const part of fmt2.formatToParts(new Date(ms))) p[part.type] = part.value;
8209
8283
  let hh = Number(p.hour);
8210
8284
  if (hh === 24) hh = 0;
8211
8285
  return { y: Number(p.year), mo: Number(p.month), d: Number(p.day), hh, mi: Number(p.minute), dow: WEEKDAY_INDEX[p.weekday] };
@@ -8442,7 +8516,7 @@ import { Queue } from "bullmq";
8442
8516
  function makeQueue(connection) {
8443
8517
  return new Queue(QUEUE, { connection });
8444
8518
  }
8445
- async function enqueueLocalJob(queue, { folder, flow, task, provider, model, maxTurns, image, chainDepth, parentJobId, jobId, now = /* @__PURE__ */ new Date() }) {
8519
+ async function enqueueLocalJob(queue, { folder, flow, task, provider, model, maxTurns, image, skillsDir, chainDepth, parentJobId, jobId, now = /* @__PURE__ */ new Date() }) {
8446
8520
  const minute = now.toISOString().slice(0, 16);
8447
8521
  const id = jobId ?? localJobId({ folder, flow, task, minute });
8448
8522
  const data = {
@@ -8454,6 +8528,11 @@ async function enqueueLocalJob(queue, { folder, flow, task, provider, model, max
8454
8528
  model,
8455
8529
  maxTurns,
8456
8530
  ...image !== void 0 && { image },
8531
+ // The host directory of operator-authored skills this trigger injects (REQ-PER-TRIGGER-SKILLS).
8532
+ // Conditional like `image`, so an unflagged job's data stays byte-identical, and at JOB level rather
8533
+ // than inside `trigger` because a worker-host path is an execution knob, not a fact about the
8534
+ // delivery -- and `trigger` is the object copied into /job/event.json.
8535
+ ...skillsDir !== void 0 && { skillsDir },
8457
8536
  ...chainDepth !== void 0 && { chainDepth },
8458
8537
  ...parentJobId !== void 0 && { parentJobId }
8459
8538
  };
@@ -8521,9 +8600,9 @@ var init_flow_gate = __esm({
8521
8600
 
8522
8601
  // ../worker/src/git-dirty.mjs
8523
8602
  import { execFileSync } from "node:child_process";
8524
- function gitDirty(folder, { exec: exec3 = execFileSync } = {}) {
8603
+ function gitDirty(folder, { exec: exec4 = execFileSync } = {}) {
8525
8604
  try {
8526
- const out = exec3("git", ["-C", folder, "status", "--porcelain"], { encoding: "utf8" });
8605
+ const out = exec4("git", ["-C", folder, "status", "--porcelain"], { encoding: "utf8" });
8527
8606
  return out.trim().length > 0;
8528
8607
  } catch {
8529
8608
  return null;
@@ -8534,14 +8613,40 @@ var init_git_dirty = __esm({
8534
8613
  }
8535
8614
  });
8536
8615
 
8616
+ // ../worker/src/host-pi.mjs
8617
+ var init_host_pi = __esm({
8618
+ "../worker/src/host-pi.mjs"() {
8619
+ init_packages();
8620
+ }
8621
+ });
8622
+
8623
+ // ../worker/src/copy-tree.mjs
8624
+ var ENTRY_NAME_RE, INJECT_LIMITS;
8625
+ var init_copy_tree = __esm({
8626
+ "../worker/src/copy-tree.mjs"() {
8627
+ ENTRY_NAME_RE = /^[a-z0-9](?:[a-z0-9_.-]{0,62}[a-z0-9])?$/i;
8628
+ INJECT_LIMITS = Object.freeze({
8629
+ // The binding one, and its reason is NOT disk. Every loaded skill contributes a <name> and a
8630
+ // description (spec-capped at 1024 chars) to the SYSTEM PROMPT of every job of that trigger, so
8631
+ // this bounds the cached prefix rather than the filesystem. UNVERIFIED figure, in the sense
8632
+ // ISOLATION_FLAGS' --pids-limit=512 is: a ceiling on absurdity, not a measured budget.
8633
+ maxDirs: 64,
8634
+ maxFiles: 512,
8635
+ maxBytes: 4 << 20,
8636
+ maxDepth: 8
8637
+ });
8638
+ }
8639
+ });
8640
+
8537
8641
  // ../worker/src/import-pi.mjs
8538
8642
  import { execFile as execFile2 } from "node:child_process";
8539
8643
  import { promisify as promisify2 } from "node:util";
8540
- var ENTRY_NAME_RE, execFileAsync;
8644
+ var execFileAsync;
8541
8645
  var init_import_pi = __esm({
8542
8646
  "../worker/src/import-pi.mjs"() {
8543
8647
  init_packages();
8544
- ENTRY_NAME_RE = /^[a-z0-9](?:[a-z0-9_.-]{0,62}[a-z0-9])?$/i;
8648
+ init_host_pi();
8649
+ init_copy_tree();
8545
8650
  execFileAsync = promisify2(execFile2);
8546
8651
  }
8547
8652
  });
@@ -8627,14 +8732,14 @@ function readStageManifest({ globalPiDir, readFile = readFileSync, fileExists =
8627
8732
  const { name, version, dir } = entry;
8628
8733
  if (!isNonEmptyString5(name) || !isNonEmptyString5(version) || !isNonEmptyString5(dir)) return null;
8629
8734
  if (!ENTRY_NAME_RE.test(dir) || dir.length > MAX_DIR_LENGTH) return null;
8630
- packages.push({ name, version, dir });
8735
+ packages.push({ name, version, dir, from: entry.from === FROM_HOST ? FROM_HOST : FROM_DECLARED });
8631
8736
  }
8632
8737
  return { stagedAt: typeof parsed.stagedAt === "string" ? parsed.stagedAt : null, packages };
8633
8738
  } catch {
8634
8739
  return null;
8635
8740
  }
8636
8741
  }
8637
- var PACKAGES_SUBDIR, STAGE_MANIFEST, CONTAINER_PACKAGES_ROOT, MAX_DIR_LENGTH;
8742
+ var PACKAGES_SUBDIR, STAGE_MANIFEST, CONTAINER_PACKAGES_ROOT, FROM_DECLARED, FROM_HOST, MAX_DIR_LENGTH;
8638
8743
  var init_packages = __esm({
8639
8744
  "../worker/src/packages.mjs"() {
8640
8745
  init_config2();
@@ -8643,10 +8748,499 @@ var init_packages = __esm({
8643
8748
  PACKAGES_SUBDIR = "packages";
8644
8749
  STAGE_MANIFEST = "packages.json";
8645
8750
  CONTAINER_PACKAGES_ROOT = `${CONTAINER_GLOBAL_PI_DIR}/${PACKAGES_SUBDIR}`;
8751
+ FROM_DECLARED = "pi-packages";
8752
+ FROM_HOST = "host";
8646
8753
  MAX_DIR_LENGTH = 64;
8647
8754
  }
8648
8755
  });
8649
8756
 
8757
+ // ../worker/src/materialize.mjs
8758
+ import { execFile as execFile3 } from "node:child_process";
8759
+ import { promisify as promisify3 } from "node:util";
8760
+ function isWindowsDeviceName(segment) {
8761
+ const stem = segment.split(".")[0].toLowerCase();
8762
+ return WINDOWS_DEVICE_NAMES.has(stem);
8763
+ }
8764
+ function classifyPiPath(path) {
8765
+ if (path === APPEND_SYSTEM) return { outRel: "pi/APPEND_SYSTEM.md", skill: null };
8766
+ if (!path.startsWith(SKILLS_PREFIX)) return null;
8767
+ const segments = path.slice(SKILLS_PREFIX.length).split("/");
8768
+ if (segments.length < 2 || segments.length > 1 + PI_LIMITS.maxTailSegments) return null;
8769
+ const [name, ...tail] = segments;
8770
+ if (!SKILL_NAME_RE.test(name)) return null;
8771
+ for (const seg of tail) {
8772
+ if (!PI_SEGMENT_RE.test(seg)) return null;
8773
+ if (isWindowsDeviceName(seg)) return null;
8774
+ }
8775
+ const outRel = ["pi", "skills", name, ...tail].join("/");
8776
+ if (outRel.length > PI_LIMITS.maxOutRelChars) return null;
8777
+ return { outRel, skill: name };
8778
+ }
8779
+ function selectEntries(lsTreeZ) {
8780
+ const entries = [];
8781
+ let skipped = 0;
8782
+ for (const record of lsTreeZ.split("\0")) {
8783
+ if (!record) continue;
8784
+ const tab = record.indexOf(" ");
8785
+ if (tab === -1) continue;
8786
+ const [mode, type, oid, sizeField] = record.slice(0, tab).split(/\s+/);
8787
+ const path = record.slice(tab + 1);
8788
+ if (mode !== "100644" || type !== "blob") {
8789
+ skipped++;
8790
+ continue;
8791
+ }
8792
+ const size = Number(sizeField);
8793
+ if (!Number.isSafeInteger(size) || size < 0) {
8794
+ throw new Error(`git ls-tree: unreadable object size (is -l still in the argv?) for ${type} ${mode} ${oid}`);
8795
+ }
8796
+ const classified = classifyPiPath(path);
8797
+ if (!classified) {
8798
+ skipped++;
8799
+ continue;
8800
+ }
8801
+ entries.push({ oid, path, size, outRel: classified.outRel, skill: classified.skill });
8802
+ }
8803
+ return { entries, skipped };
8804
+ }
8805
+ function keepOnlyDeclaredSkills(entries) {
8806
+ const declared = /* @__PURE__ */ new Set();
8807
+ for (const e of entries) {
8808
+ if (e.skill !== null && e.outRel.endsWith(`/${SKILL_FILE}`)) declared.add(e.skill);
8809
+ }
8810
+ const kept = entries.filter((e) => e.skill === null || declared.has(e.skill));
8811
+ return { kept, skipped: entries.length - kept.length };
8812
+ }
8813
+ var exec2, PI_DIR, APPEND_SYSTEM, SKILLS_PREFIX, SKILL_FILE, PI_LIMITS, LS_TREE_MAX_BYTES, PI_SEGMENT_RE, WINDOWS_DEVICE_NAMES;
8814
+ var init_materialize = __esm({
8815
+ "../worker/src/materialize.mjs"() {
8816
+ init_flow_gate();
8817
+ exec2 = promisify3(execFile3);
8818
+ PI_DIR = ".pi";
8819
+ APPEND_SYSTEM = `${PI_DIR}/APPEND_SYSTEM.md`;
8820
+ SKILLS_PREFIX = `${PI_DIR}/skills/`;
8821
+ SKILL_FILE = "SKILL.md";
8822
+ PI_LIMITS = Object.freeze({
8823
+ maxFiles: 256,
8824
+ // ~50 typical skills; also bounds cat-file spawns per job
8825
+ maxFilesPerSkill: 64,
8826
+ // SKILL.md + references/ + scripts/ + assets/ runs to a handful
8827
+ maxFileBytes: 1 << 20,
8828
+ // 1 MiB: ~250k words of markdown. Larger is a dataset, not a skill.
8829
+ maxTotalBytes: 8 << 20,
8830
+ // 8 MiB: trivial beside the clone, and it bounds host disk under retention
8831
+ maxTailSegments: 4,
8832
+ // references/x.md is 2, scripts/lib/util.sh is 3
8833
+ maxOutRelChars: 200
8834
+ // Windows MAX_PATH is 260 and jobDir is an OS temp path (~40-70)
8835
+ });
8836
+ LS_TREE_MAX_BYTES = 1 << 20;
8837
+ PI_SEGMENT_RE = /^[a-z0-9](?:[a-z0-9_.-]{0,62}[a-z0-9])?$/i;
8838
+ WINDOWS_DEVICE_NAMES = /* @__PURE__ */ new Set([
8839
+ "con",
8840
+ "prn",
8841
+ "aux",
8842
+ "nul",
8843
+ "com1",
8844
+ "com2",
8845
+ "com3",
8846
+ "com4",
8847
+ "com5",
8848
+ "com6",
8849
+ "com7",
8850
+ "com8",
8851
+ "com9",
8852
+ "lpt1",
8853
+ "lpt2",
8854
+ "lpt3",
8855
+ "lpt4",
8856
+ "lpt5",
8857
+ "lpt6",
8858
+ "lpt7",
8859
+ "lpt8",
8860
+ "lpt9"
8861
+ ]);
8862
+ }
8863
+ });
8864
+
8865
+ // src/graph-model.mjs
8866
+ function parseSkillMeta(text) {
8867
+ const empty = { name: null, description: null };
8868
+ if (typeof text !== "string") return empty;
8869
+ const normalized = text.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
8870
+ const block = FRONTMATTER_BLOCK_RE.exec(normalized);
8871
+ if (!block) return empty;
8872
+ return {
8873
+ name: frontmatterValue(block[1], "name"),
8874
+ description: frontmatterValue(block[1], "description")
8875
+ };
8876
+ }
8877
+ function frontmatterValue(block, key) {
8878
+ const m = new RegExp(`^${key}:[ \\t]*(.*)$`, "m").exec(block);
8879
+ if (!m) return null;
8880
+ let value = m[1].trim();
8881
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
8882
+ if (value === "") return null;
8883
+ return value.length > META_VALUE_MAX_CHARS ? `${value.slice(0, META_VALUE_MAX_CHARS)}\u2026` : value;
8884
+ }
8885
+ function findSiblingMentions(text, siblingNames) {
8886
+ if (typeof text !== "string" || !Array.isArray(siblingNames)) return [];
8887
+ const mentions = [];
8888
+ for (const name of siblingNames) {
8889
+ if (typeof name !== "string" || name === "") continue;
8890
+ const re = new RegExp(`(?<![a-z0-9_-])${escapeRegExp(name)}(?![a-z0-9_-])`, "g");
8891
+ let found = false;
8892
+ let strong = false;
8893
+ let match;
8894
+ let occurrences = 0;
8895
+ while ((match = re.exec(text)) !== null && occurrences++ < 32) {
8896
+ found = true;
8897
+ const windowStart = Math.max(0, match.index - CHAIN_VOCAB_RADIUS);
8898
+ const windowEnd = Math.min(text.length, match.index + name.length + CHAIN_VOCAB_RADIUS);
8899
+ if (CHAIN_VOCAB_RE.test(text.slice(windowStart, windowEnd))) {
8900
+ strong = true;
8901
+ break;
8902
+ }
8903
+ }
8904
+ if (found) mentions.push({ name, strong });
8905
+ }
8906
+ return mentions;
8907
+ }
8908
+ function escapeRegExp(s) {
8909
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
8910
+ }
8911
+ function findLoopHints(text) {
8912
+ if (typeof text !== "string") return [];
8913
+ const normalized = text.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
8914
+ const body = normalized.replace(FRONTMATTER_BLOCK_RE, "");
8915
+ const hints = [];
8916
+ const seen = /* @__PURE__ */ new Set();
8917
+ let match;
8918
+ LOOP_HINT_RE.lastIndex = 0;
8919
+ while ((match = LOOP_HINT_RE.exec(body)) !== null && hints.length < LOOP_HINT_MAX) {
8920
+ const hint = match[0].trim();
8921
+ const key = hint.toLowerCase();
8922
+ if (seen.has(key)) continue;
8923
+ seen.add(key);
8924
+ hints.push({ hint });
8925
+ }
8926
+ return hints;
8927
+ }
8928
+ function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, foldersTruncated, forgeRepos, cronStats, runJoin, chainEdges, caps, nowMs } = {}) {
8929
+ const triggerList2 = Array.isArray(triggers?.triggers) ? triggers.triggers : [];
8930
+ const schedulerList = Array.isArray(schedulers) ? schedulers : [];
8931
+ const folders = folderSkills && typeof folderSkills === "object" ? folderSkills : {};
8932
+ const injected = injectedSkills && typeof injectedSkills === "object" ? injectedSkills : {};
8933
+ const statsById = cronStats?.byId && typeof cronStats.byId === "object" ? cronStats.byId : {};
8934
+ const statsByIndex = runJoin?.byIndex && typeof runJoin.byIndex === "object" ? runJoin.byIndex : {};
8935
+ const observed = Array.isArray(chainEdges?.edges) ? chainEdges.edges : [];
8936
+ const model = {
8937
+ folders: [],
8938
+ nodes: [],
8939
+ edges: [],
8940
+ flags: [],
8941
+ caps: {
8942
+ chainDepthMax: Number.isInteger(caps?.chainDepthMax) ? caps.chainDepthMax : null,
8943
+ chainMaxPerJob: Number.isInteger(caps?.chainMaxPerJob) ? caps.chainMaxPerJob : null,
8944
+ sameFolderOnly: true,
8945
+ windowDays: Number.isInteger(caps?.windowDays) ? caps.windowDays : null
8946
+ },
8947
+ meta: {
8948
+ generatedAt: Number.isFinite(nowMs) ? nowMs : null,
8949
+ triggersMissing: triggers?.missing === true,
8950
+ triggersInvalid: typeof triggers?.invalid === "string" ? triggers.invalid : null,
8951
+ unattributedRuns: Number.isInteger(runJoin?.unattributed) ? runJoin.unattributed : 0,
8952
+ chainRefusals: chainEdges?.refusals && typeof chainEdges.refusals === "object" ? chainEdges.refusals : {},
8953
+ truncated: {
8954
+ // collectGraphInputs' cap flag rides its spread into this input (review finding: this was
8955
+ // hardcoded false, so the cap-reached banner could never fire on any surface).
8956
+ folders: foldersTruncated === true,
8957
+ skills: Object.values(folders).some((f) => f?.truncated === true),
8958
+ edges: chainEdges?.truncated === true
8959
+ },
8960
+ droppedObservedEdges: 0,
8961
+ // Injected dirs whose readdir failed (review finding): the OQ-022 badge is the one fact the
8962
+ // injected enumeration exists for, and an unreadable dir must not make it silently vanish.
8963
+ injectedUnreachable: Object.entries(injected).filter(([, r]) => typeof r?.unreachable === "string").map(([dir]) => dir).sort()
8964
+ }
8965
+ };
8966
+ const folderByPath = /* @__PURE__ */ new Map();
8967
+ for (const [path, result] of Object.entries(folders)) {
8968
+ const group = {
8969
+ key: `folder:${path}`,
8970
+ path,
8971
+ label: basenameOf(path),
8972
+ kind: "local",
8973
+ head: typeof result?.head === "string" ? result.head : null,
8974
+ unreachable: typeof result?.unreachable === "string" ? result.unreachable : null,
8975
+ triggerIds: [],
8976
+ skillIds: []
8977
+ };
8978
+ folderByPath.set(path, group);
8979
+ model.folders.push(group);
8980
+ }
8981
+ const forgeGroups = /* @__PURE__ */ new Map();
8982
+ const forgeGroup = (forge) => {
8983
+ const name = typeof forge === "string" && forge !== "" ? forge : "forge";
8984
+ let group = forgeGroups.get(name);
8985
+ if (!group) {
8986
+ const repos = Array.isArray(forgeRepos?.[name]) ? forgeRepos[name] : [];
8987
+ group = { key: `forge:${name}`, path: null, label: name, kind: "forge", head: null, unreachable: "remote-repo", repos, triggerIds: [], skillIds: [] };
8988
+ forgeGroups.set(name, group);
8989
+ model.folders.push(group);
8990
+ }
8991
+ return group;
8992
+ };
8993
+ const skillNode = /* @__PURE__ */ new Map();
8994
+ const addSkill = (group, skill) => {
8995
+ const id = `skill:${group.key}:${skill.name}`;
8996
+ const node = {
8997
+ id,
8998
+ kind: "skill",
8999
+ name: skill.name,
9000
+ folderKey: group.key,
9001
+ isSub: skill.isSub === true,
9002
+ group: skill.isSub === true ? skill.group : null,
9003
+ aiTrigger: skill.aiTrigger === true,
9004
+ meta: skill.meta ?? null,
9005
+ // The prose-loop hints read from the SKILL.md body (findLoopHints): everything a loop does
9006
+ // happens INSIDE this one node's job, which is why consumers group the marker inside the
9007
+ // skill rather than drawing an edge anywhere.
9008
+ loops: Array.isArray(skill.loops) ? skill.loops.filter((l) => typeof l?.hint === "string") : [],
9009
+ unread: skill.unread === true,
9010
+ isFlow: false,
9011
+ // set true when a config edge lands on it
9012
+ mentionedBy: 0
9013
+ };
9014
+ skillNode.set(`${group.key} ${skill.name}`, node);
9015
+ model.nodes.push(node);
9016
+ group.skillIds.push(id);
9017
+ if (node.unread) model.flags.push({ nodeId: id, flag: "unread", detail: "SKILL.md unreadable at enumeration; gate read as closed" });
9018
+ return node;
9019
+ };
9020
+ for (const [path, result] of Object.entries(folders)) {
9021
+ const group = folderByPath.get(path);
9022
+ for (const skill of Array.isArray(result?.skills) ? result.skills : []) {
9023
+ if (typeof skill?.name === "string" && skill.name !== "") addSkill(group, skill);
9024
+ }
9025
+ }
9026
+ for (const [dir, result] of Object.entries(injected)) {
9027
+ for (const skill of Array.isArray(result?.skills) ? result.skills : []) {
9028
+ if (typeof skill?.name !== "string" || skill.name === "") continue;
9029
+ const id = `injected:${dir}:${skill.name}`;
9030
+ model.nodes.push({ id, kind: "injected", name: skill.name, dir, aiTrigger: skill.aiTrigger === true });
9031
+ if (skill.aiTrigger === true) {
9032
+ model.flags.push({ nodeId: id, flag: "injected-ai-trigger", detail: "ai-trigger: allow on an injected skill is a silent no-op (OQ-022)" });
9033
+ }
9034
+ }
9035
+ }
9036
+ const missingNode = (group, name) => {
9037
+ const key = `${group.key} ${name}`;
9038
+ let node = skillNode.get(key);
9039
+ if (node) return node;
9040
+ node = { id: `skill:${group.key}:${name}`, kind: "skill-missing", name, folderKey: group.key, isSub: false, group: null, aiTrigger: false, meta: null, unread: false, isFlow: false, mentionedBy: 0 };
9041
+ skillNode.set(key, node);
9042
+ model.nodes.push(node);
9043
+ group.skillIds.push(node.id);
9044
+ return node;
9045
+ };
9046
+ for (const t of triggerList2) {
9047
+ if (!t || typeof t !== "object" || !Number.isInteger(t.index)) continue;
9048
+ const id = `trigger:${t.index}`;
9049
+ const isCron = t.type === "cron";
9050
+ let group = null;
9051
+ if (isCron) {
9052
+ if (typeof t.folder === "string" && t.folder !== "") {
9053
+ group = folderByPath.get(t.folder) ?? null;
9054
+ if (!group) {
9055
+ group = { key: `folder:${t.folder}`, path: t.folder, label: basenameOf(t.folder), kind: "local", head: null, unreachable: "not-enumerated", triggerIds: [], skillIds: [] };
9056
+ folderByPath.set(t.folder, group);
9057
+ model.folders.push(group);
9058
+ }
9059
+ } else {
9060
+ group = folderByPath.get("") ?? null;
9061
+ if (!group) {
9062
+ group = { key: "folder:(none)", path: null, label: "(no folder)", kind: "local", head: null, unreachable: "no-folder", triggerIds: [], skillIds: [] };
9063
+ folderByPath.set("", group);
9064
+ model.folders.push(group);
9065
+ }
9066
+ }
9067
+ } else {
9068
+ group = forgeGroup(t.forge);
9069
+ }
9070
+ const stats = isCron ? typeof t.id === "string" ? statsById[t.id] : void 0 : statsByIndex[t.index];
9071
+ const sched = isCron ? matchScheduler(schedulerList, t) : null;
9072
+ const node = {
9073
+ id,
9074
+ kind: "trigger",
9075
+ index: t.index,
9076
+ onType: t.type,
9077
+ forge: isCron ? null : t.forge ?? null,
9078
+ cronId: isCron ? t.id ?? null : null,
9079
+ pattern: isCron ? t.pattern ?? null : null,
9080
+ label: triggerMatchLabel(t),
9081
+ flow: t.flow ?? null,
9082
+ replicas: t.replicas ?? null,
9083
+ folderKey: group?.key ?? null,
9084
+ runs: Number.isInteger(stats?.runs) ? stats.runs : 0,
9085
+ lastOutcome: stats?.lastOutcome ?? null,
9086
+ lastEndedAt: stats?.lastEndedAt ?? null,
9087
+ next: sched?.next ?? null,
9088
+ overdueMs: sched?.overdueMs ?? null
9089
+ };
9090
+ model.nodes.push(node);
9091
+ if (group) group.triggerIds.push(id);
9092
+ if (t.type === "pull_request" && Array.isArray(t.action) && t.action.some((a) => a === "opened" || a === "synchronize")) {
9093
+ model.flags.push({ nodeId: id, flag: "pr-spend-loop-risk", detail: "fires on opened/synchronize; a flow that pushes can loop with another bot (OQ-020)" });
9094
+ }
9095
+ if (isCron) model.edges.push({ from: id, to: id, kind: "cron-rearm", label: t.pattern ?? null });
9096
+ if (typeof t.flow === "string" && t.flow !== "") {
9097
+ if (!SKILL_NAME_RE.test(t.flow)) {
9098
+ const target = group ? missingNode(group, clipName(t.flow)) : null;
9099
+ if (target) model.edges.push({ from: id, to: target.id, kind: "config" });
9100
+ model.flags.push({ nodeId: id, flag: "charset-invalid", detail: `run.flow ${JSON.stringify(clipName(t.flow))} fails the skill charset and can never materialise` });
9101
+ continue;
9102
+ }
9103
+ if (isCron && group && group.unreachable === null) {
9104
+ const existing = skillNode.get(`${group.key} ${t.flow}`);
9105
+ if (existing && existing.kind === "skill" && !existing.isSub) {
9106
+ existing.isFlow = true;
9107
+ model.edges.push({ from: id, to: existing.id, kind: "config" });
9108
+ } else {
9109
+ const target = missingNode(group, t.flow);
9110
+ model.edges.push({ from: id, to: target.id, kind: "config" });
9111
+ model.flags.push({ nodeId: id, flag: "no-skill", detail: `.pi/skills/${t.flow}/SKILL.md absent at HEAD` });
9112
+ }
9113
+ } else if (group) {
9114
+ const target = missingNode(group, t.flow);
9115
+ if (target.kind === "skill-missing") target.kind = "skill-unverified";
9116
+ target.isFlow = true;
9117
+ model.edges.push({ from: id, to: target.id, kind: "config" });
9118
+ }
9119
+ }
9120
+ }
9121
+ const localGroups = model.folders.filter((f) => f.kind === "local");
9122
+ for (const edge of observed) {
9123
+ if (typeof edge?.parentFlow !== "string" || typeof edge?.childFlow !== "string") continue;
9124
+ const base = typeof edge.target === "string" && edge.target.startsWith("local:") ? edge.target.slice("local:".length) : null;
9125
+ const matches = base === null ? [] : localGroups.filter((f) => f.label === base);
9126
+ if (matches.length !== 1) {
9127
+ model.meta.droppedObservedEdges++;
9128
+ continue;
9129
+ }
9130
+ const group = matches[0];
9131
+ if (group.unreachable !== null) {
9132
+ model.meta.droppedObservedEdges++;
9133
+ continue;
9134
+ }
9135
+ const from = missingNode(group, edge.parentFlow);
9136
+ const to = missingNode(group, edge.childFlow);
9137
+ model.edges.push({ from: from.id, to: to.id, kind: "observed", count: Number.isInteger(edge.count) ? edge.count : 0, lastEndedAt: edge.lastEndedAt ?? null });
9138
+ }
9139
+ for (const [path, result] of Object.entries(folders)) {
9140
+ const group = folderByPath.get(path);
9141
+ for (const skill of Array.isArray(result?.skills) ? result.skills : []) {
9142
+ if (skill?.isSub === true) continue;
9143
+ const from = skillNode.get(`${group.key} ${skill?.name}`);
9144
+ if (!from) continue;
9145
+ for (const mention of Array.isArray(skill?.mentions) ? skill.mentions : []) {
9146
+ const to = skillNode.get(`${group.key} ${mention?.name}`);
9147
+ if (!to || to.isSub) continue;
9148
+ to.mentionedBy++;
9149
+ model.edges.push({
9150
+ from: from.id,
9151
+ to: to.id,
9152
+ kind: "potential",
9153
+ strong: mention.strong === true,
9154
+ // Eligibility is the exact static half: without ai-trigger: allow on the TARGET, the
9155
+ // outbox gate refuses this edge every time, so a mention alone renders "can never fire".
9156
+ eligible: to.aiTrigger === true
9157
+ });
9158
+ }
9159
+ }
9160
+ }
9161
+ for (const [path] of Object.entries(folders)) {
9162
+ const group = folderByPath.get(path);
9163
+ if (group.unreachable !== null) continue;
9164
+ for (const id of group.skillIds) {
9165
+ const node = model.nodes.find((n) => n.id === id);
9166
+ if (!node || node.kind !== "skill" || node.isSub || node.unread) continue;
9167
+ if (node.isFlow) continue;
9168
+ if (node.aiTrigger) {
9169
+ model.flags.push({ nodeId: id, flag: "ai-reachable-no-trigger", detail: "no trigger names it, but ai-trigger: allow keeps it chain/dispatch_run-reachable" });
9170
+ } else if (node.mentionedBy === 0) {
9171
+ model.flags.push({ nodeId: id, flag: "orphan", detail: "no trigger, no ai-trigger, no mention: dead by every path the system has" });
9172
+ }
9173
+ }
9174
+ }
9175
+ return model;
9176
+ }
9177
+ function triggerMatchLabel(t) {
9178
+ switch (t?.type) {
9179
+ case "cron":
9180
+ return `${t.id ?? "-"} ${t.pattern ?? "-"}`;
9181
+ case "label":
9182
+ return selectorLabel(t);
9183
+ case "comment":
9184
+ return `"${t.phrase ?? "-"}"`;
9185
+ case "pull_request":
9186
+ return `action[${(Array.isArray(t.action) ? t.action : []).join(",")}]`;
9187
+ default:
9188
+ return "(unknown)";
9189
+ }
9190
+ }
9191
+ function selectorLabel(t) {
9192
+ const parts = [];
9193
+ if (Array.isArray(t.any) && t.any.length) parts.push(`any[${t.any.join(",")}]`);
9194
+ if (Array.isArray(t.all) && t.all.length) parts.push(`all[${t.all.join(",")}]`);
9195
+ if (Array.isArray(t.none) && t.none.length) parts.push(`none[${t.none.join(",")}]`);
9196
+ return parts.join(" ") || "(no selector)";
9197
+ }
9198
+ function matchScheduler(schedulers, t) {
9199
+ if (typeof t?.id === "string") {
9200
+ const byId = schedulers.find((s) => s?.key === t.id || s?.name === t.id);
9201
+ if (byId) return byId;
9202
+ }
9203
+ return typeof t?.pattern === "string" ? schedulers.find((s) => s?.pattern === t.pattern) ?? null : null;
9204
+ }
9205
+ function basenameOf(path) {
9206
+ const trimmed = String(path).replace(/[\\/]+$/, "");
9207
+ const at = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
9208
+ return at === -1 ? trimmed : trimmed.slice(at + 1);
9209
+ }
9210
+ function clipName(name) {
9211
+ const s = String(name);
9212
+ return s.length > 64 ? `${s.slice(0, 64)}\u2026` : s;
9213
+ }
9214
+ var FRONTMATTER_BLOCK_RE, META_VALUE_MAX_CHARS, CHAIN_VOCAB_RE, CHAIN_VOCAB_RADIUS, LOOP_HINT_RE, LOOP_HINT_MAX, GRAPH_EDGE_KINDS, GRAPH_FLAGS;
9215
+ var init_graph_model = __esm({
9216
+ "src/graph-model.mjs"() {
9217
+ init_flow_gate();
9218
+ FRONTMATTER_BLOCK_RE = /^---\n([\s\S]*?)\n---(?:\n|$)/;
9219
+ META_VALUE_MAX_CHARS = 120;
9220
+ CHAIN_VOCAB_RE = /outbox|request-|"flow"|\bchain\b|follow-up/i;
9221
+ CHAIN_VOCAB_RADIUS = 200;
9222
+ LOOP_HINT_RE = /\b(?:until|repeat(?:ing)?|iterate(?:s|d)?|loop(?:s|ing)?|while|for each|keep \w+ing|try again)\b[^.\n]{0,60}/gi;
9223
+ LOOP_HINT_MAX = 3;
9224
+ GRAPH_EDGE_KINDS = Object.freeze(["config", "observed", "potential", "cron-rearm"]);
9225
+ GRAPH_FLAGS = Object.freeze([
9226
+ "no-skill",
9227
+ // config edge target absent at HEAD in an ENUMERATED folder (readFlowGate's precise token)
9228
+ "charset-invalid",
9229
+ // run.flow fails SKILL_NAME_RE: can never materialise, a distinct defect from no-skill
9230
+ "orphan",
9231
+ // no trigger, no ai-trigger, no incoming mention: dead by every path the system has
9232
+ "ai-reachable-no-trigger",
9233
+ // no trigger but ai-trigger: allow -- deliberately chain/dispatch_run-reachable
9234
+ "injected-ai-trigger",
9235
+ // an injected skill carrying ai-trigger: allow is a silent no-op (OQ-022)
9236
+ "unread",
9237
+ // SKILL.md unreadable/oversized at enumeration: facts unknown, gate read as closed
9238
+ "pr-spend-loop-risk"
9239
+ // pull_request on opened/synchronize: the OQ-020 cross-actor spend-loop signature
9240
+ ]);
9241
+ }
9242
+ });
9243
+
8650
9244
  // src/read-model.mjs
8651
9245
  import * as nodeFs2 from "node:fs";
8652
9246
  import { join as join2, delimiter, sep } from "node:path";
@@ -8688,7 +9282,18 @@ function resolvePaths(env = process.env) {
8688
9282
  dispatchRunPerHour: parseNonNegInt(env.PI_DISPATCH_RUN_PER_HOUR, 3),
8689
9283
  // The per-scheduler stall threshold, mirrored from the worker's PI_SCHEDULER_STALL_MAX (default 2) so the
8690
9284
  // cron drill-in can show `stalls n/threshold`. Read directly from env for the same reason as above.
8691
- schedulerStallMax: parseNonNegInt(env.PI_SCHEDULER_STALL_MAX, 2)
9285
+ schedulerStallMax: parseNonNegInt(env.PI_SCHEDULER_STALL_MAX, 2),
9286
+ // The chain caps the graph states (issue #54), on the schedulerStallMax pattern: env read directly
9287
+ // (never loadConfig), DEFAULTS imported from the worker so there is no second literal to drift --
9288
+ // a graph printing "depth <= 1" while the worker enforces 2 would be the exact dishonesty the
9289
+ // GRAPH view exists to remove.
9290
+ chainDepthMax: parseNonNegInt(env.PI_CHAIN_DEPTH_MAX, CHAIN_DEPTH_MAX_DEFAULT),
9291
+ chainMaxPerJob: parseNonNegInt(env.PI_CHAIN_MAX_PER_JOB, CHAIN_MAX_PER_JOB_DEFAULT),
9292
+ // Where the graph HTML artifact lands (issue #54): the worker's own temp-dir default, imported
9293
+ // like defaultLogsDir/defaultSandboxDir above, so the admin and any future worker consumer agree
9294
+ // on the path without loadConfig. Deliberately NOT logsDir -- that directory's filename shape is
9295
+ // contract (INT-RUN-HISTORY-FILE-CONTRACT).
9296
+ graphDir: env.PI_GRAPH_DIR || defaultGraphDir(env)
8692
9297
  };
8693
9298
  }
8694
9299
  function parseNonNegInt(raw, fallback) {
@@ -8725,9 +9330,9 @@ async function setQueuePaused({ url, paused, makeQueueFn = makeQueue, parseConne
8725
9330
  });
8726
9331
  }
8727
9332
  }
8728
- function revParseHead(folder, { exec: exec3 = execFileSync2 } = {}) {
9333
+ function revParseHead(folder, { exec: exec4 = execFileSync2 } = {}) {
8729
9334
  try {
8730
- const out = exec3("git", ["-C", folder, "rev-parse", "HEAD"], { encoding: "utf8" });
9335
+ const out = exec4("git", ["-C", folder, "rev-parse", "HEAD"], { encoding: "utf8" });
8731
9336
  const sha = out.trim();
8732
9337
  return sha.length > 0 ? sha : null;
8733
9338
  } catch {
@@ -9042,11 +9647,11 @@ function readTriggers({ triggersPath, fs = nodeFs2 }) {
9042
9647
  return { invalid: 'triggers file must have a "triggers" array' };
9043
9648
  }
9044
9649
  const triggers = [];
9045
- for (const entry of entries) {
9650
+ entries.forEach((entry, index) => {
9046
9651
  const display = normalizeTriggerForDisplay(entry);
9047
- if (display) triggers.push(display);
9048
- }
9049
- return { triggers };
9652
+ if (display) triggers.push({ ...display, index });
9653
+ });
9654
+ return { triggers, count: entries.length };
9050
9655
  }
9051
9656
  function normalizeTriggerForDisplay(entry) {
9052
9657
  if (entry === null || typeof entry !== "object") return null;
@@ -9056,6 +9661,8 @@ function normalizeTriggerForDisplay(entry) {
9056
9661
  const flow = typeof run.flow === "string" ? run.flow : null;
9057
9662
  const packages = run.packages !== false;
9058
9663
  const image = typeof run.image === "string" && run.image.trim() !== "" ? run.image : null;
9664
+ const skillsDir = typeof run.skillsDir === "string" && run.skillsDir.trim() !== "" ? run.skillsDir : null;
9665
+ const instructions = typeof run.instructions === "string" && run.instructions.trim() !== "";
9059
9666
  const resume = run.resume === true;
9060
9667
  const forge = typeof run.kind === "string" && run.kind.trim() !== "" ? run.kind : null;
9061
9668
  const replicas = Number.isInteger(run.replicas) && run.replicas > 1 ? run.replicas : null;
@@ -9072,12 +9679,14 @@ function normalizeTriggerForDisplay(entry) {
9072
9679
  model: typeof run.model === "string" ? run.model : null,
9073
9680
  packages,
9074
9681
  image,
9682
+ skillsDir,
9683
+ instructions,
9075
9684
  resume
9076
9685
  };
9077
9686
  case "label":
9078
- return { type: "label", any: normalizeSelector(on.any), all: normalizeSelector(on.all), none: normalizeSelector(on.none), flow, packages, image, resume, replicas, forge };
9687
+ return { type: "label", any: normalizeSelector(on.any), all: normalizeSelector(on.all), none: normalizeSelector(on.none), flow, packages, image, skillsDir, instructions, resume, replicas, forge };
9079
9688
  case "comment":
9080
- return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, packages, image, resume, replicas, forge };
9689
+ return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, packages, image, skillsDir, instructions, resume, replicas, forge };
9081
9690
  case "pull_request":
9082
9691
  return {
9083
9692
  type: "pull_request",
@@ -9088,6 +9697,8 @@ function normalizeTriggerForDisplay(entry) {
9088
9697
  flow,
9089
9698
  packages,
9090
9699
  image,
9700
+ skillsDir,
9701
+ instructions,
9091
9702
  resume,
9092
9703
  replicas,
9093
9704
  forge
@@ -9122,6 +9733,225 @@ function readStagedPackages({ globalPiDir, fs = nodeFs2, readManifest: readManif
9122
9733
  function nameAtVersion(pkg) {
9123
9734
  return typeof pkg.version === "string" && pkg.version !== "" ? `${pkg.name}@${pkg.version}` : pkg.name;
9124
9735
  }
9736
+ function cronRunStats({ records, schedulerIds } = {}) {
9737
+ const byId = {};
9738
+ if (!Array.isArray(records) || !Array.isArray(schedulerIds)) return { byId };
9739
+ for (const id of schedulerIds) {
9740
+ if (typeof id !== "string" || id === "") continue;
9741
+ const re = new RegExp(`^repeat:${escapeRegExp2(id)}:(\\d+)$`);
9742
+ let runs = 0;
9743
+ let lastMillis = -1;
9744
+ let last = null;
9745
+ for (const record of records) {
9746
+ const m = typeof record?.jobId === "string" ? re.exec(record.jobId) : null;
9747
+ if (!m) continue;
9748
+ runs++;
9749
+ const millis = Number(m[1]);
9750
+ if (millis > lastMillis) {
9751
+ lastMillis = millis;
9752
+ last = record;
9753
+ }
9754
+ }
9755
+ byId[id] = {
9756
+ runs,
9757
+ lastOutcome: last?.outcome ?? null,
9758
+ lastEndedAt: last ? last.endedAt ?? last.startedAt ?? null : null
9759
+ };
9760
+ }
9761
+ return { byId };
9762
+ }
9763
+ function joinRunsToTriggers({ records, triggerCount, triggerTypes } = {}) {
9764
+ const byIndex = {};
9765
+ let unattributed = 0;
9766
+ if (!Array.isArray(records)) return { byIndex, unattributed };
9767
+ const count = Number.isInteger(triggerCount) && triggerCount >= 0 ? triggerCount : 0;
9768
+ const types = triggerTypes && typeof triggerTypes === "object" ? triggerTypes : null;
9769
+ for (const record of records) {
9770
+ if (!isForgeKind(record?.kind)) continue;
9771
+ const idx = record?.triggerIndex;
9772
+ if (!Number.isInteger(idx) || idx < 0 || idx >= count) {
9773
+ unattributed++;
9774
+ continue;
9775
+ }
9776
+ if (types && (types[idx] === void 0 || types[idx] !== record.triggerType)) {
9777
+ unattributed++;
9778
+ continue;
9779
+ }
9780
+ const slot = byIndex[idx] ??= { runs: 0, lastOutcome: null, lastEndedAt: null };
9781
+ slot.runs++;
9782
+ const at = record.endedAt ?? record.startedAt ?? null;
9783
+ if (at !== null && (slot.lastEndedAt === null || at > slot.lastEndedAt)) {
9784
+ slot.lastEndedAt = at;
9785
+ slot.lastOutcome = record.outcome ?? null;
9786
+ }
9787
+ }
9788
+ return { byIndex, unattributed };
9789
+ }
9790
+ function observedChainEdges({ records } = {}) {
9791
+ const empty = { edges: [], refusals: {}, truncated: false };
9792
+ if (!Array.isArray(records)) return empty;
9793
+ const byJobId = /* @__PURE__ */ new Map();
9794
+ for (const record of records) {
9795
+ if (typeof record?.jobId === "string" && record.jobId !== "") byJobId.set(record.jobId, record);
9796
+ }
9797
+ const folded = /* @__PURE__ */ new Map();
9798
+ const refusals = {};
9799
+ let truncated = false;
9800
+ for (const child of records) {
9801
+ const parentId = child?.parentJobId;
9802
+ if (typeof parentId !== "string" || parentId === "") continue;
9803
+ const parent = byJobId.get(parentId);
9804
+ if (!parent) continue;
9805
+ if (typeof parent.flow !== "string" || typeof child.flow !== "string") continue;
9806
+ if (parent.target !== child.target) continue;
9807
+ const key = `${parent.flow} ${child.flow} ${parent.target ?? ""}`;
9808
+ let edge = folded.get(key);
9809
+ if (!edge) {
9810
+ if (folded.size >= GRAPH_LIMITS.maxEdges) {
9811
+ truncated = true;
9812
+ continue;
9813
+ }
9814
+ edge = { parentFlow: parent.flow, childFlow: child.flow, target: parent.target ?? null, count: 0, lastEndedAt: null };
9815
+ folded.set(key, edge);
9816
+ }
9817
+ edge.count++;
9818
+ const at = child.endedAt ?? child.startedAt ?? null;
9819
+ if (at !== null && (edge.lastEndedAt === null || at > edge.lastEndedAt)) edge.lastEndedAt = at;
9820
+ }
9821
+ for (const record of records) {
9822
+ if (Number.isInteger(record?.chainRefused) && record.chainRefused > 0 && typeof record?.flow === "string") {
9823
+ const scope = typeof record.target === "string" && record.target.startsWith("local:") ? `${record.target.slice("local:".length)}/${record.flow}` : record.flow;
9824
+ refusals[scope] = (refusals[scope] ?? 0) + record.chainRefused;
9825
+ }
9826
+ }
9827
+ return { edges: [...folded.values()], refusals, truncated };
9828
+ }
9829
+ function readFolderSkills({ folder, exec: exec4 = execFileSync2 } = {}) {
9830
+ const empty = { head: null, skills: [], truncated: false };
9831
+ if (typeof folder !== "string" || folder === "") return { ...empty, unreachable: "no-folder" };
9832
+ const head = revParseHead(folder, { exec: exec4 });
9833
+ if (head === null) return { ...empty, unreachable: "not-a-git-repo" };
9834
+ let listing;
9835
+ try {
9836
+ listing = exec4(
9837
+ "git",
9838
+ ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "--no-pager", "-C", folder, "ls-tree", "-r", "-l", "-z", head, ".pi/"],
9839
+ { encoding: "utf8", maxBuffer: GRAPH_LS_TREE_MAX_BYTES }
9840
+ );
9841
+ } catch {
9842
+ return { head, skills: [], truncated: false, unreachable: "ls-tree-failed" };
9843
+ }
9844
+ let kept;
9845
+ try {
9846
+ const selected = selectEntries(listing);
9847
+ kept = keepOnlyDeclaredSkills(selected.entries).kept;
9848
+ } catch {
9849
+ return { head, skills: [], truncated: false, unreachable: "listing-unparseable" };
9850
+ }
9851
+ const topLevel = /* @__PURE__ */ new Map();
9852
+ const subs = [];
9853
+ for (const entry of kept) {
9854
+ if (entry.skill === null || !entry.outRel.endsWith("/SKILL.md")) continue;
9855
+ if (entry.outRel === `pi/skills/${entry.skill}/SKILL.md`) {
9856
+ topLevel.set(entry.skill, entry);
9857
+ } else {
9858
+ subs.push({ name: entry.outRel.slice("pi/skills/".length, -"/SKILL.md".length), group: entry.skill });
9859
+ }
9860
+ }
9861
+ const names = [...topLevel.keys()].sort();
9862
+ const truncated = names.length > GRAPH_LIMITS.maxSkillsPerFolder;
9863
+ const keptNames = names.slice(0, GRAPH_LIMITS.maxSkillsPerFolder);
9864
+ const skills = [];
9865
+ let anyUnread = false;
9866
+ for (const name of keptNames) {
9867
+ const entry = topLevel.get(name);
9868
+ let text = null;
9869
+ try {
9870
+ const buf = exec4(
9871
+ "git",
9872
+ ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "--no-pager", "-C", folder, "cat-file", "blob", entry.oid],
9873
+ { maxBuffer: GRAPH_LIMITS.maxSkillBytes }
9874
+ );
9875
+ text = buf.toString("utf8");
9876
+ } catch {
9877
+ anyUnread = true;
9878
+ }
9879
+ skills.push({
9880
+ name,
9881
+ isSub: false,
9882
+ group: null,
9883
+ // Fail-closed like the gate: an unreadable SKILL.md reads as not-chainable, never as chainable.
9884
+ aiTrigger: text !== null && aiTriggerAllows(text),
9885
+ meta: text !== null ? parseSkillMeta(text) : null,
9886
+ mentions: text !== null ? findSiblingMentions(text.slice(0, GRAPH_LIMITS.maxMentionScanBytes), keptNames.filter((n) => n !== name)) : [],
9887
+ // The prose-loop hints (issue #54's grouped-inside-the-skill visual): scanned over the same
9888
+ // bounded window as the mentions, because both are text evidence of the same trust class.
9889
+ loops: text !== null ? findLoopHints(text.slice(0, GRAPH_LIMITS.maxMentionScanBytes)) : [],
9890
+ unread: text === null
9891
+ });
9892
+ }
9893
+ for (const sub of subs.sort((a, b) => a.name < b.name ? -1 : 1)) {
9894
+ skills.push({ name: sub.name, isSub: true, group: sub.group, aiTrigger: false, meta: null, mentions: [], unread: false });
9895
+ }
9896
+ return { head, skills, truncated: truncated || anyUnread, unreachable: null };
9897
+ }
9898
+ function readInjectedSkills({ skillsDir, fs = nodeFs2 } = {}) {
9899
+ if (typeof skillsDir !== "string" || skillsDir === "") return { skills: [], truncated: false, unreachable: null };
9900
+ let names;
9901
+ try {
9902
+ names = fs.readdirSync(skillsDir);
9903
+ } catch {
9904
+ return { skills: [], truncated: false, unreachable: "unreadable" };
9905
+ }
9906
+ const valid = names.filter((n) => SKILL_NAME_RE.test(n)).sort();
9907
+ const truncated = valid.length > GRAPH_LIMITS.maxSkillsPerFolder;
9908
+ const skills = [];
9909
+ for (const name of valid.slice(0, GRAPH_LIMITS.maxSkillsPerFolder)) {
9910
+ let aiTrigger = false;
9911
+ try {
9912
+ const text = fs.readFileSync(join2(skillsDir, name, "SKILL.md"), "utf8");
9913
+ aiTrigger = aiTriggerAllows(text);
9914
+ } catch {
9915
+ continue;
9916
+ }
9917
+ skills.push({ name, aiTrigger });
9918
+ }
9919
+ return { skills, truncated, unreachable: null };
9920
+ }
9921
+ function collectGraphInputs({ triggers, readFolder = readFolderSkills, readInjected = readInjectedSkills } = {}) {
9922
+ const out = { folderSkills: {}, injectedSkills: {}, foldersTruncated: false };
9923
+ if (!Array.isArray(triggers)) return out;
9924
+ const folders = [];
9925
+ const injectedDirs = [];
9926
+ for (const t of triggers) {
9927
+ if (t?.type === "cron" && typeof t.folder === "string" && t.folder !== "" && !folders.includes(t.folder)) folders.push(t.folder);
9928
+ if (typeof t?.skillsDir === "string" && t.skillsDir !== "" && !injectedDirs.includes(t.skillsDir)) injectedDirs.push(t.skillsDir);
9929
+ }
9930
+ out.foldersTruncated = folders.length > GRAPH_LIMITS.maxFoldersScanned;
9931
+ for (const folder of folders.slice(0, GRAPH_LIMITS.maxFoldersScanned)) {
9932
+ out.folderSkills[folder] = readFolder({ folder });
9933
+ }
9934
+ for (const dir of injectedDirs.slice(0, GRAPH_LIMITS.maxFoldersScanned)) {
9935
+ out.injectedSkills[dir] = readInjected({ skillsDir: dir });
9936
+ }
9937
+ return out;
9938
+ }
9939
+ function forgeRepoTargets({ records } = {}) {
9940
+ const byKind = {};
9941
+ if (!Array.isArray(records)) return byKind;
9942
+ for (const record of records) {
9943
+ if (!isForgeKind(record?.kind) || typeof record?.target !== "string") continue;
9944
+ const repo = record.target.replace(/[#!]\d+$/, "");
9945
+ if (repo === "" || repo === record.target) continue;
9946
+ (byKind[record.kind] ??= /* @__PURE__ */ new Set()).add(repo);
9947
+ }
9948
+ return Object.fromEntries(
9949
+ Object.entries(byKind).map(([kind, set]) => [kind, [...set].sort().slice(0, GRAPH_LIMITS.maxReposListed)])
9950
+ );
9951
+ }
9952
+ function escapeRegExp2(s) {
9953
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
9954
+ }
9125
9955
  function listRunIds({ logsDir, fs = nodeFs2 }) {
9126
9956
  let names;
9127
9957
  try {
@@ -9160,7 +9990,7 @@ function clampLines(lines) {
9160
9990
  const n = Number.isFinite(lines) ? Math.floor(lines) : 200;
9161
9991
  return Math.min(2e3, Math.max(1, n));
9162
9992
  }
9163
- var DISPATCH_RUN_TTL_SECONDS, SCAN_WINDOW_MAX_DAYS;
9993
+ var DISPATCH_RUN_TTL_SECONDS, SCAN_WINDOW_MAX_DAYS, GRAPH_LIMITS, GRAPH_LS_TREE_MAX_BYTES;
9164
9994
  var init_read_model = __esm({
9165
9995
  "src/read-model.mjs"() {
9166
9996
  init_config2();
@@ -9175,8 +10005,1058 @@ var init_read_model = __esm({
9175
10005
  init_flow_gate();
9176
10006
  init_git_dirty();
9177
10007
  init_packages();
10008
+ init_materialize();
10009
+ init_forges();
10010
+ init_graph_model();
9178
10011
  DISPATCH_RUN_TTL_SECONDS = 2 * 60 * 60;
9179
10012
  SCAN_WINDOW_MAX_DAYS = 92;
10013
+ GRAPH_LIMITS = Object.freeze({
10014
+ maxFoldersScanned: 16,
10015
+ // distinct cron folders enumerated per graph build; git spawns are the cost
10016
+ maxSkillsPerFolder: 64,
10017
+ // cat-file spawns per folder; mirrors PI_LIMITS.maxFilesPerSkill's altitude
10018
+ maxSkillBytes: 64 * 1024,
10019
+ // one SKILL.md read cap; frontmatter + prose, never a dataset
10020
+ maxMentionScanBytes: 32 * 1024,
10021
+ // sibling-name scan window into each SKILL.md
10022
+ maxEdges: 200,
10023
+ // distinct observed chain edges kept; beyond this the graph says "truncated"
10024
+ windowDays: 30,
10025
+ // run-record window the graph folds; matches the default PI_LOG_RETENTION_DAYS
10026
+ maxReposListed: 5
10027
+ // repos named per forge group, from record targets; a scope label, not an inventory
10028
+ });
10029
+ GRAPH_LS_TREE_MAX_BYTES = 1 << 20;
10030
+ }
10031
+ });
10032
+
10033
+ // src/graph-html.mjs
10034
+ function escapeHtml(s) {
10035
+ return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
10036
+ }
10037
+ function embedJson(value) {
10038
+ return JSON.stringify(value).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
10039
+ }
10040
+ function fmt(v) {
10041
+ const n = Number.isFinite(v) ? Math.round(v * 10) / 10 : 0;
10042
+ return String(n);
10043
+ }
10044
+ function finOr(v, fallback) {
10045
+ return Number.isFinite(v) ? v : fallback;
10046
+ }
10047
+ function intOr(v, fallback) {
10048
+ return Number.isInteger(v) ? v : fallback;
10049
+ }
10050
+ function strOr(v, fallback) {
10051
+ return typeof v === "string" && v !== "" ? v : fallback;
10052
+ }
10053
+ function clip(s, max) {
10054
+ const t = String(s);
10055
+ return t.length > max ? `${t.slice(0, max)}\u2026` : t;
10056
+ }
10057
+ function normalizeModel(model) {
10058
+ const ok = model !== null && typeof model === "object";
10059
+ const m = ok ? model : {};
10060
+ const caps = {
10061
+ chainDepthMax: intOr(m.caps?.chainDepthMax, null),
10062
+ chainMaxPerJob: intOr(m.caps?.chainMaxPerJob, null),
10063
+ sameFolderOnly: m.caps?.sameFolderOnly !== false,
10064
+ windowDays: intOr(m.caps?.windowDays, null)
10065
+ };
10066
+ const meta = {
10067
+ generatedAt: finOr(m.meta?.generatedAt, null),
10068
+ triggersMissing: m.meta?.triggersMissing === true,
10069
+ triggersInvalid: typeof m.meta?.triggersInvalid === "string" ? m.meta.triggersInvalid : null,
10070
+ unattributedRuns: intOr(m.meta?.unattributedRuns, 0),
10071
+ droppedObservedEdges: intOr(m.meta?.droppedObservedEdges, 0),
10072
+ truncated: {
10073
+ folders: m.meta?.truncated?.folders === true,
10074
+ skills: m.meta?.truncated?.skills === true,
10075
+ edges: m.meta?.truncated?.edges === true
10076
+ }
10077
+ };
10078
+ const folders = [];
10079
+ for (const f of Array.isArray(m.folders) ? m.folders : []) {
10080
+ if (!f || typeof f !== "object" || typeof f.key !== "string") continue;
10081
+ folders.push({
10082
+ key: f.key,
10083
+ label: strOr(f.label, "(folder)"),
10084
+ // The absolute host path rides the model but is RENDERED only under { fullPaths: true } (an
10085
+ // operator opt-in); the default page stays basename-only, so a shared screenshot or artifact
10086
+ // cannot leak home-directory names -- the canary test pins the default.
10087
+ path: typeof f.path === "string" ? f.path : null,
10088
+ kind: f.kind === "forge" ? "forge" : "local",
10089
+ head: typeof f.head === "string" ? f.head : null,
10090
+ unreachable: typeof f.unreachable === "string" ? f.unreachable : null,
10091
+ // Record-derived scope for forge groups ("which repos is this group even about"); already
10092
+ // id-only repo#n-style strings upstream, clipped and capped again here anyway.
10093
+ repos: Array.isArray(f.repos) ? f.repos.slice(0, 5).filter((r) => typeof r === "string").map((r) => clip(r, 60)) : []
10094
+ });
10095
+ }
10096
+ folders.sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
10097
+ const nodes = [];
10098
+ const seen = /* @__PURE__ */ new Set();
10099
+ for (const n of Array.isArray(m.nodes) ? m.nodes : []) {
10100
+ if (!n || typeof n !== "object" || typeof n.id !== "string" || seen.has(n.id)) continue;
10101
+ seen.add(n.id);
10102
+ nodes.push({
10103
+ id: n.id,
10104
+ kind: typeof n.kind === "string" ? n.kind : "skill",
10105
+ name: typeof n.name === "string" ? n.name : null,
10106
+ label: typeof n.label === "string" ? n.label : null,
10107
+ onType: typeof n.onType === "string" ? n.onType : null,
10108
+ pattern: typeof n.pattern === "string" ? n.pattern : null,
10109
+ flow: typeof n.flow === "string" ? n.flow : null,
10110
+ replicas: intOr(n.replicas, null),
10111
+ folderKey: typeof n.folderKey === "string" ? n.folderKey : null,
10112
+ runs: intOr(n.runs, 0),
10113
+ lastOutcome: typeof n.lastOutcome === "string" ? n.lastOutcome : null,
10114
+ lastEndedAt: typeof n.lastEndedAt === "string" || Number.isFinite(n.lastEndedAt) ? n.lastEndedAt : null,
10115
+ isSub: n.isSub === true,
10116
+ group: typeof n.group === "string" ? n.group : null,
10117
+ // Prose-loop hints from the SKILL.md body; capped and clipped so a hostile skill cannot
10118
+ // inflate its own group box into a page-filling banner.
10119
+ loops: Array.isArray(n.loops) ? n.loops.slice(0, 3).flatMap((l) => typeof l?.hint === "string" && l.hint !== "" ? [{ hint: clip(l.hint, 80) }] : []) : [],
10120
+ aiTrigger: n.aiTrigger === true,
10121
+ unread: n.unread === true,
10122
+ description: typeof n.meta?.description === "string" ? clip(n.meta.description, 120) : null
10123
+ });
10124
+ }
10125
+ nodes.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
10126
+ const edges = [];
10127
+ for (const e of Array.isArray(m.edges) ? m.edges : []) {
10128
+ if (!e || typeof e !== "object") continue;
10129
+ if (typeof e.from !== "string" || typeof e.to !== "string") continue;
10130
+ if (!GRAPH_HTML_KINDS.includes(e.kind)) continue;
10131
+ edges.push({
10132
+ from: e.from,
10133
+ to: e.to,
10134
+ kind: e.kind,
10135
+ count: intOr(e.count, null),
10136
+ strong: e.strong === true,
10137
+ eligible: e.eligible === true,
10138
+ label: typeof e.label === "string" ? e.label : null
10139
+ });
10140
+ }
10141
+ edges.sort((a, b) => cmpStr(a.kind, b.kind) || cmpStr(a.from, b.from) || cmpStr(a.to, b.to) || cmpStr(a.label ?? "", b.label ?? "") || (a.count ?? -1) - (b.count ?? -1) || (a.strong ? 1 : 0) - (b.strong ? 1 : 0));
10142
+ const flags = [];
10143
+ for (const f of Array.isArray(m.flags) ? m.flags : []) {
10144
+ if (!f || typeof f !== "object" || typeof f.nodeId !== "string" || typeof f.flag !== "string") continue;
10145
+ flags.push({ nodeId: f.nodeId, flag: f.flag, detail: typeof f.detail === "string" ? clip(f.detail, 160) : null });
10146
+ }
10147
+ flags.sort((a, b) => cmpStr(a.nodeId, b.nodeId) || cmpStr(a.flag, b.flag) || cmpStr(a.detail ?? "", b.detail ?? ""));
10148
+ return { ok, folders, nodes, edges, flags, caps, meta };
10149
+ }
10150
+ function cmpStr(a, b) {
10151
+ return a < b ? -1 : a > b ? 1 : 0;
10152
+ }
10153
+ function chipLabel(n) {
10154
+ if (n.kind === "trigger") return clip(n.label ?? n.onType ?? "trigger", CHIP_MAX_CHARS);
10155
+ return clip(n.name ?? "(unnamed)", CHIP_MAX_CHARS);
10156
+ }
10157
+ function chipWidth(label) {
10158
+ const raw = LABEL_X + label.length * CHAR_W + 12;
10159
+ const snapped = Math.ceil(raw / GRID) * GRID;
10160
+ return Math.min(NODE_MAX_W, Math.max(NODE_MIN_W, snapped));
10161
+ }
10162
+ function layoutNormalized(norm) {
10163
+ const empty = { nodes: [], wires: [], groups: [], skillGroups: [], viewBox: { x: 0, y: 0, w: 800, h: 600 } };
10164
+ if (!norm.ok && norm.nodes.length === 0) return empty;
10165
+ const groups = [];
10166
+ const groupIndexByKey = /* @__PURE__ */ new Map();
10167
+ const addGroup = (key, label, kind, head, unreachable, path, repos) => {
10168
+ const g = { id: `g${groups.length}`, label, kind, head, unreachable, path: path ?? null, repos: repos ?? [], members: [], x: 0, y: 0, w: 0, h: 0 };
10169
+ groups.push(g);
10170
+ groupIndexByKey.set(key, g);
10171
+ return g;
10172
+ };
10173
+ for (const f of norm.folders) addGroup(f.key, f.label, f.kind, f.head, f.unreachable, f.path, f.repos);
10174
+ const placed = [];
10175
+ const placedByOrig = /* @__PURE__ */ new Map();
10176
+ for (const n of norm.nodes) {
10177
+ const label = chipLabel(n);
10178
+ const p = { id: `n${placed.length}`, kind: n.kind, label, x: 0, y: 0, w: chipWidth(label), h: NODE_H, groupId: null, node: n };
10179
+ placed.push(p);
10180
+ placedByOrig.set(n.id, p);
10181
+ let g = n.folderKey === null ? null : groupIndexByKey.get(n.folderKey) ?? null;
10182
+ if (!g && n.kind === "injected") g = groupIndexByKey.get("~injected") ?? addGroup("~injected", "injected skills (run.skillsDir)", "local", null, null);
10183
+ if (!g) g = groupIndexByKey.get(n.folderKey ?? "~ungrouped") ?? addGroup(n.folderKey ?? "~ungrouped", "ungrouped", "local", null, null);
10184
+ g.members.push(p);
10185
+ p.groupId = g.id;
10186
+ }
10187
+ const wires = [];
10188
+ for (const e of norm.edges) {
10189
+ const f = placedByOrig.get(e.from);
10190
+ const t = placedByOrig.get(e.to);
10191
+ if (!f || !t) continue;
10192
+ wires.push({ id: `w${wires.length}`, kind: e.kind, from: f.id, to: t.id, self: f === t, back: false, edge: e, f, t, d: "", labelX: 0, labelY: 0 });
10193
+ }
10194
+ markBackEdges(placed, wires);
10195
+ const skillGroups = [];
10196
+ let groupY = 0;
10197
+ let maxGroupW = 0;
10198
+ for (const g of groups) {
10199
+ const sgStart = skillGroups.length;
10200
+ const size = layoutGroup(g, wires, skillGroups);
10201
+ g.x = 0;
10202
+ g.y = groupY;
10203
+ g.w = size.w;
10204
+ g.h = size.h;
10205
+ for (const p of g.members) {
10206
+ p.x += g.x;
10207
+ p.y += g.y;
10208
+ }
10209
+ for (let i = sgStart; i < skillGroups.length; i++) {
10210
+ const sg = skillGroups[i];
10211
+ sg.x += g.x;
10212
+ sg.y += g.y;
10213
+ for (const m of sg.markers) {
10214
+ m.x += g.x;
10215
+ m.y += g.y;
10216
+ m.hintX += g.x;
10217
+ m.hintY += g.y;
10218
+ }
10219
+ sg.points = sg.points.map(([px, py]) => [px + g.x, py + g.y]);
10220
+ sg.d = sg.points.map(([px, py], j) => `${j === 0 ? "M" : "L"} ${fmt(px)} ${fmt(py)}`).join(" ");
10221
+ }
10222
+ groupY += g.h + GROUP_GAP;
10223
+ if (g.w > maxGroupW) maxGroupW = g.w;
10224
+ }
10225
+ const parallelCount = /* @__PURE__ */ new Map();
10226
+ for (const w of wires) {
10227
+ const key = `${w.from} ${w.to}`;
10228
+ w.parallel = parallelCount.get(key) ?? 0;
10229
+ parallelCount.set(key, w.parallel + 1);
10230
+ }
10231
+ for (const w of wires) routeWire(w);
10232
+ for (const w of wires) {
10233
+ delete w.f;
10234
+ delete w.t;
10235
+ }
10236
+ const totalH = groups.length > 0 ? groupY - GROUP_GAP : 0;
10237
+ const viewBox = groups.length > 0 ? { x: -VIEW_MARGIN, y: -VIEW_MARGIN, w: maxGroupW + VIEW_MARGIN * 2, h: totalH + VIEW_MARGIN * 2 } : { x: 0, y: 0, w: 800, h: 600 };
10238
+ return { nodes: placed, wires, groups, skillGroups, viewBox };
10239
+ }
10240
+ function markBackEdges(placed, wires) {
10241
+ const out = /* @__PURE__ */ new Map();
10242
+ for (const w of wires) {
10243
+ if (w.self) continue;
10244
+ if (!out.has(w.from)) out.set(w.from, []);
10245
+ out.get(w.from).push(w);
10246
+ }
10247
+ for (const list of out.values()) list.sort((a, b) => cmpStr(a.to, b.to) || cmpStr(a.id, b.id));
10248
+ const state = /* @__PURE__ */ new Map();
10249
+ for (const p of placed) {
10250
+ if (state.has(p.id)) continue;
10251
+ const stack = [{ id: p.id, i: 0 }];
10252
+ state.set(p.id, 1);
10253
+ while (stack.length > 0) {
10254
+ const top = stack[stack.length - 1];
10255
+ const edges = out.get(top.id) ?? [];
10256
+ if (top.i >= edges.length) {
10257
+ state.set(top.id, 2);
10258
+ stack.pop();
10259
+ continue;
10260
+ }
10261
+ const w = edges[top.i++];
10262
+ const s = state.get(w.to);
10263
+ if (s === 1) w.back = true;
10264
+ else if (s !== 2) {
10265
+ state.set(w.to, 1);
10266
+ stack.push({ id: w.to, i: 0 });
10267
+ }
10268
+ }
10269
+ }
10270
+ }
10271
+ function layoutGroup(g, allWires, sgOut) {
10272
+ const members = g.members;
10273
+ if (members.length === 0) return { w: GROUP_PAD_L + NODE_MAX_W + GROUP_PAD_R, h: GROUP_PAD_T + GROUP_PAD_B };
10274
+ const subsByParent = /* @__PURE__ */ new Map();
10275
+ const grid = [];
10276
+ for (const p of members) {
10277
+ if (p.node.isSub && typeof p.node.group === "string") {
10278
+ if (!subsByParent.has(p.node.group)) subsByParent.set(p.node.group, []);
10279
+ subsByParent.get(p.node.group).push(p);
10280
+ } else {
10281
+ grid.push(p);
10282
+ }
10283
+ }
10284
+ const parentByName = /* @__PURE__ */ new Map();
10285
+ for (const p of grid) {
10286
+ if (p.kind === "skill" && p.node.name !== null && !parentByName.has(p.node.name)) parentByName.set(p.node.name, p);
10287
+ }
10288
+ for (const [name, subs] of subsByParent) {
10289
+ if (!parentByName.has(name)) for (const s of subs) grid.push(s);
10290
+ }
10291
+ const supers = /* @__PURE__ */ new Map();
10292
+ for (const p of grid) {
10293
+ if (p.kind !== "skill") continue;
10294
+ const subs = parentByName.get(p.node.name) === p ? subsByParent.get(p.node.name) ?? [] : [];
10295
+ if (p.node.loops.length === 0 && subs.length === 0) continue;
10296
+ supers.set(p.id, computeSkillGroup(p, p.node.loops, subs));
10297
+ }
10298
+ const effW = (p) => supers.has(p.id) ? supers.get(p.id).w : p.w;
10299
+ const effH = (p) => supers.has(p.id) ? supers.get(p.id).h : p.h;
10300
+ const gridSet = new Set(grid.map((p) => p.id));
10301
+ const rankEdges = allWires.filter((w) => !w.self && !w.back && gridSet.has(w.from) && gridSet.has(w.to));
10302
+ const rank = /* @__PURE__ */ new Map();
10303
+ for (const p of grid) rank.set(p.id, p.kind === "trigger" ? 0 : 1);
10304
+ for (let i = 0; i < grid.length; i++) {
10305
+ let changed = false;
10306
+ for (const w of rankEdges) {
10307
+ if (w.t.kind === "trigger") continue;
10308
+ const want = rank.get(w.from) + 1;
10309
+ if (rank.get(w.to) < want) {
10310
+ rank.set(w.to, want);
10311
+ changed = true;
10312
+ }
10313
+ }
10314
+ if (!changed) break;
10315
+ }
10316
+ const maxRank = Math.max(...[...rank.values()]);
10317
+ const rows = [];
10318
+ for (let r = 0; r <= maxRank; r++) rows.push([]);
10319
+ for (const p of grid) rows[rank.get(p.id)].push(p);
10320
+ for (const row of rows) row.sort((a, b) => cmpStr(a.label, b.label) || cmpStr(a.id, b.id));
10321
+ orderRows(rows, rankEdges);
10322
+ const rankMaxW = [];
10323
+ for (let r = 0; r <= maxRank; r++) rankMaxW.push(Math.max(NODE_MAX_W, ...rows[r].map(effW)));
10324
+ const colX = [GROUP_PAD_L];
10325
+ for (let r = 1; r <= maxRank; r++) colX.push(colX[r - 1] + Math.max(RANK_PITCH, rankMaxW[r - 1] + 20));
10326
+ const underIds = /* @__PURE__ */ new Set();
10327
+ for (const w of allWires) {
10328
+ if (w.self && gridSet.has(w.from)) underIds.add(w.from);
10329
+ if (w.back) {
10330
+ if (gridSet.has(w.from)) underIds.add(w.from);
10331
+ if (gridSet.has(w.to)) underIds.add(w.to);
10332
+ }
10333
+ }
10334
+ const maxRows = Math.max(...rows.map((row) => row.length));
10335
+ const rowY = [];
10336
+ let y = GROUP_PAD_T;
10337
+ for (let i = 0; i < maxRows; i++) {
10338
+ rowY.push(y);
10339
+ const band = rows.flatMap((row) => row.length > i ? [row[i]] : []);
10340
+ const bandH = Math.max(NODE_H, ...band.map(effH));
10341
+ const under = band.some((p) => underIds.has(p.id));
10342
+ y += bandH + (under ? UNDER_ROUTE_EXTRA : 0) + ROW_GAP;
10343
+ }
10344
+ for (let r = 0; r <= maxRank; r++) {
10345
+ for (let i = 0; i < rows[r].length; i++) {
10346
+ const p = rows[r][i];
10347
+ if (supers.has(p.id)) {
10348
+ placeSkillGroup(g, p, supers.get(p.id), colX[r], rowY[i], sgOut);
10349
+ } else {
10350
+ p.x = colX[r];
10351
+ p.y = rowY[i];
10352
+ }
10353
+ }
10354
+ }
10355
+ return {
10356
+ w: colX[maxRank] + rankMaxW[maxRank] + GROUP_PAD_R,
10357
+ h: y - ROW_GAP + GROUP_PAD_B
10358
+ };
10359
+ }
10360
+ function computeSkillGroup(p, loops, subs) {
10361
+ let cy = SG_CHIP_Y + NODE_H + 14;
10362
+ const markers = [];
10363
+ for (const l of loops.slice(0, 3)) {
10364
+ markers.push({ x: SG_CHIP_X, y: cy, w: SG_MARKER, h: SG_MARKER, hint: clip(l.hint, SG_HINT_CHARS), hintX: SG_CHIP_X + SG_MARKER + 6, hintY: cy + 24 });
10365
+ cy += SG_MARKER + 8;
10366
+ }
10367
+ const subPlaced = [];
10368
+ for (const s of subs) {
10369
+ const label = clip(s.node.name ?? "(sub)", 16);
10370
+ s.label = label;
10371
+ s.w = Math.min(140, Math.max(80, 12 + label.length * 7));
10372
+ s.h = SUB_CHIP_H;
10373
+ s.nested = true;
10374
+ subPlaced.push({ p: s, x: SG_CHIP_X, y: cy });
10375
+ cy += SUB_CHIP_H + 6;
10376
+ }
10377
+ const wCandidates = [SG_CHIP_X + p.w + 18];
10378
+ if (markers.length > 0) wCandidates.push(SG_CHIP_X + SG_MARKER + 6 + SG_HINT_CHARS * SG_SMALL_CHAR_W + 14);
10379
+ for (const s of subPlaced) wCandidates.push(SG_CHIP_X + s.p.w + 18);
10380
+ const w = Math.max(...wCandidates);
10381
+ const h = cy + 8;
10382
+ const py = SG_CHIP_Y + NODE_H / 2;
10383
+ const points = [
10384
+ [SG_CHIP_X + p.w, py],
10385
+ [w - SG_RING, py],
10386
+ [w - SG_RING, h - SG_RING],
10387
+ [SG_RING, h - SG_RING],
10388
+ [SG_RING, py],
10389
+ [SG_CHIP_X, py]
10390
+ ];
10391
+ return { w, h, markers, subPlaced, points };
10392
+ }
10393
+ function placeSkillGroup(g, p, box2, x, y, sgOut) {
10394
+ p.x = x + SG_CHIP_X;
10395
+ p.y = y + SG_CHIP_Y;
10396
+ const markers = box2.markers.map((m) => ({ ...m, x: m.x + x, y: m.y + y, hintX: m.hintX + x, hintY: m.hintY + y }));
10397
+ for (const s of box2.subPlaced) {
10398
+ s.p.x = x + s.x;
10399
+ s.p.y = y + s.y;
10400
+ }
10401
+ sgOut.push({
10402
+ id: `sg${sgOut.length}`,
10403
+ groupId: g.id,
10404
+ nodeId: p.id,
10405
+ label: p.node.name ?? p.label,
10406
+ x,
10407
+ y,
10408
+ w: box2.w,
10409
+ h: box2.h,
10410
+ markers,
10411
+ subIds: box2.subPlaced.map((s) => s.p.id),
10412
+ points: box2.points.map(([px, py]) => [px + x, py + y]),
10413
+ d: ""
10414
+ });
10415
+ }
10416
+ function orderRows(rows, rankEdges) {
10417
+ const pos = /* @__PURE__ */ new Map();
10418
+ const setPos = (row) => row.forEach((p, i) => pos.set(p.id, i));
10419
+ for (const row of rows) setPos(row);
10420
+ const preds = /* @__PURE__ */ new Map();
10421
+ const succs = /* @__PURE__ */ new Map();
10422
+ for (const w of rankEdges) {
10423
+ if (!preds.has(w.to)) preds.set(w.to, []);
10424
+ preds.get(w.to).push(w.from);
10425
+ if (!succs.has(w.from)) succs.set(w.from, []);
10426
+ succs.get(w.from).push(w.to);
10427
+ }
10428
+ const median = (ids) => {
10429
+ const xs = (ids ?? []).map((id) => pos.get(id)).filter((v) => Number.isInteger(v)).sort((a, b) => a - b);
10430
+ return xs.length === 0 ? null : xs[xs.length - 1 >> 1];
10431
+ };
10432
+ const sweep = (nbrs, indices) => {
10433
+ for (const r of indices) {
10434
+ const keyed = rows[r].map((p, i) => ({ p, key: median(nbrs.get(p.id)) ?? i }));
10435
+ keyed.sort((a, b) => a.key - b.key || cmpStr(a.p.label, b.p.label) || cmpStr(a.p.id, b.p.id));
10436
+ rows[r] = keyed.map((k) => k.p);
10437
+ setPos(rows[r]);
10438
+ }
10439
+ };
10440
+ const down = [];
10441
+ const up = [];
10442
+ for (let r = 1; r < rows.length; r++) down.push(r);
10443
+ for (let r = rows.length - 2; r >= 0; r--) up.push(r);
10444
+ sweep(preds, down);
10445
+ sweep(succs, up);
10446
+ }
10447
+ function routeWire(w) {
10448
+ const f = w.f;
10449
+ const t = w.t;
10450
+ const y1 = f.y + NODE_H / 2;
10451
+ const y2 = t.y + NODE_H / 2;
10452
+ const LOOP_OFF = 18;
10453
+ if (w.self) {
10454
+ const x12 = f.x + f.w;
10455
+ const x22 = f.x;
10456
+ const yb = f.y + NODE_H + SELF_LOOP_DROP + w.parallel * 12;
10457
+ w.d = `M ${fmt(x12)} ${fmt(y1)} C ${fmt(x12 + LOOP_OFF)} ${fmt(y1)}, ${fmt(x12 + LOOP_OFF)} ${fmt(yb)}, ${fmt(x12)} ${fmt(yb)} L ${fmt(x22)} ${fmt(yb)} C ${fmt(x22 - LOOP_OFF)} ${fmt(yb)}, ${fmt(x22 - LOOP_OFF)} ${fmt(y2)}, ${fmt(x22)} ${fmt(y2)}`;
10458
+ w.labelX = f.x + f.w / 2;
10459
+ w.labelY = yb + 12;
10460
+ return;
10461
+ }
10462
+ if (w.back) {
10463
+ const x12 = f.x + f.w;
10464
+ const x22 = t.x;
10465
+ const yb = Math.max(f.y, t.y) + NODE_H + SELF_LOOP_DROP + 10 + w.parallel * 12;
10466
+ w.d = `M ${fmt(x12)} ${fmt(y1)} C ${fmt(x12 + LOOP_OFF)} ${fmt(y1)}, ${fmt(x12 + LOOP_OFF)} ${fmt(yb)}, ${fmt(x12)} ${fmt(yb)} L ${fmt(x22)} ${fmt(yb)} C ${fmt(x22 - LOOP_OFF)} ${fmt(yb)}, ${fmt(x22 - LOOP_OFF)} ${fmt(y2)}, ${fmt(x22)} ${fmt(y2)}`;
10467
+ w.labelX = (x12 + x22) / 2;
10468
+ w.labelY = yb + 12;
10469
+ return;
10470
+ }
10471
+ const x1 = f.x + f.w;
10472
+ const x2 = t.x;
10473
+ const dx = Math.max(x2 - x1, 4);
10474
+ const sc = dx < 100 ? 0.75 * (dx / 100) : 0.75;
10475
+ const off = Math.max(dx * sc, 10);
10476
+ const sign = w.parallel % 2 === 1 ? -1 : 1;
10477
+ const steps = Math.ceil(w.parallel / 2);
10478
+ const bow = sign * steps * PARALLEL_BOW;
10479
+ w.d = `M ${fmt(x1)} ${fmt(y1)} C ${fmt(x1 + off)} ${fmt(y1 + bow)}, ${fmt(x2 - off)} ${fmt(y2 + bow)}, ${fmt(x2)} ${fmt(y2)}`;
10480
+ w.labelX = (x1 + x2) / 2;
10481
+ if (x2 - x1 < LABEL_MIN_SPAN) {
10482
+ w.labelY = Math.min(y1, y2) - 12 - w.parallel * 12;
10483
+ } else {
10484
+ w.labelY = (y1 + y2) / 2 - 5 + sign * steps * PARALLEL_LABEL_STEP;
10485
+ }
10486
+ }
10487
+ function relTime(nowMs, v) {
10488
+ const t = typeof v === "number" ? v : typeof v === "string" ? Date.parse(v) : null;
10489
+ if (!Number.isFinite(t) || !Number.isFinite(nowMs)) return null;
10490
+ const s = Math.max(0, Math.floor((nowMs - t) / 1e3));
10491
+ if (s < 60) return `${s}s ago`;
10492
+ const mn = Math.floor(s / 60);
10493
+ if (mn < 60) return `${mn}m ago`;
10494
+ const h = Math.floor(mn / 60);
10495
+ if (h < 48) return `${h}h ago`;
10496
+ return `${Math.floor(h / 24)}d ago`;
10497
+ }
10498
+ function buildTip(n, flags, groupLabel, nowMs) {
10499
+ const lines = [];
10500
+ if (n.kind === "trigger") {
10501
+ lines.push(`trigger \xB7 ${n.onType ?? "?"} \xB7 ${n.label ?? ""}`.trim());
10502
+ if (n.flow !== null) lines.push(`flow: ${n.flow}${n.replicas !== null ? ` \xD7${n.replicas}` : ""}`);
10503
+ } else if (n.kind === "injected") {
10504
+ lines.push(`injected skill \xB7 ${n.name ?? "?"}`);
10505
+ lines.push("trigger-reachable via run.skillsDir, never AI-reachable");
10506
+ } else if (n.kind === "skill-missing") {
10507
+ lines.push(`skill \xB7 ${n.name ?? "?"} \xB7 missing at HEAD`);
10508
+ } else if (n.kind === "skill-unverified") {
10509
+ lines.push(`skill \xB7 ${n.name ?? "?"} \xB7 unverified (repo not readable from this host)`);
10510
+ } else {
10511
+ lines.push(`skill \xB7 ${n.name ?? "?"}${n.isSub ? " \xB7 sub-skill (never a flow)" : ""}`);
10512
+ }
10513
+ if (groupLabel !== null) lines.push(`folder: ${groupLabel}`);
10514
+ if (n.description !== null) lines.push(n.description);
10515
+ if (n.kind === "trigger") {
10516
+ if (n.runs > 0) {
10517
+ const rel = relTime(nowMs, n.lastEndedAt);
10518
+ lines.push(`runs: ${n.runs}${n.lastOutcome !== null ? ` \xB7 last ${n.lastOutcome}${rel !== null ? ` ${rel}` : ""}` : ""}`);
10519
+ } else {
10520
+ lines.push("no runs in window");
10521
+ }
10522
+ }
10523
+ if (n.aiTrigger) lines.push("chainable: ai-trigger allow");
10524
+ if (n.kind === "skill" && n.loops.length > 0) {
10525
+ lines.push(`loops in skill: ${n.loops.map((l) => l.hint).join(" \xB7 ")}`);
10526
+ }
10527
+ for (const f of flags) lines.push(f.detail !== null ? `${f.flag}: ${f.detail}` : f.flag);
10528
+ return lines.join("\n");
10529
+ }
10530
+ function groupTitle(g, fullPaths) {
10531
+ if (g.kind === "forge") {
10532
+ const scope = Array.isArray(g.repos) && g.repos.length > 0 ? ` \xB7 ran against ${g.repos.join(", ")}` : "";
10533
+ return `${g.label}${scope} \xB7 forge \xB7 unverifiable from this host`;
10534
+ }
10535
+ const name = fullPaths === true && typeof g.path === "string" && g.path !== "" ? g.path : g.label;
10536
+ if (g.unreachable !== null && g.unreachable !== void 0 && g.unreachable !== "") return `${name} \xB7 ${g.unreachable}`;
10537
+ if (g.head !== null && g.head !== void 0 && g.head !== "") return `${name} \xB7 HEAD ${String(g.head).slice(0, 7)}`;
10538
+ return name;
10539
+ }
10540
+ function capsLineText(caps) {
10541
+ const d = caps.chainDepthMax ?? "?";
10542
+ const m = caps.chainMaxPerJob ?? "?";
10543
+ const k = caps.windowDays ?? "?";
10544
+ return `chains: depth \u2264 ${d} \xB7 \u2264 ${m} per job \xB7 same folder only \xB7 window ${k}d`;
10545
+ }
10546
+ function nodeState(n, flagNames) {
10547
+ if (n.kind === "skill-missing" || [...flagNames].some((f) => DANGLING_FLAGS.has(f))) {
10548
+ return { stroke: DANGER, dash: "10,4", faded: false };
10549
+ }
10550
+ if (n.kind === "skill-unverified") return { stroke: PAGE_DIM, dash: "10,4", faded: false };
10551
+ if (flagNames.has("orphan")) return { stroke: CHIP_STROKE, dash: "8,3", faded: true };
10552
+ return { stroke: CHIP_STROKE, dash: null, faded: false };
10553
+ }
10554
+ function chipFill(n) {
10555
+ if (n.kind === "trigger") return CHIP_FILL[n.onType] ?? PORT_FILL;
10556
+ return CHIP_FILL.skill;
10557
+ }
10558
+ function chipGlyph(n) {
10559
+ if (n.kind === "trigger") return GLYPH[n.onType] ?? "\u2022";
10560
+ return GLYPH[n.kind] ?? GLYPH.skill;
10561
+ }
10562
+ function statusColor(outcome) {
10563
+ if (outcome === "completed") return STATUS_COMPLETED;
10564
+ if (outcome === "failed") return STATUS_FAILED;
10565
+ return STATUS_OTHER;
10566
+ }
10567
+ function nodeSvg(p, flags, hasIn, hasOut) {
10568
+ const n = p.node;
10569
+ if (p.nested === true) {
10570
+ return [
10571
+ `<g class="gnode" id="${p.id}" transform="translate(${fmt(p.x)},${fmt(p.y)})">`,
10572
+ `<rect width="${fmt(p.w)}" height="${fmt(p.h)}" rx="4" fill="${CHIP_FILL.skill}" stroke="${CHIP_STROKE}" stroke-width="1"/>`,
10573
+ `<text x="8" y="16" font-size="11" fill="${CHIP_LABEL}">${escapeHtml(p.label)}</text>`,
10574
+ "</g>"
10575
+ ].join("");
10576
+ }
10577
+ const flagNames = new Set(flags.map((f) => f.flag));
10578
+ const state = nodeState(n, flagNames);
10579
+ const parts = [`<g class="gnode" id="${p.id}" transform="translate(${fmt(p.x)},${fmt(p.y)})">`];
10580
+ parts.push(
10581
+ `<rect width="${fmt(p.w)}" height="${fmt(NODE_H)}" rx="5" fill="${chipFill(n)}"${state.faded ? ' fill-opacity=".5"' : ""} stroke="${state.stroke}" stroke-width="1"${state.dash !== null ? ` stroke-dasharray="${state.dash}"` : ""}/>`
10582
+ );
10583
+ parts.push(`<path d="M 30 1 L 30 29" stroke="${CHIP_STROKE}" stroke-width="1" opacity=".5"/>`);
10584
+ parts.push(`<text x="15" y="20" text-anchor="middle" font-size="13" fill="${CHIP_LABEL}">${escapeHtml(chipGlyph(n))}</text>`);
10585
+ parts.push(`<text x="${LABEL_X}" y="20" font-size="14" fill="${CHIP_LABEL}">${escapeHtml(p.label)}</text>`);
10586
+ if (hasIn) parts.push(`<rect x="-5" y="${fmt(NODE_H / 2 - 5)}" width="10" height="10" rx="3" fill="${PORT_FILL}" stroke="${CHIP_STROKE}" stroke-width="1"/>`);
10587
+ if (hasOut) parts.push(`<rect x="${fmt(p.w - 5)}" y="${fmt(NODE_H / 2 - 5)}" width="10" height="10" rx="3" fill="${PORT_FILL}" stroke="${CHIP_STROKE}" stroke-width="1"/>`);
10588
+ if (n.kind === "trigger") {
10589
+ parts.push(`<rect x="3" y="${fmt(NODE_H + 3)}" width="9" height="9" rx="2" fill="${statusColor(n.runs > 0 ? n.lastOutcome : null)}"/>`);
10590
+ parts.push(`<text x="16" y="${fmt(NODE_H + 11)}" font-size="10" fill="${PAGE_DIM}">${n.runs > 0 ? `${fmt(n.runs)} runs` : "no runs"}</text>`);
10591
+ }
10592
+ const orange = [...flagNames].some((f) => ORANGE_FLAGS.has(f));
10593
+ if (orange) parts.push(`<circle cx="${fmt(p.w - 4)}" cy="-2" r="5" fill="${BADGE_ORANGE}"/>`);
10594
+ if (n.aiTrigger) parts.push(`<circle cx="${fmt(p.w - 4 - (orange ? 14 : 0))}" cy="-2" r="4" fill="none" stroke="${BADGE_GREEN}" stroke-width="2"/>`);
10595
+ parts.push("</g>");
10596
+ return parts.join("");
10597
+ }
10598
+ function wireStyle(w) {
10599
+ if (w.kind === "observed") return { stroke: WIRE_OBSERVED, width: 3, dash: null };
10600
+ if (w.kind === "config") return { stroke: WIRE_CONFIG, width: 2, dash: null };
10601
+ if (w.kind === "potential") return { stroke: w.edge.strong ? PAGE_ACCENT : WIRE_POTENTIAL, width: 2, dash: "6,4" };
10602
+ return { stroke: CHIP_FILL.cron, width: 2, dash: "4,3" };
10603
+ }
10604
+ function wireSvg(w) {
10605
+ const s = wireStyle(w);
10606
+ const parts = [`<g class="gwire" id="${w.id}">`];
10607
+ parts.push(`<path d="${w.d}" fill="none" stroke="${s.stroke}" stroke-width="${fmt(s.width)}"${s.dash !== null ? ` stroke-dasharray="${s.dash}"` : ""}/>`);
10608
+ let label = null;
10609
+ let fill = PAGE_DIM;
10610
+ if (w.kind === "observed" && w.edge.count !== null) {
10611
+ label = `(${w.edge.count}\xD7)`;
10612
+ fill = WIRE_OBSERVED;
10613
+ } else if (w.kind === "potential") {
10614
+ label = "mention";
10615
+ fill = w.edge.strong ? PAGE_ACCENT : WIRE_POTENTIAL;
10616
+ } else if (w.kind === "cron-rearm" && w.edge.label !== null) {
10617
+ label = w.edge.label;
10618
+ fill = CHIP_FILL.cron;
10619
+ }
10620
+ if (label !== null) parts.push(`<text x="${fmt(w.labelX)}" y="${fmt(w.labelY)}" text-anchor="middle" font-size="10" fill="${fill}">${escapeHtml(label)}</text>`);
10621
+ parts.push("</g>");
10622
+ return parts.join("");
10623
+ }
10624
+ function groupSvg(g, fullPaths) {
10625
+ const opacity = g.kind === "forge" ? "0.03" : "0.06";
10626
+ return [
10627
+ `<g class="ggroup">`,
10628
+ `<rect x="${fmt(g.x)}" y="${fmt(g.y)}" width="${fmt(g.w)}" height="${fmt(g.h)}" rx="2" fill="${GROUP_FILL}" fill-opacity="${opacity}" stroke="${PAGE_BORDER}" stroke-width="2"/>`,
10629
+ `<text x="${fmt(g.x + 8)}" y="${fmt(g.y + 18)}" font-size="11" fill="${PAGE_DIM}">${escapeHtml(groupTitle(g, fullPaths))}</text>`,
10630
+ `</g>`
10631
+ ].join("");
10632
+ }
10633
+ function skillGroupSvg(sg) {
10634
+ const parts = [`<g class="sgroup" id="${sg.id}">`];
10635
+ parts.push(`<rect x="${fmt(sg.x)}" y="${fmt(sg.y)}" width="${fmt(sg.w)}" height="${fmt(sg.h)}" rx="2" fill="${GROUP_FILL}" fill-opacity="0.08" stroke="${PAGE_BORDER}" stroke-width="2"/>`);
10636
+ parts.push(`<text x="${fmt(sg.x + 8)}" y="${fmt(sg.y + 16)}" font-size="11" fill="${PAGE_DIM}">${escapeHtml(sg.label)}</text>`);
10637
+ parts.push(`<path d="${sg.d}" fill="none" stroke="${WIRE_POTENTIAL}" stroke-width="1.5" stroke-dasharray="4,3"/>`);
10638
+ for (const m of sg.markers) {
10639
+ parts.push(`<rect x="${fmt(m.x)}" y="${fmt(m.y)}" width="${fmt(m.w)}" height="${fmt(m.h)}" rx="6" fill="${CHIP_FILL.skill}" stroke="${CHIP_STROKE}" stroke-width="1"/>`);
10640
+ parts.push(`<text x="${fmt(m.x + m.w / 2)}" y="${fmt(m.y + m.h / 2 + 6)}" text-anchor="middle" font-size="16" fill="${CHIP_LABEL}">\u27F3</text>`);
10641
+ parts.push(`<text x="${fmt(m.hintX)}" y="${fmt(m.hintY)}" font-size="10" fill="${PAGE_DIM}">${escapeHtml(m.hint)}</text>`);
10642
+ }
10643
+ parts.push("</g>");
10644
+ return parts.join("");
10645
+ }
10646
+ function legendSample(stroke, width, dash) {
10647
+ return `<svg width="46" height="10" aria-hidden="true"><path d="M 2 5 C 16 5, 30 5, 44 5" fill="none" stroke="${stroke}" stroke-width="${fmt(width)}"${dash !== null ? ` stroke-dasharray="${dash}"` : ""}/></svg>`;
10648
+ }
10649
+ function legendSwatch(inner) {
10650
+ return `<svg width="16" height="12" aria-hidden="true">${inner}</svg>`;
10651
+ }
10652
+ function legendHtml(norm) {
10653
+ const rows = [];
10654
+ const row = (sample, text) => rows.push(`<div class="row">${sample}<span>${escapeHtml(text)}</span></div>`);
10655
+ rows.push("<h2>edges</h2>");
10656
+ row(legendSample(WIRE_CONFIG, 2, null), "config: the trigger names this flow");
10657
+ row(legendSample(WIRE_OBSERVED, 3, null), "observed \xD7n: chained in run records");
10658
+ row(legendSample(WIRE_POTENTIAL, 2, "6,4"), "potential: a text mention, not a promise");
10659
+ row(legendSample(PAGE_ACCENT, 2, "6,4"), "potential (strong): near chain vocabulary");
10660
+ row(legendSample(CHIP_FILL.cron, 2, "4,3"), "cron re-arm: the schedule itself");
10661
+ rows.push("<h2>badges</h2>");
10662
+ row(legendSwatch(`<circle cx="8" cy="6" r="5" fill="${BADGE_ORANGE}"/>`), "unread / injected ai-trigger / PR spend-loop risk");
10663
+ row(legendSwatch(`<circle cx="8" cy="6" r="4" fill="none" stroke="${BADGE_GREEN}" stroke-width="2"/>`), "chainable: ai-trigger allow");
10664
+ row(legendSwatch(`<rect x="1" y="1" width="14" height="10" rx="2" fill="none" stroke="${CHIP_STROKE}" stroke-dasharray="8,3"/>`), "orphan: no trigger, no ai-trigger, no mention");
10665
+ row(legendSwatch(`<rect x="1" y="1" width="14" height="10" rx="2" fill="none" stroke="${DANGER}" stroke-dasharray="10,4"/>`), "dangling: flow absent or name invalid");
10666
+ row(legendSwatch(`<rect x="1" y="1" width="14" height="10" rx="2" fill="none" stroke="${PAGE_DIM}" stroke-dasharray="10,4"/>`), "unverified: repo not readable from this host");
10667
+ rows.push(`<div class="caps">${escapeHtml(capsLineText(norm.caps))}</div>`);
10668
+ const honesty = [];
10669
+ if (norm.meta.unattributedRuns > 0) honesty.push(`${norm.meta.unattributedRuns} runs unattributed`);
10670
+ if (norm.meta.truncated.folders) honesty.push("folder scan truncated (cap reached)");
10671
+ if (norm.meta.truncated.skills) honesty.push("skill enumeration truncated or partly unread");
10672
+ if (norm.meta.truncated.edges) honesty.push("observed edges truncated (cap reached)");
10673
+ if (norm.meta.droppedObservedEdges > 0) honesty.push(`${norm.meta.droppedObservedEdges} observed edges dropped (no unique folder)`);
10674
+ for (const line of honesty) rows.push(`<div class="honesty">${escapeHtml(line)}</div>`);
10675
+ return `<div id="legend">${rows.join("")}</div>`;
10676
+ }
10677
+ function bannersHtml(norm) {
10678
+ const banners = [];
10679
+ if (!norm.ok) banners.push("no graph model supplied; the page has nothing to draw");
10680
+ if (norm.meta.triggersMissing) banners.push("no triggers file found");
10681
+ if (norm.meta.triggersInvalid !== null) banners.push(`triggers file invalid: ${norm.meta.triggersInvalid}`);
10682
+ if (banners.length === 0) return "";
10683
+ return `<div id="banners">${banners.map((b) => `<div class="banner">${escapeHtml(b)}</div>`).join("")}</div>`;
10684
+ }
10685
+ function buildGraphHtml(model, { now, fullPaths } = {}) {
10686
+ let norm;
10687
+ try {
10688
+ norm = normalizeModel(model);
10689
+ } catch {
10690
+ norm = normalizeModel(null);
10691
+ }
10692
+ const layout = layoutNormalized(norm);
10693
+ const nowMs = Number.isFinite(now) ? now : norm.meta.generatedAt ?? 0;
10694
+ const flagsByOrig = /* @__PURE__ */ new Map();
10695
+ for (const f of norm.flags) {
10696
+ if (!flagsByOrig.has(f.nodeId)) flagsByOrig.set(f.nodeId, []);
10697
+ flagsByOrig.get(f.nodeId).push(f);
10698
+ }
10699
+ const groupById = new Map(layout.groups.map((g) => [g.id, g]));
10700
+ const hasIn = /* @__PURE__ */ new Set();
10701
+ const hasOut = /* @__PURE__ */ new Set();
10702
+ const adj = /* @__PURE__ */ new Map();
10703
+ const touch = (id) => {
10704
+ if (!adj.has(id)) adj.set(id, { nb: /* @__PURE__ */ new Set(), w: /* @__PURE__ */ new Set() });
10705
+ return adj.get(id);
10706
+ };
10707
+ for (const w of layout.wires) {
10708
+ hasOut.add(w.from);
10709
+ hasIn.add(w.to);
10710
+ touch(w.from).nb.add(w.to);
10711
+ touch(w.from).w.add(w.id);
10712
+ touch(w.to).nb.add(w.from);
10713
+ touch(w.to).w.add(w.id);
10714
+ }
10715
+ const graphData = { nodes: {} };
10716
+ const nodeParts = [];
10717
+ for (const p of layout.nodes) {
10718
+ const n = p.node;
10719
+ const flags = norm.nodes.length > 0 ? findFlags(flagsByOrig, norm.nodes, p) : [];
10720
+ const group = groupById.get(p.groupId);
10721
+ const drawIn = hasIn.has(p.id) || n.kind !== "trigger" && n.kind !== "injected";
10722
+ const drawOut = hasOut.has(p.id) || n.kind === "trigger";
10723
+ nodeParts.push(nodeSvg(p, flags, drawIn, drawOut));
10724
+ const a = adj.get(p.id);
10725
+ graphData.nodes[p.id] = {
10726
+ tip: buildTip(n, flags, group ? group.label : null, nowMs),
10727
+ nb: a ? [...a.nb].sort() : [],
10728
+ w: a ? [...a.w].sort() : []
10729
+ };
10730
+ }
10731
+ const svgBody = [
10732
+ layout.groups.map((g) => groupSvg(g, fullPaths)).join(""),
10733
+ layout.skillGroups.map(skillGroupSvg).join(""),
10734
+ layout.wires.map(wireSvg).join(""),
10735
+ nodeParts.join("")
10736
+ ].join("");
10737
+ const vb = layout.viewBox;
10738
+ const svgEl = [
10739
+ `<svg id="graph" class="canvas" viewBox="${fmt(vb.x)} ${fmt(vb.y)} ${fmt(vb.w)} ${fmt(vb.h)}" role="img" aria-label="pi-dispatch trigger and flow graph" preserveAspectRatio="xMidYMid meet">`,
10740
+ `<g id="root">${svgBody}</g>`,
10741
+ `</svg>`
10742
+ ].join("");
10743
+ return [
10744
+ "<!doctype html>",
10745
+ '<meta charset="utf-8">',
10746
+ "<title>pi-dispatch graph</title>",
10747
+ `<style>${PAGE_CSS}</style>`,
10748
+ "<body>",
10749
+ '<div id="hdr">',
10750
+ "<h1>pi-dispatch graph</h1>",
10751
+ '<span id="stamp"></span>',
10752
+ '<button id="reload" type="button">Reload</button>',
10753
+ '<select id="auto" aria-label="auto reload"><option value="0">off</option><option value="5">5s</option><option value="30">30s</option></select>',
10754
+ "</div>",
10755
+ bannersHtml(norm),
10756
+ `<div id="wrap">${svgEl}<div id="tip"></div></div>`,
10757
+ legendHtml(norm),
10758
+ `<script>
10759
+ "use strict";
10760
+ var GENERATED_AT = ${fmt(nowMs)};
10761
+ var GRAPH = ${embedJson(graphData)};
10762
+ ${PAGE_JS}</script>`,
10763
+ "</body>"
10764
+ ].join("\n");
10765
+ }
10766
+ function findFlags(flagsByOrig, _nodes, placed) {
10767
+ return flagsByOrig.get(placed.node.id) ?? [];
10768
+ }
10769
+ var GRAPH_HTML_KINDS, NODE_H, NODE_MIN_W, GRID, NODE_MAX_W, CHAR_W, LABEL_X, CHIP_MAX_CHARS, RANK_PITCH, ROW_GAP, GROUP_PAD_L, GROUP_PAD_R, GROUP_PAD_T, GROUP_PAD_B, GROUP_GAP, SELF_LOOP_DROP, UNDER_ROUTE_EXTRA, PARALLEL_BOW, PARALLEL_LABEL_STEP, LABEL_MIN_SPAN, SG_CHIP_X, SG_CHIP_Y, SG_RING, SG_MARKER, SG_HINT_CHARS, SG_SMALL_CHAR_W, SUB_CHIP_H, VIEW_MARGIN, PAGE_CANVAS, PAGE_PANEL, PAGE_BORDER, PAGE_FG, PAGE_DIM, PAGE_ACCENT, PAGE_AMBER, CHIP_STROKE, CHIP_LABEL, PORT_FILL, CHIP_FILL, STATUS_COMPLETED, STATUS_FAILED, STATUS_OTHER, BADGE_ORANGE, BADGE_GREEN, DANGER, GROUP_FILL, WIRE_OBSERVED, WIRE_CONFIG, WIRE_POTENTIAL, GLYPH, ORANGE_FLAGS, DANGLING_FLAGS, PAGE_CSS, PAGE_JS;
10770
+ var init_graph_html = __esm({
10771
+ "src/graph-html.mjs"() {
10772
+ GRAPH_HTML_KINDS = Object.freeze(["config", "observed", "potential", "cron-rearm"]);
10773
+ NODE_H = 30;
10774
+ NODE_MIN_W = 100;
10775
+ GRID = 20;
10776
+ NODE_MAX_W = 160;
10777
+ CHAR_W = 8;
10778
+ LABEL_X = 38;
10779
+ CHIP_MAX_CHARS = 14;
10780
+ RANK_PITCH = 180;
10781
+ ROW_GAP = 26;
10782
+ GROUP_PAD_L = 20;
10783
+ GROUP_PAD_R = 20;
10784
+ GROUP_PAD_T = 40;
10785
+ GROUP_PAD_B = 44;
10786
+ GROUP_GAP = 40;
10787
+ SELF_LOOP_DROP = 25;
10788
+ UNDER_ROUTE_EXTRA = 40;
10789
+ PARALLEL_BOW = 14;
10790
+ PARALLEL_LABEL_STEP = 10.5;
10791
+ LABEL_MIN_SPAN = 40;
10792
+ SG_CHIP_X = 22;
10793
+ SG_CHIP_Y = 26;
10794
+ SG_RING = 10;
10795
+ SG_MARKER = 40;
10796
+ SG_HINT_CHARS = 24;
10797
+ SG_SMALL_CHAR_W = 6;
10798
+ SUB_CHIP_H = 24;
10799
+ VIEW_MARGIN = 80;
10800
+ PAGE_CANVAS = "#0d1117";
10801
+ PAGE_PANEL = "#161b22";
10802
+ PAGE_BORDER = "#30363d";
10803
+ PAGE_FG = "#c9d1d9";
10804
+ PAGE_DIM = "#8b949e";
10805
+ PAGE_ACCENT = "#58a6ff";
10806
+ PAGE_AMBER = "#d29922";
10807
+ CHIP_STROKE = "#999";
10808
+ CHIP_LABEL = "#333";
10809
+ PORT_FILL = "#d9d9d9";
10810
+ CHIP_FILL = Object.freeze({
10811
+ cron: "#a6bbcf",
10812
+ label: "#d8bfd8",
10813
+ comment: "#e7e7ae",
10814
+ pull_request: "#c0deed",
10815
+ skill: "#fdd0a2"
10816
+ });
10817
+ STATUS_COMPLETED = "#3fb950";
10818
+ STATUS_FAILED = "#ff5f57";
10819
+ STATUS_OTHER = "#6e7681";
10820
+ BADGE_ORANGE = "#db6d28";
10821
+ BADGE_GREEN = "#3fb950";
10822
+ DANGER = "#f85149";
10823
+ GROUP_FILL = "#E6E0F8";
10824
+ WIRE_OBSERVED = "#999";
10825
+ WIRE_CONFIG = "#6e7681";
10826
+ WIRE_POTENTIAL = "#8b949e";
10827
+ GLYPH = Object.freeze({
10828
+ cron: "\u25F7",
10829
+ label: "\u25C8",
10830
+ comment: "\u275D",
10831
+ pull_request: "\u21C4",
10832
+ skill: "\u0192",
10833
+ "skill-missing": "!",
10834
+ "skill-unverified": "?",
10835
+ injected: "+"
10836
+ });
10837
+ ORANGE_FLAGS = /* @__PURE__ */ new Set(["unread", "injected-ai-trigger", "pr-spend-loop-risk"]);
10838
+ DANGLING_FLAGS = /* @__PURE__ */ new Set(["no-skill", "charset-invalid"]);
10839
+ PAGE_CSS = `
10840
+ *{box-sizing:border-box;margin:0;padding:0}
10841
+ body{background:${PAGE_CANVAS};color:${PAGE_FG};font:14px/1.4 -apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
10842
+ #hdr{position:fixed;top:0;left:0;right:0;height:46px;display:flex;align-items:center;gap:12px;padding:0 14px;background:${PAGE_PANEL};border-bottom:1px solid ${PAGE_BORDER};z-index:3}
10843
+ #hdr h1{font-size:14px;font-weight:600}
10844
+ #stamp{color:${PAGE_DIM};font-size:12px}
10845
+ #stamp.stale{color:${PAGE_AMBER}}
10846
+ #hdr button,#hdr select{background:#21262d;color:${PAGE_FG};border:1px solid ${PAGE_BORDER};border-radius:6px;padding:3px 10px;font-size:12px}
10847
+ #banners{position:fixed;top:52px;left:10px;z-index:3;max-width:46%}
10848
+ .banner{background:#2d1517;border:1px solid ${DANGER};color:${DANGER};border-radius:6px;padding:6px 10px;margin-bottom:6px;font-size:12px}
10849
+ #wrap{position:relative;height:100vh;padding-top:46px}
10850
+ svg.canvas{display:block;width:100%;height:100%;cursor:grab;touch-action:none}
10851
+ #tip{position:absolute;display:none;max-width:340px;background:${PAGE_PANEL};border:1px solid ${PAGE_BORDER};border-radius:6px;padding:8px 10px;font-size:12px;color:${PAGE_FG};white-space:pre-line;pointer-events:none;z-index:4}
10852
+ #legend{position:fixed;top:56px;right:10px;width:256px;background:${PAGE_PANEL};border:1px solid ${PAGE_BORDER};border-radius:6px;padding:10px 12px;font-size:11px;color:${PAGE_DIM};z-index:2}
10853
+ #legend h2{font-size:11px;color:${PAGE_FG};margin:6px 0 4px}
10854
+ #legend .row{display:flex;align-items:center;gap:6px;margin:2px 0}
10855
+ #legend .caps{margin-top:8px;color:${PAGE_FG}}
10856
+ #legend .honesty{margin-top:4px;color:${PAGE_AMBER}}
10857
+ .gnode{cursor:pointer}
10858
+ .dim .gnode,.dim .gwire{opacity:.15}
10859
+ .dim .hi{opacity:1}
10860
+ `;
10861
+ PAGE_JS = `
10862
+ (function () {
10863
+ "use strict";
10864
+ var svg = document.getElementById("graph");
10865
+ var root = document.getElementById("root");
10866
+ var tip = document.getElementById("tip");
10867
+ var wrap = document.getElementById("wrap");
10868
+ var stamp = document.getElementById("stamp");
10869
+ var auto = document.getElementById("auto");
10870
+ var reloadBtn = document.getElementById("reload");
10871
+ var selected = null;
10872
+ var autoTimer = null;
10873
+ var hashTimer = null;
10874
+
10875
+ function nowClock() { return new Date().getTime(); }
10876
+
10877
+ function fmtAge(ms) {
10878
+ var s = Math.max(0, Math.floor(ms / 1000));
10879
+ if (s < 60) return s + "s";
10880
+ var m = Math.floor(s / 60);
10881
+ if (m < 60) return m + "m";
10882
+ return Math.floor(m / 60) + "h";
10883
+ }
10884
+ function tickStamp() {
10885
+ var age = nowClock() - GENERATED_AT;
10886
+ stamp.textContent = "generated " + fmtAge(age) + " ago";
10887
+ stamp.className = age > 600000 ? "stale" : "";
10888
+ }
10889
+ tickStamp();
10890
+ setInterval(tickStamp, 1000);
10891
+
10892
+ if (reloadBtn) reloadBtn.addEventListener("click", function () { location.reload(); });
10893
+ function applyAuto() {
10894
+ if (autoTimer) { clearInterval(autoTimer); autoTimer = null; }
10895
+ var s = auto ? parseInt(auto.value, 10) : 0;
10896
+ if (s > 0) autoTimer = setInterval(function () { location.reload(); }, s * 1000);
10897
+ }
10898
+ if (auto) auto.addEventListener("change", function () { applyAuto(); writeHash(); });
10899
+
10900
+ if (!svg || !root) return;
10901
+ var vb = svg.viewBox.baseVal;
10902
+ var baseW = vb.width;
10903
+
10904
+ function writeHash() {
10905
+ hashTimer = null;
10906
+ var parts = ["vb=" + [vb.x, vb.y, vb.width, vb.height].map(function (v) { return Math.round(v * 10) / 10; }).join("_")];
10907
+ if (selected) parts.push("sel=" + selected);
10908
+ if (auto && auto.value !== "0") parts.push("ar=" + auto.value);
10909
+ history.replaceState(null, "", "#" + parts.join("&"));
10910
+ }
10911
+ function scheduleHash() {
10912
+ if (hashTimer) return;
10913
+ hashTimer = setTimeout(writeHash, 300);
10914
+ }
10915
+ function readHash() {
10916
+ var h = location.hash.replace(/^#/, "");
10917
+ if (!h) return;
10918
+ var kvs = h.split("&");
10919
+ for (var i = 0; i < kvs.length; i++) {
10920
+ var at = kvs[i].indexOf("=");
10921
+ if (at < 0) continue;
10922
+ var k = kvs[i].slice(0, at);
10923
+ var v = kvs[i].slice(at + 1);
10924
+ if (k === "vb") {
10925
+ var n = v.split("_").map(parseFloat);
10926
+ if (n.length === 4 && n.every(isFinite) && n[2] > 0 && n[3] > 0) {
10927
+ vb.x = n[0]; vb.y = n[1]; vb.width = n[2]; vb.height = n[3];
10928
+ }
10929
+ } else if (k === "sel" && ownNode(v)) {
10930
+ select(v);
10931
+ } else if (k === "ar" && auto) {
10932
+ auto.value = v === "5" || v === "30" ? v : "0";
10933
+ applyAuto();
10934
+ }
10935
+ }
10936
+ }
10937
+
10938
+ // With preserveAspectRatio meet, the browser scales UNIFORMLY (the smaller of the two ratios)
10939
+ // and centres the letterboxed remainder; mapping each axis by its own ratio pans at the wrong
10940
+ // speed and zooms beside the cursor whenever the element and viewBox aspects differ.
10941
+ function view() {
10942
+ var r = svg.getBoundingClientRect();
10943
+ var s = Math.min(r.width / vb.width, r.height / vb.height);
10944
+ return { r: r, s: s, ox: (r.width - vb.width * s) / 2, oy: (r.height - vb.height * s) / 2 };
10945
+ }
10946
+
10947
+ var panning = null;
10948
+ var suppressClick = false;
10949
+ svg.addEventListener("pointerdown", function (e) {
10950
+ if (e.button !== 0) return;
10951
+ panning = { x: e.clientX, y: e.clientY, moved: 0 };
10952
+ svg.setPointerCapture(e.pointerId);
10953
+ });
10954
+ svg.addEventListener("pointermove", function (e) {
10955
+ if (panning) {
10956
+ var v = view();
10957
+ var dx = e.clientX - panning.x;
10958
+ var dy = e.clientY - panning.y;
10959
+ panning.moved += Math.abs(dx) + Math.abs(dy);
10960
+ vb.x -= dx / v.s;
10961
+ vb.y -= dy / v.s;
10962
+ panning.x = e.clientX;
10963
+ panning.y = e.clientY;
10964
+ scheduleHash();
10965
+ }
10966
+ moveTip(e);
10967
+ });
10968
+ svg.addEventListener("pointerup", function (e) {
10969
+ if (panning && svg.hasPointerCapture && svg.hasPointerCapture(e.pointerId)) svg.releasePointerCapture(e.pointerId);
10970
+ // Pointer capture makes the click after a pan land on the svg, which would read as a
10971
+ // background click and wipe the selection; a real drag therefore swallows that click.
10972
+ suppressClick = panning !== null && panning.moved > 3;
10973
+ panning = null;
10974
+ });
10975
+ svg.addEventListener("wheel", function (e) {
10976
+ e.preventDefault();
10977
+ var k = Math.exp(-e.deltaY * 0.0015);
10978
+ var scale = baseW / (vb.width / k);
10979
+ if (scale < 0.15 || scale > 4) return;
10980
+ var v = view();
10981
+ var px = vb.x + (e.clientX - v.r.left - v.ox) / v.s;
10982
+ var py = vb.y + (e.clientY - v.r.top - v.oy) / v.s;
10983
+ vb.x = px - (px - vb.x) / k;
10984
+ vb.y = py - (py - vb.y) / k;
10985
+ vb.width = vb.width / k;
10986
+ vb.height = vb.height / k;
10987
+ scheduleHash();
10988
+ }, { passive: false });
10989
+
10990
+ // The ONLY indexed read of GRAPH.nodes: an id arriving from the hash (or a DOM id) could be a
10991
+ // prototype-chain key like "constructor", which a bare truthy lookup would happily return and a
10992
+ // later .nb dereference would throw on. A test pins this as the single raw read.
10993
+ function ownNode(id) {
10994
+ return Object.prototype.hasOwnProperty.call(GRAPH.nodes, id) ? GRAPH.nodes[id] : null;
10995
+ }
10996
+ function nodeAt(e) {
10997
+ var el = e.target && e.target.closest ? e.target.closest(".gnode") : null;
10998
+ return el && ownNode(el.id) ? el : null;
10999
+ }
11000
+ function moveTip(e) {
11001
+ var el = nodeAt(e);
11002
+ if (!el) { tip.style.display = "none"; return; }
11003
+ tip.textContent = ownNode(el.id).tip;
11004
+ tip.style.display = "block";
11005
+ var wr = wrap.getBoundingClientRect();
11006
+ tip.style.left = e.clientX - wr.left + 14 + "px";
11007
+ tip.style.top = e.clientY - wr.top + 14 + "px";
11008
+ }
11009
+
11010
+ function mark(id) {
11011
+ var el = document.getElementById(id);
11012
+ if (el) el.classList.add("hi");
11013
+ }
11014
+ function clearSel() {
11015
+ selected = null;
11016
+ root.classList.remove("dim");
11017
+ var hi = root.querySelectorAll(".hi");
11018
+ for (var i = 0; i < hi.length; i++) hi[i].classList.remove("hi");
11019
+ scheduleHash();
11020
+ }
11021
+ function select(id) {
11022
+ clearSel();
11023
+ var g = ownNode(id);
11024
+ if (!g) return;
11025
+ selected = id;
11026
+ root.classList.add("dim");
11027
+ mark(id);
11028
+ for (var i = 0; i < g.nb.length; i++) mark(g.nb[i]);
11029
+ for (var j = 0; j < g.w.length; j++) mark(g.w[j]);
11030
+ scheduleHash();
11031
+ }
11032
+ svg.addEventListener("click", function (e) {
11033
+ if (suppressClick) { suppressClick = false; return; }
11034
+ var el = nodeAt(e);
11035
+ if (el) { if (selected === el.id) clearSel(); else select(el.id); }
11036
+ else clearSel();
11037
+ });
11038
+ document.addEventListener("keydown", function (e) { if (e.key === "Escape") clearSel(); });
11039
+
11040
+ readHash();
11041
+ })();
11042
+ `;
11043
+ }
11044
+ });
11045
+
11046
+ // ../worker/src/open-browser.mjs
11047
+ import { spawn as nodeSpawn } from "node:child_process";
11048
+ function openBrowser(url, { spawn: spawn2 = nodeSpawn, platform = process.platform } = {}) {
11049
+ const [cmd, args] = platform === "darwin" ? ["open", [url]] : platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
11050
+ try {
11051
+ const child = spawn2(cmd, args, { stdio: "ignore", detached: true });
11052
+ child.on("error", () => {
11053
+ });
11054
+ child.unref();
11055
+ } catch {
11056
+ }
11057
+ }
11058
+ var init_open_browser = __esm({
11059
+ "../worker/src/open-browser.mjs"() {
9180
11060
  }
9181
11061
  });
9182
11062
 
@@ -35208,7 +37088,7 @@ function setGlyphs(ascii) {
35208
37088
  function stripControls(s) {
35209
37089
  return String(s ?? "").replace(CONTROL_CHARS, "");
35210
37090
  }
35211
- function clip(line, w) {
37091
+ function clip2(line, w) {
35212
37092
  const width = Math.max(0, Math.trunc(w) || 0);
35213
37093
  const clean = stripControls(line);
35214
37094
  if (clean.length <= width) return clean;
@@ -35218,13 +37098,13 @@ function clip(line, w) {
35218
37098
  }
35219
37099
  function pad(line, w) {
35220
37100
  const width = Math.max(0, Math.trunc(w) || 0);
35221
- return clip(line, width).padEnd(width);
37101
+ return clip2(line, width).padEnd(width);
35222
37102
  }
35223
37103
  function box({ title = "", sections = [], footer, width = 40 } = {}) {
35224
37104
  const w = Math.max(MIN_WIDTH, Math.trunc(width) || MIN_WIDTH);
35225
37105
  const inner = w - 4;
35226
37106
  const lines = [];
35227
- const titleText = title ? ` ${clip(title, Math.max(0, inner - 2))} ` : "";
37107
+ const titleText = title ? ` ${clip2(title, Math.max(0, inner - 2))} ` : "";
35228
37108
  const topFill = Math.max(0, w - 2 - 1 - titleText.length);
35229
37109
  lines.push(active.tl + active.h + titleText + active.h.repeat(topFill) + active.tr);
35230
37110
  const framed = (text) => `${active.v} ${pad(text, inner)} ${active.v}`;
@@ -35244,14 +37124,14 @@ function box({ title = "", sections = [], footer, width = 40 } = {}) {
35244
37124
  function meter(reserved, cap, width = 24, state = "ok") {
35245
37125
  const r = Number.isFinite(reserved) ? Math.max(0, Math.trunc(reserved)) : 0;
35246
37126
  if (!Number.isInteger(cap) || cap <= 0) {
35247
- return clip(`${r} / ? (cap unknown)`, width);
37127
+ return clip2(`${r} / ? (cap unknown)`, width);
35248
37128
  }
35249
37129
  const tag = state === "soft-hold" ? " soft-hold" : state === "over" ? " over" : "";
35250
37130
  const label = ` ${r}/${cap}${tag}`;
35251
37131
  const barCells = Math.max(0, Math.trunc(width) - label.length - 2);
35252
37132
  const filled = Math.min(barCells, Math.round(Math.min(r, cap) / cap * barCells));
35253
37133
  const bar = `[${active.full.repeat(filled)}${active.empty.repeat(barCells - filled)}]`;
35254
- return clip(bar + label, width);
37134
+ return clip2(bar + label, width);
35255
37135
  }
35256
37136
  function sparkline(values, width = 24, opts = {}) {
35257
37137
  const w = Math.max(0, Math.trunc(width) || 0);
@@ -35259,7 +37139,7 @@ function sparkline(values, width = 24, opts = {}) {
35259
37139
  const norm = (Array.isArray(values) ? values : []).map((v) => Number.isFinite(v) ? Math.max(0, v) : null);
35260
37140
  const finite = norm.filter((v) => v !== null);
35261
37141
  const ceiling = Number.isFinite(opts?.max) && opts.max > 0 ? opts.max : Math.max(0, ...finite);
35262
- if (finite.length === 0 || ceiling <= 0) return clip("no data", w);
37142
+ if (finite.length === 0 || ceiling <= 0) return clip2("no data", w);
35263
37143
  let cells = norm;
35264
37144
  if (norm.length > w) {
35265
37145
  cells = [];
@@ -35437,9 +37317,9 @@ var init_sandbox_store = __esm({
35437
37317
  });
35438
37318
 
35439
37319
  // ../worker/src/sandbox.mjs
35440
- import { execFile as execFile3, spawn } from "node:child_process";
37320
+ import { execFile as execFile4, spawn } from "node:child_process";
35441
37321
  import { existsSync as existsSync2 } from "node:fs";
35442
- import { promisify as promisify3 } from "node:util";
37322
+ import { promisify as promisify4 } from "node:util";
35443
37323
  function sandboxContainerName(jobId) {
35444
37324
  return `${SANDBOX_NAME_PREFIX}${sanitizeJobId(jobId)}`;
35445
37325
  }
@@ -35484,14 +37364,14 @@ function launchSandbox({ args, spawnFn = spawn }) {
35484
37364
  child.on("close", (code) => resolve({ code }));
35485
37365
  });
35486
37366
  }
35487
- var exec2, SANDBOX_NAME_PREFIX;
37367
+ var exec3, SANDBOX_NAME_PREFIX;
35488
37368
  var init_sandbox = __esm({
35489
37369
  "../worker/src/sandbox.mjs"() {
35490
37370
  init_config2();
35491
37371
  init_docker_run();
35492
37372
  init_run_history();
35493
37373
  init_sandbox_store();
35494
- exec2 = promisify3(execFile3);
37374
+ exec3 = promisify4(execFile4);
35495
37375
  SANDBOX_NAME_PREFIX = "pi-sandbox-";
35496
37376
  }
35497
37377
  });
@@ -35517,8 +37397,8 @@ function renderRuns(runs) {
35517
37397
  const headers = RUN_COLUMNS.map((c) => c.header);
35518
37398
  const rows = list.map((r) => RUN_COLUMNS.map((c) => c.derive ? c.derive(r) : cell(r?.[c.key])));
35519
37399
  const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((row) => row[i].length)));
35520
- const fmt = (cells) => cells.map((v, i) => v.padEnd(widths[i])).join(" ").trimEnd();
35521
- return [fmt(headers), ...rows.map(fmt)].join("\n");
37400
+ const fmt2 = (cells) => cells.map((v, i) => v.padEnd(widths[i])).join(" ").trimEnd();
37401
+ return [fmt2(headers), ...rows.map(fmt2)].join("\n");
35522
37402
  }
35523
37403
  function renderBudget({ budget, settings: settings2 } = {}) {
35524
37404
  if (!budget || budget.unreachable) {
@@ -35570,19 +37450,21 @@ function triggerLine(t) {
35570
37450
  const forge = t?.forge && t.forge !== "github" ? ` [${t.forge}]` : "";
35571
37451
  const pkgs = t?.packages === true ? " [packages]" : "";
35572
37452
  const img = t?.image ? ` [image ${t.image}]` : "";
37453
+ const skl = t?.skillsDir ? ` [skills ${String(t.skillsDir).split(/[\\/]/).filter(Boolean).pop()}]` : "";
37454
+ const ins = t?.instructions === true ? " [instructions]" : "";
35573
37455
  const res = t?.resume === true ? " [resume]" : "";
35574
37456
  const rep = t?.replicas > 1 ? ` [x${t.replicas}]` : "";
35575
37457
  switch (t?.type) {
35576
37458
  case "cron":
35577
- return `cron ${t.id ?? "-"} ${t.pattern ?? "-"} \u2192 ${t.folder ?? "-"}/${flow}${forge}${pkgs}${img}${res}${rep}`;
37459
+ return `cron ${t.id ?? "-"} ${t.pattern ?? "-"} \u2192 ${t.folder ?? "-"}/${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}`;
35578
37460
  case "label":
35579
- return `label ${ruleClauses(t) || "(no selector)"} \u2192 ${flow}${forge}${pkgs}${img}${res}${rep}`;
37461
+ return `label ${ruleClauses(t) || "(no selector)"} \u2192 ${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}`;
35580
37462
  case "comment":
35581
- return `comment "${t.phrase ?? "-"}" \u2192 ${flow}${forge}${pkgs}${img}${res}${rep}`;
37463
+ return `comment "${t.phrase ?? "-"}" \u2192 ${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}`;
35582
37464
  case "pull_request": {
35583
37465
  const clauses = ruleClauses(t);
35584
37466
  const action = `action[${(t.action ?? []).join(",")}]`;
35585
- return `pull_request ${action}${clauses ? ` ${clauses}` : ""} \u2192 ${flow}${forge}${pkgs}${img}${res}${rep}`;
37467
+ return `pull_request ${action}${clauses ? ` ${clauses}` : ""} \u2192 ${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}`;
35586
37468
  }
35587
37469
  default:
35588
37470
  return "(unknown trigger)";
@@ -35693,8 +37575,8 @@ function renderWhatIf(result, { flow, target } = {}) {
35693
37575
  }
35694
37576
  function tableLines(headers, rows) {
35695
37577
  const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((row) => row[i].length)));
35696
- const fmt = (cells) => cells.map((v, i) => v.padEnd(widths[i])).join(" ").trimEnd();
35697
- return [fmt(headers), ...rows.map(fmt)];
37578
+ const fmt2 = (cells) => cells.map((v, i) => v.padEnd(widths[i])).join(" ").trimEnd();
37579
+ return [fmt2(headers), ...rows.map(fmt2)];
35698
37580
  }
35699
37581
  function renderSettingsView(settings2) {
35700
37582
  const path = settings2?.path ?? "(unknown path)";
@@ -35707,6 +37589,105 @@ function renderSettingsView(settings2) {
35707
37589
  }
35708
37590
  return out.join("\n");
35709
37591
  }
37592
+ function renderGraph(model) {
37593
+ const m = model ?? {};
37594
+ if (m.meta?.triggersMissing) return "Graph: no triggers file found";
37595
+ if (typeof m.meta?.triggersInvalid === "string" && m.meta.triggersInvalid !== "") return `Graph: triggers file invalid: ${m.meta.triggersInvalid}`;
37596
+ const nodes = Array.isArray(m.nodes) ? m.nodes : [];
37597
+ const nodeById = new Map(nodes.map((n) => [n.id, n]));
37598
+ const edges = Array.isArray(m.edges) ? m.edges : [];
37599
+ const flagsByNode = /* @__PURE__ */ new Map();
37600
+ for (const f of Array.isArray(m.flags) ? m.flags : []) {
37601
+ if (!flagsByNode.has(f.nodeId)) flagsByNode.set(f.nodeId, []);
37602
+ flagsByNode.get(f.nodeId).push(f.flag);
37603
+ }
37604
+ const out = ["Graph: triggers and flows"];
37605
+ const triggerCount = nodes.filter((n) => n.kind === "trigger").length;
37606
+ if (triggerCount === 0) out.push("", "no triggers configured");
37607
+ for (const group of Array.isArray(m.folders) ? m.folders : []) {
37608
+ if ((group.triggerIds?.length ?? 0) === 0 && (group.skillIds?.length ?? 0) === 0) continue;
37609
+ out.push("", groupHeading(group));
37610
+ for (const id of group.triggerIds ?? []) {
37611
+ const t = nodeById.get(id);
37612
+ if (t) out.push(` ${graphTriggerLine(t, flagsByNode.get(id) ?? [])}`);
37613
+ }
37614
+ for (const id of group.skillIds ?? []) {
37615
+ const s = nodeById.get(id);
37616
+ if (!s || s.kind === "skill-unverified") continue;
37617
+ out.push(` ${graphSkillLine(s, flagsByNode.get(id) ?? [])}`);
37618
+ for (const e of edges) {
37619
+ if (e.from !== id || e.kind === "cron-rearm") continue;
37620
+ const target = nodeById.get(e.to);
37621
+ out.push(` ${graphEdgeLine(e, target)}`);
37622
+ }
37623
+ }
37624
+ }
37625
+ const injected = nodes.filter((n) => n.kind === "injected");
37626
+ if (injected.length > 0) {
37627
+ out.push("", "injected skills (trigger-reachable, never AI-reachable):");
37628
+ for (const n of injected) {
37629
+ const marks = (flagsByNode.get(n.id) ?? []).includes("injected-ai-trigger") ? " [ai-trigger: allow is a silent no-op here]" : "";
37630
+ out.push(` ${n.name} (${n.dir})${marks}`);
37631
+ }
37632
+ }
37633
+ out.push("");
37634
+ if ((m.meta?.unattributedRuns ?? 0) > 0) out.push(`${m.meta.unattributedRuns} runs unattributed (triggers file changed since they ran, or they predate attribution)`);
37635
+ const refusals = Object.entries(m.meta?.chainRefusals ?? {});
37636
+ if (refusals.length > 0) out.push(`chain refusals: ${refusals.map(([flow, n]) => `${flow} ${n}`).join(", ")}`);
37637
+ const trunc = m.meta?.truncated ?? {};
37638
+ if (trunc.folders) out.push("folder scan truncated (cap reached): unlisted folders are not empty, they are unscanned");
37639
+ if (trunc.skills) out.push("skill enumeration truncated or partly unread");
37640
+ if (trunc.edges) out.push("observed edges truncated (cap reached)");
37641
+ if ((m.meta?.droppedObservedEdges ?? 0) > 0) out.push(`${m.meta.droppedObservedEdges} observed edges dropped (no unique readable folder to attach them to)`);
37642
+ if ((m.meta?.injectedUnreachable ?? []).length > 0) out.push(`injected skills dir unreadable: ${m.meta.injectedUnreachable.join(", ")}`);
37643
+ out.push("edges: -> configured, observed xN (from records), mention (potential; a mention is not a promise)");
37644
+ out.push(capsLine(m.caps));
37645
+ return out.join("\n");
37646
+ }
37647
+ function groupHeading(group) {
37648
+ if (group.kind === "forge") {
37649
+ const seen = Array.isArray(group.repos) && group.repos.length > 0 ? ` -- ran against: ${group.repos.join(", ")}` : "";
37650
+ return `forge ${group.label}${seen} (skills unverifiable from the admin host)`;
37651
+ }
37652
+ const head = group.head ? `, HEAD ${String(group.head).slice(0, 7)}` : "";
37653
+ const state = group.unreachable ? `, ${group.unreachable}` : "";
37654
+ return `folder ${group.path ?? group.label}${head}${state}`;
37655
+ }
37656
+ function graphTriggerLine(t, flags) {
37657
+ const stats = t.runs > 0 ? `runs ${t.runs}${t.lastOutcome ? `, last ${t.lastOutcome}` : ""}` : "no runs in window";
37658
+ const badges = [];
37659
+ if (flags.includes("no-skill")) badges.push("[no-skill: flow absent at HEAD]");
37660
+ if (flags.includes("charset-invalid")) badges.push("[invalid flow name: can never materialise]");
37661
+ if (flags.includes("pr-spend-loop-risk")) badges.push("[spend-loop risk: fires on opened/synchronize]");
37662
+ const replicas = t.replicas ? ` x${t.replicas}` : "";
37663
+ return `${t.onType} ${t.label} -> ${t.flow ?? "(no flow)"}${replicas} (${stats})${badges.length ? ` ${badges.join(" ")}` : ""}`;
37664
+ }
37665
+ function graphSkillLine(s, flags) {
37666
+ const badges = [];
37667
+ if (s.kind === "skill-missing") badges.push("[missing at HEAD]");
37668
+ if (s.isSub) badges.push(`[sub-skill of ${s.group}: loadable, never a flow]`);
37669
+ if (s.aiTrigger) badges.push("[chainable]");
37670
+ for (const loop of Array.isArray(s.loops) ? s.loops : []) badges.push(`[loop: "${loop.hint}"]`);
37671
+ if (flags.includes("orphan")) badges.push("[orphan: no trigger, no ai-trigger, no mention]");
37672
+ if (flags.includes("ai-reachable-no-trigger")) badges.push("[AI-reachable, no trigger]");
37673
+ if (flags.includes("unread")) badges.push("[SKILL.md unread: facts unknown]");
37674
+ return `skill ${s.name}${badges.length ? ` ${badges.join(" ")}` : ""}`;
37675
+ }
37676
+ function graphEdgeLine(e, target) {
37677
+ const name = target?.name ?? "(unknown)";
37678
+ if (e.kind === "observed") return `-> ${name} observed x${e.count}`;
37679
+ if (e.kind === "potential") {
37680
+ const fire = e.eligible ? "could fire" : "can never fire: target has no ai-trigger allow";
37681
+ return `-> ${name} mention (potential${e.strong ? ", strong" : ""}; ${fire})`;
37682
+ }
37683
+ return `-> ${name} ${e.kind}`;
37684
+ }
37685
+ function capsLine(caps) {
37686
+ const depth = caps?.chainDepthMax ?? "?";
37687
+ const width = caps?.chainMaxPerJob ?? "?";
37688
+ const window = caps?.windowDays ?? "?";
37689
+ return `caps: chain depth <= ${depth}, <= ${width} per job, same folder only, window ${window}d`;
37690
+ }
35710
37691
  var SETTINGS_KEYS, BUDGET_WINDOWS, RUN_COLUMNS, COSTS_WINDOW_LABELS;
35711
37692
  var init_render = __esm({
35712
37693
  "src/render.mjs"() {
@@ -35926,8 +37907,8 @@ var init_style = __esm({
35926
37907
  inverse: (t) => t,
35927
37908
  strikethrough: (t) => t
35928
37909
  };
35929
- OVERLAY_GLYPHS = { tl: "\u250C", tr: "\u2510", bl: "\u2514", br: "\u2518", ml: "\u251C", mr: "\u2524", h: "\u2500", v: "\u2502", full: "\u2588", empty: "\u2591", ellipsis: "\u2026" };
35930
- OVERLAY_ASCII = { tl: "+", tr: "+", bl: "+", br: "+", ml: "+", mr: "+", h: "-", v: "|", full: "#", empty: ".", ellipsis: "..." };
37910
+ OVERLAY_GLYPHS = { tl: "\u250C", tr: "\u2510", bl: "\u2514", br: "\u2518", ml: "\u251C", mr: "\u2524", h: "\u2500", v: "\u2502", full: "\u2588", empty: "\u2591", ellipsis: "\u2026", arrowRight: "\u2500\u25B6", foldOpen: "\u25BE", foldClosed: "\u25B8", rearm: "\u21BB" };
37911
+ OVERLAY_ASCII = { tl: "+", tr: "+", bl: "+", br: "+", ml: "+", mr: "+", h: "-", v: "|", full: "#", empty: ".", ellipsis: "...", arrowRight: "->", foldOpen: "v", foldClosed: ">", rearm: "~" };
35931
37912
  COST_COLORS = {
35932
37913
  metered: "text",
35933
37914
  plan: "accent",
@@ -36003,6 +37984,30 @@ function createDashboardDeps(paths) {
36003
37984
  const fold = foldCosts({ records, subscriptions, pricing: pricing_exports, nowMs, piAiPin: piAiVersion() });
36004
37985
  return { fold, records, subscriptions };
36005
37986
  },
37987
+ /**
37988
+ * One GRAPH read (issue #54): triggers FRESH (OQ-008 -- a cached topology is a stale topology),
37989
+ * one bounded record scan, the folder/injected enumerations, and the pure fold. Schedulers ride
37990
+ * in from the caller's snapshot so this seam opens no second queue read. All fs/git access lives
37991
+ * in the read-model functions this calls; this module still touches nothing itself.
37992
+ */
37993
+ fetchGraph({ schedulers } = {}) {
37994
+ const nowMs = Date.now();
37995
+ const triggersView = readTriggers({ triggersPath: paths.triggersPath });
37996
+ const triggerList2 = Array.isArray(triggersView?.triggers) ? triggersView.triggers : [];
37997
+ const records = scanRunRecords({ logsDir: paths.logsDir, sinceMs: nowMs - GRAPH_LIMITS.windowDays * 24 * 60 * 60 * 1e3, nowMs });
37998
+ const recs = Array.isArray(records) ? records : [];
37999
+ return buildGraphModel({
38000
+ triggers: triggersView,
38001
+ schedulers: Array.isArray(schedulers) ? schedulers : [],
38002
+ ...collectGraphInputs({ triggers: triggerList2 }),
38003
+ cronStats: cronRunStats({ records: recs, schedulerIds: triggerList2.filter((t) => t.type === "cron" && typeof t.id === "string").map((t) => t.id) }),
38004
+ runJoin: joinRunsToTriggers({ records: recs, triggerCount: triggersView?.count, triggerTypes: Object.fromEntries(triggerList2.map((t) => [t.index, t.type])) }),
38005
+ chainEdges: observedChainEdges({ records: recs }),
38006
+ forgeRepos: forgeRepoTargets({ records: recs }),
38007
+ caps: { chainDepthMax: paths.chainDepthMax, chainMaxPerJob: paths.chainMaxPerJob, windowDays: GRAPH_LIMITS.windowDays },
38008
+ nowMs
38009
+ });
38010
+ },
36006
38011
  /** The priced-model catalog for the what-if `/` filter -- the façade stays behind this seam. */
36007
38012
  listPricedModels() {
36008
38013
  return listPricedModels();
@@ -36037,7 +38042,7 @@ function makeDashboard({
36037
38042
  intervalMs = REFRESH_MS,
36038
38043
  deps = createDashboardDeps(paths)
36039
38044
  } = {}) {
36040
- const styler = makeStyler(theme);
38045
+ const styler = makeStyler(theme, { ascii: paths?.asciiGlyphs === true });
36041
38046
  let snapshot = null;
36042
38047
  let fetching = false;
36043
38048
  let disposed = false;
@@ -36047,6 +38052,7 @@ function makeDashboard({
36047
38052
  let detailRun = null;
36048
38053
  let detailSandbox = null;
36049
38054
  let detailTrigger = null;
38055
+ let detailReturnTo = "LIST";
36050
38056
  let tailJobId = null;
36051
38057
  let tail = null;
36052
38058
  let tailTop = 0;
@@ -36060,6 +38066,9 @@ function makeDashboard({
36060
38066
  let costs = { data: null, windowKey: "mtd", fetchedAt: 0, table: "flow", whatIf: null };
36061
38067
  let costsSel = 0;
36062
38068
  let costsFetching = false;
38069
+ let graph = { model: null, error: null, fetchedAt: 0, folded: /* @__PURE__ */ new Set() };
38070
+ let graphSel = 0;
38071
+ let graphFetching = false;
36063
38072
  const refresh = async () => {
36064
38073
  if (fetching || disposed) return;
36065
38074
  fetching = true;
@@ -36100,6 +38109,20 @@ function makeDashboard({
36100
38109
  tui?.requestRender?.();
36101
38110
  }
36102
38111
  };
38112
+ const fetchGraphNow = async () => {
38113
+ if (typeof deps?.fetchGraph !== "function" || graphFetching || disposed) return;
38114
+ graphFetching = true;
38115
+ try {
38116
+ graph.model = await deps.fetchGraph({ schedulers: snapshot?.schedulers ?? [] });
38117
+ graph.error = null;
38118
+ } catch (err) {
38119
+ graph.error = err?.message ?? String(err);
38120
+ } finally {
38121
+ graph.fetchedAt = Date.now();
38122
+ graphFetching = false;
38123
+ tui?.requestRender?.();
38124
+ }
38125
+ };
36103
38126
  const computeWhatIf = () => {
36104
38127
  const wi = costs.whatIf;
36105
38128
  if (!wi) return;
@@ -36140,6 +38163,9 @@ function makeDashboard({
36140
38163
  if (view === "COSTS" && costsSel > costsTableRows(costs).length - 1) {
36141
38164
  costsSel = Math.max(0, costsTableRows(costs).length - 1);
36142
38165
  }
38166
+ if (view === "GRAPH" && graphSel > graphRows(graph.model, graph.folded).length - 1) {
38167
+ graphSel = Math.max(0, graphRows(graph.model, graph.folded).length - 1);
38168
+ }
36143
38169
  return renderPanel(snapshot, width, {
36144
38170
  view,
36145
38171
  selected,
@@ -36160,6 +38186,9 @@ function makeDashboard({
36160
38186
  costs,
36161
38187
  costsSel,
36162
38188
  costsAvailable: typeof deps?.fetchCosts === "function",
38189
+ graph,
38190
+ graphSel,
38191
+ graphAvailable: typeof deps?.fetchGraph === "function",
36163
38192
  copiedNote,
36164
38193
  copyAvailable: typeof deps?.copyText === "function",
36165
38194
  // Height through the injected seam, read per frame (a resize changes it): null (seam absent, or
@@ -36229,7 +38258,7 @@ function makeDashboard({
36229
38258
  return;
36230
38259
  }
36231
38260
  if (matchesKey(data, "escape")) {
36232
- view = "LIST";
38261
+ view = detailReturnTo;
36233
38262
  detailTrigger = null;
36234
38263
  tui?.requestRender?.();
36235
38264
  return;
@@ -36410,6 +38439,45 @@ function makeDashboard({
36410
38439
  }
36411
38440
  return;
36412
38441
  }
38442
+ if (view === "GRAPH") {
38443
+ if (matchesKey(data, "escape")) {
38444
+ view = "LIST";
38445
+ tui?.requestRender?.();
38446
+ return;
38447
+ }
38448
+ if (matchesKey(data, "up") || matchesKey(data, "down")) {
38449
+ const step = matchesKey(data, "up") ? -1 : 1;
38450
+ graphSel = Math.min(Math.max(0, graphRows(graph.model, graph.folded).length - 1), Math.max(0, graphSel + step));
38451
+ tui?.requestRender?.();
38452
+ return;
38453
+ }
38454
+ if (data === "\r" || data === "\n") {
38455
+ const row = graphRows(graph.model, graph.folded)[graphSel];
38456
+ if (!row) return;
38457
+ if (row.kind === "folder") {
38458
+ if (graph.folded.has(row.key)) graph.folded.delete(row.key);
38459
+ else graph.folded.add(row.key);
38460
+ tui?.requestRender?.();
38461
+ return;
38462
+ }
38463
+ if (row.kind === "gtrigger") {
38464
+ const record = (snapshot?.triggers?.triggers ?? []).find((t) => t?.index === row.node.index);
38465
+ if (!record) return;
38466
+ detailTrigger = { record, index: row.node.index };
38467
+ pendingDelete = false;
38468
+ detailReturnTo = "GRAPH";
38469
+ view = "TRIGGER_DETAIL";
38470
+ tui?.requestRender?.();
38471
+ return;
38472
+ }
38473
+ return;
38474
+ }
38475
+ if (data === "r" || data === "R") {
38476
+ void fetchGraphNow();
38477
+ return;
38478
+ }
38479
+ return;
38480
+ }
36413
38481
  if (matchesKey(data, "escape") || data === "q" || data === "Q") {
36414
38482
  void dispose().finally(() => done(void 0));
36415
38483
  return;
@@ -36421,6 +38489,7 @@ function makeDashboard({
36421
38489
  if (row.kind === "trigger") {
36422
38490
  detailTrigger = { record: row.trigger, index: row.index };
36423
38491
  pendingDelete = false;
38492
+ detailReturnTo = "LIST";
36424
38493
  view = "TRIGGER_DETAIL";
36425
38494
  tui?.requestRender?.();
36426
38495
  } else if (row.kind === "active") {
@@ -36454,6 +38523,13 @@ function makeDashboard({
36454
38523
  tui?.requestRender?.();
36455
38524
  return;
36456
38525
  }
38526
+ if (data === "g" || data === "G") {
38527
+ view = "GRAPH";
38528
+ graphSel = 0;
38529
+ void fetchGraphNow();
38530
+ tui?.requestRender?.();
38531
+ return;
38532
+ }
36457
38533
  if (data === " ") {
36458
38534
  const rows = buildRows(snapshot, runSort);
36459
38535
  if (rows.length === 0) return;
@@ -36522,7 +38598,7 @@ function budgetMeters(budget, settings2, width) {
36522
38598
  return out;
36523
38599
  }
36524
38600
  function renderPanel(snapshot, width, state, styler) {
36525
- const { view, selected, detailRun, detailTrigger, tailJobId, tail, tailTop, tailFollow, tailAvailable, tailSearchInput, tailQuery, tailMatchLine, detailSandbox, sandboxAvailable, pendingDelete, runSort, costs, costsSel, costsAvailable, copiedNote, copyAvailable, terminalRows } = state;
38601
+ const { view, selected, detailRun, detailTrigger, tailJobId, tail, tailTop, tailFollow, tailAvailable, tailSearchInput, tailQuery, tailMatchLine, detailSandbox, sandboxAvailable, pendingDelete, runSort, costs, costsSel, costsAvailable, graph, graphSel, graphAvailable, copiedNote, copyAvailable, terminalRows } = state;
36526
38602
  const framed = Number.isFinite(width) && Math.trunc(width) >= MIN_WIDTH2;
36527
38603
  const inner = Math.trunc(width) - 4;
36528
38604
  const title = "pi-dispatch";
@@ -36559,6 +38635,9 @@ function renderPanel(snapshot, width, state, styler) {
36559
38635
  if (view === "COSTS") {
36560
38636
  return renderCosts2({ costs, costsSel, costsAvailable, framed, width: Math.trunc(width), styler, availableRows: terminalRows });
36561
38637
  }
38638
+ if (view === "GRAPH") {
38639
+ return renderGraphView({ graph, graphSel, graphAvailable, framed, width: Math.trunc(width), styler });
38640
+ }
36562
38641
  if (snapshot === null) {
36563
38642
  if (!framed) return [`${title} -- loading`, "", KEY_HINTS];
36564
38643
  return box({ title, sections: [{ lines: ["loading"] }], footer: KEY_HINTS, width });
@@ -36968,7 +39047,9 @@ function trustModel(t) {
36968
39047
  ];
36969
39048
  default:
36970
39049
  return [
36971
- t?.type === "comment" ? "authorized by a collaborator comment (author_association) + HMAC" : "authorized by a collaborator's label + HMAC webhook + author gate",
39050
+ // A review trigger reads the REVIEWER's author_association, not the PR author's (issue #66), so it
39051
+ // cannot share the generic line: an operator reading "author gate" would picture the wrong person.
39052
+ t?.type === "comment" ? "authorized by a collaborator comment (author_association) + HMAC" : t?.type === "pull_request" && (t?.action ?? []).includes("review_submitted") ? "authorized by the REVIEWER's author_association (not the PR author's) + HMAC" : "authorized by a collaborator's label + HMAC webhook + author gate",
36972
39053
  "dedup by X-GitHub-Delivery GUID (redelivery-safe)",
36973
39054
  where
36974
39055
  ];
@@ -36983,7 +39064,162 @@ function triggerDetailHints(inner, styler, pendingDelete = false) {
36983
39064
  }
36984
39065
  function keyHints(inner, styler) {
36985
39066
  const k = (key, label) => styler.fg("accent", key) + " " + styler.fg("dim", label);
36986
- const hints = [k("\u2191\u2193\u21B5", "open"), k("a", "add"), k("w", "pauses"), k("l", "logs"), k("c", "costs"), k("p", "pause"), k("r", "resume"), k("q", "quit")].join(styler.fg("dim", " \xB7 "));
39067
+ const hints = [k("\u2191\u2193", "open"), k("a", "add"), k("w", "pauses"), k("l", "logs"), k("c", "costs"), k("g", "graph"), k("p/r", "pause"), k("q", "quit")].join(styler.fg("dim", " \xB7 "));
39068
+ return fitLine(hints, inner, styler);
39069
+ }
39070
+ function graphRows(model, folded) {
39071
+ if (!model) return [];
39072
+ const nodeById = new Map((model.nodes ?? []).map((n) => [n.id, n]));
39073
+ const flagsByNode = /* @__PURE__ */ new Map();
39074
+ for (const f of model.flags ?? []) {
39075
+ if (!flagsByNode.has(f.nodeId)) flagsByNode.set(f.nodeId, []);
39076
+ flagsByNode.get(f.nodeId).push(f.flag);
39077
+ }
39078
+ const rows = [];
39079
+ for (const group of model.folders ?? []) {
39080
+ if ((group.triggerIds?.length ?? 0) === 0 && (group.skillIds?.length ?? 0) === 0) continue;
39081
+ const isFolded = folded?.has?.(group.key) === true;
39082
+ rows.push({ kind: "folder", key: group.key, group, folded: isFolded });
39083
+ if (isFolded) continue;
39084
+ for (const id of group.triggerIds ?? []) {
39085
+ const node = nodeById.get(id);
39086
+ if (node) rows.push({ kind: "gtrigger", node, flags: flagsByNode.get(id) ?? [] });
39087
+ }
39088
+ for (const id of group.skillIds ?? []) {
39089
+ const node = nodeById.get(id);
39090
+ if (!node || node.kind === "skill-unverified") continue;
39091
+ rows.push({ kind: "gskill", node, flags: flagsByNode.get(id) ?? [] });
39092
+ for (const e of model.edges ?? []) {
39093
+ if (e.from !== id || e.kind === "cron-rearm") continue;
39094
+ rows.push({ kind: "gedge", edge: e, target: nodeById.get(e.to) });
39095
+ }
39096
+ }
39097
+ }
39098
+ for (const n of model.nodes ?? []) {
39099
+ if (n.kind === "injected") rows.push({ kind: "ginjected", node: n, flags: flagsByNode.get(n.id) ?? [] });
39100
+ }
39101
+ return rows;
39102
+ }
39103
+ function graphRowLine(row, cursor, inner, styler) {
39104
+ const G = styler.glyphs;
39105
+ const pre = cursor ? styler.fg("accent", "\u203A ") : " ";
39106
+ if (row.kind === "folder") {
39107
+ const g = row.group;
39108
+ const fold = styler.fg("accent", row.folded ? G.foldClosed : G.foldOpen);
39109
+ const label = g.kind === "forge" ? `forge ${g.label}` : `folder ${g.path ?? g.label}`;
39110
+ const seen = g.kind === "forge" && Array.isArray(g.repos) && g.repos.length > 0 ? styler.fg("dim", ` \xB7 ran against ${g.repos.join(", ")}`) : "";
39111
+ const state = g.unreachable ? styler.fg("warning", ` \xB7 ${g.kind === "forge" ? "skills unverifiable from this host" : g.unreachable}`) : g.head ? styler.fg("dim", ` \xB7 HEAD ${String(g.head).slice(0, 7)}`) : "";
39112
+ return fitLine(pre + fold + " " + styler.fg("accent", label) + seen + state, inner, styler);
39113
+ }
39114
+ if (row.kind === "gtrigger") {
39115
+ const t = row.node;
39116
+ const kind = styler.cell(t.onType, KIND_WIDTH, { color: KIND_COLOR[t.onType] ?? "text" });
39117
+ const stats = t.runs > 0 ? `runs ${t.runs}${t.lastOutcome ? ` \xB7 last ${t.lastOutcome}` : ""}` : "no runs in window";
39118
+ const statColor = t.lastOutcome === "completed" ? "success" : t.lastOutcome ? "warning" : "dim";
39119
+ const badges = graphBadges(row.flags, styler);
39120
+ const left = pre + kind + styler.stripAnsi(t.label ?? "") + " " + styler.fg("dim", G.arrowRight) + " " + (t.flow ?? "(no flow)") + (t.replicas ? styler.fg("warning", ` x${t.replicas}`) : "") + badges;
39121
+ const right = styler.fg(statColor, stats);
39122
+ return joinEnds(left, right, inner, styler);
39123
+ }
39124
+ if (row.kind === "gskill") {
39125
+ const s = row.node;
39126
+ const missing = s.kind === "skill-missing";
39127
+ const name = missing ? styler.fg("error", s.name) : s.name;
39128
+ const marks = [];
39129
+ if (missing) marks.push(styler.fg("error", "[missing at HEAD]"));
39130
+ if (s.isSub) marks.push(styler.fg("dim", `[sub of ${s.group}]`));
39131
+ if (s.aiTrigger) marks.push(styler.fg("success", "[chainable]"));
39132
+ for (const loop of Array.isArray(s.loops) ? s.loops : []) marks.push(styler.fg("accent", `${G.rearm} "${loop.hint}"`));
39133
+ const badges = graphBadges(row.flags, styler);
39134
+ return fitLine(pre + styler.fg("dim", "skill ") + name + (marks.length ? " " + marks.join(" ") : "") + badges, inner, styler);
39135
+ }
39136
+ if (row.kind === "gedge") {
39137
+ const e = row.edge;
39138
+ const name = row.target?.name ?? "(unknown)";
39139
+ const label = e.kind === "observed" ? styler.fg("accent", `observed x${e.count}`) : e.kind === "potential" ? styler.fg("dim", `mention (potential${e.strong ? ", strong" : ""}; ${e.eligible ? "could fire" : "can never fire"})`) : styler.fg("dim", e.kind);
39140
+ return fitLine(pre + " " + styler.fg("dim", `${G.bl} ${G.arrowRight} `) + name + " " + label, inner, styler);
39141
+ }
39142
+ if (row.kind === "ginjected") {
39143
+ const warn = row.flags.includes("injected-ai-trigger") ? " " + styler.fg("error", "[ai-trigger is a silent no-op here]") : "";
39144
+ return fitLine(pre + styler.fg("dim", "injected ") + row.node.name + styler.fg("dim", ` (${row.node.dir})`) + warn, inner, styler);
39145
+ }
39146
+ return fitLine(pre, inner, styler);
39147
+ }
39148
+ function graphBadges(flags, styler) {
39149
+ const parts = [];
39150
+ for (const flag of flags ?? []) {
39151
+ if (flag === "no-skill") parts.push(styler.fg("error", "[no-skill]"));
39152
+ else if (flag === "charset-invalid") parts.push(styler.fg("error", "[invalid name]"));
39153
+ else if (flag === "orphan") parts.push(styler.fg("warning", "[orphan]"));
39154
+ else if (flag === "ai-reachable-no-trigger") parts.push(styler.fg("warning", "[AI-reachable, no trigger]"));
39155
+ else if (flag === "unread") parts.push(styler.fg("warning", "[unread]"));
39156
+ else if (flag === "pr-spend-loop-risk") parts.push(styler.fg("error", "[spend-loop risk]"));
39157
+ }
39158
+ return parts.length ? " " + parts.join(" ") : "";
39159
+ }
39160
+ function joinEnds(left, right, inner, styler) {
39161
+ const rightLen = styler.visibleLen(right);
39162
+ const leftMax = Math.max(0, inner - rightLen - 2);
39163
+ const leftFit = styler.visibleLen(left) > leftMax ? styler.cell(styler.stripAnsi(left), leftMax) : left + " ".repeat(leftMax - styler.visibleLen(left));
39164
+ return leftFit + " " + right;
39165
+ }
39166
+ function renderGraphView({ graph, graphSel, graphAvailable, framed, width, styler }) {
39167
+ const title = `pi-dispatch ${styler.glyphs.h}${styler.glyphs.h} GRAPH \xB7 triggers and flows`;
39168
+ const inner = width - 4;
39169
+ const model = graph?.model ?? null;
39170
+ if (!framed) {
39171
+ const plain = graph?.error ? [`graph unreachable (${graph.error})`] : model ? renderGraph(model).split("\n") : [graphAvailable ? "loading graph\u2026" : "graph unavailable in this build"];
39172
+ return [styler.stripAnsi(title), "", ...plain, "", "\u2191\u2193 move \xB7 \u21B5 open/fold \xB7 r refresh \xB7 esc back"];
39173
+ }
39174
+ const lines = [];
39175
+ if (graph?.error) {
39176
+ lines.push(fitLine(styler.fg("error", `graph unreachable (${graph.error})`), inner, styler));
39177
+ } else if (model === null) {
39178
+ lines.push(fitLine(styler.fg("dim", graphAvailable ? "loading graph\u2026" : "graph unavailable in this build"), inner, styler));
39179
+ } else if (model.meta?.triggersMissing) {
39180
+ lines.push(fitLine(styler.fg("warning", "no triggers file found"), inner, styler));
39181
+ } else if (typeof model.meta?.triggersInvalid === "string" && model.meta.triggersInvalid !== "") {
39182
+ lines.push(fitLine(styler.fg("error", `triggers file invalid: ${model.meta.triggersInvalid}`), inner, styler));
39183
+ } else {
39184
+ const rows = graphRows(model, graph.folded);
39185
+ if (rows.length === 0) {
39186
+ lines.push(fitLine(styler.fg("dim", "no triggers configured"), inner, styler));
39187
+ } else {
39188
+ const top = Math.max(0, Math.min(graphSel - GRAPH_VIEWPORT + 1, rows.length - GRAPH_VIEWPORT));
39189
+ if (top > 0) lines.push(fitLine(styler.fg("dim", `\u2026 ${top} above`), inner, styler));
39190
+ rows.slice(top, top + GRAPH_VIEWPORT).forEach((row, i) => {
39191
+ lines.push(graphRowLine(row, top + i === graphSel, inner, styler));
39192
+ });
39193
+ const below = rows.length - (top + GRAPH_VIEWPORT);
39194
+ if (below > 0) lines.push(fitLine(styler.fg("dim", `\u2026 ${below} below`), inner, styler));
39195
+ }
39196
+ const counters = graphCounterLine(model, styler);
39197
+ lines.push(RULE);
39198
+ if (counters) lines.push(fitLine(counters, inner, styler));
39199
+ lines.push(fitLine(styler.fg("dim", capsLineText2(model.caps)), inner, styler));
39200
+ }
39201
+ return frame(styler, { title, width, lines, footer: graphHints(inner, styler) });
39202
+ }
39203
+ function graphCounterLine(model, styler) {
39204
+ const bits = [];
39205
+ const meta = model?.meta ?? {};
39206
+ if ((meta.unattributedRuns ?? 0) > 0) bits.push(`${meta.unattributedRuns} runs unattributed`);
39207
+ const refused = Object.values(meta.chainRefusals ?? {}).reduce((a, n) => a + (Number(n) || 0), 0);
39208
+ if (refused > 0) bits.push(`${refused} chain requests refused`);
39209
+ const t = meta.truncated ?? {};
39210
+ if (t.folders) bits.push("folders truncated");
39211
+ if (t.skills) bits.push("skills truncated/unread");
39212
+ if (t.edges) bits.push("edges truncated");
39213
+ if ((meta.droppedObservedEdges ?? 0) > 0) bits.push(`${meta.droppedObservedEdges} observed edges dropped`);
39214
+ if ((meta.injectedUnreachable ?? []).length > 0) bits.push(`${meta.injectedUnreachable.length} injected dirs unreadable`);
39215
+ return bits.length ? styler.fg("warning", bits.join(" \xB7 ")) : null;
39216
+ }
39217
+ function capsLineText2(caps) {
39218
+ return `caps: chain depth <= ${caps?.chainDepthMax ?? "?"} \xB7 <= ${caps?.chainMaxPerJob ?? "?"} per job \xB7 same folder only \xB7 window ${caps?.windowDays ?? "?"}d`;
39219
+ }
39220
+ function graphHints(inner, styler) {
39221
+ const k = (key, label) => styler.fg("accent", key) + " " + styler.fg("dim", label);
39222
+ const hints = [k("\u2191\u2193", "move"), k("\u21B5", "open/fold"), k("r", "refresh"), k("esc", "back")].join(styler.fg("dim", " \xB7 "));
36987
39223
  return fitLine(hints, inner, styler);
36988
39224
  }
36989
39225
  function costsWindowSinceMs(windowKey, nowMs) {
@@ -37248,7 +39484,7 @@ function countdownText(ms) {
37248
39484
  return h ? `resets ${h}` : "";
37249
39485
  }
37250
39486
  function buildRows(snapshot, runSort = "time") {
37251
- const triggers = (snapshot?.triggers?.triggers ?? []).map((t, i) => ({ kind: "trigger", trigger: t, index: i }));
39487
+ const triggers = (snapshot?.triggers?.triggers ?? []).map((t, i) => ({ kind: "trigger", trigger: t, index: Number.isInteger(t?.index) ? t.index : i }));
37252
39488
  const active2 = snapshot?.activeJobId ? [{ kind: "active", jobId: snapshot.activeJobId }] : [];
37253
39489
  const runs = (Array.isArray(snapshot?.runs) ? snapshot.runs : []).map((record) => ({ kind: "run", record }));
37254
39490
  return [...triggers, ...active2, ...sortRuns(runs, runSort)];
@@ -37264,24 +39500,24 @@ function sortRuns(rows, runSort) {
37264
39500
  return sorted;
37265
39501
  }
37266
39502
  function renderRunList(rows, selected, w) {
37267
- if (!Array.isArray(rows) || rows.length === 0) return [clip("(no runs)", w)];
39503
+ if (!Array.isArray(rows) || rows.length === 0) return [clip2("(no runs)", w)];
37268
39504
  const { top, count } = runsWindow(rows.length, selected);
37269
39505
  const out = [];
37270
- if (top > 0) out.push(clip(`^ ${top} more`, w));
39506
+ if (top > 0) out.push(clip2(`^ ${top} more`, w));
37271
39507
  for (let i = top; i < top + count; i++) {
37272
39508
  const row = rows[i];
37273
39509
  const cursor = i === selected ? "\u203A" : " ";
37274
39510
  if (row.kind === "active") {
37275
- out.push(clip(`${cursor} * ACTIVE ${row.jobId} running`, w));
39511
+ out.push(clip2(`${cursor} * ACTIVE ${row.jobId} running`, w));
37276
39512
  continue;
37277
39513
  }
37278
39514
  const run = row.record;
37279
39515
  const rep = run?.replica > 0 ? `r${run.replica}/${run.replicas ?? "?"} ` : "";
37280
39516
  const cells = [run?.jobId, run?.target, run?.flow, run?.outcome, run?.turns, run?.tokens?.total].map((f) => f === null || f === void 0 ? "-" : String(f)).join(" \xB7 ");
37281
- out.push(clip(`${cursor} ${rep}${cells}`, w));
39517
+ out.push(clip2(`${cursor} ${rep}${cells}`, w));
37282
39518
  }
37283
39519
  const below = rows.length - top - count;
37284
- if (below > 0) out.push(clip(`v ${below} more`, w));
39520
+ if (below > 0) out.push(clip2(`v ${below} more`, w));
37285
39521
  return out;
37286
39522
  }
37287
39523
  function tailMatches(lines, query) {
@@ -37322,14 +39558,14 @@ function renderLiveTail({ snapshot, framed, width, tailJobId, tail, tailTop, tai
37322
39558
  return [boxTitle, "", ...plain, "", footer];
37323
39559
  }
37324
39560
  const inner = Math.trunc(width) - 4;
37325
- const lines = [clip(`live ${tailJobId} -- ${len} line(s)`, inner)];
39561
+ const lines = [clip2(`live ${tailJobId} -- ${len} line(s)`, inner)];
37326
39562
  for (let i = top; i < Math.min(top + TAIL_VIEWPORT, len); i++) {
37327
- const safe = clip(all[i], inner);
39563
+ const safe = clip2(all[i], inner);
37328
39564
  lines.push(tailQuery !== null && i === tailMatchLine ? styler.fg("warning", safe) : safe);
37329
39565
  }
37330
- if (ended) lines.push(clip("(run ended -- Esc to go back)", inner));
39566
+ if (ended) lines.push(clip2("(run ended -- Esc to go back)", inner));
37331
39567
  if (tailSearchInput) lines.push(styler.fg("accent", "/ ") + styler.lineInput(tailSearchInput, Math.max(1, inner - 2)));
37332
- return frame(styler, { title: boxTitle, width: Math.trunc(width), lines, footer: clip(footer, inner) });
39568
+ return frame(styler, { title: boxTitle, width: Math.trunc(width), lines, footer: clip2(footer, inner) });
37333
39569
  }
37334
39570
  function renderRunDetail(record, inner, styler, allRuns = [], sandbox = null) {
37335
39571
  const r = record ?? {};
@@ -37425,7 +39661,7 @@ function runDetailHints(inner, styler, canOpen = false, canCopy = false, copiedN
37425
39661
  if (copiedNote) return fitLine(styler.fg("success", copiedNote) + styler.fg("dim", " \xB7 ") + line, inner, styler);
37426
39662
  return fitLine(line, inner, styler);
37427
39663
  }
37428
- var KEY_HINTS, RUNS_ON_DASHBOARD, RUNS_VIEWPORT, RUN_SORTS, REFRESH_MS, TAIL_LINES, TAIL_VIEWPORT, MIN_WIDTH2, DRILL_WIDTH, COSTS_WINDOWS, COSTS_STALE_MS, KIND_COLOR, KIND_WIDTH, FRAME_CHROME_ROWS, MONTHS;
39664
+ var KEY_HINTS, RUNS_ON_DASHBOARD, RUNS_VIEWPORT, RUN_SORTS, REFRESH_MS, TAIL_LINES, TAIL_VIEWPORT, MIN_WIDTH2, DRILL_WIDTH, COSTS_WINDOWS, COSTS_STALE_MS, GRAPH_VIEWPORT, KIND_COLOR, KIND_WIDTH, FRAME_CHROME_ROWS, MONTHS;
37429
39665
  var init_dashboard = __esm({
37430
39666
  "src/dashboard.ts"() {
37431
39667
  init_budget();
@@ -37436,6 +39672,7 @@ var init_dashboard = __esm({
37436
39672
  init_pricing();
37437
39673
  init_read_model();
37438
39674
  init_render();
39675
+ init_graph_model();
37439
39676
  init_keys2();
37440
39677
  init_panel();
37441
39678
  init_style();
@@ -37451,6 +39688,7 @@ var init_dashboard = __esm({
37451
39688
  DRILL_WIDTH = 70;
37452
39689
  COSTS_WINDOWS = ["7d", "30d", "mtd"];
37453
39690
  COSTS_STALE_MS = 1e4;
39691
+ GRAPH_VIEWPORT = 16;
37454
39692
  KIND_COLOR = { cron: "accent", label: "syntaxType", comment: "syntaxKeyword", pull_request: "syntaxFunction" };
37455
39693
  KIND_WIDTH = 13;
37456
39694
  FRAME_CHROME_ROWS = 4;
@@ -37478,7 +39716,7 @@ __export(setup_wizard_exports, {
37478
39716
  runtimeDirFor: () => runtimeDirFor
37479
39717
  });
37480
39718
  import * as nodeFs4 from "node:fs";
37481
- import { execFileSync as execFileSync3, spawn as nodeSpawn } from "node:child_process";
39719
+ import { execFileSync as execFileSync3, spawn as nodeSpawn2 } from "node:child_process";
37482
39720
  import { homedir as homedir2 } from "node:os";
37483
39721
  import { join as join6 } from "node:path";
37484
39722
  function envIsSet(env, key) {
@@ -37513,7 +39751,7 @@ async function detectDeployment({
37513
39751
  }
37514
39752
  return { state: "none", detail: "no pointer, no env, no deployment files here, queue unreachable" };
37515
39753
  }
37516
- async function runAttached(ctx, { title, argv0, args, cwd, env, shell, spawnFn = nodeSpawn }) {
39754
+ async function runAttached(ctx, { title, argv0, args, cwd, env, shell, spawnFn = nodeSpawn2 }) {
37517
39755
  if (ctx?.mode !== "tui" || typeof ctx?.ui?.custom !== "function") {
37518
39756
  return { code: null, error: new Error("needs the terminal UI") };
37519
39757
  }
@@ -37987,8 +40225,8 @@ var init_setup_wizard = __esm({
37987
40225
  init_deployment_pointer();
37988
40226
  init_read_model();
37989
40227
  init_index();
37990
- RUNTIME_VERSION = "0.1.2";
37991
- RECEIVER_VERSION = "0.1.1";
40228
+ RUNTIME_VERSION = "0.3.0";
40229
+ RECEIVER_VERSION = "0.2.1";
37992
40230
  RUNTIME_PKG = "@edgehero/pi-dispatch";
37993
40231
  RECEIVER_PKG = "@edgehero/pi-dispatch-receiver";
37994
40232
  NUDGE_MARKER_BASENAME = "pi-dispatch-setup.nudged";
@@ -38005,7 +40243,7 @@ var init_setup_wizard = __esm({
38005
40243
 
38006
40244
  // src/index.ts
38007
40245
  import { VERSION } from "@earendil-works/pi-coding-agent";
38008
- import { fileURLToPath as fileURLToPath2 } from "node:url";
40246
+ import { fileURLToPath as fileURLToPath2, pathToFileURL } from "node:url";
38009
40247
  import * as nodeFs5 from "node:fs";
38010
40248
  function computePiVersionAdvisory(version, supported) {
38011
40249
  if (version === supported || version === "0.0.0") return void 0;
@@ -38190,7 +40428,7 @@ function registerTools(pi) {
38190
40428
  pi.registerTool({
38191
40429
  name: "dispatch_trigger_add",
38192
40430
  label: "pi-dispatch add trigger",
38193
- description: "Adds a trigger to triggers.json and applies it live. The operator MUST approve a confirm dialog showing the entry; with no interactive operator it is refused. `flow` is the .pi/skills/<name> skill the job runs (its SKILL.md is the agent's instructions). `kind` = cron|label|comment|pull_request. cron (local) needs id, pattern, `folder` (absolute host path the job runs in), `flow`, and `task` (the prompt text handed to the agent), and may set optional model/provider/maxTurns for that schedule (omit = deployment default). label needs labels[]+flow; comment needs phrase+flow; pull_request needs action[] (+ optional labels[]) + flow. Webhook triggers take an optional `forge` = github (default) | gitlab, which also decides which action words pull_request accepts: github is labeled|opened|synchronize|reopened, gitlab is open|update|reopen|approved. For webhook triggers the repo and the task come from the triggering issue/PR event \u2014 set only the match + flow \u2014 and they run under the deployment default model.",
40431
+ description: "Adds a trigger to triggers.json and applies it live. The operator MUST approve a confirm dialog showing the entry; with no interactive operator it is refused. `flow` is the .pi/skills/<name> skill the job runs (its SKILL.md is the agent's instructions). `kind` = cron|label|comment|pull_request. cron (local) needs id, pattern, `folder` (absolute host path the job runs in), `flow`, and `task` (the prompt text handed to the agent), and may set optional model/provider/maxTurns for that schedule (omit = deployment default). label needs labels[]+flow; comment needs phrase+flow; pull_request needs action[] (+ optional labels[]) + flow. Webhook triggers take an optional `forge` = github (default) | gitlab, which also decides which action words pull_request accepts: github is labeled|opened|synchronize|reopened|review_submitted, gitlab is open|update|reopen|approved. A github review_submitted trigger may also set reviewState[] (approved|changes_requested|commented) to narrow which verdicts fire; omitted, all three do. For webhook triggers the repo and the task come from the triggering issue/PR event \u2014 set only the match + flow \u2014 and they run under the deployment default model.",
38194
40432
  executionMode: "sequential",
38195
40433
  parameters: typebox_exports.Object({
38196
40434
  kind: typebox_exports.String(),
@@ -38522,6 +40760,18 @@ ${renderBudget({ budget, settings: settings2 })}`);
38522
40760
  costsCommand(pi, paths, tokens, notify);
38523
40761
  return;
38524
40762
  }
40763
+ case "graph": {
40764
+ if (tokens[1] === "html") {
40765
+ await graphHtmlCommand(paths, tokens, notify);
40766
+ return;
40767
+ }
40768
+ if (tokens.length > 1) {
40769
+ notify?.(GRAPH_USAGE, "warning");
40770
+ return;
40771
+ }
40772
+ send(pi, renderGraph(await assembleGraph(paths)));
40773
+ return;
40774
+ }
38525
40775
  case "run": {
38526
40776
  const folder = tokens[1];
38527
40777
  const flow = tokens[2];
@@ -38598,6 +40848,63 @@ function assembleCosts(paths, window, flow) {
38598
40848
  });
38599
40849
  return { fold };
38600
40850
  }
40851
+ async function assembleGraph(paths) {
40852
+ const nowMs = Date.now();
40853
+ const triggers = readTriggers({ triggersPath: paths.triggersPath });
40854
+ const triggerList2 = Array.isArray(triggers?.triggers) ? triggers.triggers : [];
40855
+ const schedulers = await readSchedulers({ url: paths.valkeyUrl });
40856
+ const records = scanRunRecords({ logsDir: paths.logsDir, sinceMs: nowMs - GRAPH_LIMITS.windowDays * 24 * 60 * 60 * 1e3, nowMs });
40857
+ const recs = Array.isArray(records) ? records : [];
40858
+ return buildGraphModel({
40859
+ triggers,
40860
+ schedulers: Array.isArray(schedulers) ? schedulers : [],
40861
+ ...collectGraphInputs({ triggers: triggerList2 }),
40862
+ cronStats: cronRunStats({ records: recs, schedulerIds: triggerList2.filter((t) => t.type === "cron" && typeof t.id === "string").map((t) => t.id) }),
40863
+ runJoin: joinRunsToTriggers({ records: recs, triggerCount: triggers?.count, triggerTypes: Object.fromEntries(triggerList2.map((t) => [t.index, t.type])) }),
40864
+ chainEdges: observedChainEdges({ records: recs }),
40865
+ forgeRepos: forgeRepoTargets({ records: recs }),
40866
+ caps: { chainDepthMax: paths.chainDepthMax, chainMaxPerJob: paths.chainMaxPerJob, windowDays: GRAPH_LIMITS.windowDays },
40867
+ nowMs
40868
+ });
40869
+ }
40870
+ function realGraphHtmlDeps() {
40871
+ return { fs: nodeFs5, openBrowser, env: process.env, now: () => Date.now(), platform: process.platform };
40872
+ }
40873
+ function isHeadlessEnv(env, platform) {
40874
+ if (env?.SSH_CONNECTION || env?.SSH_TTY) return "SSH session";
40875
+ if (platform === "linux" && !env?.DISPLAY && !env?.WAYLAND_DISPLAY) return "no display";
40876
+ return null;
40877
+ }
40878
+ async function graphHtmlCommand(paths, tokens, notify, deps = realGraphHtmlDeps()) {
40879
+ const rest = tokens.slice(2);
40880
+ const noOpen = rest.includes("--no-open");
40881
+ const fullPaths = rest.includes("--full-paths");
40882
+ if (rest.some((t) => t !== "--no-open" && t !== "--full-paths")) {
40883
+ notify?.(GRAPH_USAGE, "warning");
40884
+ return;
40885
+ }
40886
+ const model = await assembleGraph(paths);
40887
+ const html = buildGraphHtml(model, { now: deps.now(), fullPaths });
40888
+ const file = `${paths.graphDir}/graph.html`;
40889
+ try {
40890
+ deps.fs.mkdirSync(paths.graphDir, { recursive: true });
40891
+ const tmp = `${file}.tmp`;
40892
+ deps.fs.writeFileSync(tmp, html, { mode: 420 });
40893
+ deps.fs.renameSync(tmp, file);
40894
+ } catch (err) {
40895
+ notify?.(`graph html: could not write ${file} (${err?.message ?? err})`, "error");
40896
+ return;
40897
+ }
40898
+ const url = pathToFileURL(file).href;
40899
+ notify?.(`graph written: ${url}`, "info");
40900
+ if (noOpen) return;
40901
+ const headless = isHeadlessEnv(deps.env, deps.platform);
40902
+ if (headless) {
40903
+ notify?.(`not opening a browser (${headless}): open the URL on this machine's desktop, or scp the file there`, "info");
40904
+ return;
40905
+ }
40906
+ deps.openBrowser(url);
40907
+ }
38601
40908
  function costsCommand(pi, paths, tokens, notify) {
38602
40909
  if (tokens[1] === "whatif") {
38603
40910
  costsWhatIf(pi, paths, tokens, notify);
@@ -39037,6 +41344,11 @@ function completeArguments(prefix) {
39037
41344
  const items = ["7d", "30d", "mtd", "whatif"].filter((w) => w.startsWith(partial)).map((w) => ({ value: `costs ${w}`, label: w }));
39038
41345
  return items.length > 0 ? items : null;
39039
41346
  }
41347
+ if (parts[0] === "graph" && parts.length === 2) {
41348
+ const partial = parts[1];
41349
+ const items = ["html"].filter((w) => w.startsWith(partial)).map((w) => ({ value: `graph ${w}`, label: w }));
41350
+ return items.length > 0 ? items : null;
41351
+ }
39040
41352
  if ((parts[0] === "set" || parts[0] === "unset") && parts.length === 2) {
39041
41353
  const partial = parts[1];
39042
41354
  const items = KNOWN_KEYS.filter((k) => k.startsWith(partial)).map((k) => ({
@@ -39047,11 +41359,14 @@ function completeArguments(prefix) {
39047
41359
  }
39048
41360
  return null;
39049
41361
  }
39050
- var USED_API, SUPPORTED_PI_VERSION, piVersionAdvisory, piVersionCheckDone, runtimeSkewNoticed, CHANNEL, REBUILT_NOTICE, USAGE, KNOWN_SUBCOMMANDS, FORGE_PROMPT, PR_ACTION_VOCAB, COSTS_WINDOWS2, COSTS_USAGE, DAY_MS2, PRICING, VIEWPORT_LINES;
41362
+ var USED_API, SUPPORTED_PI_VERSION, piVersionAdvisory, piVersionCheckDone, runtimeSkewNoticed, CHANNEL, REBUILT_NOTICE, USAGE, KNOWN_SUBCOMMANDS, FORGE_PROMPT, PR_ACTION_VOCAB, COSTS_WINDOWS2, COSTS_USAGE, DAY_MS2, PRICING, GRAPH_USAGE, VIEWPORT_LINES;
39051
41363
  var init_index = __esm({
39052
41364
  "src/index.ts"() {
39053
41365
  init_build();
39054
41366
  init_read_model();
41367
+ init_graph_model();
41368
+ init_graph_html();
41369
+ init_open_browser();
39055
41370
  init_deployment_pointer();
39056
41371
  init_costs();
39057
41372
  init_pricing();
@@ -39068,7 +41383,7 @@ var init_index = __esm({
39068
41383
  runtimeSkewNoticed = false;
39069
41384
  CHANNEL = "pi-dispatch-admin";
39070
41385
  REBUILT_NOTICE = (reason) => `replaced invalid settings file (${reason}) \u2014 other keys were lost`;
39071
- USAGE = "usage: /dispatch <status|pause|resume|run|runs|logs|budget|costs|triggers|settings|set|unset|setup>";
41386
+ USAGE = "usage: /dispatch <status|pause|resume|run|runs|logs|budget|costs|graph|triggers|settings|set|unset|setup>";
39072
41387
  KNOWN_SUBCOMMANDS = [
39073
41388
  "status",
39074
41389
  "pause",
@@ -39078,6 +41393,7 @@ var init_index = __esm({
39078
41393
  "logs",
39079
41394
  "budget",
39080
41395
  "costs",
41396
+ "graph",
39081
41397
  "triggers",
39082
41398
  "settings",
39083
41399
  "set",
@@ -39086,7 +41402,7 @@ var init_index = __esm({
39086
41402
  ];
39087
41403
  FORGE_PROMPT = "forge \u2014 github, gitlab, forgejo or azure";
39088
41404
  PR_ACTION_VOCAB = {
39089
- github: { hint: "labeled opened synchronize reopened", dflt: "labeled" },
41405
+ github: { hint: "labeled opened synchronize reopened review_submitted", dflt: "labeled" },
39090
41406
  gitlab: { hint: "open update reopen approved", dflt: "update" },
39091
41407
  forgejo: { hint: "label_updated opened synchronized reopened", dflt: "label_updated" },
39092
41408
  azure: { hint: "created updated", dflt: "updated" }
@@ -39095,6 +41411,7 @@ var init_index = __esm({
39095
41411
  COSTS_USAGE = "usage: /dispatch costs [7d|30d|mtd] | /dispatch costs whatif <provider/model> --flow <flow>";
39096
41412
  DAY_MS2 = 24 * 60 * 60 * 1e3;
39097
41413
  PRICING = { listPricedModels, getPricedModel, isZeroRated, reprice, piAiVersion };
41414
+ GRAPH_USAGE = "usage: /dispatch graph [html [--no-open] [--full-paths]]";
39098
41415
  VIEWPORT_LINES = 20;
39099
41416
  }
39100
41417
  });
@@ -39106,5 +41423,7 @@ export {
39106
41423
  buildTriggerEntry,
39107
41424
  computePiVersionAdvisory,
39108
41425
  admin as default,
39109
- handleDashboardAction
41426
+ graphHtmlCommand,
41427
+ handleDashboardAction,
41428
+ isHeadlessEnv
39110
41429
  };