@nanocollective/roster 0.1.0-alpha.4 → 0.1.0-alpha.40
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 +70 -84
- package/dist/cli.js +4562 -2744
- package/docs/README.md +9 -6
- package/docs/agents.md +24 -20
- package/docs/architecture.md +13 -5
- package/docs/charters/analyst.md +65 -0
- package/docs/charters/cmo.md +69 -0
- package/docs/charters/community.md +63 -0
- package/docs/charters/cto.md +71 -0
- package/docs/charters/designer.md +65 -0
- package/docs/charters/devops.md +65 -0
- package/docs/charters/pm.md +70 -0
- package/docs/charters/qa.md +65 -0
- package/docs/charters/support.md +60 -0
- package/docs/charters/writer.md +63 -0
- package/docs/commands.md +90 -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/export.md +2 -0
- package/docs/extending.md +2 -2
- package/docs/getting-started.md +126 -77
- package/docs/images/brain.jpg +0 -0
- package/docs/images/org.jpg +0 -0
- package/docs/images/prompt.jpg +0 -0
- package/docs/images/setup-org.jpg +0 -0
- package/docs/images/setup-plan.jpg +0 -0
- package/docs/images/staff.jpg +0 -0
- package/docs/manual-steps.md +94 -123
- package/docs/memory.md +21 -3
- package/docs/org-yaml.md +39 -2
- package/docs/portal.md +119 -44
- package/docs/prompts.md +31 -4
- package/docs/security.md +37 -5
- package/docs/session-workflow.md +49 -17
- package/docs/staff-yaml.md +14 -2
- package/docs/troubleshooting.md +8 -8
- package/docs/upgrading.md +9 -3
- package/docs/writing-a-charter.md +28 -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/briefs/priorities.md +46 -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/base.css +146 -73
- package/templates/portal/css/brain.css +23 -20
- package/templates/portal/css/diff.css +10 -9
- package/templates/portal/css/graph.css +12 -7
- package/templates/portal/css/health.css +13 -11
- package/templates/portal/css/inbox.css +57 -25
- package/templates/portal/css/layout.css +96 -41
- package/templates/portal/css/markdown.css +36 -14
- package/templates/portal/css/runs.css +13 -0
- package/templates/portal/css/setup.css +116 -34
- package/templates/portal/index.html +18 -2
- package/templates/portal/js/api.js +41 -4
- package/templates/portal/js/app.js +132 -9
- package/templates/portal/js/dialog.js +82 -0
- package/templates/portal/js/icons.js +37 -0
- package/templates/portal/js/inflight.js +18 -0
- package/templates/portal/js/md.js +5 -2
- package/templates/portal/js/mdedit.js +84 -0
- package/templates/portal/js/readiness.js +35 -0
- package/templates/portal/js/refresh.js +2 -1
- package/templates/portal/js/state.js +17 -4
- package/templates/portal/js/views/app.js +24 -7
- package/templates/portal/js/views/checklist.js +10 -4
- package/templates/portal/js/views/credential.js +84 -0
- package/templates/portal/js/views/graph.js +1 -1
- package/templates/portal/js/views/health.js +17 -4
- package/templates/portal/js/views/hire.js +583 -0
- package/templates/portal/js/views/inbox.js +248 -81
- package/templates/portal/js/views/org.js +46 -106
- package/templates/portal/js/views/orgedit.js +234 -0
- package/templates/portal/js/views/paste.js +87 -21
- package/templates/portal/js/views/prompt.js +11 -4
- package/templates/portal/js/views/repos.js +20 -15
- package/templates/portal/js/views/runonce.js +94 -0
- package/templates/portal/js/views/runs.js +170 -0
- package/templates/portal/js/views/setup.js +257 -75
- package/templates/portal/js/views/staff.js +158 -243
- package/templates/portal/js/views/todo.js +62 -0
package/package.json
CHANGED
|
@@ -18,6 +18,12 @@ concurrency:
|
|
|
18
18
|
|
|
19
19
|
jobs:
|
|
20
20
|
session:
|
|
21
|
+
# The ceiling on what session.yaml's job token may do: reading the checkout, and the one
|
|
22
|
+
# comment that says a run failed when the App that would normally say so is what broke.
|
|
23
|
+
# A called workflow cannot raise these, so they have to be granted here.
|
|
24
|
+
permissions:
|
|
25
|
+
contents: read
|
|
26
|
+
issues: write
|
|
21
27
|
uses: %%OPS_REPO%%/.github/workflows/session.yaml@main
|
|
22
28
|
with:
|
|
23
29
|
staff: %%STAFF%%
|
|
@@ -53,6 +53,12 @@ jobs:
|
|
|
53
53
|
(github.event_name == 'issues' &&
|
|
54
54
|
contains(github.event.issue.body, '%%MENTION%%'))
|
|
55
55
|
)
|
|
56
|
+
# The ceiling on what session.yaml's job token may do: reading the checkout, and the one
|
|
57
|
+
# comment that says a run failed when the App that would normally say so is what broke.
|
|
58
|
+
# A called workflow cannot raise these, so they have to be granted here.
|
|
59
|
+
permissions:
|
|
60
|
+
contents: read
|
|
61
|
+
issues: write
|
|
56
62
|
uses: %%OPS_REPO%%/.github/workflows/session.yaml@main
|
|
57
63
|
with:
|
|
58
64
|
staff: %%STAFF%%
|
|
@@ -11,10 +11,10 @@ between %%MENTION%% and everyone else.
|
|
|
11
11
|
Write it before the first unattended run. A generated charter would produce a generic agent,
|
|
12
12
|
which is the failure this whole arrangement exists to avoid.
|
|
13
13
|
|
|
14
|
-
Write it with your own AI
|
|
14
|
+
Write it with your own AI. This prints a brief to paste into whichever agent you use (in
|
|
15
|
+
Claude Code it is also /charter, from inside this repo):
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
/charter
|
|
17
|
+
roster brief charter %%STAFF%%
|
|
18
18
|
|
|
19
19
|
Or write it by hand. The headings below are the shape that has worked; the words are yours.
|
|
20
20
|
|
|
@@ -10,6 +10,7 @@ on, and has decided.
|
|
|
10
10
|
| `memory/INDEX.md` | One line per fact, read at every boot. |
|
|
11
11
|
| `memory/notes/` | The argument behind a fact, read on demand. |
|
|
12
12
|
| `log/decisions.md` | Why things were decided. Not boot context. |
|
|
13
|
+
| `strategy/` | Longer role documents, and `ideas.md`: ideas parked here rather than filed as issues. |
|
|
13
14
|
| `.github/workflows/` | Three callers. The body lives in `%%OPS_REPO%%`. |
|
|
14
15
|
|
|
15
16
|
Scheduled runs and mentions are wired up by roster. To see what this staff member is actually
|
|
@@ -4,3 +4,6 @@ Why things were decided, newest first. Not boot context: this is read when a dec
|
|
|
4
4
|
being revisited, not every morning.
|
|
5
5
|
|
|
6
6
|
One entry per decision. What was decided, why, and what would change it back.
|
|
7
|
+
|
|
8
|
+
This file holds the current month. Move anything older into `log/decisions/<YYYY-MM>.md`:
|
|
9
|
+
`roster lint` warns past 24KB.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Ideas
|
|
2
|
+
|
|
3
|
+
Speculative ideas, one line each, parked here rather than filed as issues. An issue is for
|
|
4
|
+
something that needs a ruling; an idea that does not yet is noise on the tracker.
|
|
5
|
+
|
|
6
|
+
Promote one to an issue when it serves a priority in `org/priorities.md` and needs a ruling.
|
|
7
|
+
Delete one when it stops being interesting.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
Write `org/priorities.md` for %%ORG_NAME%%.
|
|
2
|
+
|
|
3
|
+
You are helping %%HUMAN%% decide what their AI staff should work on this month. Every staff
|
|
4
|
+
member reads this file at the start of every run and picks work that serves it, so a vague
|
|
5
|
+
priority produces scattered work.
|
|
6
|
+
|
|
7
|
+
## Where you are
|
|
8
|
+
|
|
9
|
+
- `%%OPS_REPO_DIR%%/org/business.md`: what the business is. **Read it first**, and don't ask
|
|
10
|
+
anything it already answers.
|
|
11
|
+
- `%%OPS_REPO_DIR%%/org/priorities.md`: the current file, which you are replacing.
|
|
12
|
+
|
|
13
|
+
If you cannot read files where you are running, ask %%HUMAN%% to paste `org/business.md`.
|
|
14
|
+
|
|
15
|
+
## Interview
|
|
16
|
+
|
|
17
|
+
Ask one question at a time. You are after:
|
|
18
|
+
|
|
19
|
+
- **The one outcome that matters most this month**, and how they will know it happened.
|
|
20
|
+
- **At most two more**, in order. Push back on a fourth: past three it is a wish list.
|
|
21
|
+
- **What is out of scope this month**: work that is tempting but not now. Naming it is what
|
|
22
|
+
stops the staff doing it.
|
|
23
|
+
|
|
24
|
+
Prefer outcomes ("a stranger pays for it") to activities ("improve the landing page"). If an
|
|
25
|
+
answer is an activity, ask what it is for.
|
|
26
|
+
|
|
27
|
+
## Write it
|
|
28
|
+
|
|
29
|
+
Use exactly this shape:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
## What matters this month
|
|
33
|
+
|
|
34
|
+
### Priorities, in order
|
|
35
|
+
|
|
36
|
+
1. The first outcome, and how you will know it happened.
|
|
37
|
+
2. The second.
|
|
38
|
+
3. The third.
|
|
39
|
+
|
|
40
|
+
### Out of scope this month
|
|
41
|
+
|
|
42
|
+
- One thing per line.
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Keep each priority to a sentence or two. Don't add anything %%HUMAN%% didn't say without
|
|
46
|
+
listing it after the file so they can check it.
|
|
@@ -25,7 +25,7 @@ on:
|
|
|
25
25
|
type: string
|
|
26
26
|
model:
|
|
27
27
|
required: false
|
|
28
|
-
default: claude-opus-5
|
|
28
|
+
default: claude-opus-5-5
|
|
29
29
|
type: string
|
|
30
30
|
timeout_minutes:
|
|
31
31
|
required: false
|
|
@@ -69,11 +69,22 @@ jobs:
|
|
|
69
69
|
runs-on: ubuntu-latest
|
|
70
70
|
timeout-minutes: ${{ inputs.timeout_minutes }}
|
|
71
71
|
|
|
72
|
+
# `issues: write` is for the failure notice alone, which falls back to this job's own token
|
|
73
|
+
# when the App's cannot be minted. A called workflow cannot raise what its caller granted, so
|
|
74
|
+
# the callers ask for the same.
|
|
75
|
+
permissions:
|
|
76
|
+
contents: read
|
|
77
|
+
issues: write
|
|
78
|
+
|
|
72
79
|
# secrets are not usable in a step-level `if`, so the presence check is hoisted here.
|
|
73
80
|
env:
|
|
74
81
|
HAS_PUBLIC_APP: ${{ secrets.PUBLIC_APP_ID != '' }}
|
|
75
82
|
|
|
76
83
|
steps:
|
|
84
|
+
# For the run record at the end. A job has no start time of its own to read back.
|
|
85
|
+
- name: Start the clock
|
|
86
|
+
run: echo "ROSTER_STARTED=$(date +%s)" >> "$GITHUB_ENV"
|
|
87
|
+
|
|
77
88
|
- name: Mint the private-tracker token
|
|
78
89
|
id: private
|
|
79
90
|
uses: actions/create-github-app-token@v2
|
|
@@ -161,6 +172,36 @@ jobs:
|
|
|
161
172
|
echo "product: $repo -> $dir"
|
|
162
173
|
done
|
|
163
174
|
|
|
175
|
+
# What people have open on the product repos, so the agent does not open competing work
|
|
176
|
+
# on files a human branch is rewriting. Read by "Compose the prompt" below; never
|
|
177
|
+
# fatal, because without it the prompt just has no section about it.
|
|
178
|
+
- name: Gather human work in flight
|
|
179
|
+
if: steps.plan.outputs.products != ''
|
|
180
|
+
continue-on-error: true
|
|
181
|
+
env:
|
|
182
|
+
GH_TOKEN: ${{ steps.public.outputs.token || steps.private.outputs.token }}
|
|
183
|
+
run: node roster-ops/inflight.mjs --staff "${{ inputs.staff }}" --ops roster-ops --brains . --out .roster-run/inflight.md
|
|
184
|
+
|
|
185
|
+
# staff.yaml names the variable its prompts use for the public token (`public_token_env`),
|
|
186
|
+
# and an `env:` key cannot be an expression, so the name is exported here for every later
|
|
187
|
+
# step. PUBLIC_TOKEN is always set as well; this only adds the name the manifest chose.
|
|
188
|
+
- name: Export the public token under its manifest name
|
|
189
|
+
if: steps.public.outputs.token != ''
|
|
190
|
+
env:
|
|
191
|
+
BRAIN_DIR: ${{ steps.plan.outputs.brain_dir }}
|
|
192
|
+
TOKEN: ${{ steps.public.outputs.token }}
|
|
193
|
+
run: |
|
|
194
|
+
set -euo pipefail
|
|
195
|
+
name=$(sed -n 's/^public_token_env:[[:space:]]*//p' "$BRAIN_DIR/staff.yaml" | head -1 | tr -d "\"' ")
|
|
196
|
+
case "$name" in
|
|
197
|
+
""|PUBLIC_TOKEN|GH_TOKEN|GITHUB_TOKEN) exit 0 ;;
|
|
198
|
+
esac
|
|
199
|
+
if ! [[ "$name" =~ ^[A-Z_][A-Z0-9_]*$ ]]; then
|
|
200
|
+
echo "::warning::public_token_env '$name' is not a variable name; only PUBLIC_TOKEN is set"
|
|
201
|
+
exit 0
|
|
202
|
+
fi
|
|
203
|
+
echo "$name=$TOKEN" >> "$GITHUB_ENV"
|
|
204
|
+
|
|
164
205
|
# Commits read as the bot, not as a human, so the git history stays legible.
|
|
165
206
|
- name: Set git identity
|
|
166
207
|
env:
|
|
@@ -236,13 +277,12 @@ jobs:
|
|
|
236
277
|
# `uses:` cannot be an expression, so an Action-based runner has to be written out
|
|
237
278
|
# literally. This is the reference one; every other agent goes through the step below.
|
|
238
279
|
- name: Run the session
|
|
280
|
+
id: session_action
|
|
239
281
|
if: steps.agent.outputs.kind == 'action'
|
|
240
282
|
uses: anthropics/claude-code-action@v1
|
|
241
283
|
env:
|
|
242
284
|
GH_TOKEN: ${{ steps.private.outputs.token }}
|
|
243
285
|
PUBLIC_TOKEN: ${{ steps.public.outputs.token }}
|
|
244
|
-
# Kept as an alias while charters and memory still name it. Retire once they do not.
|
|
245
|
-
PIPWEB_TOKEN: ${{ steps.public.outputs.token }}
|
|
246
286
|
with:
|
|
247
287
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN || secrets.AGENT_TOKEN }}
|
|
248
288
|
# These repos are private and single-user, so the usual reason to hide a run's output does
|
|
@@ -264,11 +304,11 @@ jobs:
|
|
|
264
304
|
# argument: it is thousands of words containing quotes and backticks, and argv limits and
|
|
265
305
|
# shell quoting fail at 07:00 rather than in review.
|
|
266
306
|
- name: Run the session
|
|
307
|
+
id: session_cli
|
|
267
308
|
if: steps.agent.outputs.kind == 'cli'
|
|
268
309
|
env:
|
|
269
310
|
GH_TOKEN: ${{ steps.private.outputs.token }}
|
|
270
311
|
PUBLIC_TOKEN: ${{ steps.public.outputs.token }}
|
|
271
|
-
PIPWEB_TOKEN: ${{ steps.public.outputs.token }}
|
|
272
312
|
AGENT_MODEL: ${{ steps.agent.outputs.model || inputs.model }}
|
|
273
313
|
AGENT_FLAGS: ${{ steps.agent.outputs.flags }}
|
|
274
314
|
# Kept for a custom `run` written before permissions existed.
|
|
@@ -289,6 +329,10 @@ jobs:
|
|
|
289
329
|
# an `env:` key cannot be an expression.
|
|
290
330
|
export "$TOKEN_ENV=$TOKEN"
|
|
291
331
|
export AGENT_PROMPT_FILE="$PWD/.roster-prompt.txt"
|
|
332
|
+
# Where an agent that can report its own turns and cost writes them. Optional: the
|
|
333
|
+
# run record reads it if it is there and records what it cannot know as unknown.
|
|
334
|
+
mkdir -p .roster-run
|
|
335
|
+
export AGENT_RESULT_FILE="$PWD/.roster-run/agent-result.json"
|
|
292
336
|
|
|
293
337
|
if [ -n "$INSTALL" ]; then
|
|
294
338
|
echo "::group::install ${{ steps.agent.outputs.id }}"
|
|
@@ -297,20 +341,70 @@ jobs:
|
|
|
297
341
|
fi
|
|
298
342
|
eval "$RUN"
|
|
299
343
|
|
|
300
|
-
#
|
|
301
|
-
#
|
|
344
|
+
# What this run was and what it cost, in the job summary and as an artifact with a stable
|
|
345
|
+
# name, which is what the portal's Runs screen and `roster doctor` read back. Never fatal:
|
|
346
|
+
# a missing record costs a row in a table, and failing the job over it would cost the run.
|
|
347
|
+
- name: Write down the run
|
|
348
|
+
if: always()
|
|
349
|
+
continue-on-error: true
|
|
350
|
+
env:
|
|
351
|
+
STAFF: ${{ inputs.staff }}
|
|
352
|
+
KIND: ${{ inputs.kind }}
|
|
353
|
+
AGENT_ID: ${{ steps.agent.outputs.id }}
|
|
354
|
+
MODEL: ${{ steps.agent.outputs.model || inputs.model }}
|
|
355
|
+
AGENT_OUTCOME: ${{ steps.session_action.outcome != 'skipped' && steps.session_action.outcome || steps.session_cli.outcome }}
|
|
356
|
+
JOB_STATUS: ${{ job.status }}
|
|
357
|
+
RESULT_FILE: ${{ steps.session_action.outputs.execution_file || format('{0}/.roster-run/agent-result.json', github.workspace) }}
|
|
358
|
+
run: node roster-ops/run-record.mjs --out .roster-run/run.json
|
|
359
|
+
|
|
360
|
+
- name: Keep the run record
|
|
361
|
+
if: always()
|
|
362
|
+
continue-on-error: true
|
|
363
|
+
uses: actions/upload-artifact@v6
|
|
364
|
+
with:
|
|
365
|
+
name: roster-run
|
|
366
|
+
path: .roster-run/run.json
|
|
367
|
+
if-no-files-found: ignore
|
|
368
|
+
|
|
369
|
+
# A failed unattended run is otherwise a red X in a tab nobody opens, so every failure path
|
|
370
|
+
# has to end somewhere a human reads.
|
|
371
|
+
#
|
|
372
|
+
# It cannot lean on anything that might be what failed. The App token is the first thing a
|
|
373
|
+
# renamed repo, a rotated key or an uninstalled App breaks, and a canary sat red for twelve
|
|
374
|
+
# days because its alert used exactly that token. So: the App token when there is one, and
|
|
375
|
+
# this job's own token when there is not or it is refused. The plan may not have run
|
|
376
|
+
# either, so the brain repo falls back to the caller's, which is the same repo, and
|
|
377
|
+
# staff.yaml is read over the API when it was never checked out.
|
|
302
378
|
- name: Say so if the run did not finish
|
|
303
379
|
if: failure() || cancelled()
|
|
304
380
|
env:
|
|
305
|
-
|
|
381
|
+
APP_TOKEN: ${{ steps.private.outputs.token }}
|
|
382
|
+
JOB_TOKEN: ${{ github.token }}
|
|
306
383
|
BRAIN_DIR: ${{ steps.plan.outputs.brain_dir }}
|
|
307
|
-
BRAIN_REPO: ${{ steps.plan.outputs.brain_repo }}
|
|
384
|
+
BRAIN_REPO: ${{ steps.plan.outputs.brain_repo || github.repository }}
|
|
385
|
+
ISSUE: ${{ inputs.issue_number }}
|
|
386
|
+
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
|
308
387
|
run: |
|
|
309
|
-
set -
|
|
310
|
-
issue="$
|
|
388
|
+
set -uo pipefail
|
|
389
|
+
issue="$ISSUE"
|
|
390
|
+
if [ -z "$issue" ] && [ -n "$BRAIN_DIR" ] && [ -f "$BRAIN_DIR/staff.yaml" ]; then
|
|
391
|
+
issue=$(sed -n 's/^status_issue:[[:space:]]*//p' "$BRAIN_DIR/staff.yaml" | head -1)
|
|
392
|
+
fi
|
|
393
|
+
if [ -z "$issue" ]; then
|
|
394
|
+
issue=$(GH_TOKEN="${APP_TOKEN:-$JOB_TOKEN}" gh api "repos/$BRAIN_REPO/contents/staff.yaml" \
|
|
395
|
+
-H "Accept: application/vnd.github.raw" 2>/dev/null \
|
|
396
|
+
| sed -n 's/^status_issue:[[:space:]]*//p' | head -1)
|
|
397
|
+
fi
|
|
398
|
+
issue="${issue%%[[:space:]#]*}"
|
|
311
399
|
if [ -z "$issue" ]; then
|
|
312
|
-
|
|
400
|
+
echo "::error::no status_issue in staff.yaml and no issue in the trigger, so nobody was told"
|
|
401
|
+
exit 1
|
|
402
|
+
fi
|
|
403
|
+
|
|
404
|
+
body="This run did not finish, so there is no answer coming. [Run ${GITHUB_RUN_ID}]($RUN_URL) has the error."
|
|
405
|
+
if [ -n "$APP_TOKEN" ] && GH_TOKEN="$APP_TOKEN" gh issue comment "$issue" --repo "$BRAIN_REPO" --body "$body"; then
|
|
406
|
+
exit 0
|
|
313
407
|
fi
|
|
314
|
-
|
|
315
|
-
gh issue comment "$issue" --repo "$BRAIN_REPO" --body \
|
|
316
|
-
"
|
|
408
|
+
# Posted as github-actions rather than as the staff member, which is itself the clue.
|
|
409
|
+
GH_TOKEN="$JOB_TOKEN" gh issue comment "$issue" --repo "$BRAIN_REPO" --body \
|
|
410
|
+
"$body The staff member's own App could not post it, so check the App is installed on this repo and its APP_ID and APP_PRIVATE_KEY secrets are current."
|
package/templates/ops/agents.mjs
CHANGED
|
@@ -43,7 +43,7 @@ export const PRESETS = {
|
|
|
43
43
|
"claude-code-action": {
|
|
44
44
|
kind: "action",
|
|
45
45
|
token_env: "CLAUDE_CODE_OAUTH_TOKEN",
|
|
46
|
-
model: "claude-opus-5",
|
|
46
|
+
model: "claude-opus-5-5",
|
|
47
47
|
permissions: CLAUDE_TOOLS,
|
|
48
48
|
option: (k, v) => `--${k} ${shellArg(v)}`,
|
|
49
49
|
},
|
|
@@ -52,9 +52,13 @@ export const PRESETS = {
|
|
|
52
52
|
claude: {
|
|
53
53
|
kind: "cli",
|
|
54
54
|
install: "npm install -g @anthropic-ai/claude-code",
|
|
55
|
-
run
|
|
55
|
+
// JSON rather than text so the run record can read turns and cost off it. The log still
|
|
56
|
+
// carries the answer, inside the `result` field.
|
|
57
|
+
run:
|
|
58
|
+
'claude -p --model "$AGENT_MODEL" $AGENT_FLAGS --output-format json < "$AGENT_PROMPT_FILE"' +
|
|
59
|
+
' | tee "$AGENT_RESULT_FILE"',
|
|
56
60
|
token_env: "CLAUDE_CODE_OAUTH_TOKEN",
|
|
57
|
-
model: "claude-opus-5",
|
|
61
|
+
model: "claude-opus-5-5",
|
|
58
62
|
permissions: CLAUDE_TOOLS,
|
|
59
63
|
option: (k, v) => `--${k} ${shellArg(v)}`,
|
|
60
64
|
},
|
|
@@ -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}}
|