@nanocollective/roster 0.1.0-alpha.42 → 0.1.0-alpha.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -337,7 +337,8 @@ function tokensFor(org, s) {
337
337
  APP: s.app,
338
338
  PUBLIC_APP: s.publicApp,
339
339
  PUBLIC_TOKEN_ENV: s.publicTokenEnv,
340
- AGENT_SECRET: s.agentSecret
340
+ AGENT_SECRET: s.agentSecret,
341
+ MAX_RUNS: String(s.maxRunsPerDay)
341
342
  };
342
343
  }
343
344
  function render(text, tokens, where = "template") {
@@ -426,7 +427,9 @@ function specFromManifest(m, dir) {
426
427
  app: String(priv.app ?? ""),
427
428
  publicApp: String(pub.app ?? ""),
428
429
  publicTokenEnv: String(m.public_token_env ?? "PUBLIC_TOKEN"),
429
- agentSecret: String(m.agent_secret ?? "CLAUDE_CODE_OAUTH_TOKEN")
430
+ agentSecret: String(m.agent_secret ?? "CLAUDE_CODE_OAUTH_TOKEN"),
431
+ // Peer and follow-on runs a day. Mentions are a person asking and never count.
432
+ maxRunsPerDay: Number(m.max_runs_per_day ?? 6)
430
433
  };
431
434
  }
432
435
 
@@ -1471,6 +1474,14 @@ import { execFileSync as execFileSync3 } from "child_process";
1471
1474
  import { existsSync as existsSync12, mkdirSync as mkdirSync2, readFileSync as readFileSync11, writeFileSync as writeFileSync3 } from "fs";
1472
1475
  import { dirname as dirname5, join as join13 } from "path";
1473
1476
 
1477
+ // src/lib/asks.ts
1478
+ var ASK_KINDS = ["decision", "review", "chore"];
1479
+ var KEEP_OPEN = "keep-open";
1480
+ var OWNED_LABELS = [...ASK_KINDS, KEEP_OPEN];
1481
+ function askKinds(labels) {
1482
+ return labels.filter((l) => ASK_KINDS.includes(l));
1483
+ }
1484
+
1474
1485
  // src/lib/commit.ts
1475
1486
  import { execFileSync as execFileSync2 } from "child_process";
1476
1487
  function commitAndPush(repoDir, paths, message) {
@@ -1757,7 +1768,8 @@ function buildPlan(ws, org, handle, opts, parseYaml) {
1757
1768
  app: app ?? `${handle}`,
1758
1769
  publicApp: publicApp || `${org.org}-robot`,
1759
1770
  publicTokenEnv: String(sample?.public_token_env ?? "PUBLIC_TOKEN"),
1760
- agentSecret: opts.agentSecret ?? agentTokenEnv(org)
1771
+ agentSecret: opts.agentSecret ?? agentTokenEnv(org),
1772
+ maxRunsPerDay: Number(org.defaults?.max_runs_per_day ?? 6)
1761
1773
  };
1762
1774
  if (!opts.name) warnings.push(`no --name given, so the role is called "${staff.name}"`);
1763
1775
  if (staff.worksIn.length) {
@@ -1799,8 +1811,8 @@ function buildPlan(ws, org, handle, opts, parseYaml) {
1799
1811
  .../* @__PURE__ */ new Set([
1800
1812
  orgSpec2.humanMarker,
1801
1813
  handle,
1802
- "decision",
1803
- "setup",
1814
+ ...ASK_KINDS,
1815
+ "keep-open",
1804
1816
  "build",
1805
1817
  "blocked",
1806
1818
  ...peers.map((p) => `from-${p.handle}`)
@@ -2062,7 +2074,10 @@ async function applyPlan(ws, plan2, opts) {
2062
2074
  "-f",
2063
2075
  "title=\u{1F4CD} Where we are (living status - always current)",
2064
2076
  "-f",
2065
- `body=${statusBody(staff)}`
2077
+ `body=${statusBody(staff)}`,
2078
+ // A sweep closes anything with nothing left to do on it. This one never runs out.
2079
+ "-f",
2080
+ "labels[]=keep-open"
2066
2081
  ]);
2067
2082
  if (issue.ok && issue.data?.number) {
2068
2083
  await ghJson([
@@ -3707,7 +3722,8 @@ async function checkStaff(ws, org, entry, composer, online) {
3707
3722
  });
3708
3723
  }
3709
3724
  if (online && manifest.brain) {
3710
- out.push(...await checkStaffOnline(scope, manifest, callers));
3725
+ const logins = readHumans(org).flatMap((h) => h.github ? [h.github] : []);
3726
+ out.push(...await checkStaffOnline(scope, manifest, callers, logins));
3711
3727
  }
3712
3728
  return out;
3713
3729
  }
@@ -3834,14 +3850,14 @@ async function checkReviewGates(ws, org, composer) {
3834
3850
  const readings = await Promise.all([...repos].map((r) => readGate(r, [...apps])));
3835
3851
  return readings.map((r) => ({ scope: "workspace", id: "review-gate", ...judgeGate(r) }));
3836
3852
  }
3837
- async function checkStaffOnline(scope, manifest, callers) {
3853
+ async function checkStaffOnline(scope, manifest, callers, humans = []) {
3838
3854
  const out = [];
3839
3855
  const repo = manifest.brain;
3840
3856
  const needed = /* @__PURE__ */ new Set();
3841
3857
  for (const c of callers) {
3842
3858
  for (const m of c.text.matchAll(/secrets\.([A-Z0-9_]+)/g)) needed.add(m[1]);
3843
3859
  }
3844
- const [secrets, orgSecrets, labels, runs, pinned, workflows] = await Promise.all([
3860
+ const [secrets, orgSecrets, labels, runs, pinned, workflows, open2] = await Promise.all([
3845
3861
  api(`repos/${repo}/actions/secrets`),
3846
3862
  /* An agent credential kept once for the org, and shared with this repo, is as good as one
3847
3863
  on the repo: Actions resolves `secrets.X` from either. Read separately because the two
@@ -3869,7 +3885,8 @@ async function checkStaffOnline(scope, manifest, callers) {
3869
3885
  { owner: repo.split("/")[0], name: repo.split("/")[1] }
3870
3886
  ) : Promise.resolve(null),
3871
3887
  // The callers are read in detail below, so only everything else in the repo is looked at.
3872
- checkWorkflows(scope, repo, new Set(callers.map((c) => c.name)))
3888
+ checkWorkflows(scope, repo, new Set(callers.map((c) => c.name))),
3889
+ api(`repos/${repo}/issues?state=open&per_page=100`)
3873
3890
  ]);
3874
3891
  out.push(...workflows);
3875
3892
  if (!secrets.ok) {
@@ -3921,7 +3938,9 @@ async function checkStaffOnline(scope, manifest, callers) {
3921
3938
  fix: "An agent applying a label that does not exist gets an API error mid-run."
3922
3939
  } : { scope, level: "ok", id: "labels", title: "every declared label exists" }
3923
3940
  );
3941
+ out.push(ownedLabels(scope, repo, have));
3924
3942
  }
3943
+ if (open2.ok) out.push(...unkindedAsks(scope, repo, open2.data, humans));
3925
3944
  const peers = (manifest.peers ?? []).filter((p) => p.label && p.brain);
3926
3945
  const peerLabels = await Promise.all(
3927
3946
  peers.map(
@@ -4231,6 +4250,32 @@ function parseFlags6(argv) {
4231
4250
  }
4232
4251
  return out;
4233
4252
  }
4253
+ function ownedLabels(scope, repo, have) {
4254
+ const gone = OWNED_LABELS.filter((l) => !have.has(l));
4255
+ return gone.length ? {
4256
+ scope,
4257
+ level: "warn",
4258
+ id: "owned-labels",
4259
+ title: `roster's labels are missing from ${repo}: ${gone.join(", ")}`,
4260
+ fix: gone.map((l) => `gh label create ${l} --repo ${repo} --force`).join("\n")
4261
+ } : { scope, level: "ok", id: "owned-labels", title: "roster's own labels exist" };
4262
+ }
4263
+ function unkindedAsks(scope, repo, issues, humans) {
4264
+ const on = new Set(humans.map((h) => h.toLowerCase()));
4265
+ const bad = issues.filter(
4266
+ (i) => !i.pull_request && (i.assignees ?? []).some((a) => on.has(a.login.toLowerCase())) && askKinds(i.labels.map((l) => l.name)).length !== 1
4267
+ );
4268
+ if (!bad.length) return [];
4269
+ return [
4270
+ {
4271
+ scope,
4272
+ level: "warn",
4273
+ id: "ask-kind",
4274
+ title: `${bad.length} open ask${bad.length === 1 ? "" : "s"} on ${repo} without exactly one of decision, review or chore: ${bad.map((i) => "#" + i.number).join(", ")}`,
4275
+ fix: "Label each with one kind. The next daily sweep does this too."
4276
+ }
4277
+ ];
4278
+ }
4234
4279
 
4235
4280
  // src/commands/export.ts
4236
4281
  import { writeFileSync as writeFileSync6 } from "fs";
@@ -5222,9 +5267,24 @@ import { promisify as promisify3 } from "util";
5222
5267
 
5223
5268
  // src/lib/ask.ts
5224
5269
  var MAX_PATCH_LINES = 40;
5225
- function askTitle(req) {
5270
+ function askHead(req) {
5226
5271
  const name = req.pr.repo.split("/")[1] ?? req.pr.repo;
5227
- const head2 = `${name}#${req.pr.number} \u2014 `;
5272
+ return `${name}#${req.pr.number} \u2014 `;
5273
+ }
5274
+ function askFollowUp(req) {
5275
+ const said = req.body.trim();
5276
+ const it = req.pr.kind === "issue" ? "issue" : "pull request";
5277
+ const first = stripLeadingMention(said, req.staff.mention) ? `${req.staff.mention} ${said}` : said;
5278
+ const where = req.anchor?.path ? `
5279
+
5280
+ They were looking at \`${req.anchor.path}\`.` : "";
5281
+ return `${first}${where}
5282
+
5283
+ Same as above: answer on the ${it}, then close this issue.
5284
+ `;
5285
+ }
5286
+ function askTitle(req) {
5287
+ const head2 = askHead(req);
5228
5288
  const room = 120 - head2.length;
5229
5289
  const title = req.pr.title.trim() || "a pull request";
5230
5290
  return head2 + (title.length > room ? `${title.slice(0, room - 1).trimEnd()}\u2026` : title);
@@ -5302,11 +5362,21 @@ async function act(req) {
5302
5362
  if (repo !== ask.staff.brain)
5303
5363
  throw new Error(`an ask goes to ${ask.staff.brain}, not to ${repo}`);
5304
5364
  if (!ask.body?.trim()) throw new Error("an empty ask is not an ask");
5305
- const title = (req.title ?? "").trim() || askTitle(ask);
5306
- const args = ["issue", "create", "--repo", repo, "--title", title, "--body-file", "-"];
5307
- for (const l of req.labels ?? []) args.push("--label", l);
5308
- const { stdout } = await execWithStdin(args, askBody(ask));
5309
- const url = stdout.trim().split("\n").pop();
5365
+ const open2 = await openAskFor(repo, askHead(ask));
5366
+ let url;
5367
+ if (open2) {
5368
+ const { stdout } = await execWithStdin(
5369
+ ["issue", "comment", String(open2), "--repo", repo, "--body-file", "-"],
5370
+ askFollowUp(ask)
5371
+ );
5372
+ url = stdout.trim().split("\n").pop();
5373
+ } else {
5374
+ const title = (req.title ?? "").trim() || askTitle(ask);
5375
+ const args = ["issue", "create", "--repo", repo, "--title", title, "--body-file", "-"];
5376
+ for (const l of req.labels ?? []) args.push("--label", l);
5377
+ const { stdout } = await execWithStdin(args, askBody(ask));
5378
+ url = stdout.trim().split("\n").pop();
5379
+ }
5310
5380
  if (!req.alsoOnPr) return { ok: true, action, url };
5311
5381
  const note = `${ask.body.trim()}
5312
5382
 
@@ -5356,11 +5426,84 @@ async function act(req) {
5356
5426
  if (!flag) throw new Error(`unknown merge method "${req.mergeMethod}"`);
5357
5427
  const args = ["pr", "merge", n, "--repo", repo, flag];
5358
5428
  if (req.body?.trim() && how !== "rebase") args.push("--body", req.body.trim());
5359
- await run3("gh", args, { encoding: "utf8" });
5429
+ try {
5430
+ await run3("gh", args, { encoding: "utf8" });
5431
+ } catch (err) {
5432
+ throw new Error(whyNotMerged(errText(err), await prState(repo, n)));
5433
+ }
5360
5434
  return { ok: true, action, mergedBy: how };
5361
5435
  }
5362
5436
  throw new Error(`unknown action "${action}"`);
5363
5437
  }
5438
+ async function openAskFor(repo, head2) {
5439
+ try {
5440
+ const { stdout } = await run3(
5441
+ "gh",
5442
+ [
5443
+ "issue",
5444
+ "list",
5445
+ "--repo",
5446
+ repo,
5447
+ "--state",
5448
+ "open",
5449
+ "--limit",
5450
+ "20",
5451
+ "--search",
5452
+ `in:title "${head2.replace(/ — $/, "")}"`,
5453
+ "--json",
5454
+ "number,title"
5455
+ ],
5456
+ { encoding: "utf8" }
5457
+ );
5458
+ const found = JSON.parse(stdout).find(
5459
+ (i) => i.title.startsWith(head2)
5460
+ );
5461
+ return found?.number ?? null;
5462
+ } catch {
5463
+ return null;
5464
+ }
5465
+ }
5466
+ function whyNotMerged(ghError, pr) {
5467
+ const checks = pr?.statusCheckRollup ?? [];
5468
+ const name = (c) => c.name || c.context || "a check";
5469
+ const running = checks.filter(
5470
+ (c) => c.status && c.status !== "COMPLETED" || c.state === "PENDING" || c.state === "EXPECTED"
5471
+ );
5472
+ const failing = checks.filter(
5473
+ (c) => ["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "ERROR"].includes(
5474
+ String(c.conclusion || c.state || "")
5475
+ )
5476
+ );
5477
+ const list = (cs) => cs.map(name).join(", ");
5478
+ if (pr?.mergeStateStatus === "BLOCKED" && failing.length)
5479
+ return `Not merged: checks failed (${list(failing)}).`;
5480
+ if (pr?.mergeStateStatus === "BLOCKED" && running.length)
5481
+ return `Not merged: checks are still running (${list(running)}). Merge again once they pass.`;
5482
+ if (pr?.mergeStateStatus === "BLOCKED")
5483
+ return "Not merged: the branch rules require something first, such as a review. Open it in GitHub to see what.";
5484
+ if (pr?.mergeStateStatus === "BEHIND")
5485
+ return "Not merged: the branch is behind its base, and the branch rules require it to be up to date.";
5486
+ if (pr?.mergeStateStatus === "DIRTY") return "Not merged: the branch conflicts with its base.";
5487
+ const line = ghError.split("\n").map((l) => l.replace(/^[!X✗]\s*/, "").trim()).find((l) => l && !l.startsWith("Command failed") && !/--auto|--admin/.test(l));
5488
+ return `Not merged: ${line ?? "GitHub refused it without saying why."}`;
5489
+ }
5490
+ function errText(err) {
5491
+ const e = err;
5492
+ return `${e?.stderr ?? ""}
5493
+ ${e?.message ?? ""}`;
5494
+ }
5495
+ async function prState(repo, n) {
5496
+ try {
5497
+ const { stdout } = await run3(
5498
+ "gh",
5499
+ ["pr", "view", n, "--repo", repo, "--json", "mergeStateStatus,statusCheckRollup"],
5500
+ { encoding: "utf8" }
5501
+ );
5502
+ return JSON.parse(stdout);
5503
+ } catch {
5504
+ return null;
5505
+ }
5506
+ }
5364
5507
  async function mergeMethodFor(repo) {
5365
5508
  try {
5366
5509
  const { stdout } = await run3(
@@ -5560,11 +5703,15 @@ var PR_TYPES = `[ISSUE_COMMENT, CROSS_REFERENCED_EVENT, REFERENCED_EVENT, CLOSED
5560
5703
  REOPENED_EVENT, MERGED_EVENT, LABELED_EVENT, UNLABELED_EVENT, ASSIGNED_EVENT,
5561
5704
  UNASSIGNED_EVENT, RENAMED_TITLE_EVENT, PULL_REQUEST_REVIEW, READY_FOR_REVIEW_EVENT,
5562
5705
  REVIEW_REQUESTED_EVENT]`;
5563
- var LIGHT = `number title url state createdAt updatedAt
5706
+ var LIGHT = `number title url state createdAt updatedAt closedAt body
5564
5707
  author { login }
5565
5708
  labels(first:12) { nodes { name } }
5566
5709
  assignees(first:8) { nodes { login } }
5567
- comments { totalCount }`;
5710
+ comments { totalCount }
5711
+ last: comments(last:1) { nodes { author { login } createdAt body } }
5712
+ closer: timelineItems(last:1, itemTypes:[CLOSED_EVENT]) {
5713
+ nodes { ... on ClosedEvent { actor { login } } }
5714
+ }`;
5568
5715
  var LIGHT_PR = `${LIGHT} isDraft mergeable
5569
5716
  commits(last:1) { nodes { commit { statusCheckRollup { state } } } }`;
5570
5717
  var QUERY = `
@@ -5658,7 +5805,10 @@ function shape(n, repo, role, kind) {
5658
5805
  kind,
5659
5806
  number: n.number,
5660
5807
  title: n.title ?? "",
5661
- body: n.body ?? "",
5808
+ // The list carries the body only to find a decision's default; a row never shows it, and
5809
+ // a thread that is opened is read whole on its own.
5810
+ body: n.timelineItems ? n.body ?? "" : "",
5811
+ ...extras(n),
5662
5812
  labels: (n.labels?.nodes ?? []).map((l) => l.name),
5663
5813
  assignees: (n.assignees?.nodes ?? []).map((a) => a.login),
5664
5814
  author: n.author?.login ?? "",
@@ -5674,6 +5824,29 @@ function shape(n, repo, role, kind) {
5674
5824
  reactions: reactions(n)
5675
5825
  };
5676
5826
  }
5827
+ function extras(n) {
5828
+ const out = {};
5829
+ if (n.closedAt) out.closedAt = n.closedAt;
5830
+ const closer = n.closer?.nodes?.[0]?.actor?.login;
5831
+ if (closer) out.closedBy = closer;
5832
+ const last = n.last?.nodes?.[0];
5833
+ if (last) {
5834
+ out.lastComment = {
5835
+ author: last.author?.login ?? "",
5836
+ createdAt: last.createdAt,
5837
+ body: String(last.body ?? "").slice(0, 800)
5838
+ };
5839
+ }
5840
+ const due = dueOf(n.body ?? "");
5841
+ if (due) out.due = due;
5842
+ return out;
5843
+ }
5844
+ function dueOf(body) {
5845
+ const m = /if i hear nothing by \**(\d{4}-\d{2}-\d{2})\**,?\s*i(?:'|’)?ll\s+([^\n]+)/i.exec(body);
5846
+ if (!m) return void 0;
5847
+ const action = m[2].replace(/[*_"“”]+/g, "").replace(/\.\s*$/, "").trim();
5848
+ return { date: m[1], action };
5849
+ }
5677
5850
  function reactions(n) {
5678
5851
  return (n.reactionGroups ?? []).filter((g) => (g.reactors?.totalCount ?? 0) > 0).map((g) => ({
5679
5852
  content: g.content,
@@ -5824,6 +5997,74 @@ function short4(e) {
5824
5997
  return line.length > 200 ? line.slice(0, 199) + "\u2026" : line;
5825
5998
  }
5826
5999
 
6000
+ // src/lib/live.ts
6001
+ var FINISHED_HOURS = 6;
6002
+ function parseTitle(handle, title, workflow = "") {
6003
+ const m = new RegExp(
6004
+ `^${handle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} (daily|manual|follow-on|mention|peer)(?: #(\\d+))?\\s*$`
6005
+ ).exec(title.trim());
6006
+ if (m) return { trigger: m[1], issue: m[2] ? Number(m[2]) : null };
6007
+ if (/daily/i.test(workflow)) return { trigger: "daily", issue: null };
6008
+ if (/mention/i.test(workflow)) return { trigger: "mention", issue: null };
6009
+ return { trigger: "unknown", issue: null };
6010
+ }
6011
+ function shapeLive(handle, brain, raw, limit, now = Date.now()) {
6012
+ const today = new Date(now).toISOString().slice(0, 10);
6013
+ const since = now - FINISHED_HOURS * 36e5;
6014
+ const out = { handle, brain, running: [], finished: [], automatic: 0, limit };
6015
+ for (const r of raw) {
6016
+ if (r.conclusion === "skipped") continue;
6017
+ const { trigger, issue } = parseTitle(handle, r.displayTitle ?? "", r.workflowName ?? "");
6018
+ const run6 = {
6019
+ id: Number(r.databaseId),
6020
+ trigger,
6021
+ issue,
6022
+ status: String(r.status ?? ""),
6023
+ conclusion: r.conclusion ?? null,
6024
+ createdAt: String(r.createdAt ?? ""),
6025
+ updatedAt: String(r.updatedAt ?? r.createdAt ?? ""),
6026
+ url: String(r.url ?? "")
6027
+ };
6028
+ if ((trigger === "peer" || trigger === "follow-on") && run6.createdAt.slice(0, 10) === today) {
6029
+ out.automatic++;
6030
+ }
6031
+ if (run6.status !== "completed") out.running.push(run6);
6032
+ else if (new Date(run6.updatedAt).getTime() >= since) out.finished.push(run6);
6033
+ }
6034
+ out.running.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
6035
+ out.finished.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
6036
+ return out;
6037
+ }
6038
+ async function liveFor(staff) {
6039
+ return Promise.all(
6040
+ staff.map(async (s) => {
6041
+ const res = await ghJson([
6042
+ "run",
6043
+ "list",
6044
+ "--repo",
6045
+ s.brain,
6046
+ "--limit",
6047
+ // Skipped mentions are most of the list; this is enough to reach past them to today's.
6048
+ "60",
6049
+ "--json",
6050
+ "databaseId,displayTitle,workflowName,status,conclusion,createdAt,updatedAt,url"
6051
+ ]);
6052
+ if (!res.ok) {
6053
+ return {
6054
+ handle: s.handle,
6055
+ brain: s.brain,
6056
+ running: [],
6057
+ finished: [],
6058
+ automatic: 0,
6059
+ limit: s.limit,
6060
+ error: res.error
6061
+ };
6062
+ }
6063
+ return shapeLive(s.handle, s.brain, res.data ?? [], s.limit);
6064
+ })
6065
+ );
6066
+ }
6067
+
5827
6068
  // src/lib/notifications.ts
5828
6069
  async function unreadFor(owner, pages = 5) {
5829
6070
  const out = /* @__PURE__ */ new Map();
@@ -7432,6 +7673,24 @@ async function portalCommand(argv) {
7432
7673
  });
7433
7674
  return;
7434
7675
  }
7676
+ if (url.pathname === "/api/live") {
7677
+ const org = readOrg(w.opsDir, parseYaml);
7678
+ const staff = (org.staff ?? []).map((s) => {
7679
+ const dir = s.dir ?? s.handle;
7680
+ let m = {};
7681
+ try {
7682
+ m = readManifest2(join28(w.root, dir), parseYaml);
7683
+ } catch {
7684
+ }
7685
+ return {
7686
+ handle: String(s.handle),
7687
+ brain: String(m.brain ?? `${org.org}/${dir}`),
7688
+ limit: Number(m.max_runs_per_day ?? 6)
7689
+ };
7690
+ });
7691
+ liveFor(staff).then((list) => json(res, { fetchedAt: (/* @__PURE__ */ new Date()).toISOString(), staff: list })).catch((err) => json(res, { error: String(err?.message ?? err), staff: [] }));
7692
+ return;
7693
+ }
7435
7694
  if (url.pathname === "/api/runs") {
7436
7695
  const fresh = url.searchParams.get("refresh") === "1";
7437
7696
  if (!fresh && runsCache && Date.now() - runsCache.at < TTL) {
@@ -8149,7 +8408,9 @@ async function main(argv) {
8149
8408
  const raw = rewriteOutput();
8150
8409
  const [command, ...rest] = argv;
8151
8410
  if (!command) return portalCommand([]);
8152
- if (command === "help" || command === "--help" || command === "-h") {
8411
+ const asksHelp = command === "help" || command === "--help" || command === "-h";
8412
+ if (command.startsWith("-") && !asksHelp) return portalCommand(argv);
8413
+ if (asksHelp) {
8153
8414
  const topic = rest[0];
8154
8415
  if (!topic || !HELPS[topic]) {
8155
8416
  const name = bin();
package/docs/README.md CHANGED
@@ -26,7 +26,7 @@ Read in this order.
26
26
  | [Getting started](getting-started.md) | One command, in a browser: stand up an org, or join one that exists. |
27
27
  | [The portal](portal.md) | Where the work happens: setup, every screen, every action. |
28
28
  | [Manual steps](manual-steps.md) | What only a person can do, why, and what breaks if it is skipped. |
29
- | [Concepts](concepts.md) | The six things you need to know, then the detail. |
29
+ | [Concepts](concepts.md) | The three things you need to know, then the detail. |
30
30
  | [Choosing a coding agent](agents.md) | Claude, Codex, Nanocoder, or anything with a command line. |
31
31
  | [Writing a charter](writing-a-charter.md) | The one file nothing can generate for you. |
32
32
  | [Extending it](extending.md) | The four seams, and which one to reach for. |
package/docs/commands.md CHANGED
@@ -270,7 +270,8 @@ ROSTER_CONTEXT='{"issue_number":"1","comment_id":"1","repo":"o/r"}' \
270
270
  ## `roster portal`
271
271
 
272
272
  Serve a local UI over the checked-out repositories. **`roster` with no arguments does the same**,
273
- which is the shortest way in.
273
+ which is the shortest way in. It takes the same flags: `roster --no-open` is `roster portal
274
+ --no-open`.
274
275
 
275
276
  ```
276
277
  --port <n> default 4300
@@ -285,7 +286,7 @@ is not a terminal, or with `BROWSER=none`.
285
286
  **With no tenant where you started it, this is the setup screen**: it stands up a new org, or
286
287
  checks out one that already runs roster. Local only. See [the portal](portal.md).
287
288
 
288
- Views: Inbox, Org, Staff, Docs, and per staff member Brain, Prompt, Graph, What changed, Health.
289
+ Views: Home, Trackers, Runs, Org, Staff, Docs, and per staff member Brain, Prompt, Graph, What changed, Health.
289
290
 
290
291
  It can act as you through your own `gh`: reply, close, reopen and open issues; hire and retire;
291
292
  edit and commit the org layer, prompt fragments and charters; create a staff member's GitHub App;
package/docs/concepts.md CHANGED
@@ -6,30 +6,24 @@ sidebar_order: 3
6
6
 
7
7
  # Concepts
8
8
 
9
- ## The six things you need to know
9
+ ## The three things you need to know
10
10
 
11
11
  Enough to set up an org and read what it does. Everything after this section is detail you
12
12
  can learn when you need it.
13
13
 
14
- 1. **The org layer.** One private repo, `<org>/roster-ops`, holds what every staff member
15
- shares: what the business is (`org/business.md`), what matters this month
16
- (`org/priorities.md`), the house voice and the guardrails. Change it once and every staff
17
- member has it on their next run. [More](#the-ops-repo).
18
- 2. **A staff member is a repo.** Each one has a private repo, its *brain*: what it knows, what
19
- it is working on, and what it has decided. There is no database and no server; the portal
20
- reads the repos. [More](#the-brain).
21
- 3. **The charter.** `CHARTER.md` in the brain says who this staff member is and what it
22
- decides alone. You write it, with a brief that interviews you; roster never generates one,
23
- because a generated charter makes a generic agent. [More](#charter-and-manifest).
24
- 4. **Memory.** `memory/INDEX.md` is one line per fact, read at the start of every run. The
25
- agent writes it and deletes from it; you can read and correct it in the portal. That is how
26
- a staff member remembers yesterday. [More](memory.md).
27
- 5. **The daily run.** A scheduled GitHub Actions workflow in each brain wakes the staff member,
28
- hands it a prompt built from the org layer plus its charter and memory, and it does one piece
29
- of work and writes down what happened. [More](#kinds-of-run).
30
- 6. **Mentions.** Write `@handle` in an issue or comment on a staff member's own tracker and it
31
- runs to answer that, between daily runs. Nothing on a product repo wakes anybody; you ask
32
- them on their tracker. [More](#kinds-of-run).
14
+ 1. **The org files everyone shares.** One private repo, `<org>/roster-ops`, holds what every
15
+ staff member reads: what the business is (`org/business.md`), what matters this month
16
+ (`org/priorities.md`), the house voice and the guardrails. Change one and every staff member
17
+ has it on their next run. [More](#the-ops-repo).
18
+ 2. **One repo per staff member.** Each staff member is a private repo, its *brain*. In it,
19
+ `CHARTER.md` says who they are and what they decide alone. You write it; roster never
20
+ generates one, because a generated charter makes a generic agent. `memory/INDEX.md` is what
21
+ they know, one line per fact, which they keep and you can correct. There is no database and
22
+ no server; the portal reads the repos. [More](#the-brain).
23
+ 3. **The daily run, and asking.** Each weekday a scheduled run wakes them: it reads the org
24
+ files, their charter and their memory, does one piece of work, and hands it to you as a pull
25
+ request or a question. Between runs, write `@handle` on their tracker and they answer that.
26
+ [More](#kinds-of-run).
33
27
 
34
28
  Everything below, and the rest of the docs, is detail: identities, peers, surfaces, the
35
29
  prompt's layers, upgrading. None of it is needed to get a first run.
@@ -60,6 +54,11 @@ ranked, and what is out of scope. It is composed into every daily run, a run pic
60
54
  serves it, and a PR names the priority it serves. Keep it to three priorities or fewer, and
61
55
  rewrite it when the month turns.
62
56
 
57
+ In the last three days of each month the staff member `org.yaml` lists first opens a pull
58
+ request on the ops repo with a draft for next month, built from this month's, what shipped and
59
+ the other staff's status issues. It shows on Home with Merge; edit it first if you like. Until it
60
+ is merged, this month's stand.
61
+
63
62
  Without it each staff member picks its own work from its own charter, and they drift. `roster
64
63
  init` writes a stub; `roster doctor` warns while it is missing or still the stub. An org that
65
64
  predates it just adds the file.
@@ -127,6 +126,20 @@ control.
127
126
  | `daily` | the scheduled session. Boot, work, hand off. |
128
127
  | `mention` | `@handle` in a comment or a new issue body. A task, not a session. |
129
128
 
129
+ What starts a run:
130
+
131
+ | Trigger | Runs | Counts against `max_runs_per_day` |
132
+ |---|---|---|
133
+ | the schedule | `daily` | no |
134
+ | **Run once now**, or Actions → Run workflow | `daily` | no |
135
+ | a person writing `@handle` on the staff member's tracker | `mention` | no |
136
+ | a peer opening an issue there with their `from-<handle>` label | `mention`, framed as a peer's ask | yes |
137
+ | a daily run that ended with the next step ready | `daily`, as a follow-on | yes |
138
+
139
+ A person commenting without the `@handle` wakes nobody, so people can discuss on an issue
140
+ among themselves. A run started by a peer may not file on another peer, and the limit (6 a day
141
+ unless `staff.yaml` says otherwise) stops a chain of runs that nobody asked for.
142
+
130
143
  A `mention` prompt refuses to compose without trigger context, because it is written for the
131
144
  comment that woke it. That is correct behaviour, not a bug.
132
145
 
@@ -63,6 +63,8 @@ ran at all.
63
63
  | `surfaces` | A surface declared in `staff.yaml` is not on disk. The portal renders nothing for it. |
64
64
  | `secrets` | Every secret the callers reference exists on the brain repo, or is an organisation secret shared with it. Derived from the callers themselves, not a fixed list. |
65
65
  | `labels` | Every label declared in `staff.yaml` exists. An agent applying a label that does not exist gets an API error mid-run. |
66
+ | `owned-labels` | Roster's own labels exist on the tracker: `decision`, `review`, `chore` and `keep-open`. Checked whatever `staff.yaml` declares, since the prompts apply them. The fix is the `gh label create` lines. |
67
+ | `ask-kind` | An open issue assigned to a human has no ask kind, or more than one. Home sorts what needs you by kind, so an ask without one lands nowhere. |
66
68
  | `peer-labels` | The `from-<handle>` label exists on the *peer's* tracker, which is where this staff member's asks land. |
67
69
  | `status-issue` | The declared status issue is actually pinned. If not, the place you look is not the place the agent maintains. |
68
70
  | `runs` | A window of recent runs. See below. |
@@ -30,7 +30,7 @@ hour is the part not to rush.
30
30
  - **A credential for your [coding agent](agents.md).** For Claude Code, run
31
31
  `claude setup-token` and keep the token it prints for step 5.
32
32
 
33
- You only need [the six things in Concepts](concepts.md#the-six-things-you-need-to-know) to follow
33
+ You only need [the three things in Concepts](concepts.md#the-three-things-you-need-to-know) to follow
34
34
  this. Everything else can wait.
35
35
 
36
36
  ## 1. Say which organisation, then read the plan
@@ -51,7 +51,8 @@ can call its workflow. Without that every run fails with "workflow not found". I
51
51
  (it needs admin on the repo), the page says why and links to the setting to click instead.
52
52
 
53
53
  The rest of the steps stay on the same page. Reload it and the portal opens on **Getting
54
- started**, which keeps them in the sidebar until they are done, with hiring first.
54
+ started**, which keeps them in the sidebar until they are done, with hiring first. The sidebar
55
+ counts them: three for the org, five for each staff member.
55
56
 
56
57
  ## 2. Say what the business is
57
58
 
@@ -181,8 +182,8 @@ each.
181
182
 
182
183
  - **A second staff member**: Staff → Hire someone, then GitHub App, the charter and one run. The
183
184
  credential is already there.
184
- - **Answering your agents**: [the Inbox](portal.md#inbox) is everything open across the org, and
185
- the reply goes out as you. Work they finished sits in [Pending work](portal.md#pending-work).
185
+ - **Answering your agents**: [Home](portal.md#home) is what needs you, who is working and
186
+ what you asked for. Replies and merges go out as you.
186
187
  - **A framework update**: `roster upgrade`, or the same from the portal. See
187
188
  [upgrading](upgrading.md).
188
189
  - **The whole portal**, screen by screen: [the portal](portal.md).
package/docs/org-yaml.md CHANGED
@@ -126,6 +126,7 @@ Fallbacks for staff members who do not set their own.
126
126
  | `model` | Model id passed to the agent. |
127
127
  | `timeout_minutes` | Ceiling on a daily session. `90` if unset. |
128
128
  | `mention_timeout_minutes` | Ceiling on a mention run. Falls back to `timeout_minutes`, then `90`. |
129
+ | `max_runs_per_day` | What a new hire's `max_runs_per_day` starts at. `6` when unset. |
129
130
  | `allowed_tools` | Claude's own spelling of a permission level, kept because it predates `agent.permissions` and still wins for the agents that take a tool list. Nothing translates it for the others: a list written for one agent is not a permission level for another. Prefer [`agent.permissions`](agents.md#permissions), which every agent understands. |
130
131
 
131
132
  ### `memory`