@nanocollective/roster 0.1.0-alpha.3 → 0.1.0-alpha.5

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 (67) hide show
  1. package/dist/cli.js +856 -169
  2. package/docs/README.md +10 -5
  3. package/docs/agents.md +320 -9
  4. package/docs/commands.md +5 -6
  5. package/docs/concepts.md +27 -9
  6. package/docs/cost.md +3 -2
  7. package/docs/doctor-codes.md +13 -4
  8. package/docs/export.md +2 -1
  9. package/docs/extending.md +11 -2
  10. package/docs/getting-started.md +89 -84
  11. package/docs/images/brain.jpg +0 -0
  12. package/docs/images/org.jpg +0 -0
  13. package/docs/images/prompt.jpg +0 -0
  14. package/docs/images/setup-org.jpg +0 -0
  15. package/docs/images/setup-plan.jpg +0 -0
  16. package/docs/images/staff.jpg +0 -0
  17. package/docs/manual-steps.md +36 -13
  18. package/docs/memory.md +9 -6
  19. package/docs/org-yaml.md +37 -9
  20. package/docs/portal.md +197 -31
  21. package/docs/prompts.md +50 -11
  22. package/docs/security.md +19 -7
  23. package/docs/session-workflow.md +8 -10
  24. package/docs/staff-yaml.md +3 -5
  25. package/docs/troubleshooting.md +17 -14
  26. package/docs/writing-a-charter.md +18 -17
  27. package/package.json +1 -1
  28. package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +1 -0
  29. package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +10 -4
  30. package/templates/brain/staff.yaml +0 -1
  31. package/templates/ops/.github/workflows/session.yaml +9 -26
  32. package/templates/ops/agents.mjs +121 -6
  33. package/templates/ops/compose.mjs +61 -4
  34. package/templates/ops/org/operating.md +0 -6
  35. package/templates/ops/prompts/_identity.md +8 -1
  36. package/templates/ops/prompts/mention.md +16 -2
  37. package/templates/portal/css/base.css +116 -8
  38. package/templates/portal/css/brain.css +8 -1
  39. package/templates/portal/css/diff.css +6 -2
  40. package/templates/portal/css/health.css +21 -2
  41. package/templates/portal/css/inbox.css +105 -5
  42. package/templates/portal/css/layout.css +37 -4
  43. package/templates/portal/css/markdown.css +23 -3
  44. package/templates/portal/css/setup.css +11 -6
  45. package/templates/portal/index.html +12 -2
  46. package/templates/portal/js/api.js +33 -0
  47. package/templates/portal/js/app.js +68 -8
  48. package/templates/portal/js/dialog.js +47 -4
  49. package/templates/portal/js/dom.js +25 -0
  50. package/templates/portal/js/icons.js +8 -1
  51. package/templates/portal/js/lightbox.js +273 -0
  52. package/templates/portal/js/md.js +23 -6
  53. package/templates/portal/js/mention.js +264 -0
  54. package/templates/portal/js/refresh.js +136 -6
  55. package/templates/portal/js/state.js +53 -8
  56. package/templates/portal/js/views/checklist.js +20 -7
  57. package/templates/portal/js/views/docs.js +94 -4
  58. package/templates/portal/js/views/files.js +58 -14
  59. package/templates/portal/js/views/health.js +163 -35
  60. package/templates/portal/js/views/inbox.js +934 -98
  61. package/templates/portal/js/views/memory.js +16 -1
  62. package/templates/portal/js/views/org.js +142 -62
  63. package/templates/portal/js/views/prompt.js +50 -63
  64. package/templates/portal/js/views/staff.js +62 -2
  65. package/templates/portal/js/yaml.js +134 -0
  66. package/templates/brain/.github/workflows/%%STAFF%%-pr-mention.yaml +0 -50
  67. package/templates/ops/prompts/pr-mention.md +0 -57
package/dist/cli.js CHANGED
@@ -244,6 +244,11 @@ function opsTemplateDir() {
244
244
  // src/lib/render.ts
245
245
  var NOTE = "%%TOKENS%%";
246
246
  var TOKEN = /%%([A-Z_]+)%%/g;
247
+ function toolsOf(org) {
248
+ const list = org?.defaults?.allowed_tools;
249
+ const asked = Array.isArray(list) ? list.join(",") : typeof list === "string" ? list : "";
250
+ return asked.replace(/\s+/g, "") || "Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch";
251
+ }
247
252
  function orgTokens(org) {
248
253
  return {
249
254
  ORG: org.org,
@@ -251,7 +256,11 @@ function orgTokens(org) {
251
256
  OPS_REPO: org.opsRepo,
252
257
  OPS_REPO_DIR: org.opsDirName,
253
258
  HUMAN: org.human,
254
- HUMAN_MARKER: org.humanMarker
259
+ HUMAN_MARKER: org.humanMarker,
260
+ /* A JSON array, spelled into a GitHub expression as `fromJSON('%%HUMAN_LOGINS%%')`. One
261
+ login is still a one-element list, so the generated gate has one shape rather than two. */
262
+ HUMAN_LOGINS: JSON.stringify((org.humanLogins ?? [org.human]).filter(Boolean)),
263
+ ALLOWED_TOOLS: org.allowedTools
255
264
  };
256
265
  }
257
266
  function tokensFor(org, s) {
@@ -269,7 +278,6 @@ function tokensFor(org, s) {
269
278
  MODEL: s.model,
270
279
  TIMEOUT: String(s.timeout),
271
280
  MENTION_TIMEOUT: String(s.mentionTimeout),
272
- PR_MENTION_TIMEOUT: String(s.prMentionTimeout),
273
281
  SECRET_PREFIX: s.secretPrefix,
274
282
  PUBLIC_SECRET_PREFIX: s.publicSecretPrefix,
275
283
  APP: s.app,
@@ -354,11 +362,11 @@ function specFromManifest(m, dir) {
354
362
  worksIn: (m.works_in ?? []).map((w) => String(w?.repo)).filter(Boolean),
355
363
  schedule: String(m.schedule ?? ""),
356
364
  model: String(m.model ?? ""),
357
- timeout: Number(m.timeout_minutes ?? 60),
358
- // Absent from the manifests written before these were separate fields. The defaults are
359
- // the values those manifests were already producing, so nothing moves on the first upgrade.
360
- mentionTimeout: Number(m.mention_timeout_minutes ?? 30),
361
- prMentionTimeout: Number(m.pr_mention_timeout_minutes ?? 60),
365
+ timeout: Number(m.timeout_minutes ?? 90),
366
+ // Absent from the manifests written before these were separate fields. 90 either way: a
367
+ // mention that ends in a build needs a session's room, and the 30 these once fell back to
368
+ // killed four runs mid-gate at a ceiling nobody had chosen.
369
+ mentionTimeout: Number(m.mention_timeout_minutes ?? 90),
362
370
  secretPrefix: String(priv.secret_prefix ?? String(m.handle ?? dir).toUpperCase()),
363
371
  publicSecretPrefix: String(pub.secret_prefix ?? "BOT"),
364
372
  app: String(priv.app ?? ""),
@@ -622,11 +630,37 @@ ${text.trimEnd()}
622
630
  ${bars}`;
623
631
  }
624
632
 
633
+ // src/lib/humans.ts
634
+ function readHumans(org) {
635
+ const listed = Array.isArray(org?.humans) ? org.humans : org?.humans ? [org.humans] : [];
636
+ const raw = [...listed, org?.human].filter(Boolean);
637
+ const out = [];
638
+ for (const entry of raw) {
639
+ const github = String(entry.github ?? entry.login ?? "").trim();
640
+ const name = String(entry.name ?? github).trim();
641
+ if (!github && !name) continue;
642
+ if (github && out.some((h) => h.github.toLowerCase() === github.toLowerCase())) continue;
643
+ out.push({
644
+ github,
645
+ name: name || github,
646
+ marker: String(entry.marker ?? "").trim() || defaultMarker(name || github),
647
+ role: entry.role === void 0 || entry.role === null ? void 0 : String(entry.role)
648
+ });
649
+ }
650
+ return out;
651
+ }
652
+ function humanLogins(org) {
653
+ return readHumans(org).map((h) => h.github).filter(Boolean);
654
+ }
655
+ function defaultMarker(name) {
656
+ return String(name).trim().split(/[\s-]+/)[0].toLowerCase().replace(/[^a-z0-9]/g, "") || "human";
657
+ }
658
+
625
659
  // src/lib/prompt.ts
626
660
  import { execFileSync } from "child_process";
627
661
  import { existsSync as existsSync6, readFileSync as readFileSync5, statSync as statSync2, writeFileSync } from "fs";
628
662
  import { dirname as dirname3, join as join6, relative as relative2, resolve as resolve2 } from "path";
629
- var KINDS = ["daily", "mention", "pr-mention"];
663
+ var KINDS = ["daily", "mention"];
630
664
  function promptView(ws, compose, staffHandle, brainDir, kind) {
631
665
  const had = process.env.ROSTER_CONTEXT;
632
666
  if (kind !== "daily" && !had) {
@@ -774,12 +808,15 @@ function validateOrgYaml(text, parseYaml) {
774
808
  return `org.yaml needs a "${key}", and every prompt is composed against it`;
775
809
  }
776
810
  }
777
- for (const key of ["staff", "repos"]) {
811
+ for (const key of ["staff", "repos", "humans"]) {
778
812
  if (doc[key] !== void 0 && !Array.isArray(doc[key])) return `"${key}" has to be a list`;
779
813
  }
780
814
  for (const s of doc.staff ?? []) {
781
815
  if (!s?.handle) return "every staff entry needs a handle";
782
816
  }
817
+ for (const h of doc.humans ?? []) {
818
+ if (!h?.github) return "every entry in humans needs a github login";
819
+ }
783
820
  return null;
784
821
  }
785
822
 
@@ -814,7 +851,7 @@ roster brief <kind> [handle]
814
851
  these briefs by \`roster init\` and \`roster hire\`. There is nothing in them that is
815
852
  specific to any agent.
816
853
 
817
- --kind <k> for amend: daily | mention | pr-mention (default: daily)
854
+ --kind <k> for amend: daily | mention (default: daily)
818
855
  --want <text> for amend: what you want changed
819
856
  --ops <dir> ops repo directory (default: found by walking up)
820
857
  `;
@@ -876,14 +913,19 @@ function staffTokens(ws, org, handle, parseYaml) {
876
913
  return tokensFor(spec(org, ws), specFromManifest(manifest, dir));
877
914
  }
878
915
  function spec(org, ws) {
879
- const human = org.human ?? {};
916
+ const humans = readHumans(org);
917
+ const first = humans[0];
880
918
  return {
881
919
  org: org.org,
882
920
  name: org.name,
883
921
  opsRepo: `${org.org}/${ws.opsName}`,
884
922
  opsDirName: ws.opsName,
885
- human: human.name ?? human.github ?? "the human",
886
- humanMarker: human.marker ?? human.github ?? "human"
923
+ /* A brief is prose addressed to one person, so it names the first. The rest are in the
924
+ manifest and in the gate; a brief that said "Will and Sam" would read as a committee. */
925
+ human: first?.name ?? first?.github ?? "the human",
926
+ humanMarker: first?.marker ?? "human",
927
+ humanLogins: humans.map((h) => h.github).filter(Boolean),
928
+ allowedTools: toolsOf(org)
887
929
  };
888
930
  }
889
931
  function renderBrief(text, tokens, where) {
@@ -921,7 +963,7 @@ function parseFlags2(argv) {
921
963
  }
922
964
 
923
965
  // src/commands/doctor.ts
924
- import { existsSync as existsSync10, readdirSync as readdirSync4, readFileSync as readFileSync9 } from "fs";
966
+ import { existsSync as existsSync10, readdirSync as readdirSync5, readFileSync as readFileSync9 } from "fs";
925
967
  import { join as join11 } from "path";
926
968
 
927
969
  // src/lib/memory.ts
@@ -1124,7 +1166,15 @@ function looksUnwritten(body) {
1124
1166
 
1125
1167
  // src/commands/upgrade.ts
1126
1168
  import { execFileSync as execFileSync3 } from "child_process";
1127
- import { existsSync as existsSync9, mkdirSync, mkdtempSync as mkdtempSync2, readFileSync as readFileSync8, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
1169
+ import {
1170
+ existsSync as existsSync9,
1171
+ mkdirSync,
1172
+ mkdtempSync as mkdtempSync2,
1173
+ readdirSync as readdirSync4,
1174
+ readFileSync as readFileSync8,
1175
+ rmSync as rmSync2,
1176
+ writeFileSync as writeFileSync3
1177
+ } from "fs";
1128
1178
  import { tmpdir as tmpdir2 } from "os";
1129
1179
  import { dirname as dirname4, join as join10 } from "path";
1130
1180
 
@@ -1454,8 +1504,10 @@ function planBrains(ws, parseYaml) {
1454
1504
  name: org.name,
1455
1505
  opsRepo: `${org.org}/${ws.opsName}`,
1456
1506
  opsDirName: ws.opsName,
1457
- human: org.human?.github ?? "",
1458
- humanMarker: org.human?.marker ?? "human"
1507
+ human: readHumans(org)[0]?.github ?? "",
1508
+ humanMarker: readHumans(org)[0]?.marker ?? "human",
1509
+ humanLogins: humanLogins(org),
1510
+ allowedTools: toolsOf(org)
1459
1511
  };
1460
1512
  const out = [];
1461
1513
  for (const entry of org.staff ?? []) {
@@ -1494,6 +1546,12 @@ function planBrains(ws, parseYaml) {
1494
1546
  have === text ? { rel, kind, verdict: "same" } : { rel, kind, verdict: "regenerate", next: text, diff: unified(have, text) }
1495
1547
  );
1496
1548
  }
1549
+ for (const rel of callerFiles(root)) {
1550
+ if (want.has(rel)) continue;
1551
+ const text = readFileSync8(join10(root, rel), "utf8");
1552
+ if (!text.includes(`${orgSpec2.opsRepo}/.github/workflows/session.yaml`)) continue;
1553
+ base2.files.push({ rel, kind: "generated", verdict: "obsolete" });
1554
+ }
1497
1555
  out.push(base2);
1498
1556
  }
1499
1557
  return out;
@@ -1518,6 +1576,11 @@ function unified(before, after) {
1518
1576
  rmSync2(dir, { recursive: true, force: true });
1519
1577
  }
1520
1578
  }
1579
+ function callerFiles(root) {
1580
+ const dir = join10(root, ".github", "workflows");
1581
+ if (!existsSync9(dir)) return [];
1582
+ return readdirSync4(dir).filter((n) => n.endsWith(".yaml") || n.endsWith(".yml")).map((n) => `.github/workflows/${n}`);
1583
+ }
1521
1584
  function reportBrains(brains, opts) {
1522
1585
  for (const b of brains) {
1523
1586
  const interesting = b.files.filter((f) => f.verdict !== "same");
@@ -1541,6 +1604,13 @@ function reportBrains(brains, opts) {
1541
1604
  `);
1542
1605
  continue;
1543
1606
  }
1607
+ if (f.verdict === "obsolete") {
1608
+ process.stdout.write(
1609
+ ` - ${f.rel} the framework no longer generates this; will be removed
1610
+ `
1611
+ );
1612
+ continue;
1613
+ }
1544
1614
  process.stdout.write(` \u2191 ${f.rel} differs from the template
1545
1615
  `);
1546
1616
  if (f.diff) {
@@ -1555,6 +1625,11 @@ function applyBrains(brains) {
1555
1625
  let wrote = 0;
1556
1626
  for (const b of brains) {
1557
1627
  for (const f of b.files) {
1628
+ if (f.verdict === "obsolete") {
1629
+ rmSync2(join10(b.root, f.rel), { force: true });
1630
+ wrote++;
1631
+ continue;
1632
+ }
1558
1633
  if (f.next === void 0) continue;
1559
1634
  const dest = join10(b.root, f.rel);
1560
1635
  mkdirSync(dirname4(dest), { recursive: true });
@@ -1602,11 +1677,12 @@ async function collect(opts) {
1602
1677
  const composer = await loadComposer(ws.opsDir);
1603
1678
  const org = readOrg(ws.opsDir, composer.parseYaml);
1604
1679
  const staff = (org.staff ?? []).filter((s) => !opts.only || s.handle === opts.only);
1605
- if (!staff.length) return null;
1680
+ if (opts.only && !staff.length) return null;
1606
1681
  const findings = [];
1607
1682
  const online = !opts.offline && await gate(findings);
1608
1683
  findings.push(...checkWorkspace(ws, org));
1609
1684
  findings.push(...checkBusiness(ws));
1685
+ findings.push(...await checkAgent(ws, org));
1610
1686
  if (online) findings.push(...await checkOrgOnline(ws, org));
1611
1687
  const perStaff = await Promise.all(staff.map((s) => checkStaff(ws, org, s, composer, online)));
1612
1688
  for (const f of perStaff) findings.push(...f);
@@ -1659,13 +1735,22 @@ function checkWorkspace(ws, org) {
1659
1735
  id: "org.yaml",
1660
1736
  title: `org.yaml names ${org.repos?.length ?? 0} repos and ${org.staff?.length ?? 0} staff`
1661
1737
  });
1662
- if (!org.human?.github) {
1738
+ const humans = readHumans(org);
1739
+ if (!humans.some((h) => h.github)) {
1663
1740
  out.push({
1664
1741
  scope,
1665
1742
  level: "fail",
1666
1743
  id: "human",
1667
- title: "org.yaml has no human.github",
1668
- fix: "The mention callers gate on this login. Without it nothing can wake an agent."
1744
+ title: "org.yaml names no human with a github login",
1745
+ fix: "The mention callers gate on these logins. Without one nothing can wake an agent. Write `human: { github: you }`, or a `humans:` list for more than one."
1746
+ });
1747
+ } else if (humans.some((h) => !h.github)) {
1748
+ out.push({
1749
+ scope,
1750
+ level: "warn",
1751
+ id: "human.login",
1752
+ title: `${humans.filter((h) => !h.github).map((h) => h.name).join(", ")} has no github login`,
1753
+ fix: "Give them one, or the mention gate silently ignores everything they write."
1669
1754
  });
1670
1755
  }
1671
1756
  try {
@@ -1714,6 +1799,57 @@ function checkWorkspace(ws, org) {
1714
1799
  }
1715
1800
  return out;
1716
1801
  }
1802
+ async function checkAgent(ws, org) {
1803
+ const scope = "workspace";
1804
+ let agent;
1805
+ try {
1806
+ const { resolveAgent } = await import(`file://${join11(ws.opsDir, "agents.mjs")}`);
1807
+ agent = resolveAgent(org, {});
1808
+ } catch (err) {
1809
+ return [
1810
+ {
1811
+ scope,
1812
+ level: "fail",
1813
+ id: "agent",
1814
+ title: `no runner resolves: ${firstLine(err)}`,
1815
+ fix: "Nothing can run until org.yaml names an agent this tenant's agents.mjs knows."
1816
+ }
1817
+ ];
1818
+ }
1819
+ const out = [{ scope, level: "ok", id: "agent", title: `runs on ${agent.id}` }];
1820
+ const orgFile = join11(ws.opsDir, "org.yaml");
1821
+ if (existsSync10(orgFile) && /FILL IN/.test(readFileSync9(orgFile, "utf8"))) {
1822
+ out.push({
1823
+ scope,
1824
+ level: "fail",
1825
+ id: "agent.config",
1826
+ title: "org.yaml still has a blank in it",
1827
+ fix: "Fill in the FILL IN. A run cannot pick a model from a placeholder."
1828
+ });
1829
+ }
1830
+ if (!agent.config) return out;
1831
+ const path = join11(ws.opsDir, agent.config.path);
1832
+ if (!existsSync10(path)) {
1833
+ out.push({
1834
+ scope,
1835
+ level: "fail",
1836
+ id: "agent.config",
1837
+ title: `${agent.id} needs ${agent.config.path} and there is none`,
1838
+ fix: "roster init writes it for a new tenant. See docs/agents.md for what goes in it."
1839
+ });
1840
+ return out;
1841
+ }
1842
+ if (/FILL IN/.test(readFileSync9(path, "utf8"))) {
1843
+ out.push({
1844
+ scope,
1845
+ level: "fail",
1846
+ id: "agent.config",
1847
+ title: `${agent.config.path} still has a blank in it`,
1848
+ fix: `Fill in the FILL IN. ${agent.id} cannot pick a model without it.`
1849
+ });
1850
+ }
1851
+ return out;
1852
+ }
1717
1853
  function checkBusiness(ws) {
1718
1854
  const path = join11(ws.opsDir, "org", "business.md");
1719
1855
  if (!existsSync10(path)) {
@@ -1847,11 +1983,10 @@ async function checkStaff(ws, org, entry, composer, online) {
1847
1983
  const CONTEXT = JSON.stringify({
1848
1984
  issue_number: "1",
1849
1985
  comment_id: "1",
1850
- pr_number: "1",
1851
1986
  repo: `${org.org}/example`
1852
1987
  });
1853
1988
  const before = process.env.ROSTER_CONTEXT;
1854
- for (const kind of ["daily", "mention", "pr-mention"]) {
1989
+ for (const kind of ["daily", "mention"]) {
1855
1990
  try {
1856
1991
  if (kind === "daily") delete process.env.ROSTER_CONTEXT;
1857
1992
  else process.env.ROSTER_CONTEXT = CONTEXT;
@@ -1879,16 +2014,16 @@ async function checkStaff(ws, org, entry, composer, online) {
1879
2014
  scope,
1880
2015
  level: "ok",
1881
2016
  id: "compose",
1882
- title: "prompts compose for daily, mention and pr-mention"
2017
+ title: "prompts compose for daily and mention"
1883
2018
  });
1884
2019
  }
1885
- const callers = readCallers(root);
1886
- if (callers.length !== 3) {
2020
+ const callers = readCallers(root, entry.handle);
2021
+ if (callers.length !== 2) {
1887
2022
  out.push({
1888
2023
  scope,
1889
2024
  level: callers.length ? "warn" : "fail",
1890
2025
  id: "callers",
1891
- title: `${callers.length} caller workflow${callers.length === 1 ? "" : "s"}, expected 3 (daily, mention, pr-mention)`,
2026
+ title: `${callers.length} caller workflow${callers.length === 1 ? "" : "s"}, expected 2 (daily, mention)`,
1892
2027
  fix: "A missing caller is a route that silently never fires. Compare against roster's templates/brain."
1893
2028
  });
1894
2029
  }
@@ -1926,8 +2061,13 @@ async function checkStaff(ws, org, entry, composer, online) {
1926
2061
  });
1927
2062
  }
1928
2063
  }
1929
- if (callers.length === 3 && !out.some((f) => f.id.startsWith("callers"))) {
1930
- out.push({ scope, level: "ok", id: "callers", title: `3 callers, all pointing at ${opsRepo}` });
2064
+ if (callers.length === 2 && !out.some((f) => f.id.startsWith("callers"))) {
2065
+ out.push({
2066
+ scope,
2067
+ level: "ok",
2068
+ id: "callers",
2069
+ title: `2 callers, both pointing at ${opsRepo}`
2070
+ });
1931
2071
  }
1932
2072
  const missing = (manifest.surfaces ?? []).map((s) => s.path).filter((p) => p && !existsSync10(join11(root, p)));
1933
2073
  if (missing.length) {
@@ -1946,7 +2086,7 @@ async function checkStaff(ws, org, entry, composer, online) {
1946
2086
  }
1947
2087
  function timeoutOf(callerText) {
1948
2088
  const m = /timeout_minutes:\s*(\d+)/.exec(callerText);
1949
- return m ? Number(m[1]) : 60;
2089
+ return m ? Number(m[1]) : 90;
1950
2090
  }
1951
2091
  function runMinutes(run6) {
1952
2092
  return (new Date(run6.updatedAt).getTime() - new Date(run6.createdAt).getTime()) / 6e4;
@@ -1971,10 +2111,11 @@ function inferredCeiling(cancelled) {
1971
2111
  }
1972
2112
  return most >= 2 ? best : null;
1973
2113
  }
1974
- function readCallers(root) {
2114
+ function readCallers(root, handle) {
1975
2115
  const dir = join11(root, ".github", "workflows");
1976
2116
  if (!existsSync10(dir)) return [];
1977
- return readdirSync4(dir).filter((f) => /\.ya?ml$/.test(f)).sort().map((name) => ({ name, text: readFileSync9(join11(dir, name), "utf8") }));
2117
+ const mine = new RegExp(`^${handle}-(daily|mention)\\.ya?ml$`);
2118
+ return readdirSync5(dir).filter((f) => mine.test(f)).sort().map((name) => ({ name, text: readFileSync9(join11(dir, name), "utf8") }));
1978
2119
  }
1979
2120
  async function checkOrgOnline(ws, org) {
1980
2121
  const out = [];
@@ -2165,6 +2306,9 @@ async function checkStaffOnline(scope, manifest, callers) {
2165
2306
  }
2166
2307
  );
2167
2308
  }
2309
+ const proven = runs.some(
2310
+ ({ res }) => (res.data ?? []).some((r) => r.status === "completed" && r.conclusion === "success")
2311
+ );
2168
2312
  for (const { name, res } of runs) {
2169
2313
  if (!res.ok) {
2170
2314
  out.push({
@@ -2189,13 +2333,20 @@ async function checkStaffOnline(scope, manifest, callers) {
2189
2333
  continue;
2190
2334
  }
2191
2335
  if (!real.length) {
2192
- out.push({
2193
- scope,
2194
- level: "warn",
2195
- id: "runs",
2196
- title: `${name}: ${all.length} recent triggers, all gated out before doing anything`,
2197
- fix: "Nothing here has exercised the app grant. A skipped run proves only the trigger."
2198
- });
2336
+ out.push(
2337
+ proven ? {
2338
+ scope,
2339
+ level: "ok",
2340
+ id: "runs",
2341
+ title: `${name}: ${all.length} recent triggers, all gated out, which is its normal state`
2342
+ } : {
2343
+ scope,
2344
+ level: "warn",
2345
+ id: "runs",
2346
+ title: `${name}: ${all.length} recent triggers, all gated out before doing anything`,
2347
+ fix: "Nothing in this repo has exercised the app grant. A skipped run proves only the trigger, so trigger one workflow here by hand before trusting any of them."
2348
+ }
2349
+ );
2199
2350
  continue;
2200
2351
  }
2201
2352
  const timeout = timeoutOf(callers.find((c) => c.name === name)?.text ?? "");
@@ -2208,7 +2359,15 @@ async function checkStaffOnline(scope, manifest, callers) {
2208
2359
  const cancelled = real.filter((r) => !killed(r) && r.conclusion === "cancelled");
2209
2360
  const ok = real.filter((r) => r.conclusion === "success");
2210
2361
  const ceiling = timedOut.length ? Math.round(runMinutes(timedOut[0])) : timeout;
2211
- if (timedOut.length) {
2362
+ const provedSince = ceiling !== timeout && ok.some((r) => r.createdAt > timedOut[0].createdAt);
2363
+ if (timedOut.length && provedSince) {
2364
+ out.push({
2365
+ scope,
2366
+ level: "ok",
2367
+ id: "runs.timeout",
2368
+ title: `${name}: the ${ceiling}m ceiling that killed ${timedOut.length} of the last ${real.length} was raised to ${timeout}m, and a run has finished since`
2369
+ });
2370
+ } else if (timedOut.length) {
2212
2371
  out.push({
2213
2372
  scope,
2214
2373
  level: "fail",
@@ -2301,7 +2460,7 @@ import { writeFileSync as writeFileSync4 } from "fs";
2301
2460
 
2302
2461
  // src/lib/export.ts
2303
2462
  import { execFileSync as execFileSync4 } from "child_process";
2304
- import { existsSync as existsSync11, readdirSync as readdirSync5, readFileSync as readFileSync10, statSync as statSync3 } from "fs";
2463
+ import { existsSync as existsSync11, readdirSync as readdirSync6, readFileSync as readFileSync10, statSync as statSync3 } from "fs";
2305
2464
  import { join as join12, relative as relative3 } from "path";
2306
2465
  var SKIP = /* @__PURE__ */ new Set([".git", "node_modules", ".next", "dist", "out", ".DS_Store"]);
2307
2466
  function buildExport(ws, org, parseYaml, opts = {}) {
@@ -2344,11 +2503,13 @@ function buildExport(ws, org, parseYaml, opts = {}) {
2344
2503
  s.soloBots = s.bots.filter((b) => times.get(b) === 1);
2345
2504
  s.sharedBots = s.bots.filter((b) => (times.get(b) ?? 0) > 1);
2346
2505
  }
2506
+ const humans = readHumans(org);
2347
2507
  return {
2348
2508
  org: org.org,
2349
2509
  name: org.name,
2350
2510
  opsName: ws.opsName,
2351
- human: org.human ?? {},
2511
+ human: humans[0] ?? org.human ?? {},
2512
+ humans,
2352
2513
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2353
2514
  staff
2354
2515
  };
@@ -2369,7 +2530,7 @@ function readRig(root, manifest, commits) {
2369
2530
  const wfDir = join12(root, ".github", "workflows");
2370
2531
  const declared = manifest.surfaces ?? [];
2371
2532
  return {
2372
- workflows: existsSync11(wfDir) ? readdirSync5(wfDir).filter((f) => /\.ya?ml$/.test(f)).sort() : [],
2533
+ workflows: existsSync11(wfDir) ? readdirSync6(wfDir).filter((f) => /\.ya?ml$/.test(f)).sort() : [],
2373
2534
  hasCharter: existsSync11(join12(root, "CHARTER.md")),
2374
2535
  hasManifest: existsSync11(join12(root, "staff.yaml")),
2375
2536
  missingSurfaces: declared.filter((s) => s?.path && !existsSync11(join12(root, s.path))).map((s) => s.path),
@@ -2398,7 +2559,7 @@ function readSurfaces(root, declared) {
2398
2559
  }));
2399
2560
  }
2400
2561
  function walk2(dir, root, out = []) {
2401
- for (const name of readdirSync5(dir)) {
2562
+ for (const name of readdirSync6(dir)) {
2402
2563
  if (SKIP.has(name)) continue;
2403
2564
  const full = join12(dir, name);
2404
2565
  const st = statSync3(full);
@@ -2839,7 +3000,7 @@ import { dirname as dirname5, join as join14 } from "path";
2839
3000
  var hireHelp = `
2840
3001
  roster hire <handle> [--name "Chief Financial Officer"] [--apply]
2841
3002
 
2842
- Scaffold a new staff member: their brain repo, the three caller workflows, a manifest, a
3003
+ Scaffold a new staff member: their brain repo, the two caller workflows, a manifest, a
2843
3004
  memory index, a charter stub, labels, a pinned status issue, and the peer wiring in both
2844
3005
  directions.
2845
3006
 
@@ -2856,7 +3017,6 @@ roster hire <handle> [--name "Chief Financial Officer"] [--apply]
2856
3017
  --model <id> defaults to org.yaml's
2857
3018
  --timeout <n> daily run ceiling, minutes
2858
3019
  --mention-timeout <n> mention run ceiling, minutes
2859
- --pr-timeout <n> PR-amendment run ceiling, minutes
2860
3020
  --secret-prefix <X> secrets are <X>_APP_ID and <X>_APP_PRIVATE_KEY. Defaults to HANDLE.
2861
3021
  --app <slug> this staff member's GitHub App. Defaults to the pattern its peers use.
2862
3022
  --public-app <slug> the shared public identity. Defaults to whatever the peers use.
@@ -2937,9 +3097,11 @@ function buildPlan(ws, org, handle, opts, parseYaml) {
2937
3097
  worksIn: (org.repos ?? []).filter((r) => r.role === "product").map((r) => `${org.org}/${r.name}`),
2938
3098
  schedule,
2939
3099
  model,
2940
- timeout: opts.timeout ?? org.defaults?.timeout_minutes ?? 60,
2941
- mentionTimeout: opts.mentionTimeout ?? 30,
2942
- prMentionTimeout: opts.prMentionTimeout ?? 60,
3100
+ timeout: opts.timeout ?? org.defaults?.timeout_minutes ?? 90,
3101
+ // A mention that ends in a build needs a session's room: the product repo's gate alone
3102
+ // can outlast a short ceiling, and a hire that has to discover that costs a run. An org
3103
+ // that wants them split says so; absent that, one number governs both.
3104
+ mentionTimeout: opts.mentionTimeout ?? org.defaults?.mention_timeout_minutes ?? org.defaults?.timeout_minutes ?? 90,
2943
3105
  secretPrefix: opts.secretPrefix ?? handle.toUpperCase(),
2944
3106
  publicSecretPrefix: publicIdentity?.secret_prefix ?? "BOT",
2945
3107
  app: app ?? `${handle}`,
@@ -2956,16 +3118,24 @@ function buildPlan(ws, org, handle, opts, parseYaml) {
2956
3118
  "status_issue is 0 until --apply opens the pinned issue; prompts will not compose before then"
2957
3119
  );
2958
3120
  }
3121
+ const humans = readHumans(org);
2959
3122
  const orgSpec2 = {
2960
3123
  org: org.org,
2961
3124
  name: org.name,
2962
3125
  opsRepo: `${org.org}/${ws.opsName}`,
2963
3126
  opsDirName: ws.opsName,
2964
- human: org.human?.github ?? "",
2965
- humanMarker: org.human?.marker ?? "human"
3127
+ human: humans[0]?.github ?? "",
3128
+ humanMarker: humans[0]?.marker ?? "human",
3129
+ humanLogins: humans.map((h) => h.github).filter(Boolean),
3130
+ allowedTools: toolsOf(org)
2966
3131
  };
2967
3132
  if (!orgSpec2.human)
2968
3133
  warnings.push("org.yaml has no human.github, so the mention gate will never match");
3134
+ if (humans.length > 1) {
3135
+ warnings.push(
3136
+ `the mention gate accepts ${humans.map((h) => h.github).join(", ")} \u2014 everyone in org.yaml`
3137
+ );
3138
+ }
2969
3139
  const tokens = tokensFor(orgSpec2, staff);
2970
3140
  const files = renderTree(brainTemplateDir(), tokens);
2971
3141
  for (const [rel, text] of briefCommands(["charter"], tokens)) files.set(rel, text);
@@ -3301,7 +3471,6 @@ function parseFlags6(argv) {
3301
3471
  else if (flag === "--model") out.model = value;
3302
3472
  else if (flag === "--timeout") out.timeout = Number(value);
3303
3473
  else if (flag === "--mention-timeout") out.mentionTimeout = Number(value);
3304
- else if (flag === "--pr-timeout") out.prMentionTimeout = Number(value);
3305
3474
  else if (flag === "--secret-prefix") out.secretPrefix = value;
3306
3475
  else if (flag === "--app") out.app = value;
3307
3476
  else if (flag === "--public-app") out.publicApp = value;
@@ -3362,7 +3531,7 @@ async function initCommand(argv) {
3362
3531
  `);
3363
3532
  return 2;
3364
3533
  }
3365
- const files = initFiles({ org: opts.org, name, human, marker, opsName, agent: opts.agent });
3534
+ const files = await initFiles({ org: opts.org, name, human, marker, opsName, agent: opts.agent });
3366
3535
  process.stdout.write(`
3367
3536
  roster init \u2014 ${name} (${opts.org})
3368
3537
 
@@ -3438,12 +3607,18 @@ async function initCommand(argv) {
3438
3607
  `);
3439
3608
  return 0;
3440
3609
  }
3441
- function initFiles(o) {
3610
+ async function initFiles(o) {
3442
3611
  const files = /* @__PURE__ */ new Map();
3443
3612
  for (const rel of templateFiles(opsTemplateDir())) {
3444
3613
  files.set(rel, readFileSync13(join15(opsTemplateDir(), rel), "utf8"));
3445
3614
  }
3446
- files.set("org.yaml", orgYaml({ ...o, agent: o.agent ?? "claude-code-action" }));
3615
+ const agent = o.agent ?? "claude-code-action";
3616
+ const preset = await agentPreset(agent);
3617
+ const model = preset.model || '"FILL IN: a model the provider below serves"';
3618
+ files.set("org.yaml", orgYaml({ ...o, agent, model }));
3619
+ if (preset.config) {
3620
+ files.set(preset.config.path, JSON.stringify(preset.config.contents, null, 2) + "\n");
3621
+ }
3447
3622
  files.set("org/business.md", businessStub(o.name, o.org));
3448
3623
  for (const [rel, text] of briefCommands(["discover", "voice"], {
3449
3624
  ORG: o.org,
@@ -3458,6 +3633,18 @@ function initFiles(o) {
3458
3633
  files.set(".roster-version", stamp2() + "\n");
3459
3634
  return files;
3460
3635
  }
3636
+ async function agentPreset(id) {
3637
+ const path = join15(opsTemplateDir(), "agents.mjs");
3638
+ const { PRESETS } = await import(`file://${path}`);
3639
+ const preset = PRESETS[id];
3640
+ if (!preset) {
3641
+ throw new Error(
3642
+ `unknown agent "${id}". Known: ${Object.keys(PRESETS).join(", ")}.
3643
+ Anything else works by writing install, run and token_env into org.yaml by hand.`
3644
+ );
3645
+ }
3646
+ return preset;
3647
+ }
3461
3648
  function orgYaml(o) {
3462
3649
  return `# The org manifest. Read at the top of every composed prompt.
3463
3650
  # Kept deliberately simple: compose.mjs parses a small, strict YAML subset, and a manifest
@@ -3467,6 +3654,11 @@ org: ${o.org}
3467
3654
  name: ${o.name}
3468
3655
  ops_dir: ${o.opsName}
3469
3656
 
3657
+ # Who the staff answer to. More than one person? Use a "humans:" list instead \u2014 the mention
3658
+ # gate accepts every login in it, and the first is the one the prompts address:
3659
+ # humans:
3660
+ # - { name: ${o.human}, github: ${o.human}, marker: ${o.marker}, role: founder }
3661
+ # - { name: Sam, github: sam, marker: sam, role: operations }
3470
3662
  human:
3471
3663
  name: ${o.human}
3472
3664
  github: ${o.human}
@@ -3481,11 +3673,18 @@ experiment_private: true
3481
3673
  # works by writing the three fields out longhand. See agents.mjs.
3482
3674
  agent:
3483
3675
  id: ${o.agent}
3676
+ # How much it may do without stopping to ask: full, workspace, or read-only. One word here,
3677
+ # translated into each agent's own vocabulary by agents.mjs \u2014 a tool list for Claude, a
3678
+ # sandbox and an approval policy for Codex, a development mode for nanocoder.
3679
+ permissions: full
3680
+ # Anything else, in that agent's own words, passed through untranslated:
3681
+ # options:
3682
+ # provider: openrouter
3484
3683
 
3485
3684
  defaults:
3486
- model: claude-opus-5
3487
- timeout_minutes: 60
3488
- allowed_tools: [Bash, Read, Write, Edit, Glob, Grep, WebFetch, WebSearch]
3685
+ model: ${o.model}
3686
+ timeout_minutes: 90
3687
+ mention_timeout_minutes: 90
3489
3688
 
3490
3689
  # Every staff member, and where their brain lands in the runner checkout.
3491
3690
  # Written by \`roster hire\`.
@@ -3659,16 +3858,81 @@ function parseFlags8(argv) {
3659
3858
  }
3660
3859
 
3661
3860
  // src/commands/portal.ts
3662
- import { execFileSync as execFileSync7 } from "child_process";
3861
+ import { execFileSync as execFileSync8 } from "child_process";
3663
3862
  import { randomBytes as randomBytes2 } from "crypto";
3664
- import { existsSync as existsSync21, readFileSync as readFileSync19, statSync as statSync5 } from "fs";
3863
+ import { createReadStream, existsSync as existsSync22, readdirSync as readdirSync9, readFileSync as readFileSync19, statSync as statSync5 } from "fs";
3665
3864
  import { createServer as createServer2 } from "http";
3666
- import { extname as extname2, join as join23, resolve as resolve5 } from "path";
3865
+ import { extname as extname3, join as join24, resolve as resolve5 } from "path";
3667
3866
 
3668
3867
  // src/lib/act.ts
3669
3868
  import { execFile as execFile3 } from "child_process";
3670
3869
  import { promisify as promisify3 } from "util";
3870
+
3871
+ // src/lib/ask.ts
3872
+ var MAX_PATCH_LINES = 40;
3873
+ function askTitle(req) {
3874
+ const name = req.pr.repo.split("/")[1] ?? req.pr.repo;
3875
+ const head2 = `${name}#${req.pr.number} \u2014 `;
3876
+ const room = 120 - head2.length;
3877
+ const title = req.pr.title.trim() || "a pull request";
3878
+ return head2 + (title.length > room ? `${title.slice(0, room - 1).trimEnd()}\u2026` : title);
3879
+ }
3880
+ function askBody(req) {
3881
+ const { staff, pr, anchor } = req;
3882
+ const said = req.body.trim();
3883
+ const isPr = pr.kind !== "issue";
3884
+ const it = isPr ? "pull request" : "issue";
3885
+ const out = [];
3886
+ out.push(stripLeadingMention(said, staff.mention) ? `${staff.mention} ${said}` : said);
3887
+ out.push("", "---", "");
3888
+ out.push(`**This is about ${pr.repo}#${pr.number} \u2014 "${pr.title.trim()}".**`);
3889
+ out.push("", pr.url);
3890
+ if (pr.head && pr.base) out.push("", `Branch \`${pr.head}\` \u2192 \`${pr.base}\`.`);
3891
+ if (anchor?.path) {
3892
+ out.push("", `They were looking at \`${anchor.path}\`.`);
3893
+ const hunk = clip(anchor.patch ?? "");
3894
+ if (hunk) out.push("", fence2(hunk, "diff"));
3895
+ else if (anchor.patch) out.push("", "The diff for it is on the pull request.");
3896
+ }
3897
+ out.push(
3898
+ "",
3899
+ `**Answer on the ${it}, not here.** That is where it will be read${isPr ? ", and it is where the diff is" : ""}:`,
3900
+ "",
3901
+ fence2(`gh issue comment ${pr.number} --repo ${pr.repo} --body "\u2026"`, ""),
3902
+ "",
3903
+ ...isPr ? [
3904
+ pr.head ? `Push any change to \`${pr.head}\`. Close this issue once the reply is up.` : "Push any change to the pull request's branch. Close this issue once the reply is up."
3905
+ ] : ["Close this issue once the reply is up."]
3906
+ );
3907
+ return `${out.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}
3908
+ `;
3909
+ }
3910
+ function stripLeadingMention(said, mention) {
3911
+ return !new RegExp(`(^|\\s)${escapeRe(mention)}(\\s|$)`).test(said);
3912
+ }
3913
+ function escapeRe(s) {
3914
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3915
+ }
3916
+ function clip(patch) {
3917
+ const lines = patch.replace(/\s+$/, "").split("\n");
3918
+ if (!lines[0]) return "";
3919
+ if (lines.length <= MAX_PATCH_LINES) return lines.join("\n");
3920
+ return [
3921
+ ...lines.slice(0, MAX_PATCH_LINES),
3922
+ `\u2026 ${lines.length - MAX_PATCH_LINES} more lines, on the pull request`
3923
+ ].join("\n");
3924
+ }
3925
+ function fence2(text, lang) {
3926
+ const longest = Math.max(0, ...[...text.matchAll(/`+/g)].map((m) => m[0].length));
3927
+ const ticks = "`".repeat(Math.max(3, longest + 1));
3928
+ return `${ticks}${lang}
3929
+ ${text}
3930
+ ${ticks}`;
3931
+ }
3932
+
3933
+ // src/lib/act.ts
3671
3934
  var run3 = promisify3(execFile3);
3935
+ var MERGE_FLAG = { squash: "--squash", merge: "--merge", rebase: "--rebase" };
3672
3936
  async function act(req) {
3673
3937
  const { action, repo } = req;
3674
3938
  if (action === "create") {
@@ -3679,6 +3943,37 @@ async function act(req) {
3679
3943
  const { stdout } = await run3("gh", args, { encoding: "utf8" });
3680
3944
  return { ok: true, action, url: stdout.trim().split("\n").pop() };
3681
3945
  }
3946
+ if (action === "ask") {
3947
+ const ask = req.ask;
3948
+ if (!ask) throw new Error("an ask needs to say who it is for and what it is about");
3949
+ if (!ask.staff?.brain) throw new Error(`${ask.staff?.name ?? "they"} has no brain repo`);
3950
+ if (repo !== ask.staff.brain)
3951
+ throw new Error(`an ask goes to ${ask.staff.brain}, not to ${repo}`);
3952
+ if (!ask.body?.trim()) throw new Error("an empty ask is not an ask");
3953
+ const title = (req.title ?? "").trim() || askTitle(ask);
3954
+ const args = ["issue", "create", "--repo", repo, "--title", title, "--body-file", "-"];
3955
+ for (const l of req.labels ?? []) args.push("--label", l);
3956
+ const { stdout } = await execWithStdin(args, askBody(ask));
3957
+ const url = stdout.trim().split("\n").pop();
3958
+ if (!req.alsoOnPr) return { ok: true, action, url };
3959
+ const note = `${ask.body.trim()}
3960
+
3961
+ \u2014 asked ${ask.staff.name} (${ask.staff.mention}): ${url ?? ask.staff.brain}`;
3962
+ try {
3963
+ const posted = await execWithStdin(
3964
+ ["issue", "comment", String(ask.pr.number), "--repo", ask.pr.repo, "--body-file", "-"],
3965
+ note
3966
+ );
3967
+ return { ok: true, action, url, prUrl: posted.stdout.trim().split("\n").pop() };
3968
+ } catch (err) {
3969
+ return {
3970
+ ok: true,
3971
+ action,
3972
+ url,
3973
+ warning: `${ask.staff.name} was asked, but the copy on ${ask.pr.repo}#${ask.pr.number} did not go up: ${String(err?.message ?? err).split("\n")[0]}`
3974
+ };
3975
+ }
3976
+ }
3682
3977
  const number = req.number;
3683
3978
  if (!Number.isInteger(number) || number <= 0)
3684
3979
  throw new Error("a valid issue number is required");
@@ -3703,8 +3998,37 @@ async function act(req) {
3703
3998
  await run3("gh", ["issue", "reopen", n, "--repo", repo], { encoding: "utf8" });
3704
3999
  return { ok: true, action };
3705
4000
  }
4001
+ if (action === "merge") {
4002
+ const how = req.mergeMethod ?? await mergeMethodFor(repo);
4003
+ const flag = MERGE_FLAG[how];
4004
+ if (!flag) throw new Error(`unknown merge method "${req.mergeMethod}"`);
4005
+ const args = ["pr", "merge", n, "--repo", repo, flag];
4006
+ if (req.body?.trim() && how !== "rebase") args.push("--body", req.body.trim());
4007
+ await run3("gh", args, { encoding: "utf8" });
4008
+ return { ok: true, action, mergedBy: how };
4009
+ }
3706
4010
  throw new Error(`unknown action "${action}"`);
3707
4011
  }
4012
+ async function mergeMethodFor(repo) {
4013
+ try {
4014
+ const { stdout } = await run3(
4015
+ "gh",
4016
+ [
4017
+ "api",
4018
+ `repos/${repo}`,
4019
+ "--jq",
4020
+ "[.allow_squash_merge,.allow_merge_commit,.allow_rebase_merge]"
4021
+ ],
4022
+ { encoding: "utf8" }
4023
+ );
4024
+ const [squash, commit, rebase] = JSON.parse(stdout);
4025
+ if (squash) return "squash";
4026
+ if (commit) return "merge";
4027
+ if (rebase) return "rebase";
4028
+ } catch {
4029
+ }
4030
+ return "squash";
4031
+ }
3708
4032
  function execWithStdin(args, input) {
3709
4033
  return new Promise((resolve6, reject) => {
3710
4034
  const child = execFile3(
@@ -3717,26 +4041,144 @@ function execWithStdin(args, input) {
3717
4041
  });
3718
4042
  }
3719
4043
 
4044
+ // src/lib/attach.ts
4045
+ import { execFileSync as execFileSync7 } from "child_process";
4046
+ import { existsSync as existsSync15, mkdirSync as mkdirSync4, writeFileSync as writeFileSync7 } from "fs";
4047
+ import { basename, extname, join as join17 } from "path";
4048
+ var MAX_UPLOAD = 25 * 1024 * 1024;
4049
+ var ATTACH_DIR = "attachments";
4050
+ function attach(opts) {
4051
+ const { repoDir, repo, data, today } = opts;
4052
+ if (!existsSync15(join17(repoDir, ".git"))) {
4053
+ throw new Error(`${repo} is not checked out here, so there is nowhere to put the file`);
4054
+ }
4055
+ if (!data.length) throw new Error("that file is empty");
4056
+ if (data.length > MAX_UPLOAD) {
4057
+ throw new Error(`${basename(opts.name)} is larger than ${MAX_UPLOAD / 1024 / 1024}MB`);
4058
+ }
4059
+ const rel = free(repoDir, `${ATTACH_DIR}/${today}-${safeName(opts.name)}`);
4060
+ const full = join17(repoDir, rel);
4061
+ mkdirSync4(join17(repoDir, ATTACH_DIR), { recursive: true });
4062
+ writeFileSync7(full, data);
4063
+ const git4 = (args) => execFileSync7("git", ["-C", repoDir, ...args], {
4064
+ encoding: "utf8",
4065
+ stdio: ["ignore", "pipe", "pipe"]
4066
+ });
4067
+ try {
4068
+ git4(["add", "--", rel]);
4069
+ git4(["commit", "-m", `portal: attach ${rel}`, "--", rel]);
4070
+ } catch (err) {
4071
+ throw new Error(`commit failed: ${short2(err)}`);
4072
+ }
4073
+ const branch = git4(["rev-parse", "--abbrev-ref", "HEAD"]).trim();
4074
+ const url = `https://github.com/${repo}/blob/${branch}/${rel.split("/").map(encodeURIComponent).join("/")}`;
4075
+ try {
4076
+ git4(["push"]);
4077
+ } catch (err) {
4078
+ return { path: rel, url, bytes: data.length, pushed: false, note: short2(err) };
4079
+ }
4080
+ return { path: rel, url, bytes: data.length, pushed: true };
4081
+ }
4082
+ function safeName(raw) {
4083
+ const name = basename(String(raw ?? "")).replace(/^\.+/, "");
4084
+ const ext = extname(name).toLowerCase().slice(0, 12);
4085
+ const stem = name.slice(0, name.length - extname(name).length).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "file";
4086
+ return stem + ext.replace(/[^a-z0-9.]/g, "");
4087
+ }
4088
+ function free(repoDir, rel) {
4089
+ if (!existsSync15(join17(repoDir, rel))) return rel;
4090
+ const ext = extname(rel);
4091
+ const stem = rel.slice(0, rel.length - ext.length);
4092
+ for (let n = 2; n < 500; n++) {
4093
+ const next = `${stem}-${n}${ext}`;
4094
+ if (!existsSync15(join17(repoDir, next))) return next;
4095
+ }
4096
+ throw new Error("too many files by that name today");
4097
+ }
4098
+ function short2(e) {
4099
+ const msg = e instanceof Error ? e.stderr?.toString() || e.message : String(e);
4100
+ const line = msg.split("\n").find((l) => l.trim()) ?? msg;
4101
+ return line.length > 200 ? `${line.slice(0, 199)}\u2026` : line;
4102
+ }
4103
+
3720
4104
  // src/lib/docs.ts
3721
- import { existsSync as existsSync15, readdirSync as readdirSync6, readFileSync as readFileSync14 } from "fs";
3722
- import { dirname as dirname7, join as join17 } from "path";
4105
+ import { existsSync as existsSync16, readdirSync as readdirSync7, readFileSync as readFileSync14 } from "fs";
4106
+ import { dirname as dirname7, join as join18 } from "path";
3723
4107
  import { fileURLToPath as fileURLToPath2 } from "url";
3724
4108
  function docsDir() {
3725
4109
  const here = dirname7(fileURLToPath2(import.meta.url));
3726
- const candidates = [join17(here, "..", "..", "docs"), join17(here, "..", "docs")];
3727
- for (const path of candidates) if (existsSync15(path)) return path;
4110
+ const candidates = [join18(here, "..", "..", "docs"), join18(here, "..", "docs")];
4111
+ for (const path of candidates) if (existsSync16(path)) return path;
3728
4112
  throw new Error(`docs not found. Looked in:
3729
4113
  ${candidates.join("\n ")}`);
3730
4114
  }
3731
4115
  function docPages() {
3732
4116
  const dir = docsDir();
3733
- const all = readdirSync6(dir).filter((f) => f.endsWith(".md"));
3734
- const index = readFileSync14(join17(dir, "README.md"), "utf8");
4117
+ const all = readdirSync7(dir).filter((f) => f.endsWith(".md"));
4118
+ const index = readFileSync14(join18(dir, "README.md"), "utf8");
3735
4119
  const ordered = [...index.matchAll(/\[([^\]]+)\]\(([\w.-]+\.md)\)/g)].map((m) => ({ title: m[1], file: m[2] })).filter((d, i, xs) => all.includes(d.file) && xs.findIndex((x) => x.file === d.file) === i);
3736
4120
  const listed = new Set(ordered.map((d) => d.file));
3737
- const rest = all.filter((f) => f !== "README.md" && !listed.has(f)).map((file) => ({ file, title: titleOf(join17(dir, file)) }));
4121
+ const rest = all.filter((f) => f !== "README.md" && !listed.has(f)).map((file) => ({ file, title: titleOf(join18(dir, file)) }));
3738
4122
  return [{ file: "README.md", title: "Overview" }, ...ordered, ...rest];
3739
4123
  }
4124
+ var ASSET_TYPES = {
4125
+ ".png": "image/png",
4126
+ ".jpg": "image/jpeg",
4127
+ ".jpeg": "image/jpeg",
4128
+ ".gif": "image/gif",
4129
+ ".svg": "image/svg+xml",
4130
+ ".webp": "image/webp"
4131
+ };
4132
+ function docAsset(name) {
4133
+ const dir = join18(docsDir(), "images");
4134
+ if (!existsSync16(dir)) return null;
4135
+ const wanted = String(name ?? "").replace(/^images\//, "");
4136
+ const found = readdirSync7(dir).find((f) => f === wanted);
4137
+ if (!found) return null;
4138
+ const type = ASSET_TYPES[found.slice(found.lastIndexOf(".")).toLowerCase()];
4139
+ return type ? { path: join18(dir, found), type } : null;
4140
+ }
4141
+ function searchDocs(query2, snippets = 3) {
4142
+ const terms = String(query2 ?? "").toLowerCase().split(/\s+/).map((t) => t.trim()).filter(Boolean);
4143
+ if (!terms.length) return [];
4144
+ const dir = docsDir();
4145
+ const out = [];
4146
+ for (const page2 of docPages()) {
4147
+ const text = readFileSync14(join18(dir, page2.file), "utf8").replace(/^---\n[\s\S]*?\n---\n/, "");
4148
+ const haystack = (page2.title + "\n" + text).toLowerCase();
4149
+ if (!terms.every((t) => haystack.includes(t))) continue;
4150
+ const title = page2.title.toLowerCase();
4151
+ const matches = [];
4152
+ let body = 0;
4153
+ text.split("\n").forEach((raw, i) => {
4154
+ const line = raw.trim();
4155
+ if (!line) return;
4156
+ const hits = terms.filter((t) => line.toLowerCase().includes(t)).length;
4157
+ if (!hits) return;
4158
+ body += hits === terms.length ? 2 : 1;
4159
+ if (line.startsWith("#")) body += 8;
4160
+ if (matches.length < snippets) {
4161
+ matches.push({ line: i + 1, text: snippet(line, terms) });
4162
+ }
4163
+ });
4164
+ out.push({
4165
+ ...page2,
4166
+ score: terms.filter((t) => title.includes(t)).length * 100 + Math.min(body, 60),
4167
+ matches
4168
+ });
4169
+ }
4170
+ return out.sort((a, b) => b.score - a.score);
4171
+ }
4172
+ function snippet(line, terms, width = 150) {
4173
+ const clean = line.replace(/^#+\s*/, "").replace(/^[-*|]\s*/, "");
4174
+ if (clean.length <= width) return clean;
4175
+ const at = Math.min(
4176
+ ...terms.map((t) => clean.toLowerCase().indexOf(t)).filter((n) => n >= 0),
4177
+ clean.length
4178
+ );
4179
+ const from = Math.max(0, at - 40);
4180
+ return (from ? "\u2026" : "") + clean.slice(from, from + width).trim() + "\u2026";
4181
+ }
3740
4182
  function titleOf(path) {
3741
4183
  const first = readFileSync14(path, "utf8").split("\n").find((l) => l.startsWith("# "));
3742
4184
  return first ? first.slice(2).trim() : path.split("/").pop();
@@ -3746,9 +4188,15 @@ function titleOf(path) {
3746
4188
  import { execFile as execFile4 } from "child_process";
3747
4189
  import { promisify as promisify4 } from "util";
3748
4190
  var run4 = promisify4(execFile4);
4191
+ var REACTIONS = `
4192
+ reactionGroups {
4193
+ content
4194
+ reactors(first:6) { totalCount nodes { ... on User { login } ... on Bot { login } } }
4195
+ }
4196
+ `;
3749
4197
  var TIMELINE_COMMON = `
3750
4198
  __typename
3751
- ... on IssueComment { author { login } createdAt body url }
4199
+ ... on IssueComment { author { login } createdAt body url ${REACTIONS} }
3752
4200
  ... on CrossReferencedEvent {
3753
4201
  actor { login } createdAt
3754
4202
  source {
@@ -3804,6 +4252,7 @@ query($owner:String!, $name:String!) {
3804
4252
  nodes {
3805
4253
  number title body url state createdAt updatedAt
3806
4254
  author { login }
4255
+ ${REACTIONS}
3807
4256
  labels(first:12) { nodes { name } }
3808
4257
  assignees(first:8) { nodes { login } }
3809
4258
  timelineItems(last:80, itemTypes:${ISSUE_TYPES}) { nodes { ${TIMELINE_COMMON} } }
@@ -3811,8 +4260,9 @@ query($owner:String!, $name:String!) {
3811
4260
  }
3812
4261
  pullRequests(states:OPEN, first:60, orderBy:{field:UPDATED_AT, direction:DESC}) {
3813
4262
  nodes {
3814
- number title body url state createdAt updatedAt isDraft
4263
+ number title body url state createdAt updatedAt isDraft mergeable
3815
4264
  author { login }
4265
+ ${REACTIONS}
3816
4266
  labels(first:12) { nodes { name } }
3817
4267
  assignees(first:8) { nodes { login } }
3818
4268
  commits(last:1) { nodes { commit { statusCheckRollup { state } } } }
@@ -3827,6 +4277,7 @@ query($owner:String!, $name:String!) {
3827
4277
  nodes {
3828
4278
  number title body url state createdAt updatedAt
3829
4279
  author { login }
4280
+ ${REACTIONS}
3830
4281
  labels(first:12) { nodes { name } }
3831
4282
  assignees(first:8) { nodes { login } }
3832
4283
  timelineItems(last:${CLOSED_TIMELINE}, itemTypes:${ISSUE_TYPES}) {
@@ -3840,6 +4291,7 @@ query($owner:String!, $name:String!) {
3840
4291
  nodes {
3841
4292
  number title body url state createdAt updatedAt isDraft
3842
4293
  author { login }
4294
+ ${REACTIONS}
3843
4295
  labels(first:12) { nodes { name } }
3844
4296
  assignees(first:8) { nodes { login } }
3845
4297
  timelineItems(last:${CLOSED_TIMELINE}, itemTypes:${PR_TYPES}) {
@@ -3863,6 +4315,7 @@ async function fetchInbox(repos) {
3863
4315
  const item = shape(n, full, r.role, "pr");
3864
4316
  item.draft = n.isDraft;
3865
4317
  item.checks = rollup(n.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state);
4318
+ item.mergeable = n.mergeable;
3866
4319
  items.push(item);
3867
4320
  }
3868
4321
  const cutoff = Date.now() - CLOSED_DAYS * 864e5;
@@ -3876,7 +4329,7 @@ async function fetchInbox(repos) {
3876
4329
  items.push(item);
3877
4330
  }
3878
4331
  } catch (e) {
3879
- errors.push(`${full}: ${short2(e)}`);
4332
+ errors.push(`${full}: ${short3(e)}`);
3880
4333
  }
3881
4334
  })
3882
4335
  );
@@ -3908,15 +4361,30 @@ function shape(n, repo, role, kind) {
3908
4361
  url: n.url,
3909
4362
  state: n.state ?? "OPEN",
3910
4363
  comments: events.filter((e) => e.type === "comment").map((e) => ({ author: e.actor, createdAt: e.createdAt, body: e.body ?? "" })),
3911
- events
4364
+ events,
4365
+ reactions: reactions(n)
3912
4366
  };
3913
4367
  }
4368
+ function reactions(n) {
4369
+ return (n.reactionGroups ?? []).filter((g) => (g.reactors?.totalCount ?? 0) > 0).map((g) => ({
4370
+ content: g.content,
4371
+ count: g.reactors.totalCount,
4372
+ by: (g.reactors.nodes ?? []).map((u) => u?.login).filter(Boolean)
4373
+ }));
4374
+ }
3914
4375
  function event(e) {
3915
4376
  const who = e.actor?.login ?? e.author?.login ?? "";
3916
4377
  const at = e.createdAt;
3917
4378
  switch (e.__typename) {
3918
4379
  case "IssueComment":
3919
- return { type: "comment", actor: who, createdAt: at, body: e.body ?? "", url: e.url };
4380
+ return {
4381
+ type: "comment",
4382
+ actor: who,
4383
+ createdAt: at,
4384
+ body: e.body ?? "",
4385
+ url: e.url,
4386
+ reactions: reactions(e)
4387
+ };
3920
4388
  case "CrossReferencedEvent": {
3921
4389
  const s = e.source;
3922
4390
  if (!s?.number) return null;
@@ -4021,7 +4489,7 @@ function rollup(state) {
4021
4489
  if (/FAILURE|ERROR/i.test(state)) return "failing";
4022
4490
  return "pending";
4023
4491
  }
4024
- function short2(e) {
4492
+ function short3(e) {
4025
4493
  const msg = e instanceof Error ? e.message : String(e);
4026
4494
  const line = msg.split("\n").find((l) => l.trim() && !/^\s*$/.test(l)) ?? msg;
4027
4495
  return line.length > 200 ? line.slice(0, 199) + "\u2026" : line;
@@ -4116,8 +4584,8 @@ function ensureNewline(text) {
4116
4584
  }
4117
4585
 
4118
4586
  // src/lib/pastebrief.ts
4119
- import { existsSync as existsSync16, readFileSync as readFileSync15 } from "fs";
4120
- import { join as join18 } from "path";
4587
+ import { existsSync as existsSync17, readFileSync as readFileSync15 } from "fs";
4588
+ import { join as join19 } from "path";
4121
4589
  var WRITES = {
4122
4590
  discover: (c) => [`${c.opsName}/org/business.md`],
4123
4591
  voice: (c) => [`${c.opsName}/org/voice.md`],
@@ -4208,13 +4676,13 @@ ${END}`).join("\n\n");
4208
4676
  function fileBlock(ws, rel, note) {
4209
4677
  const body = read2(ws, rel);
4210
4678
  if (!body.trim()) return [`### \`${rel}\``, "", `*Empty or absent (${note}).*`, ""];
4211
- return [`### \`${rel}\``, "", `*${note}*`, "", fence2(body), ""];
4679
+ return [`### \`${rel}\``, "", `*${note}*`, "", fence3(body), ""];
4212
4680
  }
4213
4681
  function read2(ws, rel) {
4214
- const full = join18(ws.root, rel);
4215
- return existsSync16(full) ? readFileSync15(full, "utf8") : "";
4682
+ const full = join19(ws.root, rel);
4683
+ return existsSync17(full) ? readFileSync15(full, "utf8") : "";
4216
4684
  }
4217
- function fence2(text) {
4685
+ function fence3(text) {
4218
4686
  const longest = (text.match(/`{3,}/g) ?? []).reduce((n, m) => Math.max(n, m.length), 2);
4219
4687
  const bars = "`".repeat(longest + 1);
4220
4688
  return `${bars}
@@ -4222,15 +4690,15 @@ ${text.trimEnd()}
4222
4690
  ${bars}`;
4223
4691
  }
4224
4692
  function briefTemplate(kind) {
4225
- const path = join18(briefTemplateDir(), `${kind}.md`);
4226
- if (!existsSync16(path)) throw new Error(`no brief template for "${kind}"`);
4693
+ const path = join19(briefTemplateDir(), `${kind}.md`);
4694
+ if (!existsSync17(path)) throw new Error(`no brief template for "${kind}"`);
4227
4695
  return readFileSync15(path, "utf8");
4228
4696
  }
4229
4697
 
4230
4698
  // src/lib/setup.ts
4231
4699
  import { execFile as execFile5 } from "child_process";
4232
- import { existsSync as existsSync17, mkdirSync as mkdirSync4, readFileSync as readFileSync16 } from "fs";
4233
- import { join as join19 } from "path";
4700
+ import { existsSync as existsSync18, mkdirSync as mkdirSync5, readFileSync as readFileSync16 } from "fs";
4701
+ import { join as join20 } from "path";
4234
4702
  var AGENTS = [
4235
4703
  {
4236
4704
  id: "claude-code-action",
@@ -4294,14 +4762,14 @@ async function setupStatus(startedIn) {
4294
4762
  };
4295
4763
  }
4296
4764
  function readOrgName(opsDir) {
4297
- const path = join19(opsDir, "org.yaml");
4298
- if (!existsSync17(path)) return void 0;
4765
+ const path = join20(opsDir, "org.yaml");
4766
+ if (!existsSync18(path)) return void 0;
4299
4767
  const m = /^org:\s*(\S+)\s*$/m.exec(readFileSync16(path, "utf8"));
4300
4768
  return m?.[1];
4301
4769
  }
4302
4770
  function readRepoNames(opsDir) {
4303
- const path = join19(opsDir, "org.yaml");
4304
- if (!existsSync17(path)) return [];
4771
+ const path = join20(opsDir, "org.yaml");
4772
+ if (!existsSync18(path)) return [];
4305
4773
  const names = [];
4306
4774
  let inside = false;
4307
4775
  for (const line of readFileSync16(path, "utf8").split("\n")) {
@@ -4317,29 +4785,29 @@ function readRepoNames(opsDir) {
4317
4785
  return names;
4318
4786
  }
4319
4787
  async function loadFrameworkComposer() {
4320
- const path = join19(templatesRoot(), "ops", "compose.mjs");
4788
+ const path = join20(templatesRoot(), "ops", "compose.mjs");
4321
4789
  return await import(`file://${path}`);
4322
4790
  }
4323
4791
  async function joinTenant(root, org) {
4324
- mkdirSync4(root, { recursive: true });
4792
+ mkdirSync5(root, { recursive: true });
4325
4793
  const cloned = [];
4326
- const opsDir = join19(root, "roster-ops");
4327
- if (!existsSync17(opsDir)) {
4794
+ const opsDir = join20(root, "roster-ops");
4795
+ if (!existsSync18(opsDir)) {
4328
4796
  await clone(`https://github.com/${org}/roster-ops.git`, opsDir);
4329
4797
  cloned.push("roster-ops");
4330
4798
  }
4331
- if (!existsSync17(join19(opsDir, "org.yaml"))) {
4799
+ if (!existsSync18(join20(opsDir, "org.yaml"))) {
4332
4800
  throw new Error(`${org}/roster-ops has no org.yaml, so it is not a roster tenant`);
4333
4801
  }
4334
- const { parseYaml } = await import(`file://${join19(opsDir, "compose.mjs")}`).then(
4802
+ const { parseYaml } = await import(`file://${join20(opsDir, "compose.mjs")}`).then(
4335
4803
  (m) => m
4336
4804
  );
4337
- const orgFile = parseYaml(readFileSync16(join19(opsDir, "org.yaml"), "utf8"), "org.yaml");
4805
+ const orgFile = parseYaml(readFileSync16(join20(opsDir, "org.yaml"), "utf8"), "org.yaml");
4338
4806
  for (const person of orgFile.staff ?? []) {
4339
4807
  const dir = person.dir ?? person.handle;
4340
- if (existsSync17(join19(root, dir))) continue;
4808
+ if (existsSync18(join20(root, dir))) continue;
4341
4809
  try {
4342
- await clone(`https://github.com/${org}/${dir}.git`, join19(root, dir));
4810
+ await clone(`https://github.com/${org}/${dir}.git`, join20(root, dir));
4343
4811
  cloned.push(dir);
4344
4812
  } catch {
4345
4813
  }
@@ -4354,16 +4822,16 @@ function clone(url, into) {
4354
4822
 
4355
4823
  // src/lib/sync.ts
4356
4824
  import { execFile as execFile6 } from "child_process";
4357
- import { existsSync as existsSync18 } from "fs";
4358
- import { join as join20 } from "path";
4825
+ import { existsSync as existsSync19 } from "fs";
4826
+ import { join as join21 } from "path";
4359
4827
  import { promisify as promisify5 } from "util";
4360
4828
  var run5 = promisify5(execFile6);
4361
4829
  async function syncRepos(root, dirs) {
4362
- return Promise.all(dirs.map((dir) => syncOne(join20(root, dir), dir)));
4830
+ return Promise.all(dirs.map((dir) => syncOne(join21(root, dir), dir)));
4363
4831
  }
4364
4832
  async function syncOne(path, dir) {
4365
4833
  const base2 = { dir, behind: 0, ahead: 0, dirty: false, pulled: false };
4366
- if (!existsSync18(join20(path, ".git"))) return { ...base2, skipped: "no-remote" };
4834
+ if (!existsSync19(join21(path, ".git"))) return { ...base2, skipped: "no-remote" };
4367
4835
  const git4 = async (...args) => (await run5("git", ["-C", path, ...args], { encoding: "utf8" })).stdout.trim();
4368
4836
  try {
4369
4837
  const remotes = await git4("remote");
@@ -4384,35 +4852,37 @@ async function syncOne(path, dir) {
4384
4852
  if (dirty) return { ...base2, ahead, behind, dirty, skipped: "dirty" };
4385
4853
  if (ahead > 0) return { ...base2, ahead, behind, dirty, skipped: "diverged" };
4386
4854
  await git4("pull", "--ff-only", "--quiet");
4387
- return { ...base2, ahead, behind, dirty, pulled: true };
4855
+ return { ...base2, ahead, behind: 0, dirty, pulled: true };
4388
4856
  } catch (err) {
4389
- return { ...base2, error: firstLine2(err) };
4857
+ return { ...base2, error: reason(err) };
4390
4858
  }
4391
4859
  }
4392
- function firstLine2(e) {
4393
- const msg = e instanceof Error ? e.message : String(e);
4394
- const line = msg.split("\n").find((l) => l.trim()) ?? msg;
4860
+ function reason(e) {
4861
+ const err = e;
4862
+ const text = String(err?.stderr ?? "").trim() || String(err?.message ?? e);
4863
+ const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
4864
+ const line = lines.find((l) => !/^Command failed:/i.test(l)) ?? lines[0] ?? String(e);
4395
4865
  return line.length > 180 ? line.slice(0, 179) + "\u2026" : line;
4396
4866
  }
4397
4867
 
4398
4868
  // src/portal/assets.ts
4399
- import { existsSync as existsSync19, readFileSync as readFileSync17, statSync as statSync4 } from "fs";
4400
- import { dirname as dirname8, extname, join as join21, resolve as resolve4 } from "path";
4869
+ import { existsSync as existsSync20, readFileSync as readFileSync17, statSync as statSync4 } from "fs";
4870
+ import { dirname as dirname8, extname as extname2, join as join22, resolve as resolve4 } from "path";
4401
4871
  import { fileURLToPath as fileURLToPath3 } from "url";
4402
4872
  function portalDir() {
4403
4873
  const here = dirname8(fileURLToPath3(import.meta.url));
4404
4874
  const candidates = [
4405
- join21(here, "..", "..", "templates", "portal"),
4875
+ join22(here, "..", "..", "templates", "portal"),
4406
4876
  // src/portal → package root
4407
- join21(here, "..", "templates", "portal")
4877
+ join22(here, "..", "templates", "portal")
4408
4878
  // dist → package root
4409
4879
  ];
4410
- for (const path of candidates) if (existsSync19(join21(path, "index.html"))) return path;
4880
+ for (const path of candidates) if (existsSync20(join22(path, "index.html"))) return path;
4411
4881
  throw new Error(`portal UI not found. Looked in:
4412
4882
  ${candidates.join("\n ")}`);
4413
4883
  }
4414
4884
  function portalIndex() {
4415
- return readFileSync17(join21(portalDir(), "index.html"), "utf8");
4885
+ return readFileSync17(join22(portalDir(), "index.html"), "utf8");
4416
4886
  }
4417
4887
  var ASSET_MIME = {
4418
4888
  ".css": "text/css; charset=utf-8",
@@ -4426,15 +4896,15 @@ function portalAsset(rel) {
4426
4896
  const root = portalDir();
4427
4897
  const full = resolve4(root, rel);
4428
4898
  if (!full.startsWith(resolve4(root) + "/")) return null;
4429
- const type = ASSET_MIME[extname(full).toLowerCase()];
4899
+ const type = ASSET_MIME[extname2(full).toLowerCase()];
4430
4900
  if (!type) return null;
4431
- if (!existsSync19(full) || statSync4(full).isDirectory()) return null;
4901
+ if (!existsSync20(full) || statSync4(full).isDirectory()) return null;
4432
4902
  return { body: readFileSync17(full), type };
4433
4903
  }
4434
4904
 
4435
4905
  // src/commands/retire.ts
4436
- import { existsSync as existsSync20, readdirSync as readdirSync7, readFileSync as readFileSync18, writeFileSync as writeFileSync7 } from "fs";
4437
- import { join as join22 } from "path";
4906
+ import { existsSync as existsSync21, readdirSync as readdirSync8, readFileSync as readFileSync18, writeFileSync as writeFileSync8 } from "fs";
4907
+ import { join as join23 } from "path";
4438
4908
  var retireHelp = `
4439
4909
  roster retire <handle>
4440
4910
 
@@ -4485,23 +4955,23 @@ async function retireCommand(argv) {
4485
4955
  function buildRetirePlan(ws, org, handle, parseYaml) {
4486
4956
  const entry = (org.staff ?? []).find((s) => s.handle === handle);
4487
4957
  const dir = entry.dir ?? entry.handle;
4488
- const root = join22(ws.root, dir);
4958
+ const root = join23(ws.root, dir);
4489
4959
  const warnings = [];
4490
4960
  const manifest = readManifest3(root, parseYaml);
4491
4961
  const brain = manifest?.brain;
4492
- if (!existsSync20(root)) warnings.push(`${dir}/ is not checked out, so its files cannot be read`);
4493
- const wfDir = join22(root, ".github", "workflows");
4494
- const workflows = existsSync20(wfDir) ? readdirSync7(wfDir).filter((f) => /\.ya?ml$/.test(f)).sort() : [];
4962
+ if (!existsSync21(root)) warnings.push(`${dir}/ is not checked out, so its files cannot be read`);
4963
+ const wfDir = join23(root, ".github", "workflows");
4964
+ const workflows = existsSync21(wfDir) ? readdirSync8(wfDir).filter((f) => /\.ya?ml$/.test(f)).sort() : [];
4495
4965
  if (!workflows.length) warnings.push("no workflows found, so nothing to disable");
4496
4966
  const peers = [];
4497
4967
  for (const other of org.staff ?? []) {
4498
4968
  if (other.handle === handle) continue;
4499
4969
  const otherDir = other.dir ?? other.handle;
4500
- const path = join22(ws.root, otherDir, "staff.yaml");
4501
- if (!existsSync20(path)) continue;
4970
+ const path = join23(ws.root, otherDir, "staff.yaml");
4971
+ if (!existsSync21(path)) continue;
4502
4972
  const text = readFileSync18(path, "utf8");
4503
4973
  if (!new RegExp(`handle: ${handle}[,\\s}]`).test(text)) continue;
4504
- const theirs = readManifest3(join22(ws.root, otherDir), parseYaml);
4974
+ const theirs = readManifest3(join23(ws.root, otherDir), parseYaml);
4505
4975
  peers.push({
4506
4976
  handle: other.handle,
4507
4977
  dir: otherDir,
@@ -4509,7 +4979,7 @@ function buildRetirePlan(ws, org, handle, parseYaml) {
4509
4979
  label: `from-${handle}`
4510
4980
  });
4511
4981
  }
4512
- const facts = countFacts(join22(root, "memory", "INDEX.md"));
4982
+ const facts = countFacts(join23(root, "memory", "INDEX.md"));
4513
4983
  return {
4514
4984
  handle,
4515
4985
  name: entry.name ?? handle,
@@ -4567,9 +5037,9 @@ async function applyRetirePlan(ws, plan2) {
4567
5037
  removeFromOrgYaml(ws, plan2);
4568
5038
  process.stdout.write(" removed from org.yaml\n");
4569
5039
  for (const p of plan2.peers) {
4570
- const path = join22(ws.root, p.dir, "staff.yaml");
5040
+ const path = join23(ws.root, p.dir, "staff.yaml");
4571
5041
  const text = readFileSync18(path, "utf8");
4572
- writeFileSync7(path, removePeerLine(text, plan2.handle));
5042
+ writeFileSync8(path, removePeerLine(text, plan2.handle));
4573
5043
  process.stdout.write(` removed from ${p.dir}/staff.yaml
4574
5044
  `);
4575
5045
  if (!p.brain) continue;
@@ -4595,9 +5065,9 @@ async function applyRetirePlan(ws, plan2) {
4595
5065
  return failed ? 1 : 0;
4596
5066
  }
4597
5067
  function removeFromOrgYaml(ws, plan2) {
4598
- const path = join22(ws.opsDir, "org.yaml");
5068
+ const path = join23(ws.opsDir, "org.yaml");
4599
5069
  const text = readFileSync18(path, "utf8");
4600
- writeFileSync7(path, removeOrgLines(text, plan2.handle, plan2.dir));
5070
+ writeFileSync8(path, removeOrgLines(text, plan2.handle, plan2.dir));
4601
5071
  }
4602
5072
  function removeOrgLines(text, handle, dir) {
4603
5073
  return text.split("\n").filter((line) => {
@@ -4615,8 +5085,8 @@ function removePeerLine(text, handle) {
4615
5085
  }).join("\n");
4616
5086
  }
4617
5087
  function readManifest3(root, parseYaml) {
4618
- const path = join22(root, "staff.yaml");
4619
- if (!existsSync20(path)) return null;
5088
+ const path = join23(root, "staff.yaml");
5089
+ if (!existsSync21(path)) return null;
4620
5090
  try {
4621
5091
  return parseYaml(readFileSync18(path, "utf8"), "staff.yaml");
4622
5092
  } catch {
@@ -4624,7 +5094,7 @@ function readManifest3(root, parseYaml) {
4624
5094
  }
4625
5095
  }
4626
5096
  function countFacts(indexPath) {
4627
- if (!existsSync20(indexPath)) return 0;
5097
+ if (!existsSync21(indexPath)) return 0;
4628
5098
  return (readFileSync18(indexPath, "utf8").match(/^- \*\*`/gm) ?? []).length;
4629
5099
  }
4630
5100
  function parseFlags9(argv) {
@@ -4682,8 +5152,20 @@ var MIME = {
4682
5152
  ".svg": "image/svg+xml",
4683
5153
  ".webp": "image/webp",
4684
5154
  ".ico": "image/x-icon",
4685
- ".pdf": "application/pdf"
5155
+ ".pdf": "application/pdf",
5156
+ // A brain holds recordings as often as it holds screenshots, and a video served as
5157
+ // application/octet-stream is a download prompt rather than something you can watch.
5158
+ ".mp4": "video/mp4",
5159
+ ".m4v": "video/mp4",
5160
+ ".webm": "video/webm",
5161
+ ".mov": "video/quicktime",
5162
+ ".ogv": "video/ogg",
5163
+ ".mp3": "audio/mpeg",
5164
+ ".m4a": "audio/mp4",
5165
+ ".wav": "audio/wav",
5166
+ ".oga": "audio/ogg"
4686
5167
  };
5168
+ var SEEKABLE = /^(video|audio)\//;
4687
5169
  var pendingApps = /* @__PURE__ */ new Map();
4688
5170
  var appResults = /* @__PURE__ */ new Map();
4689
5171
  async function portalCommand(argv) {
@@ -4704,6 +5186,8 @@ async function portalCommand(argv) {
4704
5186
  };
4705
5187
  let cache = null;
4706
5188
  const TTL = 45e3;
5189
+ const labelCache = /* @__PURE__ */ new Map();
5190
+ const LABEL_TTL = 3e5;
4707
5191
  const brainDirs = () => (readOrg(ws.opsDir, parseYaml).staff ?? []).map((s) => s.dir ?? s.handle);
4708
5192
  const knownRepos = () => {
4709
5193
  const org = readOrg(ws.opsDir, parseYaml);
@@ -4741,7 +5225,7 @@ async function portalCommand(argv) {
4741
5225
  }
4742
5226
  const dir = entry.dir ?? entry.handle;
4743
5227
  const spec2 = specFromManifest(
4744
- parseYaml(readFileSync19(join23(ws.root, dir, "staff.yaml"), "utf8"), "staff.yaml"),
5228
+ parseYaml(readFileSync19(join24(ws.root, dir, "staff.yaml"), "utf8"), "staff.yaml"),
4745
5229
  dir
4746
5230
  );
4747
5231
  const isPublic = payload.scope === "public";
@@ -4845,7 +5329,7 @@ async function portalCommand(argv) {
4845
5329
  res.end(JSON.stringify({ error: "a repo name and a known role are needed" }));
4846
5330
  return;
4847
5331
  }
4848
- const path = join23(ws.opsDir, "org.yaml");
5332
+ const path = join24(ws.opsDir, "org.yaml");
4849
5333
  const text = readFileSync19(path, "utf8");
4850
5334
  if (new RegExp(`name: ${name}[,\\s}]`).test(text)) {
4851
5335
  json(res, { ok: true, note: "already there" });
@@ -4883,16 +5367,16 @@ async function portalCommand(argv) {
4883
5367
  if (route === "plan") {
4884
5368
  json(res, {
4885
5369
  org,
4886
- dir: join23(startedIn, "roster-ops"),
5370
+ dir: join24(startedIn, "roster-ops"),
4887
5371
  files: [
4888
- ...initFiles({
5372
+ ...(await initFiles({
4889
5373
  org,
4890
5374
  name: String(params.name ?? org),
4891
5375
  human: String(params.human ?? ""),
4892
5376
  marker: String(params.marker ?? "human"),
4893
5377
  opsName: "roster-ops",
4894
5378
  agent: String(params.agent ?? "claude-code-action")
4895
- }).keys()
5379
+ })).keys()
4896
5380
  ].sort()
4897
5381
  });
4898
5382
  return;
@@ -4914,11 +5398,11 @@ async function portalCommand(argv) {
4914
5398
  }
4915
5399
  res.writeHead(404).end("not found");
4916
5400
  };
4917
- const body = (req) => new Promise((resolve6, reject) => {
5401
+ const body = (req, limit = 1e6) => new Promise((resolve6, reject) => {
4918
5402
  let buf = "";
4919
5403
  req.on("data", (c) => {
4920
5404
  buf += c;
4921
- if (buf.length > 1e6) reject(new Error("body too large"));
5405
+ if (buf.length > limit) reject(new Error("body too large"));
4922
5406
  });
4923
5407
  req.on("end", () => resolve6(buf));
4924
5408
  req.on("error", reject);
@@ -4949,6 +5433,10 @@ async function portalCommand(argv) {
4949
5433
  res.end(JSON.stringify(docPages()));
4950
5434
  return;
4951
5435
  }
5436
+ if (url.pathname === "/api/docsearch") {
5437
+ json(res, { hits: searchDocs(url.searchParams.get("q") ?? "") });
5438
+ return;
5439
+ }
4952
5440
  if (url.pathname === "/api/doc") {
4953
5441
  const page2 = url.searchParams.get("page") ?? "";
4954
5442
  if (!docPages().some((d) => d.file === page2)) {
@@ -4956,7 +5444,20 @@ async function portalCommand(argv) {
4956
5444
  return;
4957
5445
  }
4958
5446
  res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
4959
- res.end(readFileSync19(join23(docsDir(), page2), "utf8"));
5447
+ res.end(readFileSync19(join24(docsDir(), page2), "utf8"));
5448
+ return;
5449
+ }
5450
+ if (url.pathname === "/api/docasset") {
5451
+ const asset = docAsset(url.searchParams.get("path") ?? "");
5452
+ if (!asset) {
5453
+ res.writeHead(404).end("not found");
5454
+ return;
5455
+ }
5456
+ res.writeHead(200, {
5457
+ "content-type": asset.type,
5458
+ "cache-control": "public, max-age=86400"
5459
+ });
5460
+ res.end(readFileSync19(asset.path));
4960
5461
  return;
4961
5462
  }
4962
5463
  if (url.pathname === "/setup/app/start") {
@@ -5041,6 +5542,23 @@ async function portalCommand(argv) {
5041
5542
  res.end(JSON.stringify(data));
5042
5543
  return;
5043
5544
  }
5545
+ if (url.pathname === "/api/orglayer") {
5546
+ const listMd = (dir, prefix) => existsSync22(join24(w.opsDir, dir)) ? readdirSync9(join24(w.opsDir, dir)).filter((f) => f.endsWith(".md")).sort().map((f) => prefix + f) : [];
5547
+ const files = ["org.yaml", ...listMd("org", "org/"), ...listMd("prompts", "prompts/")].filter((rel) => isWritable(w, `${w.opsName}/${rel}`, brainDirs())).map((rel) => {
5548
+ const full = join24(w.opsDir, rel);
5549
+ const text = readFileSync19(full, "utf8");
5550
+ return {
5551
+ path: rel,
5552
+ bytes: statSync5(full).size,
5553
+ group: rel.startsWith("prompts/") ? "prompts" : "org",
5554
+ /* The first heading, for anything the page has no description of. Markdown
5555
+ only: `# ` opens a comment in YAML, and the first line of org.yaml is one. */
5556
+ title: rel.endsWith(".md") ? text.split("\n").find((l) => l.startsWith("# "))?.slice(2).trim() ?? "" : ""
5557
+ };
5558
+ });
5559
+ json(res, { files, opsName: w.opsName });
5560
+ return;
5561
+ }
5044
5562
  if (url.pathname === "/api/prompt") {
5045
5563
  const handle = url.searchParams.get("staff") ?? "";
5046
5564
  const kind = url.searchParams.get("kind") ?? "daily";
@@ -5050,7 +5568,7 @@ async function portalCommand(argv) {
5050
5568
  res.writeHead(400).end("bad request");
5051
5569
  return;
5052
5570
  }
5053
- const brainDir = join23(w.root, entry.dir ?? entry.handle);
5571
+ const brainDir = join24(w.root, entry.dir ?? entry.handle);
5054
5572
  try {
5055
5573
  const view = promptView(w, compose, handle, brainDir, kind);
5056
5574
  view.problems = auditPrompt(w, view, workspaceRoots(org));
@@ -5168,6 +5686,34 @@ async function portalCommand(argv) {
5168
5686
  });
5169
5687
  return;
5170
5688
  }
5689
+ if (url.pathname === "/api/promptaudit") {
5690
+ const handle = url.searchParams.get("staff") ?? "";
5691
+ const org = readOrg(w.opsDir, parseYaml);
5692
+ const entry = (org.staff ?? []).find((s) => s.handle === handle);
5693
+ if (!entry) {
5694
+ res.writeHead(400).end("bad request");
5695
+ return;
5696
+ }
5697
+ const brainDir = join24(w.root, entry.dir ?? entry.handle);
5698
+ const roots = workspaceRoots(org);
5699
+ const found = /* @__PURE__ */ new Map();
5700
+ const errors = [];
5701
+ for (const kind of KINDS) {
5702
+ try {
5703
+ const view = promptView(w, compose, handle, brainDir, kind);
5704
+ for (const p of auditPrompt(w, view, roots)) {
5705
+ const key = `${p.id}|${p.path ?? ""}|${p.title}`;
5706
+ const seen = found.get(key);
5707
+ if (seen) seen.kinds.push(kind);
5708
+ else found.set(key, { ...p, kind, kinds: [kind] });
5709
+ }
5710
+ } catch (err) {
5711
+ errors.push({ kind, error: String(err.message) });
5712
+ }
5713
+ }
5714
+ json(res, { staff: handle, problems: [...found.values()], errors });
5715
+ return;
5716
+ }
5171
5717
  if (url.pathname === "/api/amend") {
5172
5718
  const handle = url.searchParams.get("staff") ?? "";
5173
5719
  const kind = url.searchParams.get("kind") ?? "daily";
@@ -5179,9 +5725,9 @@ async function portalCommand(argv) {
5179
5725
  return;
5180
5726
  }
5181
5727
  const dir = entry.dir ?? entry.handle;
5182
- const view = promptView(w, compose, handle, join23(w.root, dir), kind);
5183
- const manifestPath = join23(w.root, dir, "staff.yaml");
5184
- const tokens = existsSync21(manifestPath) ? tokensFor(
5728
+ const view = promptView(w, compose, handle, join24(w.root, dir), kind);
5729
+ const manifestPath = join24(w.root, dir, "staff.yaml");
5730
+ const tokens = existsSync22(manifestPath) ? tokensFor(
5185
5731
  orgSpec(org, w),
5186
5732
  specFromManifest(
5187
5733
  parseYaml(readFileSync19(manifestPath, "utf8"), "staff.yaml"),
@@ -5242,6 +5788,10 @@ async function portalCommand(argv) {
5242
5788
  const known = (org.repos ?? []).map((r) => `${org.org}/${r.name}`);
5243
5789
  if (!known.includes(payload.repo))
5244
5790
  throw new Error(`${payload.repo} is not a repo in org.yaml`);
5791
+ if (payload.action === "ask") {
5792
+ const prRepo = payload.ask?.pr?.repo ?? "";
5793
+ if (!known.includes(prRepo)) throw new Error(`${prRepo} is not a repo in org.yaml`);
5794
+ }
5245
5795
  const result = await act(payload);
5246
5796
  cache = null;
5247
5797
  res.writeHead(200, {
@@ -5255,11 +5805,38 @@ async function portalCommand(argv) {
5255
5805
  });
5256
5806
  return;
5257
5807
  }
5808
+ if (url.pathname === "/api/upload") {
5809
+ if (!writeAllowed(req)) {
5810
+ refuseWrite(res);
5811
+ return;
5812
+ }
5813
+ body(req, Math.ceil(MAX_UPLOAD * 1.4)).then((raw) => {
5814
+ const payload = JSON.parse(raw || "{}");
5815
+ const repo = payload.repo ?? "";
5816
+ const known = knownRepos().find((r) => `${r.owner}/${r.name}` === repo);
5817
+ if (!known) throw new Error(`${repo} is not a repo in org.yaml`);
5818
+ if (!payload.name) throw new Error("a file needs a name");
5819
+ json(
5820
+ res,
5821
+ attach({
5822
+ repoDir: join24(w.root, known.name),
5823
+ repo,
5824
+ name: payload.name,
5825
+ data: Buffer.from(payload.data ?? "", "base64"),
5826
+ today: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)
5827
+ })
5828
+ );
5829
+ }).catch((err) => {
5830
+ res.writeHead(400, { "content-type": "application/json" });
5831
+ res.end(JSON.stringify({ error: String(err?.message ?? err) }));
5832
+ });
5833
+ return;
5834
+ }
5258
5835
  if (url.pathname === "/api/sync") {
5259
5836
  const org = readOrg(w.opsDir, parseYaml);
5260
5837
  const dirs = [w.opsName, ...(org.staff ?? []).map((s) => s.dir ?? s.handle)];
5261
5838
  syncRepos(w.root, dirs).then((results) => {
5262
- cache = null;
5839
+ if (results.some((r) => r.pulled)) cache = null;
5263
5840
  res.writeHead(200, {
5264
5841
  "content-type": "application/json; charset=utf-8",
5265
5842
  "cache-control": "no-store"
@@ -5271,6 +5848,38 @@ async function portalCommand(argv) {
5271
5848
  });
5272
5849
  return;
5273
5850
  }
5851
+ if (url.pathname === "/api/repos") {
5852
+ json(res, { repos: knownRepos() });
5853
+ return;
5854
+ }
5855
+ if (url.pathname === "/api/labels") {
5856
+ const repo = url.searchParams.get("repo") ?? "";
5857
+ if (!knownRepos().some((r) => `${r.owner}/${r.name}` === repo)) {
5858
+ res.writeHead(400).end("bad request");
5859
+ return;
5860
+ }
5861
+ const hit = labelCache.get(repo);
5862
+ if (hit && Date.now() - hit.at < LABEL_TTL) {
5863
+ json(res, { labels: hit.labels });
5864
+ return;
5865
+ }
5866
+ api(
5867
+ `repos/${repo}/labels?per_page=100`
5868
+ ).then((result) => {
5869
+ if (!result.ok) {
5870
+ json(res, { labels: [], error: result.error });
5871
+ return;
5872
+ }
5873
+ const labels = (result.data ?? []).map((l) => ({
5874
+ name: l.name,
5875
+ color: l.color,
5876
+ description: l.description ?? ""
5877
+ }));
5878
+ labelCache.set(repo, { at: Date.now(), labels });
5879
+ json(res, { labels });
5880
+ }).catch((err) => json(res, { labels: [], error: String(err?.message ?? err) }));
5881
+ return;
5882
+ }
5274
5883
  if (url.pathname === "/api/inbox") {
5275
5884
  const fresh = url.searchParams.get("refresh") === "1";
5276
5885
  if (!fresh && cache && Date.now() - cache.at < TTL) {
@@ -5296,6 +5905,55 @@ async function portalCommand(argv) {
5296
5905
  });
5297
5906
  return;
5298
5907
  }
5908
+ if (url.pathname === "/api/pr") {
5909
+ const repo = url.searchParams.get("repo") ?? "";
5910
+ const number = Number(url.searchParams.get("number"));
5911
+ const allowed = knownRepos().some((r) => `${r.owner}/${r.name}` === repo);
5912
+ if (!allowed || !Number.isInteger(number) || number <= 0) {
5913
+ res.writeHead(400).end("bad request");
5914
+ return;
5915
+ }
5916
+ Promise.all([
5917
+ api(`repos/${repo}/pulls/${number}`),
5918
+ api(`repos/${repo}/pulls/${number}/commits?per_page=100`),
5919
+ api(`repos/${repo}/pulls/${number}/files?per_page=100`)
5920
+ ]).then(([pr, commits, files]) => {
5921
+ if (!pr.ok) {
5922
+ json(res, { error: pr.error });
5923
+ return;
5924
+ }
5925
+ json(res, {
5926
+ base: pr.data.base?.ref,
5927
+ head: pr.data.head?.ref,
5928
+ draft: !!pr.data.draft,
5929
+ /* GitHub computes mergeability in the background, so the first read of a fresh
5930
+ PR can honestly answer "I do not know yet". Passed through as null rather
5931
+ than flattened to false, which would read as a conflict. */
5932
+ mergeable: pr.data.mergeable,
5933
+ mergeState: pr.data.mergeable_state,
5934
+ additions: pr.data.additions,
5935
+ deletions: pr.data.deletions,
5936
+ changedFiles: pr.data.changed_files,
5937
+ commits: (commits.data ?? []).map((c) => ({
5938
+ sha: String(c.sha).slice(0, 7),
5939
+ subject: String(c.commit?.message ?? "").split("\n")[0],
5940
+ author: c.author?.login ?? c.commit?.author?.name ?? "",
5941
+ date: c.commit?.author?.date,
5942
+ url: c.html_url
5943
+ })),
5944
+ files: (files.data ?? []).map((f) => ({
5945
+ path: f.filename,
5946
+ status: f.status,
5947
+ additions: f.additions,
5948
+ deletions: f.deletions,
5949
+ // Absent on binaries and on files too large for the API to patch.
5950
+ patch: f.patch ?? ""
5951
+ })),
5952
+ errors: [commits, files].filter((r) => !r.ok).map((r) => r.error)
5953
+ });
5954
+ }).catch((err) => json(res, { error: String(err?.message ?? err) }));
5955
+ return;
5956
+ }
5299
5957
  if (url.pathname === "/api/thread") {
5300
5958
  const repo = url.searchParams.get("repo") ?? "";
5301
5959
  const number = Number(url.searchParams.get("number"));
@@ -5328,9 +5986,9 @@ async function portalCommand(argv) {
5328
5986
  res.writeHead(400).end("bad request");
5329
5987
  return;
5330
5988
  }
5331
- const out = execFileSync7(
5989
+ const out = execFileSync8(
5332
5990
  "git",
5333
- ["-C", join23(w.root, dir), "show", "--format=%an%x1f%aI%x1f%s", sha, "--", path],
5991
+ ["-C", join24(w.root, dir), "show", "--format=%an%x1f%aI%x1f%s", sha, "--", path],
5334
5992
  {
5335
5993
  encoding: "utf8",
5336
5994
  maxBuffer: 8 * 1024 * 1024
@@ -5343,15 +6001,42 @@ async function portalCommand(argv) {
5343
6001
  if (url.pathname === "/api/file") {
5344
6002
  const rel = url.searchParams.get("path") ?? "";
5345
6003
  const full = resolve5(w.root, rel);
5346
- if (!full.startsWith(resolve5(w.root) + "/") || !existsSync21(full) || statSync5(full).isDirectory()) {
6004
+ if (!full.startsWith(resolve5(w.root) + "/") || !existsSync22(full) || statSync5(full).isDirectory()) {
5347
6005
  res.writeHead(404).end("not found");
5348
6006
  return;
5349
6007
  }
5350
- const ext = extname2(full).toLowerCase();
5351
- res.writeHead(200, {
5352
- "content-type": MIME[ext] ?? "application/octet-stream",
5353
- "cache-control": "no-store"
5354
- });
6008
+ const ext = extname3(full).toLowerCase();
6009
+ const type = MIME[ext] ?? "application/octet-stream";
6010
+ if (SEEKABLE.test(type)) {
6011
+ const size2 = statSync5(full).size;
6012
+ const range = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range ?? ""));
6013
+ if (range) {
6014
+ const start = range[1] ? Number(range[1]) : 0;
6015
+ const end = range[2] ? Math.min(Number(range[2]), size2 - 1) : size2 - 1;
6016
+ if (!(start <= end && start < size2)) {
6017
+ res.writeHead(416, { "content-range": `bytes */${size2}` }).end();
6018
+ return;
6019
+ }
6020
+ res.writeHead(206, {
6021
+ "content-type": type,
6022
+ "content-length": end - start + 1,
6023
+ "content-range": `bytes ${start}-${end}/${size2}`,
6024
+ "accept-ranges": "bytes",
6025
+ "cache-control": "no-store"
6026
+ });
6027
+ createReadStream(full, { start, end }).pipe(res);
6028
+ return;
6029
+ }
6030
+ res.writeHead(200, {
6031
+ "content-type": type,
6032
+ "content-length": size2,
6033
+ "accept-ranges": "bytes",
6034
+ "cache-control": "no-store"
6035
+ });
6036
+ createReadStream(full).pipe(res);
6037
+ return;
6038
+ }
6039
+ res.writeHead(200, { "content-type": type, "cache-control": "no-store" });
5355
6040
  res.end(readFileSync19(full));
5356
6041
  return;
5357
6042
  }
@@ -5403,7 +6088,7 @@ function buildPasteBrief(ws, parseYaml, kind, handle) {
5403
6088
  throw new Error(`unknown staff handle "${handle}". org.yaml knows: ${known}`);
5404
6089
  }
5405
6090
  dir = entry.dir ?? entry.handle;
5406
- const manifestPath = join23(ws.root, dir, "staff.yaml");
6091
+ const manifestPath = join24(ws.root, dir, "staff.yaml");
5407
6092
  tokens = tokensFor(
5408
6093
  orgSpec(org, ws),
5409
6094
  specFromManifest(parseYaml(readFileSync19(manifestPath, "utf8"), "staff.yaml"), dir)
@@ -5437,7 +6122,6 @@ function hireFlags(params) {
5437
6122
  model: params.get("model") ?? void 0,
5438
6123
  timeout: num("timeout"),
5439
6124
  mentionTimeout: num("mentionTimeout"),
5440
- prMentionTimeout: num("prMentionTimeout"),
5441
6125
  secretPrefix: params.get("secretPrefix") ?? void 0,
5442
6126
  app: params.get("app") ?? void 0,
5443
6127
  publicApp: params.get("publicApp") ?? void 0,
@@ -5463,14 +6147,17 @@ function capture() {
5463
6147
  };
5464
6148
  }
5465
6149
  function orgSpec(org, w) {
5466
- const human = org.human ?? {};
6150
+ const humans = readHumans(org);
6151
+ const first = humans[0];
5467
6152
  return {
5468
6153
  org: org.org,
5469
6154
  name: org.name,
5470
6155
  opsRepo: `${org.org}/${w.opsName}`,
5471
6156
  opsDirName: w.opsName,
5472
- human: human.name ?? human.github ?? "the human",
5473
- humanMarker: human.marker ?? human.github ?? "human"
6157
+ human: first?.name ?? first?.github ?? "the human",
6158
+ humanMarker: first?.marker ?? "human",
6159
+ humanLogins: humans.map((h) => h.github).filter(Boolean),
6160
+ allowedTools: toolsOf(org)
5474
6161
  };
5475
6162
  }
5476
6163
  function workspaceRoots(org) {
@@ -5495,10 +6182,10 @@ function parseFlags10(argv) {
5495
6182
  }
5496
6183
 
5497
6184
  // src/commands/prompt.ts
5498
- import { execFileSync as execFileSync8 } from "child_process";
5499
- import { mkdtempSync as mkdtempSync3, writeFileSync as writeFileSync8 } from "fs";
6185
+ import { execFileSync as execFileSync9 } from "child_process";
6186
+ import { mkdtempSync as mkdtempSync3, writeFileSync as writeFileSync9 } from "fs";
5500
6187
  import { tmpdir as tmpdir3 } from "os";
5501
- import { join as join24 } from "path";
6188
+ import { join as join25 } from "path";
5502
6189
 
5503
6190
  // src/lib/livePrompt.ts
5504
6191
  import { readFileSync as readFileSync20 } from "fs";
@@ -5529,7 +6216,7 @@ roster prompt <handle> [options]
5529
6216
  agent is sent \u2014 the CLI imports the tenant's own compose.mjs, so there is no second
5530
6217
  implementation to drift.
5531
6218
 
5532
- --kind <k> daily | mention | pr-mention (default: daily)
6219
+ --kind <k> daily | mention (default: daily)
5533
6220
  --diff <file> diff the composed prompt against the prompt: block in a workflow file
5534
6221
  --stat with --diff, print a summary instead of the full diff
5535
6222
  --ops <dir> ops repo directory (default: found by walking up)
@@ -5559,12 +6246,12 @@ async function promptCommand(argv) {
5559
6246
  process.stdout.write(composed);
5560
6247
  return 0;
5561
6248
  }
5562
- const live = extractLivePrompt(join24(ws.root, opts.diff));
5563
- const dir = mkdtempSync3(join24(tmpdir3(), "roster-diff-"));
5564
- const a = join24(dir, "live.md");
5565
- const b = join24(dir, "composed.md");
5566
- writeFileSync8(a, normalise(live));
5567
- writeFileSync8(b, normalise(composed));
6249
+ const live = extractLivePrompt(join25(ws.root, opts.diff));
6250
+ const dir = mkdtempSync3(join25(tmpdir3(), "roster-diff-"));
6251
+ const a = join25(dir, "live.md");
6252
+ const b = join25(dir, "composed.md");
6253
+ writeFileSync9(a, normalise(live));
6254
+ writeFileSync9(b, normalise(composed));
5568
6255
  const entry = org.staff?.find((s) => s.handle === handle);
5569
6256
  process.stdout.write(`
5570
6257
  ${entry?.name ?? handle} \xB7 ${kind}
@@ -5579,7 +6266,7 @@ async function promptCommand(argv) {
5579
6266
  else args.push("--unified=2");
5580
6267
  args.push(a, b);
5581
6268
  try {
5582
- execFileSync8("git", args, { stdio: "inherit" });
6269
+ execFileSync9("git", args, { stdio: "inherit" });
5583
6270
  process.stdout.write(" identical\n\n");
5584
6271
  return 0;
5585
6272
  } catch {