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

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 +848 -164
  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 +93 -5
  42. package/templates/portal/css/layout.css +26 -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 +7 -1
  46. package/templates/portal/js/api.js +33 -0
  47. package/templates/portal/js/app.js +28 -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 +47 -5
  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 +882 -96
  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} } }
@@ -3813,6 +4262,7 @@ query($owner:String!, $name:String!) {
3813
4262
  nodes {
3814
4263
  number title body url state createdAt updatedAt isDraft
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}) {
@@ -3876,7 +4328,7 @@ async function fetchInbox(repos) {
3876
4328
  items.push(item);
3877
4329
  }
3878
4330
  } catch (e) {
3879
- errors.push(`${full}: ${short2(e)}`);
4331
+ errors.push(`${full}: ${short3(e)}`);
3880
4332
  }
3881
4333
  })
3882
4334
  );
@@ -3908,15 +4360,30 @@ function shape(n, repo, role, kind) {
3908
4360
  url: n.url,
3909
4361
  state: n.state ?? "OPEN",
3910
4362
  comments: events.filter((e) => e.type === "comment").map((e) => ({ author: e.actor, createdAt: e.createdAt, body: e.body ?? "" })),
3911
- events
4363
+ events,
4364
+ reactions: reactions(n)
3912
4365
  };
3913
4366
  }
4367
+ function reactions(n) {
4368
+ return (n.reactionGroups ?? []).filter((g) => (g.reactors?.totalCount ?? 0) > 0).map((g) => ({
4369
+ content: g.content,
4370
+ count: g.reactors.totalCount,
4371
+ by: (g.reactors.nodes ?? []).map((u) => u?.login).filter(Boolean)
4372
+ }));
4373
+ }
3914
4374
  function event(e) {
3915
4375
  const who = e.actor?.login ?? e.author?.login ?? "";
3916
4376
  const at = e.createdAt;
3917
4377
  switch (e.__typename) {
3918
4378
  case "IssueComment":
3919
- return { type: "comment", actor: who, createdAt: at, body: e.body ?? "", url: e.url };
4379
+ return {
4380
+ type: "comment",
4381
+ actor: who,
4382
+ createdAt: at,
4383
+ body: e.body ?? "",
4384
+ url: e.url,
4385
+ reactions: reactions(e)
4386
+ };
3920
4387
  case "CrossReferencedEvent": {
3921
4388
  const s = e.source;
3922
4389
  if (!s?.number) return null;
@@ -4021,7 +4488,7 @@ function rollup(state) {
4021
4488
  if (/FAILURE|ERROR/i.test(state)) return "failing";
4022
4489
  return "pending";
4023
4490
  }
4024
- function short2(e) {
4491
+ function short3(e) {
4025
4492
  const msg = e instanceof Error ? e.message : String(e);
4026
4493
  const line = msg.split("\n").find((l) => l.trim() && !/^\s*$/.test(l)) ?? msg;
4027
4494
  return line.length > 200 ? line.slice(0, 199) + "\u2026" : line;
@@ -4116,8 +4583,8 @@ function ensureNewline(text) {
4116
4583
  }
4117
4584
 
4118
4585
  // src/lib/pastebrief.ts
4119
- import { existsSync as existsSync16, readFileSync as readFileSync15 } from "fs";
4120
- import { join as join18 } from "path";
4586
+ import { existsSync as existsSync17, readFileSync as readFileSync15 } from "fs";
4587
+ import { join as join19 } from "path";
4121
4588
  var WRITES = {
4122
4589
  discover: (c) => [`${c.opsName}/org/business.md`],
4123
4590
  voice: (c) => [`${c.opsName}/org/voice.md`],
@@ -4208,13 +4675,13 @@ ${END}`).join("\n\n");
4208
4675
  function fileBlock(ws, rel, note) {
4209
4676
  const body = read2(ws, rel);
4210
4677
  if (!body.trim()) return [`### \`${rel}\``, "", `*Empty or absent (${note}).*`, ""];
4211
- return [`### \`${rel}\``, "", `*${note}*`, "", fence2(body), ""];
4678
+ return [`### \`${rel}\``, "", `*${note}*`, "", fence3(body), ""];
4212
4679
  }
4213
4680
  function read2(ws, rel) {
4214
- const full = join18(ws.root, rel);
4215
- return existsSync16(full) ? readFileSync15(full, "utf8") : "";
4681
+ const full = join19(ws.root, rel);
4682
+ return existsSync17(full) ? readFileSync15(full, "utf8") : "";
4216
4683
  }
4217
- function fence2(text) {
4684
+ function fence3(text) {
4218
4685
  const longest = (text.match(/`{3,}/g) ?? []).reduce((n, m) => Math.max(n, m.length), 2);
4219
4686
  const bars = "`".repeat(longest + 1);
4220
4687
  return `${bars}
@@ -4222,15 +4689,15 @@ ${text.trimEnd()}
4222
4689
  ${bars}`;
4223
4690
  }
4224
4691
  function briefTemplate(kind) {
4225
- const path = join18(briefTemplateDir(), `${kind}.md`);
4226
- if (!existsSync16(path)) throw new Error(`no brief template for "${kind}"`);
4692
+ const path = join19(briefTemplateDir(), `${kind}.md`);
4693
+ if (!existsSync17(path)) throw new Error(`no brief template for "${kind}"`);
4227
4694
  return readFileSync15(path, "utf8");
4228
4695
  }
4229
4696
 
4230
4697
  // src/lib/setup.ts
4231
4698
  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";
4699
+ import { existsSync as existsSync18, mkdirSync as mkdirSync5, readFileSync as readFileSync16 } from "fs";
4700
+ import { join as join20 } from "path";
4234
4701
  var AGENTS = [
4235
4702
  {
4236
4703
  id: "claude-code-action",
@@ -4294,14 +4761,14 @@ async function setupStatus(startedIn) {
4294
4761
  };
4295
4762
  }
4296
4763
  function readOrgName(opsDir) {
4297
- const path = join19(opsDir, "org.yaml");
4298
- if (!existsSync17(path)) return void 0;
4764
+ const path = join20(opsDir, "org.yaml");
4765
+ if (!existsSync18(path)) return void 0;
4299
4766
  const m = /^org:\s*(\S+)\s*$/m.exec(readFileSync16(path, "utf8"));
4300
4767
  return m?.[1];
4301
4768
  }
4302
4769
  function readRepoNames(opsDir) {
4303
- const path = join19(opsDir, "org.yaml");
4304
- if (!existsSync17(path)) return [];
4770
+ const path = join20(opsDir, "org.yaml");
4771
+ if (!existsSync18(path)) return [];
4305
4772
  const names = [];
4306
4773
  let inside = false;
4307
4774
  for (const line of readFileSync16(path, "utf8").split("\n")) {
@@ -4317,29 +4784,29 @@ function readRepoNames(opsDir) {
4317
4784
  return names;
4318
4785
  }
4319
4786
  async function loadFrameworkComposer() {
4320
- const path = join19(templatesRoot(), "ops", "compose.mjs");
4787
+ const path = join20(templatesRoot(), "ops", "compose.mjs");
4321
4788
  return await import(`file://${path}`);
4322
4789
  }
4323
4790
  async function joinTenant(root, org) {
4324
- mkdirSync4(root, { recursive: true });
4791
+ mkdirSync5(root, { recursive: true });
4325
4792
  const cloned = [];
4326
- const opsDir = join19(root, "roster-ops");
4327
- if (!existsSync17(opsDir)) {
4793
+ const opsDir = join20(root, "roster-ops");
4794
+ if (!existsSync18(opsDir)) {
4328
4795
  await clone(`https://github.com/${org}/roster-ops.git`, opsDir);
4329
4796
  cloned.push("roster-ops");
4330
4797
  }
4331
- if (!existsSync17(join19(opsDir, "org.yaml"))) {
4798
+ if (!existsSync18(join20(opsDir, "org.yaml"))) {
4332
4799
  throw new Error(`${org}/roster-ops has no org.yaml, so it is not a roster tenant`);
4333
4800
  }
4334
- const { parseYaml } = await import(`file://${join19(opsDir, "compose.mjs")}`).then(
4801
+ const { parseYaml } = await import(`file://${join20(opsDir, "compose.mjs")}`).then(
4335
4802
  (m) => m
4336
4803
  );
4337
- const orgFile = parseYaml(readFileSync16(join19(opsDir, "org.yaml"), "utf8"), "org.yaml");
4804
+ const orgFile = parseYaml(readFileSync16(join20(opsDir, "org.yaml"), "utf8"), "org.yaml");
4338
4805
  for (const person of orgFile.staff ?? []) {
4339
4806
  const dir = person.dir ?? person.handle;
4340
- if (existsSync17(join19(root, dir))) continue;
4807
+ if (existsSync18(join20(root, dir))) continue;
4341
4808
  try {
4342
- await clone(`https://github.com/${org}/${dir}.git`, join19(root, dir));
4809
+ await clone(`https://github.com/${org}/${dir}.git`, join20(root, dir));
4343
4810
  cloned.push(dir);
4344
4811
  } catch {
4345
4812
  }
@@ -4354,16 +4821,16 @@ function clone(url, into) {
4354
4821
 
4355
4822
  // src/lib/sync.ts
4356
4823
  import { execFile as execFile6 } from "child_process";
4357
- import { existsSync as existsSync18 } from "fs";
4358
- import { join as join20 } from "path";
4824
+ import { existsSync as existsSync19 } from "fs";
4825
+ import { join as join21 } from "path";
4359
4826
  import { promisify as promisify5 } from "util";
4360
4827
  var run5 = promisify5(execFile6);
4361
4828
  async function syncRepos(root, dirs) {
4362
- return Promise.all(dirs.map((dir) => syncOne(join20(root, dir), dir)));
4829
+ return Promise.all(dirs.map((dir) => syncOne(join21(root, dir), dir)));
4363
4830
  }
4364
4831
  async function syncOne(path, dir) {
4365
4832
  const base2 = { dir, behind: 0, ahead: 0, dirty: false, pulled: false };
4366
- if (!existsSync18(join20(path, ".git"))) return { ...base2, skipped: "no-remote" };
4833
+ if (!existsSync19(join21(path, ".git"))) return { ...base2, skipped: "no-remote" };
4367
4834
  const git4 = async (...args) => (await run5("git", ["-C", path, ...args], { encoding: "utf8" })).stdout.trim();
4368
4835
  try {
4369
4836
  const remotes = await git4("remote");
@@ -4384,7 +4851,7 @@ async function syncOne(path, dir) {
4384
4851
  if (dirty) return { ...base2, ahead, behind, dirty, skipped: "dirty" };
4385
4852
  if (ahead > 0) return { ...base2, ahead, behind, dirty, skipped: "diverged" };
4386
4853
  await git4("pull", "--ff-only", "--quiet");
4387
- return { ...base2, ahead, behind, dirty, pulled: true };
4854
+ return { ...base2, ahead, behind: 0, dirty, pulled: true };
4388
4855
  } catch (err) {
4389
4856
  return { ...base2, error: firstLine2(err) };
4390
4857
  }
@@ -4396,23 +4863,23 @@ function firstLine2(e) {
4396
4863
  }
4397
4864
 
4398
4865
  // 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";
4866
+ import { existsSync as existsSync20, readFileSync as readFileSync17, statSync as statSync4 } from "fs";
4867
+ import { dirname as dirname8, extname as extname2, join as join22, resolve as resolve4 } from "path";
4401
4868
  import { fileURLToPath as fileURLToPath3 } from "url";
4402
4869
  function portalDir() {
4403
4870
  const here = dirname8(fileURLToPath3(import.meta.url));
4404
4871
  const candidates = [
4405
- join21(here, "..", "..", "templates", "portal"),
4872
+ join22(here, "..", "..", "templates", "portal"),
4406
4873
  // src/portal → package root
4407
- join21(here, "..", "templates", "portal")
4874
+ join22(here, "..", "templates", "portal")
4408
4875
  // dist → package root
4409
4876
  ];
4410
- for (const path of candidates) if (existsSync19(join21(path, "index.html"))) return path;
4877
+ for (const path of candidates) if (existsSync20(join22(path, "index.html"))) return path;
4411
4878
  throw new Error(`portal UI not found. Looked in:
4412
4879
  ${candidates.join("\n ")}`);
4413
4880
  }
4414
4881
  function portalIndex() {
4415
- return readFileSync17(join21(portalDir(), "index.html"), "utf8");
4882
+ return readFileSync17(join22(portalDir(), "index.html"), "utf8");
4416
4883
  }
4417
4884
  var ASSET_MIME = {
4418
4885
  ".css": "text/css; charset=utf-8",
@@ -4426,15 +4893,15 @@ function portalAsset(rel) {
4426
4893
  const root = portalDir();
4427
4894
  const full = resolve4(root, rel);
4428
4895
  if (!full.startsWith(resolve4(root) + "/")) return null;
4429
- const type = ASSET_MIME[extname(full).toLowerCase()];
4896
+ const type = ASSET_MIME[extname2(full).toLowerCase()];
4430
4897
  if (!type) return null;
4431
- if (!existsSync19(full) || statSync4(full).isDirectory()) return null;
4898
+ if (!existsSync20(full) || statSync4(full).isDirectory()) return null;
4432
4899
  return { body: readFileSync17(full), type };
4433
4900
  }
4434
4901
 
4435
4902
  // 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";
4903
+ import { existsSync as existsSync21, readdirSync as readdirSync8, readFileSync as readFileSync18, writeFileSync as writeFileSync8 } from "fs";
4904
+ import { join as join23 } from "path";
4438
4905
  var retireHelp = `
4439
4906
  roster retire <handle>
4440
4907
 
@@ -4485,23 +4952,23 @@ async function retireCommand(argv) {
4485
4952
  function buildRetirePlan(ws, org, handle, parseYaml) {
4486
4953
  const entry = (org.staff ?? []).find((s) => s.handle === handle);
4487
4954
  const dir = entry.dir ?? entry.handle;
4488
- const root = join22(ws.root, dir);
4955
+ const root = join23(ws.root, dir);
4489
4956
  const warnings = [];
4490
4957
  const manifest = readManifest3(root, parseYaml);
4491
4958
  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() : [];
4959
+ if (!existsSync21(root)) warnings.push(`${dir}/ is not checked out, so its files cannot be read`);
4960
+ const wfDir = join23(root, ".github", "workflows");
4961
+ const workflows = existsSync21(wfDir) ? readdirSync8(wfDir).filter((f) => /\.ya?ml$/.test(f)).sort() : [];
4495
4962
  if (!workflows.length) warnings.push("no workflows found, so nothing to disable");
4496
4963
  const peers = [];
4497
4964
  for (const other of org.staff ?? []) {
4498
4965
  if (other.handle === handle) continue;
4499
4966
  const otherDir = other.dir ?? other.handle;
4500
- const path = join22(ws.root, otherDir, "staff.yaml");
4501
- if (!existsSync20(path)) continue;
4967
+ const path = join23(ws.root, otherDir, "staff.yaml");
4968
+ if (!existsSync21(path)) continue;
4502
4969
  const text = readFileSync18(path, "utf8");
4503
4970
  if (!new RegExp(`handle: ${handle}[,\\s}]`).test(text)) continue;
4504
- const theirs = readManifest3(join22(ws.root, otherDir), parseYaml);
4971
+ const theirs = readManifest3(join23(ws.root, otherDir), parseYaml);
4505
4972
  peers.push({
4506
4973
  handle: other.handle,
4507
4974
  dir: otherDir,
@@ -4509,7 +4976,7 @@ function buildRetirePlan(ws, org, handle, parseYaml) {
4509
4976
  label: `from-${handle}`
4510
4977
  });
4511
4978
  }
4512
- const facts = countFacts(join22(root, "memory", "INDEX.md"));
4979
+ const facts = countFacts(join23(root, "memory", "INDEX.md"));
4513
4980
  return {
4514
4981
  handle,
4515
4982
  name: entry.name ?? handle,
@@ -4567,9 +5034,9 @@ async function applyRetirePlan(ws, plan2) {
4567
5034
  removeFromOrgYaml(ws, plan2);
4568
5035
  process.stdout.write(" removed from org.yaml\n");
4569
5036
  for (const p of plan2.peers) {
4570
- const path = join22(ws.root, p.dir, "staff.yaml");
5037
+ const path = join23(ws.root, p.dir, "staff.yaml");
4571
5038
  const text = readFileSync18(path, "utf8");
4572
- writeFileSync7(path, removePeerLine(text, plan2.handle));
5039
+ writeFileSync8(path, removePeerLine(text, plan2.handle));
4573
5040
  process.stdout.write(` removed from ${p.dir}/staff.yaml
4574
5041
  `);
4575
5042
  if (!p.brain) continue;
@@ -4595,9 +5062,9 @@ async function applyRetirePlan(ws, plan2) {
4595
5062
  return failed ? 1 : 0;
4596
5063
  }
4597
5064
  function removeFromOrgYaml(ws, plan2) {
4598
- const path = join22(ws.opsDir, "org.yaml");
5065
+ const path = join23(ws.opsDir, "org.yaml");
4599
5066
  const text = readFileSync18(path, "utf8");
4600
- writeFileSync7(path, removeOrgLines(text, plan2.handle, plan2.dir));
5067
+ writeFileSync8(path, removeOrgLines(text, plan2.handle, plan2.dir));
4601
5068
  }
4602
5069
  function removeOrgLines(text, handle, dir) {
4603
5070
  return text.split("\n").filter((line) => {
@@ -4615,8 +5082,8 @@ function removePeerLine(text, handle) {
4615
5082
  }).join("\n");
4616
5083
  }
4617
5084
  function readManifest3(root, parseYaml) {
4618
- const path = join22(root, "staff.yaml");
4619
- if (!existsSync20(path)) return null;
5085
+ const path = join23(root, "staff.yaml");
5086
+ if (!existsSync21(path)) return null;
4620
5087
  try {
4621
5088
  return parseYaml(readFileSync18(path, "utf8"), "staff.yaml");
4622
5089
  } catch {
@@ -4624,7 +5091,7 @@ function readManifest3(root, parseYaml) {
4624
5091
  }
4625
5092
  }
4626
5093
  function countFacts(indexPath) {
4627
- if (!existsSync20(indexPath)) return 0;
5094
+ if (!existsSync21(indexPath)) return 0;
4628
5095
  return (readFileSync18(indexPath, "utf8").match(/^- \*\*`/gm) ?? []).length;
4629
5096
  }
4630
5097
  function parseFlags9(argv) {
@@ -4682,8 +5149,20 @@ var MIME = {
4682
5149
  ".svg": "image/svg+xml",
4683
5150
  ".webp": "image/webp",
4684
5151
  ".ico": "image/x-icon",
4685
- ".pdf": "application/pdf"
5152
+ ".pdf": "application/pdf",
5153
+ // A brain holds recordings as often as it holds screenshots, and a video served as
5154
+ // application/octet-stream is a download prompt rather than something you can watch.
5155
+ ".mp4": "video/mp4",
5156
+ ".m4v": "video/mp4",
5157
+ ".webm": "video/webm",
5158
+ ".mov": "video/quicktime",
5159
+ ".ogv": "video/ogg",
5160
+ ".mp3": "audio/mpeg",
5161
+ ".m4a": "audio/mp4",
5162
+ ".wav": "audio/wav",
5163
+ ".oga": "audio/ogg"
4686
5164
  };
5165
+ var SEEKABLE = /^(video|audio)\//;
4687
5166
  var pendingApps = /* @__PURE__ */ new Map();
4688
5167
  var appResults = /* @__PURE__ */ new Map();
4689
5168
  async function portalCommand(argv) {
@@ -4704,6 +5183,8 @@ async function portalCommand(argv) {
4704
5183
  };
4705
5184
  let cache = null;
4706
5185
  const TTL = 45e3;
5186
+ const labelCache = /* @__PURE__ */ new Map();
5187
+ const LABEL_TTL = 3e5;
4707
5188
  const brainDirs = () => (readOrg(ws.opsDir, parseYaml).staff ?? []).map((s) => s.dir ?? s.handle);
4708
5189
  const knownRepos = () => {
4709
5190
  const org = readOrg(ws.opsDir, parseYaml);
@@ -4741,7 +5222,7 @@ async function portalCommand(argv) {
4741
5222
  }
4742
5223
  const dir = entry.dir ?? entry.handle;
4743
5224
  const spec2 = specFromManifest(
4744
- parseYaml(readFileSync19(join23(ws.root, dir, "staff.yaml"), "utf8"), "staff.yaml"),
5225
+ parseYaml(readFileSync19(join24(ws.root, dir, "staff.yaml"), "utf8"), "staff.yaml"),
4745
5226
  dir
4746
5227
  );
4747
5228
  const isPublic = payload.scope === "public";
@@ -4845,7 +5326,7 @@ async function portalCommand(argv) {
4845
5326
  res.end(JSON.stringify({ error: "a repo name and a known role are needed" }));
4846
5327
  return;
4847
5328
  }
4848
- const path = join23(ws.opsDir, "org.yaml");
5329
+ const path = join24(ws.opsDir, "org.yaml");
4849
5330
  const text = readFileSync19(path, "utf8");
4850
5331
  if (new RegExp(`name: ${name}[,\\s}]`).test(text)) {
4851
5332
  json(res, { ok: true, note: "already there" });
@@ -4883,16 +5364,16 @@ async function portalCommand(argv) {
4883
5364
  if (route === "plan") {
4884
5365
  json(res, {
4885
5366
  org,
4886
- dir: join23(startedIn, "roster-ops"),
5367
+ dir: join24(startedIn, "roster-ops"),
4887
5368
  files: [
4888
- ...initFiles({
5369
+ ...(await initFiles({
4889
5370
  org,
4890
5371
  name: String(params.name ?? org),
4891
5372
  human: String(params.human ?? ""),
4892
5373
  marker: String(params.marker ?? "human"),
4893
5374
  opsName: "roster-ops",
4894
5375
  agent: String(params.agent ?? "claude-code-action")
4895
- }).keys()
5376
+ })).keys()
4896
5377
  ].sort()
4897
5378
  });
4898
5379
  return;
@@ -4914,11 +5395,11 @@ async function portalCommand(argv) {
4914
5395
  }
4915
5396
  res.writeHead(404).end("not found");
4916
5397
  };
4917
- const body = (req) => new Promise((resolve6, reject) => {
5398
+ const body = (req, limit = 1e6) => new Promise((resolve6, reject) => {
4918
5399
  let buf = "";
4919
5400
  req.on("data", (c) => {
4920
5401
  buf += c;
4921
- if (buf.length > 1e6) reject(new Error("body too large"));
5402
+ if (buf.length > limit) reject(new Error("body too large"));
4922
5403
  });
4923
5404
  req.on("end", () => resolve6(buf));
4924
5405
  req.on("error", reject);
@@ -4949,6 +5430,10 @@ async function portalCommand(argv) {
4949
5430
  res.end(JSON.stringify(docPages()));
4950
5431
  return;
4951
5432
  }
5433
+ if (url.pathname === "/api/docsearch") {
5434
+ json(res, { hits: searchDocs(url.searchParams.get("q") ?? "") });
5435
+ return;
5436
+ }
4952
5437
  if (url.pathname === "/api/doc") {
4953
5438
  const page2 = url.searchParams.get("page") ?? "";
4954
5439
  if (!docPages().some((d) => d.file === page2)) {
@@ -4956,7 +5441,20 @@ async function portalCommand(argv) {
4956
5441
  return;
4957
5442
  }
4958
5443
  res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
4959
- res.end(readFileSync19(join23(docsDir(), page2), "utf8"));
5444
+ res.end(readFileSync19(join24(docsDir(), page2), "utf8"));
5445
+ return;
5446
+ }
5447
+ if (url.pathname === "/api/docasset") {
5448
+ const asset = docAsset(url.searchParams.get("path") ?? "");
5449
+ if (!asset) {
5450
+ res.writeHead(404).end("not found");
5451
+ return;
5452
+ }
5453
+ res.writeHead(200, {
5454
+ "content-type": asset.type,
5455
+ "cache-control": "public, max-age=86400"
5456
+ });
5457
+ res.end(readFileSync19(asset.path));
4960
5458
  return;
4961
5459
  }
4962
5460
  if (url.pathname === "/setup/app/start") {
@@ -5041,6 +5539,23 @@ async function portalCommand(argv) {
5041
5539
  res.end(JSON.stringify(data));
5042
5540
  return;
5043
5541
  }
5542
+ if (url.pathname === "/api/orglayer") {
5543
+ const listMd = (dir, prefix) => existsSync22(join24(w.opsDir, dir)) ? readdirSync9(join24(w.opsDir, dir)).filter((f) => f.endsWith(".md")).sort().map((f) => prefix + f) : [];
5544
+ const files = ["org.yaml", ...listMd("org", "org/"), ...listMd("prompts", "prompts/")].filter((rel) => isWritable(w, `${w.opsName}/${rel}`, brainDirs())).map((rel) => {
5545
+ const full = join24(w.opsDir, rel);
5546
+ const text = readFileSync19(full, "utf8");
5547
+ return {
5548
+ path: rel,
5549
+ bytes: statSync5(full).size,
5550
+ group: rel.startsWith("prompts/") ? "prompts" : "org",
5551
+ /* The first heading, for anything the page has no description of. Markdown
5552
+ only: `# ` opens a comment in YAML, and the first line of org.yaml is one. */
5553
+ title: rel.endsWith(".md") ? text.split("\n").find((l) => l.startsWith("# "))?.slice(2).trim() ?? "" : ""
5554
+ };
5555
+ });
5556
+ json(res, { files, opsName: w.opsName });
5557
+ return;
5558
+ }
5044
5559
  if (url.pathname === "/api/prompt") {
5045
5560
  const handle = url.searchParams.get("staff") ?? "";
5046
5561
  const kind = url.searchParams.get("kind") ?? "daily";
@@ -5050,7 +5565,7 @@ async function portalCommand(argv) {
5050
5565
  res.writeHead(400).end("bad request");
5051
5566
  return;
5052
5567
  }
5053
- const brainDir = join23(w.root, entry.dir ?? entry.handle);
5568
+ const brainDir = join24(w.root, entry.dir ?? entry.handle);
5054
5569
  try {
5055
5570
  const view = promptView(w, compose, handle, brainDir, kind);
5056
5571
  view.problems = auditPrompt(w, view, workspaceRoots(org));
@@ -5168,6 +5683,34 @@ async function portalCommand(argv) {
5168
5683
  });
5169
5684
  return;
5170
5685
  }
5686
+ if (url.pathname === "/api/promptaudit") {
5687
+ const handle = url.searchParams.get("staff") ?? "";
5688
+ const org = readOrg(w.opsDir, parseYaml);
5689
+ const entry = (org.staff ?? []).find((s) => s.handle === handle);
5690
+ if (!entry) {
5691
+ res.writeHead(400).end("bad request");
5692
+ return;
5693
+ }
5694
+ const brainDir = join24(w.root, entry.dir ?? entry.handle);
5695
+ const roots = workspaceRoots(org);
5696
+ const found = /* @__PURE__ */ new Map();
5697
+ const errors = [];
5698
+ for (const kind of KINDS) {
5699
+ try {
5700
+ const view = promptView(w, compose, handle, brainDir, kind);
5701
+ for (const p of auditPrompt(w, view, roots)) {
5702
+ const key = `${p.id}|${p.path ?? ""}|${p.title}`;
5703
+ const seen = found.get(key);
5704
+ if (seen) seen.kinds.push(kind);
5705
+ else found.set(key, { ...p, kind, kinds: [kind] });
5706
+ }
5707
+ } catch (err) {
5708
+ errors.push({ kind, error: String(err.message) });
5709
+ }
5710
+ }
5711
+ json(res, { staff: handle, problems: [...found.values()], errors });
5712
+ return;
5713
+ }
5171
5714
  if (url.pathname === "/api/amend") {
5172
5715
  const handle = url.searchParams.get("staff") ?? "";
5173
5716
  const kind = url.searchParams.get("kind") ?? "daily";
@@ -5179,9 +5722,9 @@ async function portalCommand(argv) {
5179
5722
  return;
5180
5723
  }
5181
5724
  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(
5725
+ const view = promptView(w, compose, handle, join24(w.root, dir), kind);
5726
+ const manifestPath = join24(w.root, dir, "staff.yaml");
5727
+ const tokens = existsSync22(manifestPath) ? tokensFor(
5185
5728
  orgSpec(org, w),
5186
5729
  specFromManifest(
5187
5730
  parseYaml(readFileSync19(manifestPath, "utf8"), "staff.yaml"),
@@ -5242,6 +5785,10 @@ async function portalCommand(argv) {
5242
5785
  const known = (org.repos ?? []).map((r) => `${org.org}/${r.name}`);
5243
5786
  if (!known.includes(payload.repo))
5244
5787
  throw new Error(`${payload.repo} is not a repo in org.yaml`);
5788
+ if (payload.action === "ask") {
5789
+ const prRepo = payload.ask?.pr?.repo ?? "";
5790
+ if (!known.includes(prRepo)) throw new Error(`${prRepo} is not a repo in org.yaml`);
5791
+ }
5245
5792
  const result = await act(payload);
5246
5793
  cache = null;
5247
5794
  res.writeHead(200, {
@@ -5255,11 +5802,38 @@ async function portalCommand(argv) {
5255
5802
  });
5256
5803
  return;
5257
5804
  }
5805
+ if (url.pathname === "/api/upload") {
5806
+ if (!writeAllowed(req)) {
5807
+ refuseWrite(res);
5808
+ return;
5809
+ }
5810
+ body(req, Math.ceil(MAX_UPLOAD * 1.4)).then((raw) => {
5811
+ const payload = JSON.parse(raw || "{}");
5812
+ const repo = payload.repo ?? "";
5813
+ const known = knownRepos().find((r) => `${r.owner}/${r.name}` === repo);
5814
+ if (!known) throw new Error(`${repo} is not a repo in org.yaml`);
5815
+ if (!payload.name) throw new Error("a file needs a name");
5816
+ json(
5817
+ res,
5818
+ attach({
5819
+ repoDir: join24(w.root, known.name),
5820
+ repo,
5821
+ name: payload.name,
5822
+ data: Buffer.from(payload.data ?? "", "base64"),
5823
+ today: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)
5824
+ })
5825
+ );
5826
+ }).catch((err) => {
5827
+ res.writeHead(400, { "content-type": "application/json" });
5828
+ res.end(JSON.stringify({ error: String(err?.message ?? err) }));
5829
+ });
5830
+ return;
5831
+ }
5258
5832
  if (url.pathname === "/api/sync") {
5259
5833
  const org = readOrg(w.opsDir, parseYaml);
5260
5834
  const dirs = [w.opsName, ...(org.staff ?? []).map((s) => s.dir ?? s.handle)];
5261
5835
  syncRepos(w.root, dirs).then((results) => {
5262
- cache = null;
5836
+ if (results.some((r) => r.pulled)) cache = null;
5263
5837
  res.writeHead(200, {
5264
5838
  "content-type": "application/json; charset=utf-8",
5265
5839
  "cache-control": "no-store"
@@ -5271,6 +5845,38 @@ async function portalCommand(argv) {
5271
5845
  });
5272
5846
  return;
5273
5847
  }
5848
+ if (url.pathname === "/api/repos") {
5849
+ json(res, { repos: knownRepos() });
5850
+ return;
5851
+ }
5852
+ if (url.pathname === "/api/labels") {
5853
+ const repo = url.searchParams.get("repo") ?? "";
5854
+ if (!knownRepos().some((r) => `${r.owner}/${r.name}` === repo)) {
5855
+ res.writeHead(400).end("bad request");
5856
+ return;
5857
+ }
5858
+ const hit = labelCache.get(repo);
5859
+ if (hit && Date.now() - hit.at < LABEL_TTL) {
5860
+ json(res, { labels: hit.labels });
5861
+ return;
5862
+ }
5863
+ api(
5864
+ `repos/${repo}/labels?per_page=100`
5865
+ ).then((result) => {
5866
+ if (!result.ok) {
5867
+ json(res, { labels: [], error: result.error });
5868
+ return;
5869
+ }
5870
+ const labels = (result.data ?? []).map((l) => ({
5871
+ name: l.name,
5872
+ color: l.color,
5873
+ description: l.description ?? ""
5874
+ }));
5875
+ labelCache.set(repo, { at: Date.now(), labels });
5876
+ json(res, { labels });
5877
+ }).catch((err) => json(res, { labels: [], error: String(err?.message ?? err) }));
5878
+ return;
5879
+ }
5274
5880
  if (url.pathname === "/api/inbox") {
5275
5881
  const fresh = url.searchParams.get("refresh") === "1";
5276
5882
  if (!fresh && cache && Date.now() - cache.at < TTL) {
@@ -5296,6 +5902,55 @@ async function portalCommand(argv) {
5296
5902
  });
5297
5903
  return;
5298
5904
  }
5905
+ if (url.pathname === "/api/pr") {
5906
+ const repo = url.searchParams.get("repo") ?? "";
5907
+ const number = Number(url.searchParams.get("number"));
5908
+ const allowed = knownRepos().some((r) => `${r.owner}/${r.name}` === repo);
5909
+ if (!allowed || !Number.isInteger(number) || number <= 0) {
5910
+ res.writeHead(400).end("bad request");
5911
+ return;
5912
+ }
5913
+ Promise.all([
5914
+ api(`repos/${repo}/pulls/${number}`),
5915
+ api(`repos/${repo}/pulls/${number}/commits?per_page=100`),
5916
+ api(`repos/${repo}/pulls/${number}/files?per_page=100`)
5917
+ ]).then(([pr, commits, files]) => {
5918
+ if (!pr.ok) {
5919
+ json(res, { error: pr.error });
5920
+ return;
5921
+ }
5922
+ json(res, {
5923
+ base: pr.data.base?.ref,
5924
+ head: pr.data.head?.ref,
5925
+ draft: !!pr.data.draft,
5926
+ /* GitHub computes mergeability in the background, so the first read of a fresh
5927
+ PR can honestly answer "I do not know yet". Passed through as null rather
5928
+ than flattened to false, which would read as a conflict. */
5929
+ mergeable: pr.data.mergeable,
5930
+ mergeState: pr.data.mergeable_state,
5931
+ additions: pr.data.additions,
5932
+ deletions: pr.data.deletions,
5933
+ changedFiles: pr.data.changed_files,
5934
+ commits: (commits.data ?? []).map((c) => ({
5935
+ sha: String(c.sha).slice(0, 7),
5936
+ subject: String(c.commit?.message ?? "").split("\n")[0],
5937
+ author: c.author?.login ?? c.commit?.author?.name ?? "",
5938
+ date: c.commit?.author?.date,
5939
+ url: c.html_url
5940
+ })),
5941
+ files: (files.data ?? []).map((f) => ({
5942
+ path: f.filename,
5943
+ status: f.status,
5944
+ additions: f.additions,
5945
+ deletions: f.deletions,
5946
+ // Absent on binaries and on files too large for the API to patch.
5947
+ patch: f.patch ?? ""
5948
+ })),
5949
+ errors: [commits, files].filter((r) => !r.ok).map((r) => r.error)
5950
+ });
5951
+ }).catch((err) => json(res, { error: String(err?.message ?? err) }));
5952
+ return;
5953
+ }
5299
5954
  if (url.pathname === "/api/thread") {
5300
5955
  const repo = url.searchParams.get("repo") ?? "";
5301
5956
  const number = Number(url.searchParams.get("number"));
@@ -5328,9 +5983,9 @@ async function portalCommand(argv) {
5328
5983
  res.writeHead(400).end("bad request");
5329
5984
  return;
5330
5985
  }
5331
- const out = execFileSync7(
5986
+ const out = execFileSync8(
5332
5987
  "git",
5333
- ["-C", join23(w.root, dir), "show", "--format=%an%x1f%aI%x1f%s", sha, "--", path],
5988
+ ["-C", join24(w.root, dir), "show", "--format=%an%x1f%aI%x1f%s", sha, "--", path],
5334
5989
  {
5335
5990
  encoding: "utf8",
5336
5991
  maxBuffer: 8 * 1024 * 1024
@@ -5343,15 +5998,42 @@ async function portalCommand(argv) {
5343
5998
  if (url.pathname === "/api/file") {
5344
5999
  const rel = url.searchParams.get("path") ?? "";
5345
6000
  const full = resolve5(w.root, rel);
5346
- if (!full.startsWith(resolve5(w.root) + "/") || !existsSync21(full) || statSync5(full).isDirectory()) {
6001
+ if (!full.startsWith(resolve5(w.root) + "/") || !existsSync22(full) || statSync5(full).isDirectory()) {
5347
6002
  res.writeHead(404).end("not found");
5348
6003
  return;
5349
6004
  }
5350
- const ext = extname2(full).toLowerCase();
5351
- res.writeHead(200, {
5352
- "content-type": MIME[ext] ?? "application/octet-stream",
5353
- "cache-control": "no-store"
5354
- });
6005
+ const ext = extname3(full).toLowerCase();
6006
+ const type = MIME[ext] ?? "application/octet-stream";
6007
+ if (SEEKABLE.test(type)) {
6008
+ const size2 = statSync5(full).size;
6009
+ const range = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range ?? ""));
6010
+ if (range) {
6011
+ const start = range[1] ? Number(range[1]) : 0;
6012
+ const end = range[2] ? Math.min(Number(range[2]), size2 - 1) : size2 - 1;
6013
+ if (!(start <= end && start < size2)) {
6014
+ res.writeHead(416, { "content-range": `bytes */${size2}` }).end();
6015
+ return;
6016
+ }
6017
+ res.writeHead(206, {
6018
+ "content-type": type,
6019
+ "content-length": end - start + 1,
6020
+ "content-range": `bytes ${start}-${end}/${size2}`,
6021
+ "accept-ranges": "bytes",
6022
+ "cache-control": "no-store"
6023
+ });
6024
+ createReadStream(full, { start, end }).pipe(res);
6025
+ return;
6026
+ }
6027
+ res.writeHead(200, {
6028
+ "content-type": type,
6029
+ "content-length": size2,
6030
+ "accept-ranges": "bytes",
6031
+ "cache-control": "no-store"
6032
+ });
6033
+ createReadStream(full).pipe(res);
6034
+ return;
6035
+ }
6036
+ res.writeHead(200, { "content-type": type, "cache-control": "no-store" });
5355
6037
  res.end(readFileSync19(full));
5356
6038
  return;
5357
6039
  }
@@ -5403,7 +6085,7 @@ function buildPasteBrief(ws, parseYaml, kind, handle) {
5403
6085
  throw new Error(`unknown staff handle "${handle}". org.yaml knows: ${known}`);
5404
6086
  }
5405
6087
  dir = entry.dir ?? entry.handle;
5406
- const manifestPath = join23(ws.root, dir, "staff.yaml");
6088
+ const manifestPath = join24(ws.root, dir, "staff.yaml");
5407
6089
  tokens = tokensFor(
5408
6090
  orgSpec(org, ws),
5409
6091
  specFromManifest(parseYaml(readFileSync19(manifestPath, "utf8"), "staff.yaml"), dir)
@@ -5437,7 +6119,6 @@ function hireFlags(params) {
5437
6119
  model: params.get("model") ?? void 0,
5438
6120
  timeout: num("timeout"),
5439
6121
  mentionTimeout: num("mentionTimeout"),
5440
- prMentionTimeout: num("prMentionTimeout"),
5441
6122
  secretPrefix: params.get("secretPrefix") ?? void 0,
5442
6123
  app: params.get("app") ?? void 0,
5443
6124
  publicApp: params.get("publicApp") ?? void 0,
@@ -5463,14 +6144,17 @@ function capture() {
5463
6144
  };
5464
6145
  }
5465
6146
  function orgSpec(org, w) {
5466
- const human = org.human ?? {};
6147
+ const humans = readHumans(org);
6148
+ const first = humans[0];
5467
6149
  return {
5468
6150
  org: org.org,
5469
6151
  name: org.name,
5470
6152
  opsRepo: `${org.org}/${w.opsName}`,
5471
6153
  opsDirName: w.opsName,
5472
- human: human.name ?? human.github ?? "the human",
5473
- humanMarker: human.marker ?? human.github ?? "human"
6154
+ human: first?.name ?? first?.github ?? "the human",
6155
+ humanMarker: first?.marker ?? "human",
6156
+ humanLogins: humans.map((h) => h.github).filter(Boolean),
6157
+ allowedTools: toolsOf(org)
5474
6158
  };
5475
6159
  }
5476
6160
  function workspaceRoots(org) {
@@ -5495,10 +6179,10 @@ function parseFlags10(argv) {
5495
6179
  }
5496
6180
 
5497
6181
  // src/commands/prompt.ts
5498
- import { execFileSync as execFileSync8 } from "child_process";
5499
- import { mkdtempSync as mkdtempSync3, writeFileSync as writeFileSync8 } from "fs";
6182
+ import { execFileSync as execFileSync9 } from "child_process";
6183
+ import { mkdtempSync as mkdtempSync3, writeFileSync as writeFileSync9 } from "fs";
5500
6184
  import { tmpdir as tmpdir3 } from "os";
5501
- import { join as join24 } from "path";
6185
+ import { join as join25 } from "path";
5502
6186
 
5503
6187
  // src/lib/livePrompt.ts
5504
6188
  import { readFileSync as readFileSync20 } from "fs";
@@ -5529,7 +6213,7 @@ roster prompt <handle> [options]
5529
6213
  agent is sent \u2014 the CLI imports the tenant's own compose.mjs, so there is no second
5530
6214
  implementation to drift.
5531
6215
 
5532
- --kind <k> daily | mention | pr-mention (default: daily)
6216
+ --kind <k> daily | mention (default: daily)
5533
6217
  --diff <file> diff the composed prompt against the prompt: block in a workflow file
5534
6218
  --stat with --diff, print a summary instead of the full diff
5535
6219
  --ops <dir> ops repo directory (default: found by walking up)
@@ -5559,12 +6243,12 @@ async function promptCommand(argv) {
5559
6243
  process.stdout.write(composed);
5560
6244
  return 0;
5561
6245
  }
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));
6246
+ const live = extractLivePrompt(join25(ws.root, opts.diff));
6247
+ const dir = mkdtempSync3(join25(tmpdir3(), "roster-diff-"));
6248
+ const a = join25(dir, "live.md");
6249
+ const b = join25(dir, "composed.md");
6250
+ writeFileSync9(a, normalise(live));
6251
+ writeFileSync9(b, normalise(composed));
5568
6252
  const entry = org.staff?.find((s) => s.handle === handle);
5569
6253
  process.stdout.write(`
5570
6254
  ${entry?.name ?? handle} \xB7 ${kind}
@@ -5579,7 +6263,7 @@ async function promptCommand(argv) {
5579
6263
  else args.push("--unified=2");
5580
6264
  args.push(a, b);
5581
6265
  try {
5582
- execFileSync8("git", args, { stdio: "inherit" });
6266
+ execFileSync9("git", args, { stdio: "inherit" });
5583
6267
  process.stdout.write(" identical\n\n");
5584
6268
  return 0;
5585
6269
  } catch {