@nanocollective/roster 0.1.0-alpha.43 → 0.1.0-alpha.45

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 (35) hide show
  1. package/dist/cli.js +237 -21
  2. package/docs/README.md +1 -1
  3. package/docs/commands.md +3 -2
  4. package/docs/concepts.md +33 -20
  5. package/docs/doctor-codes.md +2 -0
  6. package/docs/getting-started.md +5 -4
  7. package/docs/org-yaml.md +1 -0
  8. package/docs/portal.md +43 -6
  9. package/docs/session-workflow.md +14 -0
  10. package/docs/staff-yaml.md +16 -1
  11. package/package.json +18 -20
  12. package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +20 -1
  13. package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +35 -7
  14. package/templates/brain/staff.yaml +4 -1
  15. package/templates/ops/.github/workflows/session.yaml +86 -3
  16. package/templates/ops/compose.mjs +15 -0
  17. package/templates/ops/org/operating.md +25 -10
  18. package/templates/ops/prompts/daily.md +23 -4
  19. package/templates/ops/prompts/mention.md +19 -0
  20. package/templates/portal/css/base.css +11 -0
  21. package/templates/portal/css/home.css +79 -0
  22. package/templates/portal/index.html +8 -8
  23. package/templates/portal/js/api.js +3 -0
  24. package/templates/portal/js/app.js +5 -3
  25. package/templates/portal/js/dialog.js +3 -2
  26. package/templates/portal/js/homesort.js +154 -0
  27. package/templates/portal/js/readiness.js +35 -0
  28. package/templates/portal/js/refresh.js +9 -2
  29. package/templates/portal/js/state.js +2 -6
  30. package/templates/portal/js/views/brain.js +1 -1
  31. package/templates/portal/js/views/hire.js +10 -0
  32. package/templates/portal/js/views/home.js +464 -0
  33. package/templates/portal/js/views/inbox.js +19 -2
  34. package/templates/portal/js/views/setup.js +6 -2
  35. package/templates/portal/js/views/staff.js +14 -2
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
 
@@ -5365,6 +5435,34 @@ async function act(req) {
5365
5435
  }
5366
5436
  throw new Error(`unknown action "${action}"`);
5367
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
+ }
5368
5466
  function whyNotMerged(ghError, pr) {
5369
5467
  const checks = pr?.statusCheckRollup ?? [];
5370
5468
  const name = (c) => c.name || c.context || "a check";
@@ -5605,11 +5703,15 @@ var PR_TYPES = `[ISSUE_COMMENT, CROSS_REFERENCED_EVENT, REFERENCED_EVENT, CLOSED
5605
5703
  REOPENED_EVENT, MERGED_EVENT, LABELED_EVENT, UNLABELED_EVENT, ASSIGNED_EVENT,
5606
5704
  UNASSIGNED_EVENT, RENAMED_TITLE_EVENT, PULL_REQUEST_REVIEW, READY_FOR_REVIEW_EVENT,
5607
5705
  REVIEW_REQUESTED_EVENT]`;
5608
- var LIGHT = `number title url state createdAt updatedAt
5706
+ var LIGHT = `number title url state createdAt updatedAt closedAt body
5609
5707
  author { login }
5610
5708
  labels(first:12) { nodes { name } }
5611
5709
  assignees(first:8) { nodes { login } }
5612
- 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
+ }`;
5613
5715
  var LIGHT_PR = `${LIGHT} isDraft mergeable
5614
5716
  commits(last:1) { nodes { commit { statusCheckRollup { state } } } }`;
5615
5717
  var QUERY = `
@@ -5703,7 +5805,10 @@ function shape(n, repo, role, kind) {
5703
5805
  kind,
5704
5806
  number: n.number,
5705
5807
  title: n.title ?? "",
5706
- 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),
5707
5812
  labels: (n.labels?.nodes ?? []).map((l) => l.name),
5708
5813
  assignees: (n.assignees?.nodes ?? []).map((a) => a.login),
5709
5814
  author: n.author?.login ?? "",
@@ -5719,6 +5824,29 @@ function shape(n, repo, role, kind) {
5719
5824
  reactions: reactions(n)
5720
5825
  };
5721
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
+ }
5722
5850
  function reactions(n) {
5723
5851
  return (n.reactionGroups ?? []).filter((g) => (g.reactors?.totalCount ?? 0) > 0).map((g) => ({
5724
5852
  content: g.content,
@@ -5869,6 +5997,74 @@ function short4(e) {
5869
5997
  return line.length > 200 ? line.slice(0, 199) + "\u2026" : line;
5870
5998
  }
5871
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
+
5872
6068
  // src/lib/notifications.ts
5873
6069
  async function unreadFor(owner, pages = 5) {
5874
6070
  const out = /* @__PURE__ */ new Map();
@@ -7477,6 +7673,24 @@ async function portalCommand(argv) {
7477
7673
  });
7478
7674
  return;
7479
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
+ }
7480
7694
  if (url.pathname === "/api/runs") {
7481
7695
  const fresh = url.searchParams.get("refresh") === "1";
7482
7696
  if (!fresh && runsCache && Date.now() - runsCache.at < TTL) {
@@ -8194,7 +8408,9 @@ async function main(argv) {
8194
8408
  const raw = rewriteOutput();
8195
8409
  const [command, ...rest] = argv;
8196
8410
  if (!command) return portalCommand([]);
8197
- 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) {
8198
8414
  const topic = rest[0];
8199
8415
  if (!topic || !HELPS[topic]) {
8200
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`
package/docs/portal.md CHANGED
@@ -21,8 +21,8 @@ Keep the repos checked out beside each other, in the same shape the runner uses.
21
21
  Anything that asks before it acts (a merge, a push, a hire, a retire, a paid run) asks in the
22
22
  page's own dialog, never the browser's `confirm()`, which blocks the whole tab.
23
23
 
24
- **The counts are right on load.** The badges beside Inbox and Pending work are fetched once at
25
- boot, and the Inbox screen shares that request rather than making a second one. A sidebar that
24
+ **The counts are right on load.** The badges beside Home and Trackers are fetched once at
25
+ boot, and the screens share that request rather than making a second one. A sidebar that
26
26
  says nothing until you look at it is not a sidebar.
27
27
 
28
28
  While the first answer is outstanding the badge is a placeholder rather than blank, because an
@@ -78,12 +78,49 @@ staff member* first, then the Actions setting, the credential, the repo picker,
78
78
  what doctor still says. A link to another screen still wins. When the list is empty the entry
79
79
  goes away; the repo picker lives on under [Org](#org).
80
80
 
81
- ## Inbox
81
+ ## Home
82
+
83
+ Where the portal opens. An Ask box at the top, then these, in order.
84
+
85
+ - **Ask.** Pick a staff member, type what you want, **Send**. It opens an issue on their
86
+ tracker with their `@handle` in front, which wakes them in about a minute.
87
+ - **Latest reports.** Each staff member's run report from the last day: the three lines they
88
+ post on their status issue at the end of a daily run. Nothing shows until there is one.
89
+ - **Needs you.** Every ask a staff member has put on you, and every pull request ready to
90
+ merge, oldest first. Each card has one action on its right:
91
+ - a `decision` has **Reply**, and **Go with the default** when it carries one. The default
92
+ and its date are shown, and turn amber once the date has passed.
93
+ - a `review` has **Approve**.
94
+ - a `chore` has **Done**, which closes it.
95
+ - a pull request has **Merge**, unless its checks fail, are still running, or it conflicts.
96
+
97
+ Answers go out as you, with the staff member's `@handle`, so they act on them straight away.
98
+ When nothing is waiting, it says so.
99
+ - **Working now.** Each staff member: what they are running and what started it (the daily run,
100
+ a follow-on, answering an issue, a peer's ask), for how long, with a link to the log. When
101
+ they are idle, how their last run ended. Peer and follow-on runs today are counted against
102
+ `max_runs_per_day`. This asks GitHub every few seconds while a run is going and every half
103
+ minute otherwise, and only while Home is on screen.
104
+ - **Your requests.** What you asked for: waiting, being worked on (a run for it is going), or
105
+ answered (a staff member had the last word).
106
+ - **Closed today.** What the staff closed today, with the line they closed it with, and
107
+ **Reopen** beside each. Staff close finished issues themselves, so this is where you check.
108
+
109
+ **Every item opens in a side sheet.** Click anywhere on a card or a row and its thread opens
110
+ on the right: the conversation, Reply, Close or Reopen, Merge for a pull request, and Open in
111
+ GitHub. Escape or the × closes it, and Home repaints where you were.
112
+
113
+ **Nothing is missed.** Every open issue and pull request has exactly one place: on Home, or on
114
+ the staff member's own tracker (their status issue, a peer's ask, their own work), or elsewhere
115
+ (draft pull requests, contributor issues). The line at the bottom counts the last two, with a
116
+ link to Trackers.
117
+
118
+ ## Trackers
82
119
 
83
120
  Everything open across the org, from one GraphQL call per repo. Bodies and full timelines come
84
121
  down with the list, so opening a thread is a render rather than a request.
85
122
 
86
- - **The links under Inbox in the sidebar filter it.** All; Unread; each staff member, which
123
+ - **The links under Trackers in the sidebar filter it.** All; Unread; each staff member, which
87
124
  lists the issues on their own tracker whoever filed them; and Issues, the product repos.
88
125
  - **Unread comes from your GitHub notifications.** A thread with activity you have not read has
89
126
  a bar on the left and a bold title, and Unread lists only those, with a count. Opening one
@@ -144,7 +181,7 @@ down with the list, so opening a thread is a render rather than a request.
144
181
 
145
182
  ## Pending work
146
183
 
147
- The same screen, scoped to pull requests, in its own place in the sidebar. An inbox is what is
184
+ The same screen, scoped to pull requests. It is where a pull request opens from Home. An inbox is what is
148
185
  waiting on you; a pull request is work that is finished and waiting on a merge, and the count
149
186
  that matters is not how many are open but how many are green and still sitting there.
150
187
 
@@ -339,7 +376,7 @@ already declared `memory/` as a surface.
339
376
  The navigator has three boxes, because a parsed memory section and a file on disk are
340
377
  different kinds of thing.
341
378
 
342
- **Memory** is the fact sections, the notes behind them, and `INDEX.md` itself. A note is the
379
+ **What they know** is the fact sections, the notes behind them, and `INDEX.md` itself. A note is the
343
380
  argument behind one fact, read only when that fact is in play, which is what keeps the index
344
381
  cheap enough to read at every boot. Both live here rather than among the files: `INDEX.md` is
345
382
  literally what "All facts" renders.