@nanocollective/roster 0.1.0-alpha.3 → 0.1.0-alpha.31
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 +4817 -2374
- package/docs/README.md +19 -11
- package/docs/agents.md +328 -13
- 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 +95 -11
- package/docs/concepts.md +64 -12
- package/docs/cost.md +39 -3
- package/docs/developing.md +16 -21
- package/docs/doctor-codes.md +21 -6
- package/docs/export.md +4 -1
- package/docs/extending.md +13 -4
- package/docs/getting-started.md +133 -79
- 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 +95 -101
- package/docs/memory.md +29 -8
- package/docs/org-yaml.md +76 -11
- package/docs/portal.md +290 -49
- package/docs/prompts.md +77 -11
- package/docs/security.md +51 -7
- package/docs/session-workflow.md +51 -21
- package/docs/staff-yaml.md +17 -7
- package/docs/troubleshooting.md +23 -20
- package/docs/upgrading.md +9 -3
- package/docs/writing-a-charter.md +46 -17
- package/package.json +1 -1
- package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +7 -0
- package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +16 -4
- 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 +0 -1
- package/templates/brain/strategy/ideas.md +7 -0
- package/templates/briefs/priorities.md +46 -0
- package/templates/ops/.github/workflows/session.yaml +117 -40
- package/templates/ops/agents.mjs +127 -8
- package/templates/ops/compose.mjs +77 -7
- package/templates/ops/inflight.mjs +157 -0
- package/templates/ops/org/operating.md +21 -7
- package/templates/ops/org/voice.md +9 -0
- package/templates/ops/prompts/_identity.md +8 -1
- package/templates/ops/prompts/_inflight.md +14 -0
- package/templates/ops/prompts/_paths.md +2 -1
- package/templates/ops/prompts/daily.md +16 -7
- package/templates/ops/prompts/mention.md +18 -2
- package/templates/ops/run-record.mjs +144 -0
- package/templates/portal/css/base.css +245 -64
- package/templates/portal/css/brain.css +30 -20
- package/templates/portal/css/diff.css +15 -10
- package/templates/portal/css/graph.css +12 -7
- package/templates/portal/css/health.css +32 -11
- package/templates/portal/css/inbox.css +117 -14
- package/templates/portal/css/layout.css +114 -41
- package/templates/portal/css/markdown.css +57 -15
- package/templates/portal/css/runs.css +13 -0
- package/templates/portal/css/setup.css +126 -39
- package/templates/portal/index.html +25 -3
- package/templates/portal/js/api.js +74 -4
- package/templates/portal/js/app.js +156 -14
- package/templates/portal/js/dialog.js +129 -4
- package/templates/portal/js/dom.js +25 -0
- package/templates/portal/js/icons.js +45 -1
- package/templates/portal/js/inflight.js +18 -0
- package/templates/portal/js/lightbox.js +273 -0
- package/templates/portal/js/md.js +23 -6
- package/templates/portal/js/mdedit.js +84 -0
- package/templates/portal/js/mention.js +264 -0
- package/templates/portal/js/readiness.js +35 -0
- package/templates/portal/js/refresh.js +136 -6
- package/templates/portal/js/state.js +59 -8
- package/templates/portal/js/views/app.js +24 -7
- package/templates/portal/js/views/checklist.js +29 -10
- package/templates/portal/js/views/credential.js +84 -0
- package/templates/portal/js/views/docs.js +94 -4
- package/templates/portal/js/views/files.js +58 -14
- package/templates/portal/js/views/graph.js +1 -1
- package/templates/portal/js/views/health.js +178 -37
- package/templates/portal/js/views/hire.js +583 -0
- package/templates/portal/js/views/inbox.js +959 -126
- package/templates/portal/js/views/memory.js +16 -1
- package/templates/portal/js/views/org.js +124 -104
- 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 +61 -67
- 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 +165 -0
- package/templates/portal/js/views/setup.js +257 -75
- package/templates/portal/js/views/staff.js +157 -182
- package/templates/portal/js/views/todo.js +62 -0
- package/templates/portal/js/yaml.js +134 -0
- package/templates/brain/.github/workflows/%%STAFF%%-pr-mention.yaml +0 -50
- package/templates/ops/prompts/pr-mention.md +0 -57
|
@@ -30,24 +30,35 @@ concurrency:
|
|
|
30
30
|
|
|
31
31
|
jobs:
|
|
32
32
|
answer:
|
|
33
|
-
# Only
|
|
33
|
+
# Only a human this org answers to, only on a real mention. A bot quoting the phrase must
|
|
34
|
+
# never wake the loop.
|
|
35
|
+
#
|
|
36
|
+
# The list is every login in `org.yaml`'s `humans:` (or the singular `human:`), so a second
|
|
37
|
+
# founder can wake a staff member without their comment being dropped in silence. One human
|
|
38
|
+
# is a one-element list: the gate has a single shape whatever the org looks like.
|
|
34
39
|
#
|
|
35
40
|
# The outer gate is on `sender` — the account that performed the action — and not on the author
|
|
36
41
|
# of the thing it acted on. That distinction is load-bearing on the `issues` route: the pinned
|
|
37
|
-
# status issue is opened by
|
|
42
|
+
# status issue is opened by a human and then edited by this staff member on every run, so an
|
|
38
43
|
# author check would let the agent's own edit wake another run, which would edit it again.
|
|
39
44
|
#
|
|
40
45
|
# One spelling of the mention is enough: GitHub's `contains` is documented as not case
|
|
41
46
|
# sensitive, so this already matches an uppercase mention at the start of a sentence.
|
|
42
47
|
if: >-
|
|
43
|
-
github.event.sender.login
|
|
48
|
+
contains(fromJSON('%%HUMAN_LOGINS%%'), github.event.sender.login) &&
|
|
44
49
|
(
|
|
45
50
|
(github.event_name == 'issue_comment' &&
|
|
46
|
-
github.event.comment.user.login
|
|
51
|
+
contains(fromJSON('%%HUMAN_LOGINS%%'), github.event.comment.user.login) &&
|
|
47
52
|
contains(github.event.comment.body, '%%MENTION%%')) ||
|
|
48
53
|
(github.event_name == 'issues' &&
|
|
49
54
|
contains(github.event.issue.body, '%%MENTION%%'))
|
|
50
55
|
)
|
|
56
|
+
# The ceiling on what session.yaml's job token may do: reading the checkout, and the one
|
|
57
|
+
# comment that says a run failed when the App that would normally say so is what broke.
|
|
58
|
+
# A called workflow cannot raise these, so they have to be granted here.
|
|
59
|
+
permissions:
|
|
60
|
+
contents: read
|
|
61
|
+
issues: write
|
|
51
62
|
uses: %%OPS_REPO%%/.github/workflows/session.yaml@main
|
|
52
63
|
with:
|
|
53
64
|
staff: %%STAFF%%
|
|
@@ -55,6 +66,7 @@ jobs:
|
|
|
55
66
|
ops_repo: %%OPS_REPO%%
|
|
56
67
|
model: %%MODEL%%
|
|
57
68
|
timeout_minutes: %%MENTION_TIMEOUT%%
|
|
69
|
+
allowed_tools: "%%ALLOWED_TOOLS%%"
|
|
58
70
|
issue_number: ${{ github.event.issue.number }}
|
|
59
71
|
comment_id: ${{ github.event.comment.id }}
|
|
60
72
|
secrets:
|
|
@@ -11,10 +11,10 @@ between %%MENTION%% and everyone else.
|
|
|
11
11
|
Write it before the first unattended run. A generated charter would produce a generic agent,
|
|
12
12
|
which is the failure this whole arrangement exists to avoid.
|
|
13
13
|
|
|
14
|
-
Write it with your own AI
|
|
14
|
+
Write it with your own AI. This prints a brief to paste into whichever agent you use (in
|
|
15
|
+
Claude Code it is also /charter, from inside this repo):
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
/charter
|
|
17
|
+
roster brief charter %%STAFF%%
|
|
18
18
|
|
|
19
19
|
Or write it by hand. The headings below are the shape that has worked; the words are yours.
|
|
20
20
|
|
|
@@ -10,6 +10,7 @@ on, and has decided.
|
|
|
10
10
|
| `memory/INDEX.md` | One line per fact, read at every boot. |
|
|
11
11
|
| `memory/notes/` | The argument behind a fact, read on demand. |
|
|
12
12
|
| `log/decisions.md` | Why things were decided. Not boot context. |
|
|
13
|
+
| `strategy/` | Longer role documents, and `ideas.md`: ideas parked here rather than filed as issues. |
|
|
13
14
|
| `.github/workflows/` | Three callers. The body lives in `%%OPS_REPO%%`. |
|
|
14
15
|
|
|
15
16
|
Scheduled runs and mentions are wired up by roster. To see what this staff member is actually
|
|
@@ -4,3 +4,6 @@ Why things were decided, newest first. Not boot context: this is read when a dec
|
|
|
4
4
|
being revisited, not every morning.
|
|
5
5
|
|
|
6
6
|
One entry per decision. What was decided, why, and what would change it back.
|
|
7
|
+
|
|
8
|
+
This file holds the current month. Move anything older into `log/decisions/<YYYY-MM>.md`:
|
|
9
|
+
`roster lint` warns past 24KB.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Ideas
|
|
2
|
+
|
|
3
|
+
Speculative ideas, one line each, parked here rather than filed as issues. An issue is for
|
|
4
|
+
something that needs a ruling; an idea that does not yet is noise on the tracker.
|
|
5
|
+
|
|
6
|
+
Promote one to an issue when it serves a priority in `org/priorities.md` and needs a ruling.
|
|
7
|
+
Delete one when it stops being interesting.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
Write `org/priorities.md` for %%ORG_NAME%%.
|
|
2
|
+
|
|
3
|
+
You are helping %%HUMAN%% decide what their AI staff should work on this month. Every staff
|
|
4
|
+
member reads this file at the start of every run and picks work that serves it, so a vague
|
|
5
|
+
priority produces scattered work.
|
|
6
|
+
|
|
7
|
+
## Where you are
|
|
8
|
+
|
|
9
|
+
- `%%OPS_REPO_DIR%%/org/business.md`: what the business is. **Read it first**, and don't ask
|
|
10
|
+
anything it already answers.
|
|
11
|
+
- `%%OPS_REPO_DIR%%/org/priorities.md`: the current file, which you are replacing.
|
|
12
|
+
|
|
13
|
+
If you cannot read files where you are running, ask %%HUMAN%% to paste `org/business.md`.
|
|
14
|
+
|
|
15
|
+
## Interview
|
|
16
|
+
|
|
17
|
+
Ask one question at a time. You are after:
|
|
18
|
+
|
|
19
|
+
- **The one outcome that matters most this month**, and how they will know it happened.
|
|
20
|
+
- **At most two more**, in order. Push back on a fourth: past three it is a wish list.
|
|
21
|
+
- **What is out of scope this month**: work that is tempting but not now. Naming it is what
|
|
22
|
+
stops the staff doing it.
|
|
23
|
+
|
|
24
|
+
Prefer outcomes ("a stranger pays for it") to activities ("improve the landing page"). If an
|
|
25
|
+
answer is an activity, ask what it is for.
|
|
26
|
+
|
|
27
|
+
## Write it
|
|
28
|
+
|
|
29
|
+
Use exactly this shape:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
## What matters this month
|
|
33
|
+
|
|
34
|
+
### Priorities, in order
|
|
35
|
+
|
|
36
|
+
1. The first outcome, and how you will know it happened.
|
|
37
|
+
2. The second.
|
|
38
|
+
3. The third.
|
|
39
|
+
|
|
40
|
+
### Out of scope this month
|
|
41
|
+
|
|
42
|
+
- One thing per line.
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Keep each priority to a sentence or two. Don't add anything %%HUMAN%% didn't say without
|
|
46
|
+
listing it after the file so they can check it.
|
|
@@ -15,7 +15,7 @@ on:
|
|
|
15
15
|
required: true
|
|
16
16
|
type: string
|
|
17
17
|
kind:
|
|
18
|
-
description: "daily | 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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
318
|
-
#
|
|
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
|
-
|
|
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 -
|
|
327
|
-
issue="$
|
|
388
|
+
set -uo pipefail
|
|
389
|
+
issue="$ISSUE"
|
|
390
|
+
if [ -z "$issue" ] && [ -n "$BRAIN_DIR" ] && [ -f "$BRAIN_DIR/staff.yaml" ]; then
|
|
391
|
+
issue=$(sed -n 's/^status_issue:[[:space:]]*//p' "$BRAIN_DIR/staff.yaml" | head -1)
|
|
392
|
+
fi
|
|
393
|
+
if [ -z "$issue" ]; then
|
|
394
|
+
issue=$(GH_TOKEN="${APP_TOKEN:-$JOB_TOKEN}" gh api "repos/$BRAIN_REPO/contents/staff.yaml" \
|
|
395
|
+
-H "Accept: application/vnd.github.raw" 2>/dev/null \
|
|
396
|
+
| sed -n 's/^status_issue:[[:space:]]*//p' | head -1)
|
|
397
|
+
fi
|
|
398
|
+
issue="${issue%%[[:space:]#]*}"
|
|
328
399
|
if [ -z "$issue" ]; then
|
|
329
|
-
|
|
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
|
-
|
|
332
|
-
gh issue comment "$issue" --repo "$BRAIN_REPO" --body \
|
|
333
|
-
"
|
|
408
|
+
# Posted as github-actions rather than as the staff member, which is itself the clue.
|
|
409
|
+
GH_TOKEN="$JOB_TOKEN" gh issue comment "$issue" --repo "$BRAIN_REPO" --body \
|
|
410
|
+
"$body The staff member's own App could not post it, so check the App is installed on this repo and its APP_ID and APP_PRIVATE_KEY secrets are current."
|
package/templates/ops/agents.mjs
CHANGED
|
@@ -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
|
|
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"
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
}
|