@nanocollective/roster 0.1.0-alpha.1 → 0.1.0-alpha.11

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 (94) hide show
  1. package/README.md +65 -84
  2. package/dist/cli.js +4687 -2495
  3. package/docs/README.md +19 -11
  4. package/docs/agents.md +328 -13
  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 +90 -11
  10. package/docs/concepts.md +64 -12
  11. package/docs/cost.md +39 -3
  12. package/docs/developing.md +16 -21
  13. package/docs/doctor-codes.md +21 -6
  14. package/docs/export.md +2 -1
  15. package/docs/extending.md +13 -4
  16. package/docs/getting-started.md +118 -80
  17. package/docs/images/brain.jpg +0 -0
  18. package/docs/images/org.jpg +0 -0
  19. package/docs/images/prompt.jpg +0 -0
  20. package/docs/images/setup-org.jpg +0 -0
  21. package/docs/images/setup-plan.jpg +0 -0
  22. package/docs/images/staff.jpg +0 -0
  23. package/docs/manual-steps.md +94 -101
  24. package/docs/memory.md +29 -8
  25. package/docs/org-yaml.md +74 -11
  26. package/docs/portal.md +261 -47
  27. package/docs/prompts.md +71 -11
  28. package/docs/security.md +43 -7
  29. package/docs/session-workflow.md +51 -21
  30. package/docs/staff-yaml.md +16 -7
  31. package/docs/troubleshooting.md +23 -20
  32. package/docs/upgrading.md +6 -0
  33. package/docs/writing-a-charter.md +33 -17
  34. package/package.json +1 -1
  35. package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +7 -0
  36. package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +16 -4
  37. package/templates/brain/CHARTER.md +3 -3
  38. package/templates/brain/README.md +1 -0
  39. package/templates/brain/log/decisions.md +3 -0
  40. package/templates/brain/staff.yaml +0 -1
  41. package/templates/brain/strategy/ideas.md +7 -0
  42. package/templates/ops/.github/workflows/session.yaml +117 -40
  43. package/templates/ops/agents.mjs +127 -8
  44. package/templates/ops/compose.mjs +77 -7
  45. package/templates/ops/inflight.mjs +157 -0
  46. package/templates/ops/org/operating.md +21 -7
  47. package/templates/ops/org/voice.md +9 -0
  48. package/templates/ops/prompts/_identity.md +8 -1
  49. package/templates/ops/prompts/_inflight.md +14 -0
  50. package/templates/ops/prompts/_paths.md +2 -1
  51. package/templates/ops/prompts/daily.md +16 -7
  52. package/templates/ops/prompts/mention.md +18 -2
  53. package/templates/ops/run-record.mjs +144 -0
  54. package/templates/portal/css/base.css +238 -64
  55. package/templates/portal/css/brain.css +30 -20
  56. package/templates/portal/css/diff.css +15 -10
  57. package/templates/portal/css/graph.css +12 -7
  58. package/templates/portal/css/health.css +32 -11
  59. package/templates/portal/css/inbox.css +117 -14
  60. package/templates/portal/css/layout.css +92 -41
  61. package/templates/portal/css/markdown.css +38 -15
  62. package/templates/portal/css/runs.css +13 -0
  63. package/templates/portal/css/setup.css +52 -39
  64. package/templates/portal/index.html +24 -3
  65. package/templates/portal/js/api.js +65 -4
  66. package/templates/portal/js/app.js +112 -12
  67. package/templates/portal/js/dialog.js +94 -4
  68. package/templates/portal/js/dom.js +25 -0
  69. package/templates/portal/js/icons.js +8 -1
  70. package/templates/portal/js/lightbox.js +273 -0
  71. package/templates/portal/js/md.js +23 -6
  72. package/templates/portal/js/mention.js +264 -0
  73. package/templates/portal/js/refresh.js +136 -6
  74. package/templates/portal/js/state.js +55 -8
  75. package/templates/portal/js/views/app.js +23 -5
  76. package/templates/portal/js/views/checklist.js +20 -7
  77. package/templates/portal/js/views/credential.js +93 -0
  78. package/templates/portal/js/views/docs.js +94 -4
  79. package/templates/portal/js/views/files.js +58 -14
  80. package/templates/portal/js/views/graph.js +1 -1
  81. package/templates/portal/js/views/health.js +178 -37
  82. package/templates/portal/js/views/inbox.js +938 -98
  83. package/templates/portal/js/views/memory.js +16 -1
  84. package/templates/portal/js/views/org.js +161 -61
  85. package/templates/portal/js/views/paste.js +29 -0
  86. package/templates/portal/js/views/prompt.js +57 -66
  87. package/templates/portal/js/views/repos.js +12 -7
  88. package/templates/portal/js/views/runonce.js +94 -0
  89. package/templates/portal/js/views/runs.js +165 -0
  90. package/templates/portal/js/views/setup.js +302 -56
  91. package/templates/portal/js/views/staff.js +143 -22
  92. package/templates/portal/js/yaml.js +134 -0
  93. package/templates/brain/.github/workflows/%%STAFF%%-pr-mention.yaml +0 -50
  94. package/templates/ops/prompts/pr-mention.md +0 -57
@@ -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
- cd %%DIR%% && claude
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.
@@ -13,7 +13,6 @@ schedule: "%%SCHEDULE%%"
13
13
  model: %%MODEL%%
14
14
  timeout_minutes: %%TIMEOUT%%
15
15
  mention_timeout_minutes: %%MENTION_TIMEOUT%%
16
- pr_mention_timeout_minutes: %%PR_MENTION_TIMEOUT%%
17
16
 
18
17
  bot: %%APP%%[bot]
19
18
  public_bot: %%PUBLIC_APP%%[bot]
@@ -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.
@@ -15,7 +15,7 @@ on:
15
15
  required: true
16
16
  type: string
17
17
  kind:
18
- description: "daily | mention | pr-mention"
18
+ description: "daily | mention"
19
19
  required: false
20
20
  default: daily
21
21
  type: string
@@ -25,17 +25,17 @@ 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
32
- default: 60
32
+ default: 90
33
33
  type: number
34
34
  allowed_tools:
35
35
  required: false
36
36
  default: "Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch"
37
37
  type: string
38
- # Trigger context. Empty on a scheduled run; set when a comment or a PR woke us.
38
+ # Trigger context. Empty on a scheduled run; set when a comment woke us.
39
39
  issue_number:
40
40
  required: false
41
41
  default: ""
@@ -44,10 +44,6 @@ on:
44
44
  required: false
45
45
  default: ""
46
46
  type: string
47
- pr_number:
48
- required: false
49
- default: ""
50
- type: string
51
47
  secrets:
52
48
  APP_ID:
53
49
  required: true
@@ -73,11 +69,22 @@ jobs:
73
69
  runs-on: ubuntu-latest
74
70
  timeout-minutes: ${{ inputs.timeout_minutes }}
75
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
+
76
79
  # secrets are not usable in a step-level `if`, so the presence check is hoisted here.
77
80
  env:
78
81
  HAS_PUBLIC_APP: ${{ secrets.PUBLIC_APP_ID != '' }}
79
82
 
80
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
+
81
88
  - name: Mint the private-tracker token
82
89
  id: private
83
90
  uses: actions/create-github-app-token@v2
@@ -102,9 +109,7 @@ jobs:
102
109
  # missed". It sits here, before any checkout, so the eyes land in seconds rather than after
103
110
  # the clones.
104
111
  #
105
- # `mention` only. A pr-mention is already acknowledged by the forwarder in the public product
106
- # repo, on the comment the human actually left, and reacting again here would put two on it.
107
- # A daily run has nothing to react to.
112
+ # `mention` only: a daily run has nothing to react to.
108
113
  #
109
114
  # An `issues` payload carries no comment, so the eyes go on the issue itself. That is the
110
115
  # route where a mention is typed straight into the body of a new issue.
@@ -167,6 +172,36 @@ jobs:
167
172
  echo "product: $repo -> $dir"
168
173
  done
169
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
+
170
205
  # Commits read as the bot, not as a human, so the git history stays legible.
171
206
  - name: Set git identity
172
207
  env:
@@ -191,20 +226,6 @@ jobs:
191
226
  [ -d "$dir" ] && identify "$dir" "${PUBLIC_SLUG:-$PRIVATE_SLUG}"
192
227
  done
193
228
 
194
- # A PR request is answered on the PR's own branch, never on a new one.
195
- - name: Check out the PR branch
196
- if: inputs.pr_number != ''
197
- env:
198
- GH_TOKEN: ${{ steps.public.outputs.token || steps.private.outputs.token }}
199
- PRODUCT_DIR: ${{ steps.plan.outputs.product_dir }}
200
- PRODUCT_REPO: ${{ steps.plan.outputs.product_repo }}
201
- run: |
202
- set -euo pipefail
203
- [ -n "$PRODUCT_DIR" ] || { echo "no product repo to check a PR out of"; exit 1; }
204
- cd "$PRODUCT_DIR"
205
- gh pr checkout "${{ inputs.pr_number }}" --repo "$PRODUCT_REPO"
206
- echo "on $(git branch --show-current)"
207
-
208
229
  - uses: pnpm/action-setup@v6
209
230
  if: steps.plan.outputs.needs_node == 'true'
210
231
  with:
@@ -226,7 +247,6 @@ jobs:
226
247
  ROSTER_CONTEXT: >-
227
248
  {"issue_number":"${{ inputs.issue_number }}",
228
249
  "comment_id":"${{ inputs.comment_id }}",
229
- "pr_number":"${{ inputs.pr_number }}",
230
250
  "repo":"${{ github.repository }}",
231
251
  "actor":"${{ github.actor }}"}
232
252
  run: |
@@ -257,13 +277,12 @@ jobs:
257
277
  # `uses:` cannot be an expression, so an Action-based runner has to be written out
258
278
  # literally. This is the reference one; every other agent goes through the step below.
259
279
  - name: Run the session
280
+ id: session_action
260
281
  if: steps.agent.outputs.kind == 'action'
261
282
  uses: anthropics/claude-code-action@v1
262
283
  env:
263
284
  GH_TOKEN: ${{ steps.private.outputs.token }}
264
285
  PUBLIC_TOKEN: ${{ steps.public.outputs.token }}
265
- # Kept as an alias while charters and memory still name it. Retire once they do not.
266
- PIPWEB_TOKEN: ${{ steps.public.outputs.token }}
267
286
  with:
268
287
  claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN || secrets.AGENT_TOKEN }}
269
288
  # These repos are private and single-user, so the usual reason to hide a run's output does
@@ -274,21 +293,25 @@ jobs:
274
293
  # No --max-turns. A run that legitimately needs more turns should get them: capping it
275
294
  # fails the action *after* the work is done and committed, which is a false red rather
276
295
  # than a saved penny. timeout-minutes is the real bound.
296
+ # The permission flags come from agents.mjs, which is the only thing that knows
297
+ # which agent is running and therefore how to say "may write" to it.
277
298
  claude_args: >-
278
299
  --model ${{ inputs.model }}
279
- --allowedTools "${{ inputs.allowed_tools }}"
300
+ ${{ steps.agent.outputs.flags }}
280
301
  prompt: ${{ steps.compose.outputs.text }}
281
302
 
282
303
  # Any agent with a command line. The prompt is handed over as a file, never as an
283
304
  # argument: it is thousands of words containing quotes and backticks, and argv limits and
284
305
  # shell quoting fail at 07:00 rather than in review.
285
306
  - name: Run the session
307
+ id: session_cli
286
308
  if: steps.agent.outputs.kind == 'cli'
287
309
  env:
288
310
  GH_TOKEN: ${{ steps.private.outputs.token }}
289
311
  PUBLIC_TOKEN: ${{ steps.public.outputs.token }}
290
- PIPWEB_TOKEN: ${{ steps.public.outputs.token }}
291
312
  AGENT_MODEL: ${{ steps.agent.outputs.model || inputs.model }}
313
+ AGENT_FLAGS: ${{ steps.agent.outputs.flags }}
314
+ # Kept for a custom `run` written before permissions existed.
292
315
  AGENT_TOOLS: ${{ inputs.allowed_tools }}
293
316
  AGENT_TOKEN: ${{ secrets.AGENT_TOKEN }}
294
317
  FALLBACK_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
@@ -306,6 +329,10 @@ jobs:
306
329
  # an `env:` key cannot be an expression.
307
330
  export "$TOKEN_ENV=$TOKEN"
308
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"
309
336
 
310
337
  if [ -n "$INSTALL" ]; then
311
338
  echo "::group::install ${{ steps.agent.outputs.id }}"
@@ -314,20 +341,70 @@ jobs:
314
341
  fi
315
342
  eval "$RUN"
316
343
 
317
- # A failed unattended run is otherwise a red X in a tab nobody opens. New in the roster
318
- # migration, and deliberate: every failure path should end somewhere a human reads.
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.
319
378
  - name: Say so if the run did not finish
320
379
  if: failure() || cancelled()
321
380
  env:
322
- GH_TOKEN: ${{ steps.private.outputs.token }}
381
+ APP_TOKEN: ${{ steps.private.outputs.token }}
382
+ JOB_TOKEN: ${{ github.token }}
323
383
  BRAIN_DIR: ${{ steps.plan.outputs.brain_dir }}
324
- 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 }}
325
387
  run: |
326
- set -euo pipefail
327
- issue="${{ inputs.issue_number }}"
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:]#]*}"
328
399
  if [ -z "$issue" ]; then
329
- issue=$(sed -n 's/^status_issue:[[:space:]]*//p' "$BRAIN_DIR/staff.yaml" | head -1 || true)
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
330
407
  fi
331
- [ -n "$issue" ] || { echo "no status_issue in staff.yaml; nothing to comment on"; exit 0; }
332
- gh issue comment "$issue" --repo "$BRAIN_REPO" --body \
333
- "This run did not finish, so there is no answer coming. [Run ${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) has the error."
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."
@@ -24,22 +24,43 @@ import { parseYaml } from "./compose.mjs";
24
24
  * `uses:` cannot be an expression. Only the reference Claude runner is one of these.
25
25
  * `cli` is everything else: install a package, run a command. That path is open-ended.
26
26
  */
27
+ /* Claude says what an agent may do as a list of its own tool names. The three levels are the
28
+ same list narrowed: everything, everything but the network, and nothing that writes. */
29
+ const CLAUDE_TOOLS = {
30
+ full: '--allowedTools "Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch"',
31
+ workspace: '--allowedTools "Bash,Read,Write,Edit,Glob,Grep"',
32
+ "read-only": '--allowedTools "Read,Glob,Grep,WebFetch,WebSearch"',
33
+ };
34
+
35
+ /** Single quotes, because every one of these ends up inside `eval` in the session. */
36
+ function shellArg(v) {
37
+ return `'${String(v).replace(/'/g, "'\\''")}'`;
38
+ }
39
+
27
40
  export const PRESETS = {
28
41
  // The reference runner, and the default. Uses Anthropic's own action, which handles tool
29
42
  // permissions and output for us.
30
43
  "claude-code-action": {
31
44
  kind: "action",
32
45
  token_env: "CLAUDE_CODE_OAUTH_TOKEN",
33
- model: "claude-opus-5",
46
+ model: "claude-opus-5-5",
47
+ permissions: CLAUDE_TOOLS,
48
+ option: (k, v) => `--${k} ${shellArg(v)}`,
34
49
  },
35
50
 
36
51
  // The same agent through its plain CLI, for anyone who would rather not depend on the action.
37
52
  claude: {
38
53
  kind: "cli",
39
54
  install: "npm install -g @anthropic-ai/claude-code",
40
- run: 'claude -p --model "$AGENT_MODEL" --allowedTools "$AGENT_TOOLS" < "$AGENT_PROMPT_FILE"',
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"',
41
60
  token_env: "CLAUDE_CODE_OAUTH_TOKEN",
42
- model: "claude-opus-5",
61
+ model: "claude-opus-5-5",
62
+ permissions: CLAUDE_TOOLS,
63
+ option: (k, v) => `--${k} ${shellArg(v)}`,
43
64
  },
44
65
 
45
66
  codex: {
@@ -47,20 +68,66 @@ export const PRESETS = {
47
68
  install: "npm install -g @openai/codex",
48
69
  // `exec -` reads the prompt from stdin. The sandbox has to be opened up because the whole
49
70
  // point of a session is that it edits the checkout and pushes.
50
- run: 'codex exec - --model "$AGENT_MODEL" --sandbox danger-full-access < "$AGENT_PROMPT_FILE"',
71
+ run: 'codex exec - --model "$AGENT_MODEL" $AGENT_FLAGS < "$AGENT_PROMPT_FILE"',
51
72
  token_env: "CODEX_API_KEY",
52
73
  model: "gpt-5-codex",
74
+ /* Codex spells freedom as a sandbox plus an approval policy, and both have to be said:
75
+ a sandbox that allows writes still stops to ask by default, and a run that stops to ask
76
+ at 07:00 is a run that times out having done nothing. */
77
+ permissions: {
78
+ full: '--sandbox danger-full-access -c approval_policy="never"',
79
+ workspace: '--sandbox workspace-write -c approval_policy="never"',
80
+ "read-only": '--sandbox read-only -c approval_policy="never"',
81
+ },
82
+ // `-c key=value` is its highest-precedence override, so anything else goes through it.
83
+ option: (k, v) => `-c ${k}=${shellArg(JSON.stringify(v))}`,
53
84
  },
54
85
 
55
86
  nanocoder: {
56
87
  kind: "cli",
57
88
  install: "npm install -g @nanocollective/nanocoder",
58
- // `run` is its non-interactive mode; --trust-directory skips the first-run prompt that
59
- // would otherwise hang a runner, and --plain avoids the TUI. The prompt is an argument
60
- // here rather than stdin, so it is read out of the file.
61
- run: 'nanocoder --model "$AGENT_MODEL" --mode yolo --trust-directory --plain run "$(cat "$AGENT_PROMPT_FILE")"',
89
+ /* `run` is its non-interactive mode; --trust-directory skips the first-run prompt that
90
+ would otherwise hang a runner, and --plain avoids the TUI. The prompt is an argument
91
+ here rather than stdin, so it is read out of the file.
92
+
93
+ NANOCODER_PROVIDERS_FILE is the part that makes it work unattended. Nanocoder is a
94
+ client, not a model: it reads its providers from `agents.config.json` found in the
95
+ working directory. In a session that directory is the workspace root — the place the
96
+ repos are checked out *into* — which belongs to no repo, so a committed config would
97
+ never be found. Pointing at the ops repo's copy gives every staff member the same
98
+ providers from a file that is version controlled. A missing file is ignored, so this is
99
+ safe when somebody has configured it another way. */
100
+ run:
101
+ 'NANOCODER_PROVIDERS_FILE="${NANOCODER_PROVIDERS_FILE:-roster-ops/agents.config.json}" ' +
102
+ 'nanocoder --model "$AGENT_MODEL" $AGENT_FLAGS --trust-directory --plain run "$(cat "$AGENT_PROMPT_FILE")"',
62
103
  token_env: "NANOCODER_API_KEY",
63
104
  model: "",
105
+ /* Its development modes. `plan` is genuinely read-only: it reasons and proposes and edits
106
+ nothing, which is the right answer for a staff member you are not ready to trust yet. */
107
+ permissions: {
108
+ full: "--mode yolo",
109
+ workspace: "--mode auto-accept",
110
+ "read-only": "--mode plan",
111
+ },
112
+ option: (k, v) => `--${k} ${shellArg(v)}`,
113
+ /* A client rather than a model, so it cannot run until it has been told whose model to
114
+ call. Written on init and reported by doctor when it is missing, because the failure
115
+ without it is a run that installs, starts, finds no provider and exits. */
116
+ config: {
117
+ path: "agents.config.json",
118
+ contents: {
119
+ nanocoder: {
120
+ providers: [
121
+ {
122
+ name: "openrouter",
123
+ baseUrl: "https://openrouter.ai/api/v1",
124
+ apiKey: "${NANOCODER_API_KEY}",
125
+ models: ["FILL IN: a model this provider serves, and set it as `model` in org.yaml"],
126
+ },
127
+ ],
128
+ },
129
+ },
130
+ },
64
131
  },
65
132
  };
66
133
 
@@ -96,9 +163,60 @@ export function resolveAgent(org, staff = {}) {
96
163
  token_env: merged.token_env,
97
164
  // The staff member's own model wins; then the agent's default. Empty means "the agent's".
98
165
  model: staff.model ?? merged.model ?? "",
166
+ flags: flagsFor(merged, spec, org, staff),
167
+ config: merged.config ?? null,
99
168
  };
100
169
  }
101
170
 
171
+ /** The three levels, in the order a person would climb them. */
172
+ export const LEVELS = ["read-only", "workspace", "full"];
173
+
174
+ /**
175
+ * What the agent is allowed to do, in its own words.
176
+ *
177
+ * org.yaml says `permissions: full` and every agent hears something different: Claude a list
178
+ * of tool names, Codex a sandbox and an approval policy, nanocoder a development mode. The
179
+ * translation lives here because it is the only place that knows which agent is running, and
180
+ * because the alternative is a config file written in one tool's vocabulary that quietly means
181
+ * nothing to the other two.
182
+ *
183
+ * `options` is the escape hatch, in that agent's own vocabulary, spelled onto its command line
184
+ * by the preset. Anything roster does not model is still reachable without waiting for us.
185
+ */
186
+ function flagsFor(merged, spec, org, staff) {
187
+ const out = [];
188
+ const asked = staff.permissions ?? spec.permissions ?? org.permissions ?? "full";
189
+ if (!LEVELS.includes(asked)) {
190
+ throw new Error(`unknown permissions "${asked}". One of: ${LEVELS.join(", ")}`);
191
+ }
192
+
193
+ /* `allowed_tools` predates the levels and is Claude's own vocabulary, so it still wins for
194
+ an agent that takes a tool list. Nothing translates it for the others: a list written for
195
+ one tool is not a permission level for another, and guessing would be worse than saying so. */
196
+ const tools = staff.allowed_tools ?? org.defaults?.allowed_tools;
197
+ const table = merged.permissions;
198
+ if (tools && table === CLAUDE_TOOLS) {
199
+ const list = Array.isArray(tools) ? tools.join(",") : String(tools);
200
+ out.push(`--allowedTools "${list.replace(/\s+/g, "")}"`);
201
+ } else if (table) {
202
+ out.push(table[asked]);
203
+ }
204
+
205
+ const options = { ...(spec.options ?? {}), ...(staff.options ?? {}) };
206
+ const speller = merged.option;
207
+ for (const [k, v] of Object.entries(options)) {
208
+ if (v === undefined || v === null || v === "") continue;
209
+ if (!speller) {
210
+ throw new Error(
211
+ `agent "${merged.id ?? "custom"}" has options but no way to spell them.\n` +
212
+ " A custom agent takes its options in its own `run` command.",
213
+ );
214
+ }
215
+ out.push(speller(k, v));
216
+ }
217
+ return out.filter(Boolean).join(" ");
218
+ }
219
+
102
220
  function strip(o) {
103
221
  const out = {};
104
222
  for (const [k, v] of Object.entries(o)) if (v !== undefined && v !== null && v !== "") out[k] = v;
@@ -138,6 +256,7 @@ if (import.meta.url === `file://${process.argv[1]}`) {
138
256
  `model=${agent.model}`,
139
257
  `install<<AGENT_EOF_9c1f\n${agent.install}\nAGENT_EOF_9c1f`,
140
258
  `run<<AGENT_EOF_9c1f\n${agent.run}\nAGENT_EOF_9c1f`,
259
+ `flags<<AGENT_EOF_9c1f\n${agent.flags}\nAGENT_EOF_9c1f`,
141
260
  ].join("\n");
142
261
  process.stdout.write(out + "\n");
143
262
  }
@@ -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
 
@@ -212,18 +218,18 @@ export function render(template, ctx, readPartial, depth = 0) {
212
218
  if (path.startsWith("event.")) {
213
219
  throw new Error(
214
220
  `{{${path}}} needs trigger context, which arrives as ROSTER_CONTEXT.\n` +
215
- ` A "mention" or "pr-mention" prompt is written for a comment that woke it, so it cannot\n` +
221
+ ` A "mention" prompt is written for the comment that woke it, so it cannot\n` +
216
222
  ` be composed without one. To see it locally:\n` +
217
- ` ROSTER_CONTEXT='{"issue_number":"1","comment_id":"1","pr_number":"1","repo":"o/r"}' \\\n` +
223
+ ` ROSTER_CONTEXT='{"issue_number":"1","comment_id":"1","repo":"o/r"}' \\\n` +
218
224
  ` node compose.mjs --staff <handle> --kind <kind>`,
219
225
  );
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) {
@@ -234,11 +240,55 @@ function truthy(v) {
234
240
  return !(v === undefined || v === null || v === false || v === "" || (Array.isArray(v) && v.length === 0));
235
241
  }
236
242
 
243
+ // ---------------------------------------------------------------------------
244
+ // Who the staff answer to
245
+ //
246
+ // One person was written as `human:`. More than one is a `humans:` list. Both are read, the
247
+ // singular still works, and the first entry is the one the prose addresses. Duplicated in
248
+ // roster's own src/lib/humans.ts rather than imported, for the same reason the YAML parser
249
+ // above is: a run must not depend on npm or on a network call.
250
+ // ---------------------------------------------------------------------------
251
+
252
+ export function readHumans(org) {
253
+ const listed = Array.isArray(org?.humans) ? org.humans : org?.humans ? [org.humans] : [];
254
+ const out = [];
255
+ for (const entry of [...listed, org?.human].filter(Boolean)) {
256
+ const github = String(entry.github ?? entry.login ?? "").trim();
257
+ const name = String(entry.name ?? github).trim();
258
+ if (!github && !name) continue;
259
+ if (github && out.some((h) => h.github.toLowerCase() === github.toLowerCase())) continue;
260
+ out.push({
261
+ github,
262
+ name: name || github,
263
+ marker: String(entry.marker ?? "").trim() || defaultMarker(name || github),
264
+ role: entry.role ?? undefined,
265
+ });
266
+ }
267
+ return out;
268
+ }
269
+
270
+ /** "Will (@will-lamerton) and Sam (@sam)", for a sentence in a prompt. */
271
+ export function humanSentence(humans) {
272
+ const parts = humans.map((h) => (h.github ? `${h.name} (@${h.github})` : h.name));
273
+ if (parts.length < 2) return parts[0] ?? "";
274
+ return `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`;
275
+ }
276
+
277
+ function defaultMarker(name) {
278
+ return (
279
+ String(name)
280
+ .trim()
281
+ .split(/[\s-]+/)[0]
282
+ .toLowerCase()
283
+ .replace(/[^a-z0-9]/g, "") || "human"
284
+ );
285
+ }
286
+
237
287
  // ---------------------------------------------------------------------------
238
288
  // Composition
239
289
  // ---------------------------------------------------------------------------
240
290
 
241
- export function compose({ opsDir, brainsDir, staff, kind }) {
291
+ export function compose({ opsDir, brainsDir, staff, kind, runDir }) {
242
292
  const org = parseYaml(readFileSync(join(opsDir, "org.yaml"), "utf8"), "org.yaml");
243
293
 
244
294
  const entry = (org.staff ?? []).find((s) => s.handle === staff);
@@ -268,10 +318,24 @@ export function compose({ opsDir, brainsDir, staff, kind }) {
268
318
  }
269
319
  }
270
320
 
321
+ // The `issues` route carries no comment: the ask is the body of a new issue. There is no
322
+ // `{{#unless}}`, and a prompt that tells an agent to read comment `` as its first instruction
323
+ // sends it to a 404 before it has read anything. So the absence is a value of its own.
324
+ if (Object.keys(event).length) event = { ...event, no_comment: !event.comment_id };
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
+
332
+ const humans = readHumans(org);
271
333
  const ctx = {
272
334
  org,
273
335
  event,
274
- human: org.human,
336
+ // The first is who the prose addresses; `humans` is everybody the mention gate accepts.
337
+ human: humans[0] ?? org.human,
338
+ humans,
275
339
  ops: { dir: org.ops_dir ?? "roster-ops" },
276
340
  // `dir` lives in the org registry (it is where the checkout lands), everything
277
341
  // else lives in the staff member's own manifest.
@@ -288,6 +352,12 @@ export function compose({ opsDir, brainsDir, staff, kind }) {
288
352
  // Convenience strings the fragments lean on, computed once here so a
289
353
  // fragment never has to do string work.
290
354
  peer_list: peers.map((p) => `- \`${p.dir}/\` - the ${p.name}'s brain`).join("\n"),
355
+ // Everybody who can wake this staff member and rule on their work, and the same list
356
+ // without the one the prose already names. Empty when there is only one, which is what
357
+ // makes `{{#if humans_extra}}` the right way to mention the others at all.
358
+ human_list: humanSentence(humans),
359
+ inflight,
360
+ humans_extra: humanSentence(humans.slice(1)),
291
361
  };
292
362
 
293
363
  // "staff:foo.md" resolves inside the staff member's own brain repo, which is how a
@@ -315,7 +385,7 @@ function main(argv) {
315
385
  args[argv[i].slice(2)] = argv[i + 1];
316
386
  }
317
387
  if (!args.staff || !args.kind) {
318
- console.error("usage: node compose.mjs --staff <handle> --kind <daily|mention|pr-mention> [--ops DIR] [--brains DIR]");
388
+ console.error("usage: node compose.mjs --staff <handle> --kind <daily|mention> [--ops DIR] [--brains DIR]");
319
389
  process.exit(2);
320
390
  }
321
391
  const opsDir = resolve(args.ops ?? HERE);