@nanocollective/roster 0.1.0-alpha.5 → 0.1.0-alpha.50
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 +4833 -2752
- 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 +93 -7
- package/docs/concepts.md +61 -14
- package/docs/cost.md +36 -1
- package/docs/developing.md +16 -21
- package/docs/doctor-codes.md +10 -2
- package/docs/export.md +2 -0
- package/docs/extending.md +2 -2
- package/docs/getting-started.md +128 -78
- 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 +40 -2
- package/docs/portal.md +165 -48
- package/docs/prompts.md +31 -4
- package/docs/security.md +37 -5
- package/docs/session-workflow.md +63 -17
- package/docs/staff-yaml.md +30 -3
- package/docs/troubleshooting.md +8 -8
- package/docs/upgrading.md +9 -3
- package/docs/writing-a-charter.md +28 -0
- package/package.json +18 -20
- package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +26 -1
- package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +41 -7
- 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/staff.yaml +4 -1
- package/templates/brain/strategy/ideas.md +7 -0
- package/templates/briefs/priorities.md +46 -0
- package/templates/ops/.github/workflows/session.yaml +236 -15
- package/templates/ops/agents.mjs +7 -3
- package/templates/ops/compose.mjs +31 -3
- package/templates/ops/inflight.mjs +157 -0
- package/templates/ops/org/operating.md +43 -4
- 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 +37 -9
- package/templates/ops/prompts/mention.md +21 -0
- package/templates/ops/run-record.mjs +146 -0
- package/templates/portal/css/base.css +167 -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/home.css +93 -0
- package/templates/portal/css/inbox.css +45 -25
- package/templates/portal/css/layout.css +90 -46
- 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 +21 -9
- package/templates/portal/js/api.js +44 -4
- package/templates/portal/js/app.js +94 -9
- package/templates/portal/js/dialog.js +83 -0
- package/templates/portal/js/homesort.js +174 -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 +70 -0
- package/templates/portal/js/refresh.js +10 -2
- package/templates/portal/js/state.js +11 -5
- package/templates/portal/js/views/app.js +24 -7
- package/templates/portal/js/views/brain.js +1 -1
- 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 +593 -0
- package/templates/portal/js/views/home.js +546 -0
- package/templates/portal/js/views/inbox.js +226 -70
- 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 +261 -75
- package/templates/portal/js/views/staff.js +170 -243
- 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
|
|
301
|
-
#
|
|
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
|
-
|
|
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 -
|
|
310
|
-
issue="$
|
|
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=$(
|
|
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
|
-
|
|
315
|
-
gh issue comment "$issue" --repo "$BRAIN_REPO" --body \
|
|
316
|
-
"
|
|
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."
|
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,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
|
+
}
|