@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 +283 -22
- 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 +39 -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/home.css +57 -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/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 +391 -0
- package/templates/portal/js/views/inbox.js +5 -4
- 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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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`
|