@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.
- package/dist/cli.js +237 -21
- package/docs/README.md +1 -1
- package/docs/commands.md +3 -2
- package/docs/concepts.md +33 -20
- package/docs/doctor-codes.md +2 -0
- package/docs/getting-started.md +5 -4
- package/docs/org-yaml.md +1 -0
- package/docs/portal.md +43 -6
- package/docs/session-workflow.md +14 -0
- package/docs/staff-yaml.md +16 -1
- package/package.json +18 -20
- package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +20 -1
- package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +35 -7
- package/templates/brain/staff.yaml +4 -1
- package/templates/ops/.github/workflows/session.yaml +86 -3
- package/templates/ops/compose.mjs +15 -0
- package/templates/ops/org/operating.md +25 -10
- package/templates/ops/prompts/daily.md +23 -4
- package/templates/ops/prompts/mention.md +19 -0
- package/templates/portal/css/base.css +11 -0
- package/templates/portal/css/home.css +79 -0
- package/templates/portal/index.html +8 -8
- package/templates/portal/js/api.js +3 -0
- package/templates/portal/js/app.js +5 -3
- package/templates/portal/js/dialog.js +3 -2
- package/templates/portal/js/homesort.js +154 -0
- package/templates/portal/js/readiness.js +35 -0
- package/templates/portal/js/refresh.js +9 -2
- package/templates/portal/js/state.js +2 -6
- package/templates/portal/js/views/brain.js +1 -1
- package/templates/portal/js/views/hire.js +10 -0
- package/templates/portal/js/views/home.js +464 -0
- package/templates/portal/js/views/inbox.js +19 -2
- package/templates/portal/js/views/setup.js +6 -2
- 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
|
-
|
|
1803
|
-
"
|
|
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
|
-
|
|
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
|
|
5270
|
+
function askHead(req) {
|
|
5226
5271
|
const name = req.pr.repo.split("/")[1] ?? req.pr.repo;
|
|
5227
|
-
|
|
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
|
|
5306
|
-
|
|
5307
|
-
|
|
5308
|
-
|
|
5309
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
15
|
-
|
|
16
|
-
(`org/priorities.md`), the house voice and the guardrails. Change
|
|
17
|
-
|
|
18
|
-
2. **
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
package/docs/doctor-codes.md
CHANGED
|
@@ -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. |
|
package/docs/getting-started.md
CHANGED
|
@@ -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
|
|
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**: [
|
|
185
|
-
|
|
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
|
|
25
|
-
boot, and the
|
|
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
|
-
##
|
|
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
|
|
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
|
|
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
|
-
**
|
|
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.
|