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

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 (103) hide show
  1. package/README.md +70 -84
  2. package/dist/cli.js +5161 -3012
  3. package/docs/README.md +9 -6
  4. package/docs/agents.md +24 -20
  5. package/docs/architecture.md +13 -5
  6. package/docs/charters/analyst.md +65 -0
  7. package/docs/charters/cmo.md +69 -0
  8. package/docs/charters/community.md +63 -0
  9. package/docs/charters/cto.md +71 -0
  10. package/docs/charters/designer.md +65 -0
  11. package/docs/charters/devops.md +65 -0
  12. package/docs/charters/pm.md +70 -0
  13. package/docs/charters/qa.md +65 -0
  14. package/docs/charters/support.md +60 -0
  15. package/docs/charters/writer.md +63 -0
  16. package/docs/commands.md +93 -7
  17. package/docs/concepts.md +61 -14
  18. package/docs/cost.md +36 -1
  19. package/docs/developing.md +16 -21
  20. package/docs/doctor-codes.md +10 -2
  21. package/docs/export.md +2 -0
  22. package/docs/extending.md +2 -2
  23. package/docs/getting-started.md +128 -78
  24. package/docs/images/brain.jpg +0 -0
  25. package/docs/images/org.jpg +0 -0
  26. package/docs/images/prompt.jpg +0 -0
  27. package/docs/images/setup-org.jpg +0 -0
  28. package/docs/images/setup-plan.jpg +0 -0
  29. package/docs/images/staff.jpg +0 -0
  30. package/docs/manual-steps.md +94 -123
  31. package/docs/memory.md +21 -3
  32. package/docs/org-yaml.md +40 -2
  33. package/docs/portal.md +177 -58
  34. package/docs/prompts.md +31 -4
  35. package/docs/security.md +37 -5
  36. package/docs/session-workflow.md +63 -17
  37. package/docs/staff-yaml.md +30 -3
  38. package/docs/troubleshooting.md +8 -8
  39. package/docs/upgrading.md +9 -3
  40. package/docs/writing-a-charter.md +28 -0
  41. package/package.json +18 -20
  42. package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +26 -1
  43. package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +59 -13
  44. package/templates/brain/CHARTER.md +3 -3
  45. package/templates/brain/README.md +1 -0
  46. package/templates/brain/log/decisions.md +3 -0
  47. package/templates/brain/staff.yaml +4 -1
  48. package/templates/brain/strategy/ideas.md +7 -0
  49. package/templates/briefs/amend.md +4 -3
  50. package/templates/briefs/priorities.md +46 -0
  51. package/templates/ops/.github/workflows/session.yaml +236 -15
  52. package/templates/ops/agents.mjs +7 -3
  53. package/templates/ops/compose.mjs +31 -3
  54. package/templates/ops/inflight.mjs +157 -0
  55. package/templates/ops/org/operating.md +43 -4
  56. package/templates/ops/org/voice.md +9 -0
  57. package/templates/ops/prompts/_inflight.md +14 -0
  58. package/templates/ops/prompts/_paths.md +2 -1
  59. package/templates/ops/prompts/daily.md +37 -9
  60. package/templates/ops/prompts/mention.md +21 -0
  61. package/templates/ops/run-record.mjs +146 -0
  62. package/templates/portal/css/base.css +167 -73
  63. package/templates/portal/css/brain.css +23 -20
  64. package/templates/portal/css/diff.css +10 -9
  65. package/templates/portal/css/graph.css +12 -7
  66. package/templates/portal/css/health.css +26 -11
  67. package/templates/portal/css/home.css +95 -0
  68. package/templates/portal/css/inbox.css +45 -25
  69. package/templates/portal/css/layout.css +90 -46
  70. package/templates/portal/css/markdown.css +36 -14
  71. package/templates/portal/css/runs.css +13 -0
  72. package/templates/portal/css/setup.css +117 -34
  73. package/templates/portal/index.html +21 -9
  74. package/templates/portal/js/api.js +44 -4
  75. package/templates/portal/js/app.js +94 -9
  76. package/templates/portal/js/dialog.js +83 -0
  77. package/templates/portal/js/homesort.js +174 -0
  78. package/templates/portal/js/icons.js +37 -0
  79. package/templates/portal/js/inflight.js +18 -0
  80. package/templates/portal/js/md.js +5 -2
  81. package/templates/portal/js/mdedit.js +84 -0
  82. package/templates/portal/js/readiness.js +70 -0
  83. package/templates/portal/js/refresh.js +10 -2
  84. package/templates/portal/js/state.js +11 -5
  85. package/templates/portal/js/views/app.js +24 -7
  86. package/templates/portal/js/views/brain.js +1 -1
  87. package/templates/portal/js/views/checklist.js +10 -4
  88. package/templates/portal/js/views/credential.js +84 -0
  89. package/templates/portal/js/views/graph.js +1 -1
  90. package/templates/portal/js/views/health.js +27 -9
  91. package/templates/portal/js/views/hire.js +593 -0
  92. package/templates/portal/js/views/home.js +546 -0
  93. package/templates/portal/js/views/inbox.js +226 -70
  94. package/templates/portal/js/views/org.js +46 -106
  95. package/templates/portal/js/views/orgedit.js +234 -0
  96. package/templates/portal/js/views/paste.js +137 -63
  97. package/templates/portal/js/views/prompt.js +100 -42
  98. package/templates/portal/js/views/repos.js +20 -15
  99. package/templates/portal/js/views/runonce.js +94 -0
  100. package/templates/portal/js/views/runs.js +170 -0
  101. package/templates/portal/js/views/setup.js +261 -75
  102. package/templates/portal/js/views/staff.js +170 -243
  103. package/templates/portal/js/views/todo.js +62 -0
@@ -19,13 +19,23 @@ on:
19
19
  required: false
20
20
  default: daily
21
21
  type: string
22
+ # What started the run: daily | manual | follow-on | mention | peer. Peer and follow-on
23
+ # runs start without a person asking, so they are the ones held to max_runs_per_day.
24
+ trigger:
25
+ required: false
26
+ default: ""
27
+ type: string
28
+ max_runs_per_day:
29
+ required: false
30
+ default: 6
31
+ type: number
22
32
  ops_repo:
23
33
  description: "owner/name of the ops repo holding org.yaml and the prompts"
24
34
  required: true
25
35
  type: string
26
36
  model:
27
37
  required: false
28
- default: claude-opus-5
38
+ default: claude-opus-5-5
29
39
  type: string
30
40
  timeout_minutes:
31
41
  required: false
@@ -65,15 +75,87 @@ permissions:
65
75
  contents: read
66
76
 
67
77
  jobs:
78
+ # Two staff can ask each other things, and a daily run can start a follow-on, so runs can start
79
+ # runs. This is what stops that at max_runs_per_day. A mention is a person asking and is never
80
+ # held back, so only peer and follow-on runs are counted, and only the ones that ran: a caller
81
+ # whose condition did not match leaves a skipped run behind under the same name.
82
+ budget:
83
+ runs-on: ubuntu-latest
84
+ permissions:
85
+ actions: read
86
+ issues: write
87
+ outputs:
88
+ go: ${{ steps.count.outputs.go }}
89
+ steps:
90
+ - name: Count today's runs nobody asked for
91
+ id: count
92
+ env:
93
+ GH_TOKEN: ${{ github.token }}
94
+ TRIGGER: ${{ inputs.trigger }}
95
+ STAFF: ${{ inputs.staff }}
96
+ LIMIT: ${{ inputs.max_runs_per_day }}
97
+ ISSUE: ${{ inputs.issue_number }}
98
+ run: |
99
+ set -uo pipefail
100
+ case "$TRIGGER" in
101
+ peer|follow-on) ;;
102
+ *) echo "go=true" >> "$GITHUB_OUTPUT"; exit 0 ;;
103
+ esac
104
+ today=$(date -u +%F)
105
+ # Includes this run, which is in progress and named like the rest.
106
+ filter="[.workflow_runs[]
107
+ | select(.display_title | test(\"^$STAFF (peer|follow-on)( |\$)\"))
108
+ | select(.conclusion != \"skipped\")] | length"
109
+ # One count per page, summed. pipefail makes a failed read fail the whole line.
110
+ if ! n=$(gh api --paginate "repos/${{ github.repository }}/actions/runs?created=>=$today&per_page=100" \
111
+ --jq "$filter" | awk '{s += $1} END {print s + 0}'); then
112
+ # Unknown is not under the limit. A run held back costs a day; a loop costs a bill.
113
+ echo "::warning::could not count today's runs, so this one does not start"
114
+ n=999999
115
+ fi
116
+ echo "$n of $LIMIT runs today that nobody asked for, this one included"
117
+ if [ "$n" -le "$LIMIT" ]; then
118
+ echo "go=true" >> "$GITHUB_OUTPUT"
119
+ exit 0
120
+ fi
121
+ echo "go=false" >> "$GITHUB_OUTPUT"
122
+ msg="Not started: $STAFF has had $LIMIT runs today that nobody asked for, which is max_runs_per_day. The next daily run picks this up, or mention them to start one now."
123
+ echo "$msg" >> "$GITHUB_STEP_SUMMARY"
124
+ if [ -n "$ISSUE" ]; then
125
+ gh issue comment "$ISSUE" --repo "${{ github.repository }}" --body "$msg" || true
126
+ fi
127
+
68
128
  session:
129
+ needs: budget
130
+ if: needs.budget.outputs.go == 'true'
69
131
  runs-on: ubuntu-latest
70
132
  timeout-minutes: ${{ inputs.timeout_minutes }}
71
133
 
134
+ # `issues: write` is for the failure notice alone, which falls back to this job's own token
135
+ # when the App's cannot be minted. `actions: write` starts a follow-on run. A called workflow
136
+ # cannot raise what its caller granted, so the callers ask for the same.
137
+ permissions:
138
+ contents: read
139
+ issues: write
140
+ actions: write
141
+
72
142
  # secrets are not usable in a step-level `if`, so the presence check is hoisted here.
73
143
  env:
74
144
  HAS_PUBLIC_APP: ${{ secrets.PUBLIC_APP_ID != '' }}
145
+ # Nothing is listening once the agent ends its turn: the session is over and a background
146
+ # job dies with the runner. Claude Code moves any command past its timeout (ten minutes by
147
+ # default) into the background, and an agent told "you will be notified" ends its turn to
148
+ # wait, so a slow test suite cost the whole run, unpushed, reported as a success. With
149
+ # background tasks off a slow command stays in the foreground, and the higher cap lets a
150
+ # long gate finish inside the turn. timeout-minutes is still the real bound.
151
+ CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1"
152
+ BASH_MAX_TIMEOUT_MS: "2700000"
75
153
 
76
154
  steps:
155
+ # For the run record at the end. A job has no start time of its own to read back.
156
+ - name: Start the clock
157
+ run: echo "ROSTER_STARTED=$(date +%s)" >> "$GITHUB_ENV"
158
+
77
159
  - name: Mint the private-tracker token
78
160
  id: private
79
161
  uses: actions/create-github-app-token@v2
@@ -161,6 +243,36 @@ jobs:
161
243
  echo "product: $repo -> $dir"
162
244
  done
163
245
 
246
+ # What people have open on the product repos, so the agent does not open competing work
247
+ # on files a human branch is rewriting. Read by "Compose the prompt" below; never
248
+ # fatal, because without it the prompt just has no section about it.
249
+ - name: Gather human work in flight
250
+ if: steps.plan.outputs.products != ''
251
+ continue-on-error: true
252
+ env:
253
+ GH_TOKEN: ${{ steps.public.outputs.token || steps.private.outputs.token }}
254
+ run: node roster-ops/inflight.mjs --staff "${{ inputs.staff }}" --ops roster-ops --brains . --out .roster-run/inflight.md
255
+
256
+ # staff.yaml names the variable its prompts use for the public token (`public_token_env`),
257
+ # and an `env:` key cannot be an expression, so the name is exported here for every later
258
+ # step. PUBLIC_TOKEN is always set as well; this only adds the name the manifest chose.
259
+ - name: Export the public token under its manifest name
260
+ if: steps.public.outputs.token != ''
261
+ env:
262
+ BRAIN_DIR: ${{ steps.plan.outputs.brain_dir }}
263
+ TOKEN: ${{ steps.public.outputs.token }}
264
+ run: |
265
+ set -euo pipefail
266
+ name=$(sed -n 's/^public_token_env:[[:space:]]*//p' "$BRAIN_DIR/staff.yaml" | head -1 | tr -d "\"' ")
267
+ case "$name" in
268
+ ""|PUBLIC_TOKEN|GH_TOKEN|GITHUB_TOKEN) exit 0 ;;
269
+ esac
270
+ if ! [[ "$name" =~ ^[A-Z_][A-Z0-9_]*$ ]]; then
271
+ echo "::warning::public_token_env '$name' is not a variable name; only PUBLIC_TOKEN is set"
272
+ exit 0
273
+ fi
274
+ echo "$name=$TOKEN" >> "$GITHUB_ENV"
275
+
164
276
  # Commits read as the bot, not as a human, so the git history stays legible.
165
277
  - name: Set git identity
166
278
  env:
@@ -207,7 +319,8 @@ jobs:
207
319
  {"issue_number":"${{ inputs.issue_number }}",
208
320
  "comment_id":"${{ inputs.comment_id }}",
209
321
  "repo":"${{ github.repository }}",
210
- "actor":"${{ github.actor }}"}
322
+ "actor":"${{ github.actor }}",
323
+ "trigger":"${{ inputs.trigger }}"}
211
324
  run: |
212
325
  node roster-ops/compose.mjs --staff "${{ inputs.staff }}" --kind "${{ inputs.kind }}" --ops roster-ops --brains . > .roster-prompt.txt
213
326
  {
@@ -236,13 +349,12 @@ jobs:
236
349
  # `uses:` cannot be an expression, so an Action-based runner has to be written out
237
350
  # literally. This is the reference one; every other agent goes through the step below.
238
351
  - name: Run the session
352
+ id: session_action
239
353
  if: steps.agent.outputs.kind == 'action'
240
354
  uses: anthropics/claude-code-action@v1
241
355
  env:
242
356
  GH_TOKEN: ${{ steps.private.outputs.token }}
243
357
  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
358
  with:
247
359
  claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN || secrets.AGENT_TOKEN }}
248
360
  # These repos are private and single-user, so the usual reason to hide a run's output does
@@ -264,11 +376,11 @@ jobs:
264
376
  # argument: it is thousands of words containing quotes and backticks, and argv limits and
265
377
  # shell quoting fail at 07:00 rather than in review.
266
378
  - name: Run the session
379
+ id: session_cli
267
380
  if: steps.agent.outputs.kind == 'cli'
268
381
  env:
269
382
  GH_TOKEN: ${{ steps.private.outputs.token }}
270
383
  PUBLIC_TOKEN: ${{ steps.public.outputs.token }}
271
- PIPWEB_TOKEN: ${{ steps.public.outputs.token }}
272
384
  AGENT_MODEL: ${{ steps.agent.outputs.model || inputs.model }}
273
385
  AGENT_FLAGS: ${{ steps.agent.outputs.flags }}
274
386
  # Kept for a custom `run` written before permissions existed.
@@ -289,6 +401,10 @@ jobs:
289
401
  # an `env:` key cannot be an expression.
290
402
  export "$TOKEN_ENV=$TOKEN"
291
403
  export AGENT_PROMPT_FILE="$PWD/.roster-prompt.txt"
404
+ # Where an agent that can report its own turns and cost writes them. Optional: the
405
+ # run record reads it if it is there and records what it cannot know as unknown.
406
+ mkdir -p .roster-run
407
+ export AGENT_RESULT_FILE="$PWD/.roster-run/agent-result.json"
292
408
 
293
409
  if [ -n "$INSTALL" ]; then
294
410
  echo "::group::install ${{ steps.agent.outputs.id }}"
@@ -297,20 +413,125 @@ jobs:
297
413
  fi
298
414
  eval "$RUN"
299
415
 
300
- # A failed unattended run is otherwise a red X in a tab nobody opens. New in the roster
301
- # migration, and deliberate: every failure path should end somewhere a human reads.
416
+ # A session can exit cleanly without doing what it was asked: the success above only means
417
+ # the agent stopped. A mention is answered by a reply in its thread, or by the issue being
418
+ # closed when the request said to answer somewhere else. When neither happened the job
419
+ # fails, so the notice below tells the human instead of leaving them waiting.
420
+ #
421
+ # Never fails on its own account: if the issue cannot be read, nothing is claimed.
422
+ - name: Check the request was answered
423
+ id: answered
424
+ if: >-
425
+ inputs.kind == 'mention' && inputs.issue_number != ''
426
+ && (steps.session_action.outcome == 'success' || steps.session_cli.outcome == 'success')
427
+ env:
428
+ GH_TOKEN: ${{ steps.private.outputs.token }}
429
+ BOT: ${{ steps.private.outputs.app-slug }}[bot]
430
+ ISSUE: ${{ inputs.issue_number }}
431
+ run: |
432
+ set -uo pipefail
433
+ since=$(date -u -d "@$ROSTER_STARTED" +%Y-%m-%dT%H:%M:%SZ)
434
+ if ! state=$(gh api "repos/${{ github.repository }}/issues/$ISSUE" --jq .state) ||
435
+ ! replies=$(gh api --paginate "repos/${{ github.repository }}/issues/$ISSUE/comments?since=$since" \
436
+ --jq ".[] | select(.user.login == \"$BOT\" and .created_at >= \"$since\") | .id"); then
437
+ echo "::warning::could not read #$ISSUE, so whether it was answered is unknown"
438
+ exit 0
439
+ fi
440
+ if [ "$state" = "closed" ] || [ -n "$replies" ]; then
441
+ exit 0
442
+ fi
443
+ echo "unanswered=true" >> "$GITHUB_OUTPUT"
444
+ echo "::error::the session ended without replying on #$ISSUE or closing it"
445
+ exit 1
446
+
447
+ # A daily run that ended with the next step ready says so in .roster-run/continue, and one
448
+ # more run starts once this one ends: it waits on the caller's concurrency group. Only
449
+ # after a run that finished, and the budget job above holds the chain to the day's limit.
450
+ # The job token can do this: a workflow_dispatch it sends still starts a run.
451
+ - name: Start a follow-on run
452
+ if: >-
453
+ inputs.kind == 'daily' && success()
454
+ && (steps.session_action.outcome == 'success' || steps.session_cli.outcome == 'success')
455
+ && hashFiles('.roster-run/continue') != ''
456
+ continue-on-error: true
457
+ env:
458
+ GH_TOKEN: ${{ github.token }}
459
+ CALLER: ${{ github.workflow_ref }}
460
+ run: |
461
+ set -uo pipefail
462
+ file=$(basename "${CALLER%@*}")
463
+ gh workflow run "$file" --repo "${{ github.repository }}" -f trigger=follow-on
464
+ { echo "Started a follow-on run:"; head -c 300 .roster-run/continue; echo; } >> "$GITHUB_STEP_SUMMARY"
465
+
466
+ # What this run was and what it cost, in the job summary and as an artifact with a stable
467
+ # name, which is what the portal's Runs screen and `roster doctor` read back. Never fatal:
468
+ # a missing record costs a row in a table, and failing the job over it would cost the run.
469
+ - name: Write down the run
470
+ if: always()
471
+ continue-on-error: true
472
+ env:
473
+ STAFF: ${{ inputs.staff }}
474
+ KIND: ${{ inputs.kind }}
475
+ AGENT_ID: ${{ steps.agent.outputs.id }}
476
+ MODEL: ${{ steps.agent.outputs.model || inputs.model }}
477
+ AGENT_OUTCOME: ${{ steps.session_action.outcome != 'skipped' && steps.session_action.outcome || steps.session_cli.outcome }}
478
+ JOB_STATUS: ${{ job.status }}
479
+ UNANSWERED: ${{ steps.answered.outputs.unanswered }}
480
+ RESULT_FILE: ${{ steps.session_action.outputs.execution_file || format('{0}/.roster-run/agent-result.json', github.workspace) }}
481
+ run: node roster-ops/run-record.mjs --out .roster-run/run.json
482
+
483
+ - name: Keep the run record
484
+ if: always()
485
+ continue-on-error: true
486
+ uses: actions/upload-artifact@v6
487
+ with:
488
+ name: roster-run
489
+ path: .roster-run/run.json
490
+ if-no-files-found: ignore
491
+
492
+ # A failed unattended run is otherwise a red X in a tab nobody opens, so every failure path
493
+ # has to end somewhere a human reads.
494
+ #
495
+ # It cannot lean on anything that might be what failed. The App token is the first thing a
496
+ # renamed repo, a rotated key or an uninstalled App breaks, and a canary sat red for twelve
497
+ # days because its alert used exactly that token. So: the App token when there is one, and
498
+ # this job's own token when there is not or it is refused. The plan may not have run
499
+ # either, so the brain repo falls back to the caller's, which is the same repo, and
500
+ # staff.yaml is read over the API when it was never checked out.
302
501
  - name: Say so if the run did not finish
303
502
  if: failure() || cancelled()
304
503
  env:
305
- GH_TOKEN: ${{ steps.private.outputs.token }}
504
+ APP_TOKEN: ${{ steps.private.outputs.token }}
505
+ JOB_TOKEN: ${{ github.token }}
306
506
  BRAIN_DIR: ${{ steps.plan.outputs.brain_dir }}
307
- BRAIN_REPO: ${{ steps.plan.outputs.brain_repo }}
507
+ BRAIN_REPO: ${{ steps.plan.outputs.brain_repo || github.repository }}
508
+ ISSUE: ${{ inputs.issue_number }}
509
+ UNANSWERED: ${{ steps.answered.outputs.unanswered }}
510
+ RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
308
511
  run: |
309
- set -euo pipefail
310
- issue="${{ inputs.issue_number }}"
512
+ set -uo pipefail
513
+ issue="$ISSUE"
514
+ if [ -z "$issue" ] && [ -n "$BRAIN_DIR" ] && [ -f "$BRAIN_DIR/staff.yaml" ]; then
515
+ issue=$(sed -n 's/^status_issue:[[:space:]]*//p' "$BRAIN_DIR/staff.yaml" | head -1)
516
+ fi
311
517
  if [ -z "$issue" ]; then
312
- issue=$(sed -n 's/^status_issue:[[:space:]]*//p' "$BRAIN_DIR/staff.yaml" | head -1 || true)
518
+ issue=$(GH_TOKEN="${APP_TOKEN:-$JOB_TOKEN}" gh api "repos/$BRAIN_REPO/contents/staff.yaml" \
519
+ -H "Accept: application/vnd.github.raw" 2>/dev/null \
520
+ | sed -n 's/^status_issue:[[:space:]]*//p' | head -1)
521
+ fi
522
+ issue="${issue%%[[:space:]#]*}"
523
+ if [ -z "$issue" ]; then
524
+ echo "::error::no status_issue in staff.yaml and no issue in the trigger, so nobody was told"
525
+ exit 1
526
+ fi
527
+
528
+ body="This run did not finish, so there is no answer coming. [Run ${GITHUB_RUN_ID}]($RUN_URL) has the error."
529
+ if [ "$UNANSWERED" = "true" ]; then
530
+ body="This run stopped without replying here or closing the issue, so there is no answer coming. [Run ${GITHUB_RUN_ID}]($RUN_URL) shows where it stopped."
531
+ fi
532
+ if [ -n "$APP_TOKEN" ] && GH_TOKEN="$APP_TOKEN" gh issue comment "$issue" --repo "$BRAIN_REPO" --body "$body"; then
533
+ exit 0
313
534
  fi
314
- [ -n "$issue" ] || { echo "no status_issue in staff.yaml; nothing to comment on"; exit 0; }
315
- gh issue comment "$issue" --repo "$BRAIN_REPO" --body \
316
- "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."
535
+ # Posted as github-actions rather than as the staff member, which is itself the clue.
536
+ GH_TOKEN="$JOB_TOKEN" gh issue comment "$issue" --repo "$BRAIN_REPO" --body \
537
+ "$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."
@@ -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: 'claude -p --model "$AGENT_MODEL" $AGENT_FLAGS < "$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"',
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,22 @@ 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
+ // Who started this run, as flags: the prompt language has `{{#if}}` and nothing to compare
327
+ // with. A peer's ask reads differently from a person's, and a follow-on picks up mid-task.
328
+ const trigger = String(event.trigger ?? "");
329
+ event = {
330
+ ...event,
331
+ from_peer: trigger === "peer",
332
+ from_human: trigger !== "peer",
333
+ follow_on: trigger === "follow-on",
334
+ };
335
+
336
+ // What people have open on the product repos, gathered by inflight.mjs just before this runs.
337
+ // Read as a value and never rendered as a template: it is PR titles, which are a person's
338
+ // words, and a `{{` in one must not be able to break composition.
339
+ const inflightFile = join(runDir ?? join(brainsDir, ".roster-run"), "inflight.md");
340
+ const inflight = existsSync(inflightFile) ? readFileSync(inflightFile, "utf8").trim() : "";
341
+
320
342
  const humans = readHumans(org);
321
343
  const ctx = {
322
344
  org,
@@ -333,6 +355,11 @@ export function compose({ opsDir, brainsDir, staff, kind }) {
333
355
  // The repo this role contributes to but does not own. Named explicitly in the
334
356
  // prompt because "a repo you do not own" is vaguer than an agent needs.
335
357
  product: (self.works_in ?? [])[0] ?? null,
358
+ // Absent from manifests written before it existed; the callers fall back to the same.
359
+ max_runs_per_day: self.max_runs_per_day ?? 6,
360
+ // One staff member drafts next month's org/priorities.md, so there is one draft rather
361
+ // than one per person: whoever org.yaml lists first.
362
+ drafts_priorities: (org.staff ?? [])[0]?.handle === staff,
336
363
  },
337
364
  peers,
338
365
  peer: peers[0] ?? null,
@@ -344,6 +371,7 @@ export function compose({ opsDir, brainsDir, staff, kind }) {
344
371
  // without the one the prose already names. Empty when there is only one, which is what
345
372
  // makes `{{#if humans_extra}}` the right way to mention the others at all.
346
373
  human_list: humanSentence(humans),
374
+ inflight,
347
375
  humans_extra: humanSentence(humans.slice(1)),
348
376
  };
349
377
 
@@ -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
+ }