@nanocollective/roster 0.1.0-alpha.6 → 0.1.0-alpha.8
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/README.md +65 -84
- package/dist/cli.js +4187 -2709
- package/docs/README.md +9 -6
- package/docs/agents.md +24 -20
- package/docs/architecture.md +13 -5
- package/docs/charters/cmo.md +69 -0
- package/docs/charters/cto.md +71 -0
- package/docs/charters/support.md +60 -0
- package/docs/commands.md +81 -5
- package/docs/concepts.md +48 -14
- package/docs/cost.md +36 -1
- package/docs/developing.md +16 -21
- package/docs/doctor-codes.md +8 -2
- package/docs/extending.md +2 -2
- package/docs/getting-started.md +110 -77
- package/docs/manual-steps.md +93 -123
- package/docs/memory.md +21 -3
- package/docs/org-yaml.md +37 -2
- package/docs/portal.md +83 -35
- package/docs/prompts.md +25 -4
- package/docs/security.md +29 -5
- package/docs/session-workflow.md +49 -17
- package/docs/staff-yaml.md +13 -2
- package/docs/troubleshooting.md +8 -8
- package/docs/upgrading.md +6 -0
- package/docs/writing-a-charter.md +15 -0
- package/package.json +1 -1
- package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +6 -0
- package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +6 -0
- package/templates/brain/CHARTER.md +3 -3
- package/templates/brain/README.md +1 -0
- package/templates/brain/log/decisions.md +3 -0
- package/templates/brain/strategy/ideas.md +7 -0
- package/templates/ops/.github/workflows/session.yaml +108 -14
- package/templates/ops/agents.mjs +7 -3
- package/templates/ops/compose.mjs +16 -3
- package/templates/ops/inflight.mjs +157 -0
- package/templates/ops/org/operating.md +21 -1
- package/templates/ops/org/voice.md +9 -0
- package/templates/ops/prompts/_inflight.md +14 -0
- package/templates/ops/prompts/_paths.md +2 -1
- package/templates/ops/prompts/daily.md +16 -7
- package/templates/ops/prompts/mention.md +2 -0
- package/templates/ops/run-record.mjs +144 -0
- package/templates/portal/css/runs.css +13 -0
- package/templates/portal/css/setup.css +2 -0
- package/templates/portal/index.html +12 -1
- package/templates/portal/js/api.js +32 -4
- package/templates/portal/js/app.js +42 -8
- package/templates/portal/js/dialog.js +47 -0
- package/templates/portal/js/state.js +3 -1
- package/templates/portal/js/views/app.js +23 -5
- package/templates/portal/js/views/credential.js +93 -0
- package/templates/portal/js/views/health.js +17 -4
- package/templates/portal/js/views/inbox.js +6 -2
- package/templates/portal/js/views/org.js +23 -3
- package/templates/portal/js/views/paste.js +29 -0
- package/templates/portal/js/views/prompt.js +7 -3
- package/templates/portal/js/views/repos.js +12 -7
- package/templates/portal/js/views/runonce.js +94 -0
- package/templates/portal/js/views/runs.js +165 -0
- package/templates/portal/js/views/setup.js +267 -45
- package/templates/portal/js/views/staff.js +81 -20
|
@@ -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
|
|
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
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
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.
|
|
52
|
-
essay, no narration of how you built it. It is reviewed on a phone
|
|
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}}`.
|
|
59
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/* The Runs screen: a spend strip, then one table per staff member. */
|
|
2
|
+
|
|
3
|
+
.runsum{margin-bottom:6px}
|
|
4
|
+
.hsect i.warn{color:var(--warn)}
|
|
5
|
+
.runtable{border-collapse:collapse;width:100%;margin-top:8px;font:12px var(--mono);
|
|
6
|
+
font-variant-numeric:tabular-nums}
|
|
7
|
+
.runtable th{text-align:left;font:600 10.5px var(--sans);letter-spacing:.06em;text-transform:uppercase;
|
|
8
|
+
color:var(--ink-faint);padding:6px 10px 6px 0;border-bottom:1px solid var(--line)}
|
|
9
|
+
.runtable td{padding:7px 10px 7px 0;border-bottom:1px solid var(--line);color:var(--ink-dim);
|
|
10
|
+
white-space:nowrap}
|
|
11
|
+
.runtable td:last-child,.runtable th:last-child{text-align:right;padding-right:0}
|
|
12
|
+
/* The table is wider than a phone; it scrolls inside its section, never the page. */
|
|
13
|
+
.hbody.runs{overflow-x:auto}
|
|
@@ -56,6 +56,8 @@
|
|
|
56
56
|
|
|
57
57
|
.manual{background:var(--bg-elev);border:1px solid var(--line);border-radius:var(--r);
|
|
58
58
|
padding:13px 15px;margin:11px 0}
|
|
59
|
+
/* A file step that is written says so in the border too, the way a finished step does. */
|
|
60
|
+
.manual.done{border-color:color-mix(in srgb,var(--accent) 30%,var(--line))}
|
|
59
61
|
.manual b{display:block;font:600 13px var(--sans);margin-bottom:4px}
|
|
60
62
|
.manual p{margin:0 0 6px;font-size:13px}
|
|
61
63
|
.manual small{display:block;color:var(--ink-faint);font-size:11.5px;line-height:1.5;margin-bottom:9px}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
<link rel="stylesheet" href="/assets/css/graph.css">
|
|
17
17
|
<link rel="stylesheet" href="/assets/css/diff.css">
|
|
18
18
|
<link rel="stylesheet" href="/assets/css/health.css">
|
|
19
|
+
<link rel="stylesheet" href="/assets/css/runs.css">
|
|
19
20
|
<link rel="stylesheet" href="/assets/css/setup.css">
|
|
20
21
|
</head>
|
|
21
22
|
<body>
|
|
@@ -26,6 +27,10 @@
|
|
|
26
27
|
<span class="sync" data-icon="refresh"></span><span id="loaded">data now</span>
|
|
27
28
|
</button>
|
|
28
29
|
|
|
30
|
+
<!-- Only while something is left to set up; app.js decides. -->
|
|
31
|
+
<button class="nav top" id="setupnav" data-view="setup" hidden>
|
|
32
|
+
<span class="ic" data-icon="task-done"></span> Getting started
|
|
33
|
+
</button>
|
|
29
34
|
<button class="nav top" id="inboxnav" data-view="inbox">
|
|
30
35
|
<span class="ic" data-icon="inbox"></span> Inbox
|
|
31
36
|
<span class="n" id="inboxcount"></span>
|
|
@@ -36,6 +41,9 @@
|
|
|
36
41
|
<span class="ic" data-icon="merged"></span> Pending work
|
|
37
42
|
<span class="n" id="prcount"></span>
|
|
38
43
|
</button>
|
|
44
|
+
<button class="nav" id="runsnav" data-view="runs" title="What ran, how it ended, and what it cost">
|
|
45
|
+
<span class="ic" data-icon="play"></span> Runs
|
|
46
|
+
</button>
|
|
39
47
|
<button class="nav" id="orgnav" data-view="org">
|
|
40
48
|
<span class="ic" data-icon="file"></span> Org
|
|
41
49
|
</button>
|
|
@@ -48,7 +56,10 @@
|
|
|
48
56
|
|
|
49
57
|
<!-- The heading is the fold. Collapsed, it carries the count, because a hidden list still
|
|
50
58
|
has to say how much it is hiding. -->
|
|
51
|
-
|
|
59
|
+
<!-- Named apart from the Staff screen above it: two controls both called "Staff" is how a
|
|
60
|
+
click meant for the screen landed on the fold, for people and for automation alike. -->
|
|
61
|
+
<button class="sect sectfold" id="stafffold" aria-expanded="false" aria-controls="stafflist"
|
|
62
|
+
aria-label="Staff list" title="Show or hide each staff member's own screens">
|
|
52
63
|
<span class="caret" data-icon="chevron"></span>Staff<span class="n" id="staffcount" hidden></span>
|
|
53
64
|
</button>
|
|
54
65
|
<div id="stafflist"></div>
|
|
@@ -7,6 +7,9 @@ export const getDocs = () => json("/api/docs");
|
|
|
7
7
|
export const getInbox = (force) => json("/api/inbox" + (force ? "?refresh=1" : ""));
|
|
8
8
|
export const getSync = () => json("/api/sync").catch(() => null);
|
|
9
9
|
|
|
10
|
+
/** Every staff member's recent runs and 30-day spend. Online only; offline it says so. */
|
|
11
|
+
export const getRuns = (force) => json("/api/runs" + (force ? "?refresh=1" : ""));
|
|
12
|
+
|
|
10
13
|
/** The repos org.yaml lists. Off disk, so the new-issue form does not wait on the inbox. */
|
|
11
14
|
export const getRepos = () => json("/api/repos");
|
|
12
15
|
|
|
@@ -47,7 +50,12 @@ export async function post(payload, url = "/api/act") {
|
|
|
47
50
|
body: JSON.stringify(payload),
|
|
48
51
|
});
|
|
49
52
|
const data = await res.json().catch(() => ({}));
|
|
50
|
-
if (!res.ok || data.error)
|
|
53
|
+
if (!res.ok || data.error) {
|
|
54
|
+
// The body travels with the error: a refusal often carries the next step, like a link.
|
|
55
|
+
const err = new Error(data.error || "failed with " + res.status);
|
|
56
|
+
err.data = data;
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
51
59
|
return data;
|
|
52
60
|
}
|
|
53
61
|
|
|
@@ -78,8 +86,12 @@ export const planTenant = (params) =>
|
|
|
78
86
|
export const createTenant = (params) => post(params, "/api/setup/apply");
|
|
79
87
|
|
|
80
88
|
/** The copyable prompt, with every file it refers to carried inside it. */
|
|
81
|
-
export const getBrief = (kind, staff) =>
|
|
82
|
-
json(
|
|
89
|
+
export const getBrief = (kind, staff, example) =>
|
|
90
|
+
json(
|
|
91
|
+
"/api/brief?kind=" + encodeURIComponent(kind) +
|
|
92
|
+
(staff ? "&staff=" + encodeURIComponent(staff) : "") +
|
|
93
|
+
(example ? "&example=" + encodeURIComponent(example) : ""),
|
|
94
|
+
);
|
|
83
95
|
|
|
84
96
|
/** Parse what came back from the model. Reads only: saving is a second, deliberate step. */
|
|
85
97
|
export const parsePaste = (kind, staff, answer) =>
|
|
@@ -98,10 +110,26 @@ export const getDoctor = (offline) => json("/api/doctor" + (offline ? "?offline=
|
|
|
98
110
|
export const getFix = (offline) => json("/api/fix" + (offline ? "?offline=1" : ""));
|
|
99
111
|
|
|
100
112
|
export const listRepos = (org) => json("/api/setup/repos?org=" + encodeURIComponent(org));
|
|
101
|
-
|
|
113
|
+
/** Commits org.yaml and pushes, as every other write here does. */
|
|
114
|
+
export const addRepo = (name, role, visibility) =>
|
|
115
|
+
post({ name, role, visibility }, "/api/setup/add-repo");
|
|
102
116
|
export const startApp = (staff, scope) => post({ staff, scope }, "/api/setup/app");
|
|
103
117
|
export const appResult = (state) => json("/api/setup/app-result?state=" + encodeURIComponent(state));
|
|
104
118
|
|
|
105
119
|
/** Does this org already run roster. "Create" and "join" are different answers. */
|
|
106
120
|
export const checkOrg = (org) => json("/api/setup/check-org?org=" + encodeURIComponent(org));
|
|
107
121
|
export const joinOrg = (org) => post({ org }, "/api/setup/join");
|
|
122
|
+
|
|
123
|
+
/** The ops repo's Actions access: read it, or ask the server to set it. */
|
|
124
|
+
export const getAccess = () => json("/api/setup/access");
|
|
125
|
+
export const setAccess = () => post({}, "/api/setup/access");
|
|
126
|
+
|
|
127
|
+
/** Where the agent credential goes, and storing it. The value only ever travels in a POST. */
|
|
128
|
+
export const getCredential = () => json("/api/setup/credential");
|
|
129
|
+
export const storeCredential = (value, repoSecrets) =>
|
|
130
|
+
post({ value, repoSecrets }, "/api/setup/credential");
|
|
131
|
+
|
|
132
|
+
/** One daily run, now, and where it has got to. */
|
|
133
|
+
export const startRun = (staff) => post({ staff }, "/api/run/start");
|
|
134
|
+
export const runStatus = (staff, id) =>
|
|
135
|
+
json("/api/run/state?staff=" + encodeURIComponent(staff) + "&id=" + encodeURIComponent(id));
|