@edgehero/pi-dispatch-admin 0.6.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.
Files changed (3) hide show
  1. package/README.md +6 -0
  2. package/dist/index.mjs +2259 -51
  3. package/package.json +1 -1
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
 
@@ -8262,7 +8268,7 @@ function normalizeWindow(w, index, path) {
8262
8268
  return norm;
8263
8269
  }
8264
8270
  function zonedParts(tz, ms) {
8265
- const fmt = new Intl.DateTimeFormat("en-US", {
8271
+ const fmt2 = new Intl.DateTimeFormat("en-US", {
8266
8272
  timeZone: tz,
8267
8273
  hour12: false,
8268
8274
  year: "numeric",
@@ -8273,7 +8279,7 @@ function zonedParts(tz, ms) {
8273
8279
  weekday: "short"
8274
8280
  });
8275
8281
  const p = {};
8276
- 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;
8277
8283
  let hh = Number(p.hour);
8278
8284
  if (hh === 24) hh = 0;
8279
8285
  return { y: Number(p.year), mo: Number(p.month), d: Number(p.day), hh, mi: Number(p.minute), dow: WEEKDAY_INDEX[p.weekday] };
@@ -8594,9 +8600,9 @@ var init_flow_gate = __esm({
8594
8600
 
8595
8601
  // ../worker/src/git-dirty.mjs
8596
8602
  import { execFileSync } from "node:child_process";
8597
- function gitDirty(folder, { exec: exec3 = execFileSync } = {}) {
8603
+ function gitDirty(folder, { exec: exec4 = execFileSync } = {}) {
8598
8604
  try {
8599
- const out = exec3("git", ["-C", folder, "status", "--porcelain"], { encoding: "utf8" });
8605
+ const out = exec4("git", ["-C", folder, "status", "--porcelain"], { encoding: "utf8" });
8600
8606
  return out.trim().length > 0;
8601
8607
  } catch {
8602
8608
  return null;
@@ -8748,6 +8754,493 @@ var init_packages = __esm({
8748
8754
  }
8749
8755
  });
8750
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
+
8751
9244
  // src/read-model.mjs
8752
9245
  import * as nodeFs2 from "node:fs";
8753
9246
  import { join as join2, delimiter, sep } from "node:path";
@@ -8789,7 +9282,18 @@ function resolvePaths(env = process.env) {
8789
9282
  dispatchRunPerHour: parseNonNegInt(env.PI_DISPATCH_RUN_PER_HOUR, 3),
8790
9283
  // The per-scheduler stall threshold, mirrored from the worker's PI_SCHEDULER_STALL_MAX (default 2) so the
8791
9284
  // cron drill-in can show `stalls n/threshold`. Read directly from env for the same reason as above.
8792
- 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)
8793
9297
  };
8794
9298
  }
8795
9299
  function parseNonNegInt(raw, fallback) {
@@ -8826,9 +9330,9 @@ async function setQueuePaused({ url, paused, makeQueueFn = makeQueue, parseConne
8826
9330
  });
8827
9331
  }
8828
9332
  }
8829
- function revParseHead(folder, { exec: exec3 = execFileSync2 } = {}) {
9333
+ function revParseHead(folder, { exec: exec4 = execFileSync2 } = {}) {
8830
9334
  try {
8831
- const out = exec3("git", ["-C", folder, "rev-parse", "HEAD"], { encoding: "utf8" });
9335
+ const out = exec4("git", ["-C", folder, "rev-parse", "HEAD"], { encoding: "utf8" });
8832
9336
  const sha = out.trim();
8833
9337
  return sha.length > 0 ? sha : null;
8834
9338
  } catch {
@@ -9143,11 +9647,11 @@ function readTriggers({ triggersPath, fs = nodeFs2 }) {
9143
9647
  return { invalid: 'triggers file must have a "triggers" array' };
9144
9648
  }
9145
9649
  const triggers = [];
9146
- for (const entry of entries) {
9650
+ entries.forEach((entry, index) => {
9147
9651
  const display = normalizeTriggerForDisplay(entry);
9148
- if (display) triggers.push(display);
9149
- }
9150
- return { triggers };
9652
+ if (display) triggers.push({ ...display, index });
9653
+ });
9654
+ return { triggers, count: entries.length };
9151
9655
  }
9152
9656
  function normalizeTriggerForDisplay(entry) {
9153
9657
  if (entry === null || typeof entry !== "object") return null;
@@ -9229,6 +9733,225 @@ function readStagedPackages({ globalPiDir, fs = nodeFs2, readManifest: readManif
9229
9733
  function nameAtVersion(pkg) {
9230
9734
  return typeof pkg.version === "string" && pkg.version !== "" ? `${pkg.name}@${pkg.version}` : pkg.name;
9231
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
+ }
9232
9955
  function listRunIds({ logsDir, fs = nodeFs2 }) {
9233
9956
  let names;
9234
9957
  try {
@@ -9267,7 +9990,7 @@ function clampLines(lines) {
9267
9990
  const n = Number.isFinite(lines) ? Math.floor(lines) : 200;
9268
9991
  return Math.min(2e3, Math.max(1, n));
9269
9992
  }
9270
- 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;
9271
9994
  var init_read_model = __esm({
9272
9995
  "src/read-model.mjs"() {
9273
9996
  init_config2();
@@ -9282,8 +10005,1058 @@ var init_read_model = __esm({
9282
10005
  init_flow_gate();
9283
10006
  init_git_dirty();
9284
10007
  init_packages();
10008
+ init_materialize();
10009
+ init_forges();
10010
+ init_graph_model();
9285
10011
  DISPATCH_RUN_TTL_SECONDS = 2 * 60 * 60;
9286
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"() {
9287
11060
  }
9288
11061
  });
9289
11062
 
@@ -35315,7 +37088,7 @@ function setGlyphs(ascii) {
35315
37088
  function stripControls(s) {
35316
37089
  return String(s ?? "").replace(CONTROL_CHARS, "");
35317
37090
  }
35318
- function clip(line, w) {
37091
+ function clip2(line, w) {
35319
37092
  const width = Math.max(0, Math.trunc(w) || 0);
35320
37093
  const clean = stripControls(line);
35321
37094
  if (clean.length <= width) return clean;
@@ -35325,13 +37098,13 @@ function clip(line, w) {
35325
37098
  }
35326
37099
  function pad(line, w) {
35327
37100
  const width = Math.max(0, Math.trunc(w) || 0);
35328
- return clip(line, width).padEnd(width);
37101
+ return clip2(line, width).padEnd(width);
35329
37102
  }
35330
37103
  function box({ title = "", sections = [], footer, width = 40 } = {}) {
35331
37104
  const w = Math.max(MIN_WIDTH, Math.trunc(width) || MIN_WIDTH);
35332
37105
  const inner = w - 4;
35333
37106
  const lines = [];
35334
- const titleText = title ? ` ${clip(title, Math.max(0, inner - 2))} ` : "";
37107
+ const titleText = title ? ` ${clip2(title, Math.max(0, inner - 2))} ` : "";
35335
37108
  const topFill = Math.max(0, w - 2 - 1 - titleText.length);
35336
37109
  lines.push(active.tl + active.h + titleText + active.h.repeat(topFill) + active.tr);
35337
37110
  const framed = (text) => `${active.v} ${pad(text, inner)} ${active.v}`;
@@ -35351,14 +37124,14 @@ function box({ title = "", sections = [], footer, width = 40 } = {}) {
35351
37124
  function meter(reserved, cap, width = 24, state = "ok") {
35352
37125
  const r = Number.isFinite(reserved) ? Math.max(0, Math.trunc(reserved)) : 0;
35353
37126
  if (!Number.isInteger(cap) || cap <= 0) {
35354
- return clip(`${r} / ? (cap unknown)`, width);
37127
+ return clip2(`${r} / ? (cap unknown)`, width);
35355
37128
  }
35356
37129
  const tag = state === "soft-hold" ? " soft-hold" : state === "over" ? " over" : "";
35357
37130
  const label = ` ${r}/${cap}${tag}`;
35358
37131
  const barCells = Math.max(0, Math.trunc(width) - label.length - 2);
35359
37132
  const filled = Math.min(barCells, Math.round(Math.min(r, cap) / cap * barCells));
35360
37133
  const bar = `[${active.full.repeat(filled)}${active.empty.repeat(barCells - filled)}]`;
35361
- return clip(bar + label, width);
37134
+ return clip2(bar + label, width);
35362
37135
  }
35363
37136
  function sparkline(values, width = 24, opts = {}) {
35364
37137
  const w = Math.max(0, Math.trunc(width) || 0);
@@ -35366,7 +37139,7 @@ function sparkline(values, width = 24, opts = {}) {
35366
37139
  const norm = (Array.isArray(values) ? values : []).map((v) => Number.isFinite(v) ? Math.max(0, v) : null);
35367
37140
  const finite = norm.filter((v) => v !== null);
35368
37141
  const ceiling = Number.isFinite(opts?.max) && opts.max > 0 ? opts.max : Math.max(0, ...finite);
35369
- if (finite.length === 0 || ceiling <= 0) return clip("no data", w);
37142
+ if (finite.length === 0 || ceiling <= 0) return clip2("no data", w);
35370
37143
  let cells = norm;
35371
37144
  if (norm.length > w) {
35372
37145
  cells = [];
@@ -35544,9 +37317,9 @@ var init_sandbox_store = __esm({
35544
37317
  });
35545
37318
 
35546
37319
  // ../worker/src/sandbox.mjs
35547
- import { execFile as execFile3, spawn } from "node:child_process";
37320
+ import { execFile as execFile4, spawn } from "node:child_process";
35548
37321
  import { existsSync as existsSync2 } from "node:fs";
35549
- import { promisify as promisify3 } from "node:util";
37322
+ import { promisify as promisify4 } from "node:util";
35550
37323
  function sandboxContainerName(jobId) {
35551
37324
  return `${SANDBOX_NAME_PREFIX}${sanitizeJobId(jobId)}`;
35552
37325
  }
@@ -35591,14 +37364,14 @@ function launchSandbox({ args, spawnFn = spawn }) {
35591
37364
  child.on("close", (code) => resolve({ code }));
35592
37365
  });
35593
37366
  }
35594
- var exec2, SANDBOX_NAME_PREFIX;
37367
+ var exec3, SANDBOX_NAME_PREFIX;
35595
37368
  var init_sandbox = __esm({
35596
37369
  "../worker/src/sandbox.mjs"() {
35597
37370
  init_config2();
35598
37371
  init_docker_run();
35599
37372
  init_run_history();
35600
37373
  init_sandbox_store();
35601
- exec2 = promisify3(execFile3);
37374
+ exec3 = promisify4(execFile4);
35602
37375
  SANDBOX_NAME_PREFIX = "pi-sandbox-";
35603
37376
  }
35604
37377
  });
@@ -35624,8 +37397,8 @@ function renderRuns(runs) {
35624
37397
  const headers = RUN_COLUMNS.map((c) => c.header);
35625
37398
  const rows = list.map((r) => RUN_COLUMNS.map((c) => c.derive ? c.derive(r) : cell(r?.[c.key])));
35626
37399
  const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((row) => row[i].length)));
35627
- const fmt = (cells) => cells.map((v, i) => v.padEnd(widths[i])).join(" ").trimEnd();
35628
- 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");
35629
37402
  }
35630
37403
  function renderBudget({ budget, settings: settings2 } = {}) {
35631
37404
  if (!budget || budget.unreachable) {
@@ -35802,8 +37575,8 @@ function renderWhatIf(result, { flow, target } = {}) {
35802
37575
  }
35803
37576
  function tableLines(headers, rows) {
35804
37577
  const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((row) => row[i].length)));
35805
- const fmt = (cells) => cells.map((v, i) => v.padEnd(widths[i])).join(" ").trimEnd();
35806
- 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)];
35807
37580
  }
35808
37581
  function renderSettingsView(settings2) {
35809
37582
  const path = settings2?.path ?? "(unknown path)";
@@ -35816,6 +37589,105 @@ function renderSettingsView(settings2) {
35816
37589
  }
35817
37590
  return out.join("\n");
35818
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
+ }
35819
37691
  var SETTINGS_KEYS, BUDGET_WINDOWS, RUN_COLUMNS, COSTS_WINDOW_LABELS;
35820
37692
  var init_render = __esm({
35821
37693
  "src/render.mjs"() {
@@ -36035,8 +37907,8 @@ var init_style = __esm({
36035
37907
  inverse: (t) => t,
36036
37908
  strikethrough: (t) => t
36037
37909
  };
36038
- OVERLAY_GLYPHS = { tl: "\u250C", tr: "\u2510", bl: "\u2514", br: "\u2518", ml: "\u251C", mr: "\u2524", h: "\u2500", v: "\u2502", full: "\u2588", empty: "\u2591", ellipsis: "\u2026" };
36039
- 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: "~" };
36040
37912
  COST_COLORS = {
36041
37913
  metered: "text",
36042
37914
  plan: "accent",
@@ -36112,6 +37984,30 @@ function createDashboardDeps(paths) {
36112
37984
  const fold = foldCosts({ records, subscriptions, pricing: pricing_exports, nowMs, piAiPin: piAiVersion() });
36113
37985
  return { fold, records, subscriptions };
36114
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
+ },
36115
38011
  /** The priced-model catalog for the what-if `/` filter -- the façade stays behind this seam. */
36116
38012
  listPricedModels() {
36117
38013
  return listPricedModels();
@@ -36146,7 +38042,7 @@ function makeDashboard({
36146
38042
  intervalMs = REFRESH_MS,
36147
38043
  deps = createDashboardDeps(paths)
36148
38044
  } = {}) {
36149
- const styler = makeStyler(theme);
38045
+ const styler = makeStyler(theme, { ascii: paths?.asciiGlyphs === true });
36150
38046
  let snapshot = null;
36151
38047
  let fetching = false;
36152
38048
  let disposed = false;
@@ -36156,6 +38052,7 @@ function makeDashboard({
36156
38052
  let detailRun = null;
36157
38053
  let detailSandbox = null;
36158
38054
  let detailTrigger = null;
38055
+ let detailReturnTo = "LIST";
36159
38056
  let tailJobId = null;
36160
38057
  let tail = null;
36161
38058
  let tailTop = 0;
@@ -36169,6 +38066,9 @@ function makeDashboard({
36169
38066
  let costs = { data: null, windowKey: "mtd", fetchedAt: 0, table: "flow", whatIf: null };
36170
38067
  let costsSel = 0;
36171
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;
36172
38072
  const refresh = async () => {
36173
38073
  if (fetching || disposed) return;
36174
38074
  fetching = true;
@@ -36209,6 +38109,20 @@ function makeDashboard({
36209
38109
  tui?.requestRender?.();
36210
38110
  }
36211
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
+ };
36212
38126
  const computeWhatIf = () => {
36213
38127
  const wi = costs.whatIf;
36214
38128
  if (!wi) return;
@@ -36249,6 +38163,9 @@ function makeDashboard({
36249
38163
  if (view === "COSTS" && costsSel > costsTableRows(costs).length - 1) {
36250
38164
  costsSel = Math.max(0, costsTableRows(costs).length - 1);
36251
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
+ }
36252
38169
  return renderPanel(snapshot, width, {
36253
38170
  view,
36254
38171
  selected,
@@ -36269,6 +38186,9 @@ function makeDashboard({
36269
38186
  costs,
36270
38187
  costsSel,
36271
38188
  costsAvailable: typeof deps?.fetchCosts === "function",
38189
+ graph,
38190
+ graphSel,
38191
+ graphAvailable: typeof deps?.fetchGraph === "function",
36272
38192
  copiedNote,
36273
38193
  copyAvailable: typeof deps?.copyText === "function",
36274
38194
  // Height through the injected seam, read per frame (a resize changes it): null (seam absent, or
@@ -36338,7 +38258,7 @@ function makeDashboard({
36338
38258
  return;
36339
38259
  }
36340
38260
  if (matchesKey(data, "escape")) {
36341
- view = "LIST";
38261
+ view = detailReturnTo;
36342
38262
  detailTrigger = null;
36343
38263
  tui?.requestRender?.();
36344
38264
  return;
@@ -36519,6 +38439,45 @@ function makeDashboard({
36519
38439
  }
36520
38440
  return;
36521
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
+ }
36522
38481
  if (matchesKey(data, "escape") || data === "q" || data === "Q") {
36523
38482
  void dispose().finally(() => done(void 0));
36524
38483
  return;
@@ -36530,6 +38489,7 @@ function makeDashboard({
36530
38489
  if (row.kind === "trigger") {
36531
38490
  detailTrigger = { record: row.trigger, index: row.index };
36532
38491
  pendingDelete = false;
38492
+ detailReturnTo = "LIST";
36533
38493
  view = "TRIGGER_DETAIL";
36534
38494
  tui?.requestRender?.();
36535
38495
  } else if (row.kind === "active") {
@@ -36563,6 +38523,13 @@ function makeDashboard({
36563
38523
  tui?.requestRender?.();
36564
38524
  return;
36565
38525
  }
38526
+ if (data === "g" || data === "G") {
38527
+ view = "GRAPH";
38528
+ graphSel = 0;
38529
+ void fetchGraphNow();
38530
+ tui?.requestRender?.();
38531
+ return;
38532
+ }
36566
38533
  if (data === " ") {
36567
38534
  const rows = buildRows(snapshot, runSort);
36568
38535
  if (rows.length === 0) return;
@@ -36631,7 +38598,7 @@ function budgetMeters(budget, settings2, width) {
36631
38598
  return out;
36632
38599
  }
36633
38600
  function renderPanel(snapshot, width, state, styler) {
36634
- 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;
36635
38602
  const framed = Number.isFinite(width) && Math.trunc(width) >= MIN_WIDTH2;
36636
38603
  const inner = Math.trunc(width) - 4;
36637
38604
  const title = "pi-dispatch";
@@ -36668,6 +38635,9 @@ function renderPanel(snapshot, width, state, styler) {
36668
38635
  if (view === "COSTS") {
36669
38636
  return renderCosts2({ costs, costsSel, costsAvailable, framed, width: Math.trunc(width), styler, availableRows: terminalRows });
36670
38637
  }
38638
+ if (view === "GRAPH") {
38639
+ return renderGraphView({ graph, graphSel, graphAvailable, framed, width: Math.trunc(width), styler });
38640
+ }
36671
38641
  if (snapshot === null) {
36672
38642
  if (!framed) return [`${title} -- loading`, "", KEY_HINTS];
36673
38643
  return box({ title, sections: [{ lines: ["loading"] }], footer: KEY_HINTS, width });
@@ -37094,7 +39064,162 @@ function triggerDetailHints(inner, styler, pendingDelete = false) {
37094
39064
  }
37095
39065
  function keyHints(inner, styler) {
37096
39066
  const k = (key, label) => styler.fg("accent", key) + " " + styler.fg("dim", label);
37097
- 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 "));
37098
39223
  return fitLine(hints, inner, styler);
37099
39224
  }
37100
39225
  function costsWindowSinceMs(windowKey, nowMs) {
@@ -37359,7 +39484,7 @@ function countdownText(ms) {
37359
39484
  return h ? `resets ${h}` : "";
37360
39485
  }
37361
39486
  function buildRows(snapshot, runSort = "time") {
37362
- 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 }));
37363
39488
  const active2 = snapshot?.activeJobId ? [{ kind: "active", jobId: snapshot.activeJobId }] : [];
37364
39489
  const runs = (Array.isArray(snapshot?.runs) ? snapshot.runs : []).map((record) => ({ kind: "run", record }));
37365
39490
  return [...triggers, ...active2, ...sortRuns(runs, runSort)];
@@ -37375,24 +39500,24 @@ function sortRuns(rows, runSort) {
37375
39500
  return sorted;
37376
39501
  }
37377
39502
  function renderRunList(rows, selected, w) {
37378
- 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)];
37379
39504
  const { top, count } = runsWindow(rows.length, selected);
37380
39505
  const out = [];
37381
- if (top > 0) out.push(clip(`^ ${top} more`, w));
39506
+ if (top > 0) out.push(clip2(`^ ${top} more`, w));
37382
39507
  for (let i = top; i < top + count; i++) {
37383
39508
  const row = rows[i];
37384
39509
  const cursor = i === selected ? "\u203A" : " ";
37385
39510
  if (row.kind === "active") {
37386
- out.push(clip(`${cursor} * ACTIVE ${row.jobId} running`, w));
39511
+ out.push(clip2(`${cursor} * ACTIVE ${row.jobId} running`, w));
37387
39512
  continue;
37388
39513
  }
37389
39514
  const run = row.record;
37390
39515
  const rep = run?.replica > 0 ? `r${run.replica}/${run.replicas ?? "?"} ` : "";
37391
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 ");
37392
- out.push(clip(`${cursor} ${rep}${cells}`, w));
39517
+ out.push(clip2(`${cursor} ${rep}${cells}`, w));
37393
39518
  }
37394
39519
  const below = rows.length - top - count;
37395
- if (below > 0) out.push(clip(`v ${below} more`, w));
39520
+ if (below > 0) out.push(clip2(`v ${below} more`, w));
37396
39521
  return out;
37397
39522
  }
37398
39523
  function tailMatches(lines, query) {
@@ -37433,14 +39558,14 @@ function renderLiveTail({ snapshot, framed, width, tailJobId, tail, tailTop, tai
37433
39558
  return [boxTitle, "", ...plain, "", footer];
37434
39559
  }
37435
39560
  const inner = Math.trunc(width) - 4;
37436
- const lines = [clip(`live ${tailJobId} -- ${len} line(s)`, inner)];
39561
+ const lines = [clip2(`live ${tailJobId} -- ${len} line(s)`, inner)];
37437
39562
  for (let i = top; i < Math.min(top + TAIL_VIEWPORT, len); i++) {
37438
- const safe = clip(all[i], inner);
39563
+ const safe = clip2(all[i], inner);
37439
39564
  lines.push(tailQuery !== null && i === tailMatchLine ? styler.fg("warning", safe) : safe);
37440
39565
  }
37441
- 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));
37442
39567
  if (tailSearchInput) lines.push(styler.fg("accent", "/ ") + styler.lineInput(tailSearchInput, Math.max(1, inner - 2)));
37443
- 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) });
37444
39569
  }
37445
39570
  function renderRunDetail(record, inner, styler, allRuns = [], sandbox = null) {
37446
39571
  const r = record ?? {};
@@ -37536,7 +39661,7 @@ function runDetailHints(inner, styler, canOpen = false, canCopy = false, copiedN
37536
39661
  if (copiedNote) return fitLine(styler.fg("success", copiedNote) + styler.fg("dim", " \xB7 ") + line, inner, styler);
37537
39662
  return fitLine(line, inner, styler);
37538
39663
  }
37539
- 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;
37540
39665
  var init_dashboard = __esm({
37541
39666
  "src/dashboard.ts"() {
37542
39667
  init_budget();
@@ -37547,6 +39672,7 @@ var init_dashboard = __esm({
37547
39672
  init_pricing();
37548
39673
  init_read_model();
37549
39674
  init_render();
39675
+ init_graph_model();
37550
39676
  init_keys2();
37551
39677
  init_panel();
37552
39678
  init_style();
@@ -37562,6 +39688,7 @@ var init_dashboard = __esm({
37562
39688
  DRILL_WIDTH = 70;
37563
39689
  COSTS_WINDOWS = ["7d", "30d", "mtd"];
37564
39690
  COSTS_STALE_MS = 1e4;
39691
+ GRAPH_VIEWPORT = 16;
37565
39692
  KIND_COLOR = { cron: "accent", label: "syntaxType", comment: "syntaxKeyword", pull_request: "syntaxFunction" };
37566
39693
  KIND_WIDTH = 13;
37567
39694
  FRAME_CHROME_ROWS = 4;
@@ -37589,7 +39716,7 @@ __export(setup_wizard_exports, {
37589
39716
  runtimeDirFor: () => runtimeDirFor
37590
39717
  });
37591
39718
  import * as nodeFs4 from "node:fs";
37592
- import { execFileSync as execFileSync3, spawn as nodeSpawn } from "node:child_process";
39719
+ import { execFileSync as execFileSync3, spawn as nodeSpawn2 } from "node:child_process";
37593
39720
  import { homedir as homedir2 } from "node:os";
37594
39721
  import { join as join6 } from "node:path";
37595
39722
  function envIsSet(env, key) {
@@ -37624,7 +39751,7 @@ async function detectDeployment({
37624
39751
  }
37625
39752
  return { state: "none", detail: "no pointer, no env, no deployment files here, queue unreachable" };
37626
39753
  }
37627
- 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 }) {
37628
39755
  if (ctx?.mode !== "tui" || typeof ctx?.ui?.custom !== "function") {
37629
39756
  return { code: null, error: new Error("needs the terminal UI") };
37630
39757
  }
@@ -38098,8 +40225,8 @@ var init_setup_wizard = __esm({
38098
40225
  init_deployment_pointer();
38099
40226
  init_read_model();
38100
40227
  init_index();
38101
- RUNTIME_VERSION = "0.2.0";
38102
- RECEIVER_VERSION = "0.2.0";
40228
+ RUNTIME_VERSION = "0.3.0";
40229
+ RECEIVER_VERSION = "0.2.1";
38103
40230
  RUNTIME_PKG = "@edgehero/pi-dispatch";
38104
40231
  RECEIVER_PKG = "@edgehero/pi-dispatch-receiver";
38105
40232
  NUDGE_MARKER_BASENAME = "pi-dispatch-setup.nudged";
@@ -38116,7 +40243,7 @@ var init_setup_wizard = __esm({
38116
40243
 
38117
40244
  // src/index.ts
38118
40245
  import { VERSION } from "@earendil-works/pi-coding-agent";
38119
- import { fileURLToPath as fileURLToPath2 } from "node:url";
40246
+ import { fileURLToPath as fileURLToPath2, pathToFileURL } from "node:url";
38120
40247
  import * as nodeFs5 from "node:fs";
38121
40248
  function computePiVersionAdvisory(version, supported) {
38122
40249
  if (version === supported || version === "0.0.0") return void 0;
@@ -38633,6 +40760,18 @@ ${renderBudget({ budget, settings: settings2 })}`);
38633
40760
  costsCommand(pi, paths, tokens, notify);
38634
40761
  return;
38635
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
+ }
38636
40775
  case "run": {
38637
40776
  const folder = tokens[1];
38638
40777
  const flow = tokens[2];
@@ -38709,6 +40848,63 @@ function assembleCosts(paths, window, flow) {
38709
40848
  });
38710
40849
  return { fold };
38711
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
+ }
38712
40908
  function costsCommand(pi, paths, tokens, notify) {
38713
40909
  if (tokens[1] === "whatif") {
38714
40910
  costsWhatIf(pi, paths, tokens, notify);
@@ -39148,6 +41344,11 @@ function completeArguments(prefix) {
39148
41344
  const items = ["7d", "30d", "mtd", "whatif"].filter((w) => w.startsWith(partial)).map((w) => ({ value: `costs ${w}`, label: w }));
39149
41345
  return items.length > 0 ? items : null;
39150
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
+ }
39151
41352
  if ((parts[0] === "set" || parts[0] === "unset") && parts.length === 2) {
39152
41353
  const partial = parts[1];
39153
41354
  const items = KNOWN_KEYS.filter((k) => k.startsWith(partial)).map((k) => ({
@@ -39158,11 +41359,14 @@ function completeArguments(prefix) {
39158
41359
  }
39159
41360
  return null;
39160
41361
  }
39161
- 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;
39162
41363
  var init_index = __esm({
39163
41364
  "src/index.ts"() {
39164
41365
  init_build();
39165
41366
  init_read_model();
41367
+ init_graph_model();
41368
+ init_graph_html();
41369
+ init_open_browser();
39166
41370
  init_deployment_pointer();
39167
41371
  init_costs();
39168
41372
  init_pricing();
@@ -39179,7 +41383,7 @@ var init_index = __esm({
39179
41383
  runtimeSkewNoticed = false;
39180
41384
  CHANNEL = "pi-dispatch-admin";
39181
41385
  REBUILT_NOTICE = (reason) => `replaced invalid settings file (${reason}) \u2014 other keys were lost`;
39182
- 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>";
39183
41387
  KNOWN_SUBCOMMANDS = [
39184
41388
  "status",
39185
41389
  "pause",
@@ -39189,6 +41393,7 @@ var init_index = __esm({
39189
41393
  "logs",
39190
41394
  "budget",
39191
41395
  "costs",
41396
+ "graph",
39192
41397
  "triggers",
39193
41398
  "settings",
39194
41399
  "set",
@@ -39206,6 +41411,7 @@ var init_index = __esm({
39206
41411
  COSTS_USAGE = "usage: /dispatch costs [7d|30d|mtd] | /dispatch costs whatif <provider/model> --flow <flow>";
39207
41412
  DAY_MS2 = 24 * 60 * 60 * 1e3;
39208
41413
  PRICING = { listPricedModels, getPricedModel, isZeroRated, reprice, piAiVersion };
41414
+ GRAPH_USAGE = "usage: /dispatch graph [html [--no-open] [--full-paths]]";
39209
41415
  VIEWPORT_LINES = 20;
39210
41416
  }
39211
41417
  });
@@ -39217,5 +41423,7 @@ export {
39217
41423
  buildTriggerEntry,
39218
41424
  computePiVersionAdvisory,
39219
41425
  admin as default,
39220
- handleDashboardAction
41426
+ graphHtmlCommand,
41427
+ handleDashboardAction,
41428
+ isHeadlessEnv
39221
41429
  };