@nanocollective/roster 0.1.0-alpha.5 → 0.1.0-alpha.7

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 (74) hide show
  1. package/README.md +65 -84
  2. package/dist/cli.js +4153 -2708
  3. package/docs/README.md +9 -6
  4. package/docs/agents.md +24 -20
  5. package/docs/architecture.md +13 -5
  6. package/docs/charters/cmo.md +69 -0
  7. package/docs/charters/cto.md +71 -0
  8. package/docs/charters/support.md +60 -0
  9. package/docs/commands.md +81 -5
  10. package/docs/concepts.md +48 -14
  11. package/docs/cost.md +36 -1
  12. package/docs/developing.md +16 -21
  13. package/docs/doctor-codes.md +8 -2
  14. package/docs/extending.md +2 -2
  15. package/docs/getting-started.md +100 -77
  16. package/docs/images/brain.jpg +0 -0
  17. package/docs/images/org.jpg +0 -0
  18. package/docs/images/prompt.jpg +0 -0
  19. package/docs/images/setup-org.jpg +0 -0
  20. package/docs/images/setup-plan.jpg +0 -0
  21. package/docs/images/staff.jpg +0 -0
  22. package/docs/manual-steps.md +93 -123
  23. package/docs/memory.md +21 -3
  24. package/docs/org-yaml.md +37 -2
  25. package/docs/portal.md +59 -33
  26. package/docs/prompts.md +25 -4
  27. package/docs/security.md +29 -5
  28. package/docs/session-workflow.md +49 -17
  29. package/docs/staff-yaml.md +13 -2
  30. package/docs/troubleshooting.md +8 -8
  31. package/docs/upgrading.md +6 -0
  32. package/docs/writing-a-charter.md +15 -0
  33. package/package.json +1 -1
  34. package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +6 -0
  35. package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +6 -0
  36. package/templates/brain/CHARTER.md +3 -3
  37. package/templates/brain/README.md +1 -0
  38. package/templates/brain/log/decisions.md +3 -0
  39. package/templates/brain/strategy/ideas.md +7 -0
  40. package/templates/ops/.github/workflows/session.yaml +108 -14
  41. package/templates/ops/agents.mjs +7 -3
  42. package/templates/ops/compose.mjs +16 -3
  43. package/templates/ops/inflight.mjs +157 -0
  44. package/templates/ops/org/operating.md +21 -1
  45. package/templates/ops/org/voice.md +9 -0
  46. package/templates/ops/prompts/_inflight.md +14 -0
  47. package/templates/ops/prompts/_paths.md +2 -1
  48. package/templates/ops/prompts/daily.md +16 -7
  49. package/templates/ops/prompts/mention.md +2 -0
  50. package/templates/ops/run-record.mjs +144 -0
  51. package/templates/portal/css/base.css +133 -73
  52. package/templates/portal/css/brain.css +23 -20
  53. package/templates/portal/css/diff.css +10 -9
  54. package/templates/portal/css/graph.css +12 -7
  55. package/templates/portal/css/health.css +12 -10
  56. package/templates/portal/css/inbox.css +24 -21
  57. package/templates/portal/css/layout.css +64 -46
  58. package/templates/portal/css/markdown.css +17 -14
  59. package/templates/portal/css/runs.css +13 -0
  60. package/templates/portal/css/setup.css +38 -32
  61. package/templates/portal/index.html +5 -1
  62. package/templates/portal/js/api.js +29 -3
  63. package/templates/portal/js/app.js +4 -1
  64. package/templates/portal/js/state.js +3 -1
  65. package/templates/portal/js/views/app.js +23 -5
  66. package/templates/portal/js/views/credential.js +93 -0
  67. package/templates/portal/js/views/graph.js +1 -1
  68. package/templates/portal/js/views/health.js +15 -3
  69. package/templates/portal/js/views/org.js +2 -0
  70. package/templates/portal/js/views/paste.js +29 -0
  71. package/templates/portal/js/views/runonce.js +88 -0
  72. package/templates/portal/js/views/runs.js +165 -0
  73. package/templates/portal/js/views/setup.js +67 -26
  74. package/templates/portal/js/views/staff.js +47 -10
@@ -186,6 +186,12 @@ function parseScalar(raw, line) {
186
186
 
187
187
  const MAX_INCLUDE_DEPTH = 8;
188
188
 
189
+ /* A partial's output is scanned again by the template that included it, so a value containing
190
+ `{{` would be rendered as though somebody had written it into a prompt: a PR title saying
191
+ `{{nope}}` failed the run, and one saying `{{> some/file}}` read that file in. Values are
192
+ fenced off with a character no template can contain, and let back out once, at the end. */
193
+ const FENCE = "\u0000";
194
+
189
195
  export function render(template, ctx, readPartial, depth = 0) {
190
196
  if (depth > MAX_INCLUDE_DEPTH) throw new Error("include depth exceeded; a partial probably includes itself");
191
197
 
@@ -220,10 +226,10 @@ export function render(template, ctx, readPartial, depth = 0) {
220
226
  }
221
227
  throw new Error(`unknown or empty placeholder: {{${path}}}`);
222
228
  }
223
- return String(v);
229
+ return String(v).replace(/\{\{/g, FENCE);
224
230
  });
225
231
 
226
- return out;
232
+ return depth === 0 ? out.replaceAll(FENCE, "{{") : out;
227
233
  }
228
234
 
229
235
  function lookup(ctx, path) {
@@ -282,7 +288,7 @@ function defaultMarker(name) {
282
288
  // Composition
283
289
  // ---------------------------------------------------------------------------
284
290
 
285
- export function compose({ opsDir, brainsDir, staff, kind }) {
291
+ export function compose({ opsDir, brainsDir, staff, kind, runDir }) {
286
292
  const org = parseYaml(readFileSync(join(opsDir, "org.yaml"), "utf8"), "org.yaml");
287
293
 
288
294
  const entry = (org.staff ?? []).find((s) => s.handle === staff);
@@ -317,6 +323,12 @@ export function compose({ opsDir, brainsDir, staff, kind }) {
317
323
  // sends it to a 404 before it has read anything. So the absence is a value of its own.
318
324
  if (Object.keys(event).length) event = { ...event, no_comment: !event.comment_id };
319
325
 
326
+ // What people have open on the product repos, gathered by inflight.mjs just before this runs.
327
+ // Read as a value and never rendered as a template: it is PR titles, which are a person's
328
+ // words, and a `{{` in one must not be able to break composition.
329
+ const inflightFile = join(runDir ?? join(brainsDir, ".roster-run"), "inflight.md");
330
+ const inflight = existsSync(inflightFile) ? readFileSync(inflightFile, "utf8").trim() : "";
331
+
320
332
  const humans = readHumans(org);
321
333
  const ctx = {
322
334
  org,
@@ -344,6 +356,7 @@ export function compose({ opsDir, brainsDir, staff, kind }) {
344
356
  // without the one the prose already names. Empty when there is only one, which is what
345
357
  // makes `{{#if humans_extra}}` the right way to mention the others at all.
346
358
  human_list: humanSentence(humans),
359
+ inflight,
347
360
  humans_extra: humanSentence(humans.slice(1)),
348
361
  };
349
362
 
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env node
2
+ // Finds the pull requests people have open on a staff member's product repos, before the prompt
3
+ // is composed, so the agent knows what a human is in the middle of changing.
4
+ //
5
+ // Written because nothing told them. While a person had a long branch open rewriting a product's
6
+ // copy, the staff opened five pull requests and nine issues chasing that same copy, and four of
7
+ // those pull requests were overtaken by the branch. Each one was reasonable on its own; none of
8
+ // them could see the branch.
9
+ //
10
+ // Vendored alongside compose.mjs for the same reason: it runs on the runner, and must not depend
11
+ // on npm. It asks GitHub through `gh`, which every runner has, and it never fails a run: with no
12
+ // answer the prompt simply has no section about it.
13
+ //
14
+ // Usage: node roster-ops/inflight.mjs --staff cto --ops roster-ops --brains . --out .roster-run/inflight.md
15
+
16
+ import { execFileSync } from "node:child_process";
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { dirname, join, resolve } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { parseYaml } from "./compose.mjs";
21
+
22
+ /** Enough to see an overlap without reading a 37,000-line branch into the prompt. */
23
+ const MAX_PRS = 10;
24
+ const MAX_FILES = 20;
25
+ const MAX_DIRS = 6;
26
+
27
+ /**
28
+ * A person, rather than one of this org's Apps or anybody else's automation.
29
+ *
30
+ * `gh` marks an App author as a bot and names it `app/<slug>`; a bot's own login ends `[bot]`.
31
+ * A machine user is none of those, which is why the org's own bot logins are passed in too.
32
+ */
33
+ export function isHuman(author, bots = []) {
34
+ if (!author) return false;
35
+ const login = String(author.login ?? "");
36
+ if (!login || author.is_bot) return false;
37
+ if (login.startsWith("app/") || /\[bot\]$/i.test(login)) return false;
38
+ const bare = (s) => String(s).toLowerCase().replace(/\[bot\]$/, "").replace(/^app\//, "");
39
+ return !bots.some((b) => bare(b) === bare(login));
40
+ }
41
+
42
+ /** The repos a staff member works in: their own `works_in`, or else every product repo. */
43
+ export function productRepos(org, manifest) {
44
+ const own = (manifest?.works_in ?? []).map((w) => String(w?.repo ?? "")).filter(Boolean);
45
+ if (own.length) return own;
46
+ return (org.repos ?? []).filter((r) => r.role === "product").map((r) => `${org.org}/${r.name}`);
47
+ }
48
+
49
+ /** The top directories a change touches, so a wide one reads as "the copy" rather than a list. */
50
+ function directories(paths) {
51
+ const counts = new Map();
52
+ for (const p of paths) {
53
+ const parts = p.split("/");
54
+ const dir = parts.length === 1 ? "(root)" : parts.slice(0, Math.min(2, parts.length - 1)).join("/") + "/";
55
+ counts.set(dir, (counts.get(dir) ?? 0) + 1);
56
+ }
57
+ return [...counts].sort((a, b) => b[1] - a[1]).slice(0, MAX_DIRS);
58
+ }
59
+
60
+ function days(iso, now) {
61
+ const d = Math.floor((now - new Date(iso).getTime()) / 86400_000);
62
+ return d <= 0 ? "today" : d === 1 ? "1 day" : `${d} days`;
63
+ }
64
+
65
+ /**
66
+ * The markdown the prompt carries, or "" for nothing in flight.
67
+ *
68
+ * Titles are a person's words and go in as data: compose.mjs substitutes this file as a value,
69
+ * never renders it as a template, so a `{{` in a title cannot break a run.
70
+ */
71
+ export function describe(found, now = Date.now()) {
72
+ const lines = [];
73
+ for (const { repo, prs } of found) {
74
+ for (const pr of prs.slice(0, MAX_PRS)) {
75
+ const paths = (pr.files ?? []).map((f) => f.path).filter(Boolean);
76
+ const total = Math.max(pr.changedFiles ?? 0, paths.length);
77
+ const title = String(pr.title ?? "").replace(/\s+/g, " ").trim();
78
+ lines.push(
79
+ `- **${repo}#${pr.number}** "${title}" by @${pr.author.login}, open ${days(pr.createdAt, now)}` +
80
+ `, branch \`${pr.headRefName}\`${pr.isDraft ? ", draft" : ""}, ${total} file${total === 1 ? "" : "s"}`,
81
+ );
82
+ if (!paths.length) continue;
83
+ if (total > MAX_FILES) {
84
+ lines.push(` - mostly under ${directories(paths).map(([d, n]) => `\`${d}\` (${n})`).join(", ")}`);
85
+ }
86
+ const shown = paths.slice(0, MAX_FILES).map((p) => `\`${p}\``).join(", ");
87
+ const more = total - Math.min(paths.length, MAX_FILES);
88
+ lines.push(` - ${shown}${more > 0 ? `, and ${more} more` : ""}`);
89
+ }
90
+ if (prs.length > MAX_PRS) lines.push(`- and ${prs.length - MAX_PRS} more open on ${repo}`);
91
+ }
92
+ return lines.length ? lines.join("\n") + "\n" : "";
93
+ }
94
+
95
+ /** Everything open by a person, per repo. `gh` is passed in so a test never needs the network. */
96
+ export function gather({ org, manifest, gh }) {
97
+ const bots = [manifest?.bot, manifest?.public_bot].filter(Boolean);
98
+ const out = [];
99
+ for (const repo of productRepos(org, manifest)) {
100
+ let raw;
101
+ try {
102
+ raw = gh([
103
+ "pr",
104
+ "list",
105
+ "--repo",
106
+ repo,
107
+ "--state",
108
+ "open",
109
+ "--limit",
110
+ "50",
111
+ "--json",
112
+ "number,title,author,createdAt,headRefName,isDraft,changedFiles,files",
113
+ ]);
114
+ } catch (err) {
115
+ // One unreadable repo is not a reason to say nothing about the others.
116
+ console.error(`::warning::inflight: ${repo}: ${String(err.message).split("\n")[0]}`);
117
+ continue;
118
+ }
119
+ const prs = JSON.parse(raw || "[]")
120
+ .filter((pr) => isHuman(pr.author, bots))
121
+ // Oldest first: the long-lived branch is the one most likely to be overtaking everybody.
122
+ .sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
123
+ if (prs.length) out.push({ repo, prs });
124
+ }
125
+ return out;
126
+ }
127
+
128
+ function main(argv) {
129
+ const args = {};
130
+ for (let i = 0; i < argv.length; i += 2) args[argv[i].replace(/^--/, "")] = argv[i + 1];
131
+ if (!args.staff) throw new Error("--staff is required");
132
+ const opsDir = resolve(args.ops ?? ".");
133
+ const org = parseYaml(readFileSync(join(opsDir, "org.yaml"), "utf8"), "org.yaml");
134
+ const entry = (org.staff ?? []).find((s) => s.handle === args.staff);
135
+ if (!entry) throw new Error(`org.yaml has no staff member "${args.staff}"`);
136
+ const manifestPath = join(resolve(args.brains ?? ".."), entry.dir ?? entry.handle, "staff.yaml");
137
+ const manifest = existsSync(manifestPath)
138
+ ? parseYaml(readFileSync(manifestPath, "utf8"), "staff.yaml")
139
+ : {};
140
+
141
+ const gh = (a) => execFileSync("gh", a, { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
142
+ const text = describe(gather({ org, manifest, gh }));
143
+ const out = resolve(args.out ?? ".roster-run/inflight.md");
144
+ mkdirSync(dirname(out), { recursive: true });
145
+ writeFileSync(out, text);
146
+ process.stdout.write(text || "no human pull requests open on the product repos\n");
147
+ }
148
+
149
+ if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
150
+ try {
151
+ main(process.argv.slice(2));
152
+ } catch (err) {
153
+ // Never the reason a run fails. Without it the prompt has no section, which is how every
154
+ // run went before this existed.
155
+ console.error(`::warning::inflight: ${err.message}`);
156
+ }
157
+ }
@@ -15,13 +15,33 @@ question instead of doing work has wasted its slot.
15
15
  - **Never end a run blocked.** If everything on the list is genuinely blocked, do the most useful
16
16
  unblocked thing you can find and say so in the report.
17
17
 
18
+ ## Choosing work
19
+
20
+ - **Serve the priorities.** Work that serves none of the ranked priorities in `org/priorities.md`
21
+ waits, unless something is broken. Say which priority a PR serves.
22
+ - **Product before process.** Guards, checks, claim-policing and measuring your own output earn a
23
+ run when they protect something that has shipped. Most runs should move the product forward; if
24
+ your last few went on meta-work, this one does not.
25
+
26
+ ## Keeping your tracker clean
27
+
28
+ {{human.name}} should never have to ask whether an issue can be closed.
29
+
30
+ - **Close your own issues** when the work is done or superseded, with one line naming what closed
31
+ it: the PR, the commit, or the issue that replaced it. Do not leave one open "in case".
32
+ - **Sweep them on every daily run.** Read every open issue you opened; close what is finished or
33
+ stale, and fold duplicates into one.
34
+ - **Ideas live in your brain, not on the tracker.** Park a speculative idea as one line in
35
+ `strategy/ideas.md`. Open an `IDEA:` issue only when it needs a ruling, and never more than one
36
+ at a time.
37
+
18
38
  ## What you may not do
19
39
 
20
40
  - **You cannot ship to the outside world.** Anything public goes through {{human.name}}. The gate is
21
41
  mechanical rather than a promise: protected branches mean you open a PR and their merge is the
22
42
  approval. **Do not look for a way around it.** Being unable to ship unreviewed is what earns the
23
43
  autonomy.
24
- - **Never close a `decision` issue.** Those are {{human.name}}'s rulings to close.
44
+ - **Never close a `decision` issue**, even in a sweep. Those are {{human.name}}'s rulings to close.
25
45
  - **Never `git add -A` in another staff member's repo, or in a repo where a human may have work in
26
46
  flight.** Stage explicit paths. Doing otherwise has swept someone else's uncommitted work into an
27
47
  unrelated commit.
@@ -12,6 +12,15 @@ bodies, run reports, briefs to other staff, and how you talk to them in a sessio
12
12
  - **An issue title is the ask, not the topic.**
13
13
  - **Say the default** on anything needing a ruling: what you do if they say nothing.
14
14
 
15
+ **Length ceilings.** Lead with the outcome or the ask, then stop at:
16
+
17
+ - **A comment or reply: 100 words.** Most need three lines.
18
+ - **An issue or PR body: 200 words.**
19
+ - **The pinned status issue: 300 words**, readable on one phone screen.
20
+
21
+ Past the ceiling, the detail goes in a file in your brain and the comment links to it in one
22
+ line. A status {{human.name}} has to rewrite before they can use it has cost more than it saved.
23
+
15
24
  **Cut on sight:**
16
25
 
17
26
  - Context they already have. They founded this; it does not need explaining to them.
@@ -0,0 +1,14 @@
1
+ {{#if inflight}}
2
+ ## Human work in flight
3
+
4
+ People have these pull requests open on the product repos, and each one is theirs. A long-lived
5
+ branch is usually rewriting what you would otherwise be about to change.
6
+
7
+ - **Do not open competing work on files they touch**: no pull request, and no issue asking for a
8
+ change there. It gets overtaken when their branch lands, and it costs them a review first.
9
+ - **If you have something to say about that work, say it on their pull request**, briefly, and
10
+ leave the decision to them.
11
+ - If today's task sits on those files, say so in your report and take the next thing.
12
+
13
+ {{inflight}}
14
+ {{/if}}
@@ -5,7 +5,8 @@ repos sit side by side inside it:
5
5
 
6
6
  - `{{staff.dir}}/` - your brain. **Start by reading it.**
7
7
  - `{{ops.dir}}/` - the org's shared brain: `org/operating.md`, `org/voice.md`, `org/guardrails.md`,
8
- `org/business.md`. **Read-only to you.** Propose a change as a PR; do not edit it in place.
8
+ `org/business.md`, `org/priorities.md`. **Read-only to you.** Propose a change as a PR; do not
9
+ edit it in place.
9
10
  {{#if peers}}
10
11
  {{peer_list}}
11
12
  {{/if}}
@@ -33,11 +33,15 @@ your brain. Reconstitute yourself, do a day's work, hand off.
33
33
  **Do not read `log/decisions.md` at boot**; it is the audit trail, for when you need to know why
34
34
  something was decided.
35
35
 
36
+ {{> prompts/_inflight.md}}
37
+ {{>? org/priorities.md}}
38
+
36
39
  ## Then work. Autonomously.
37
40
 
38
- Take the top item off #{{staff.status_issue}}'s ordered list, unless something above changed the
39
- priority, in which case say so in your report and do the more urgent thing. **Then actually do it.**
40
- You are not writing a plan for {{human.name}} to approve.
41
+ Take the top item off #{{staff.status_issue}}'s ordered list that serves the org's priorities
42
+ (`org/priorities.md`, where there is one), unless something above changed the priority, in which
43
+ case say so in your report and do the more urgent thing. **Then actually do it.** You are not
44
+ writing a plan for {{human.name}} to approve.
41
45
 
42
46
  {{> org/operating.md}}
43
47
 
@@ -48,15 +52,16 @@ You are not writing a plan for {{human.name}} to approve.
48
52
  {{#if staff.product}}
49
53
  1. **Open the PR** on `{{staff.product.repo}}` if you produced anything there, from a branch:
50
54
  `GH_TOKEN=${{staff.public_token_env}} gh pr create --repo {{staff.product.repo}} ...`
51
- **Body: what it does, what the gate covered, what it did not cover. Nothing else** - no design
52
- essay, no narration of how you built it. It is reviewed on a phone and the diff is right there.
55
+ **Body: which priority it serves, what it does, what the gate covered, what it did not cover.
56
+ Nothing else** - no design essay, no narration of how you built it. It is reviewed on a phone
57
+ and the diff is right there.
53
58
  {{/if}}
54
59
  2. **Rewrite pinned issue #{{staff.status_issue}} "Where we are"**: the situation in a line, what
55
60
  this run did, what the next run picks up in priority order. **It is a handover for the next run,
56
61
  not a diary.** Rewrite it, do not append, and cut anything the next run can find for itself.
57
62
  3. **Reconcile the tracker.** Open issues for anything new needing {{human.name}}, labelled by owner
58
- plus kind, assigned to `{{human.github}}`. Close what genuinely completed, citing evidence.
59
- **Never close a `decision` issue.** **Comments and replies get the same concision as everything
63
+ plus kind, assigned to `{{human.github}}`. Sweep every open issue you opened: close what is done
64
+ or superseded, citing what closed it. **Comments and replies get the same concision as everything
60
65
  else:** what changed and what it means for them. A comment that only says an issue is still open
61
66
  is not worth the notification.
62
67
  4. **Update `{{staff.dir}}/memory/` only if a fact or watch-out changed.** A new fact is **one line**
@@ -64,6 +69,10 @@ You are not writing a plan for {{human.name}} to approve.
64
69
  with "updated:". If it needs an argument, that goes in `memory/notes/<slug>.md` and the line stays
65
70
  one line. **Delete any line that no longer changes a decision** and say so in the decision log.
66
71
  If the run was purely work, touch nothing.
72
+ **Then check the budget:** `wc -c {{staff.dir}}/memory/INDEX.md {{staff.dir}}/log/decisions.md`.
73
+ Over 24KB either, or any fact over 400 characters (unless a `memory:` block in `staff.yaml` or
74
+ `org.yaml` sets other limits), and pruning is this run's job: delete, shorten, move arguments
75
+ to notes, and roll older decisions into `log/decisions/<YYYY-MM>.md`.
67
76
  5. **Log real decisions** in `{{staff.dir}}/log/decisions.md`, dated, newest at top, with the why.
68
77
  {{#if peers}}
69
78
  6. **Write to the other staff** if anything shipped, changed or broke that touches their patch.
@@ -35,6 +35,8 @@ An issue opened this way often carries a pull request somewhere else, and says w
35
35
 
36
36
  `gh issue view --comments` is broken; use `gh api` as above.
37
37
 
38
+ {{> prompts/_inflight.md}}
39
+
38
40
  ## Do the work
39
41
 
40
42
  - **Read `{{staff.dir}}/CHARTER.md` and `{{staff.dir}}/memory/INDEX.md` before acting.** They are
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+ // Writes down what one run was and what it cost, after the agent has finished or failed.
3
+ //
4
+ // Vendored alongside compose.mjs for the same reason: it runs on the runner, and must not depend
5
+ // on npm or on an org the tenant does not control.
6
+ //
7
+ // The record is small and deliberately incomplete. Staff, kind, outcome and duration are always
8
+ // known. Turns, cost and tokens are known only when the agent says so: claude-code-action writes
9
+ // an execution file, the `claude` CLI writes JSON when asked, and anything else may say nothing.
10
+ // An unknown is null, never a guess, because a total built from guesses is worse than none.
11
+ //
12
+ // Usage: node roster-ops/run-record.mjs --out .roster-run/run.json
13
+ // (everything else arrives in the environment; see session.yaml)
14
+
15
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
16
+ import { dirname, resolve } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ /**
20
+ * The agent's own account of the run, from whatever it wrote.
21
+ *
22
+ * Three shapes turn up: an array of messages whose last `result` is the summary (the Action's
23
+ * execution file), a single result object (`claude -p --output-format json`), and one message
24
+ * per line (`stream-json`). All three end in the same object, so find that.
25
+ */
26
+ export function readResult(text) {
27
+ if (!text || !text.trim()) return null;
28
+ let items;
29
+ try {
30
+ const parsed = JSON.parse(text);
31
+ items = Array.isArray(parsed) ? parsed : [parsed];
32
+ } catch {
33
+ items = [];
34
+ for (const line of text.split("\n")) {
35
+ try {
36
+ items.push(JSON.parse(line));
37
+ } catch {
38
+ // A line of log output between the JSON is not ours to fail on.
39
+ }
40
+ }
41
+ }
42
+ const results = items.filter(
43
+ (m) => m && typeof m === "object" && (m.type === "result" || "total_cost_usd" in m),
44
+ );
45
+ const last = results[results.length - 1];
46
+ if (!last) return null;
47
+
48
+ const n = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
49
+ const u = last.usage ?? {};
50
+ const tokens = {
51
+ input: n(u.input_tokens),
52
+ output: n(u.output_tokens),
53
+ cache_read: n(u.cache_read_input_tokens),
54
+ cache_write: n(u.cache_creation_input_tokens),
55
+ };
56
+ return {
57
+ turns: n(last.num_turns),
58
+ cost_usd: n(last.total_cost_usd ?? last.cost_usd),
59
+ tokens: Object.values(tokens).some((v) => v !== null) ? tokens : null,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * What happened, in one word.
65
+ *
66
+ * The agent step's own outcome when it ran. When it never ran, the job's status says whether
67
+ * that was a cancel (a timeout is one) or a failure somewhere in the setup before it.
68
+ */
69
+ export function outcomeOf(agentOutcome, jobStatus) {
70
+ if (["success", "failure", "cancelled"].includes(agentOutcome)) return agentOutcome;
71
+ if (jobStatus === "cancelled") return "cancelled";
72
+ return "setup-failure";
73
+ }
74
+
75
+ export function buildRecord(env, resultText, now = Date.now()) {
76
+ const started = Number(env.ROSTER_STARTED) || null;
77
+ const result = readResult(resultText);
78
+ return {
79
+ v: 1,
80
+ staff: env.STAFF ?? "",
81
+ kind: env.KIND ?? "",
82
+ outcome: outcomeOf(env.AGENT_OUTCOME, env.JOB_STATUS),
83
+ started: started ? new Date(started * 1000).toISOString() : null,
84
+ duration_s: started ? Math.max(0, Math.round(now / 1000 - started)) : null,
85
+ agent: env.AGENT_ID || null,
86
+ model: env.MODEL || null,
87
+ turns: result?.turns ?? null,
88
+ cost_usd: result?.cost_usd ?? null,
89
+ tokens: result?.tokens ?? null,
90
+ run_id: env.GITHUB_RUN_ID ?? null,
91
+ run_url:
92
+ env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY && env.GITHUB_RUN_ID
93
+ ? `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}`
94
+ : null,
95
+ };
96
+ }
97
+
98
+ /** The same record as the job summary shows it: one table, unknowns as a dash. */
99
+ export function summary(record) {
100
+ const dash = (v) => (v === null || v === undefined ? "—" : String(v));
101
+ const mins = record.duration_s === null ? null : `${Math.round(record.duration_s / 60)}m`;
102
+ const cost = record.cost_usd === null ? null : `$${record.cost_usd.toFixed(2)}`;
103
+ const t = record.tokens;
104
+ const tokens = t
105
+ ? [
106
+ t.input !== null ? `${t.input} in` : "",
107
+ t.output !== null ? `${t.output} out` : "",
108
+ t.cache_read !== null ? `${t.cache_read} cached` : "",
109
+ ]
110
+ .filter(Boolean)
111
+ .join(", ")
112
+ : null;
113
+ return [
114
+ `### ${record.staff} · ${record.kind} · ${record.outcome}`,
115
+ "",
116
+ "| duration | turns | cost | tokens | agent |",
117
+ "|---|---|---|---|---|",
118
+ `| ${dash(mins)} | ${dash(record.turns)} | ${dash(cost)} | ${dash(tokens)} | ${dash(record.agent)} |`,
119
+ "",
120
+ ].join("\n");
121
+ }
122
+
123
+ function main(argv) {
124
+ const args = {};
125
+ for (let i = 0; i < argv.length; i += 2) args[argv[i].replace(/^--/, "")] = argv[i + 1];
126
+ const out = resolve(args.out ?? ".roster-run/run.json");
127
+ const file = process.env.RESULT_FILE;
128
+ const text = file && existsSync(file) ? readFileSync(file, "utf8") : "";
129
+ const record = buildRecord(process.env, text);
130
+
131
+ mkdirSync(dirname(out), { recursive: true });
132
+ writeFileSync(out, JSON.stringify(record, null, 2) + "\n");
133
+ if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary(record));
134
+ process.stdout.write(JSON.stringify(record) + "\n");
135
+ }
136
+
137
+ if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
138
+ try {
139
+ main(process.argv.slice(2));
140
+ } catch (err) {
141
+ console.error(`run-record: ${err.message}`);
142
+ process.exit(1);
143
+ }
144
+ }