@nanocollective/roster 0.1.0-alpha.1

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 (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +129 -0
  3. package/dist/cli.js +5679 -0
  4. package/docs/README.md +99 -0
  5. package/docs/agents.md +163 -0
  6. package/docs/architecture.md +121 -0
  7. package/docs/commands.md +223 -0
  8. package/docs/concepts.md +112 -0
  9. package/docs/cost.md +61 -0
  10. package/docs/developing.md +147 -0
  11. package/docs/doctor-codes.md +74 -0
  12. package/docs/export.md +113 -0
  13. package/docs/extending.md +97 -0
  14. package/docs/getting-started.md +134 -0
  15. package/docs/hosting.md +72 -0
  16. package/docs/manual-steps.md +163 -0
  17. package/docs/memory.md +71 -0
  18. package/docs/org-yaml.md +143 -0
  19. package/docs/portal.md +342 -0
  20. package/docs/prompts.md +133 -0
  21. package/docs/security.md +122 -0
  22. package/docs/session-workflow.md +112 -0
  23. package/docs/staff-yaml.md +163 -0
  24. package/docs/troubleshooting.md +189 -0
  25. package/docs/upgrading.md +83 -0
  26. package/docs/writing-a-charter.md +83 -0
  27. package/package.json +60 -0
  28. package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +33 -0
  29. package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +65 -0
  30. package/templates/brain/.github/workflows/%%STAFF%%-pr-mention.yaml +50 -0
  31. package/templates/brain/CHARTER.md +49 -0
  32. package/templates/brain/README.md +18 -0
  33. package/templates/brain/drafts/README.md +7 -0
  34. package/templates/brain/log/decisions.md +6 -0
  35. package/templates/brain/memory/INDEX.md +28 -0
  36. package/templates/brain/staff.yaml +44 -0
  37. package/templates/brain/strategy/README.md +7 -0
  38. package/templates/briefs/amend.md +60 -0
  39. package/templates/briefs/charter.md +47 -0
  40. package/templates/briefs/discover.md +61 -0
  41. package/templates/briefs/voice.md +53 -0
  42. package/templates/ops/.github/workflows/session.yaml +333 -0
  43. package/templates/ops/agents.mjs +143 -0
  44. package/templates/ops/compose.mjs +333 -0
  45. package/templates/ops/org/guardrails.md +14 -0
  46. package/templates/ops/org/operating.md +82 -0
  47. package/templates/ops/org/voice.md +40 -0
  48. package/templates/ops/prompts/_identity.md +14 -0
  49. package/templates/ops/prompts/_paths.md +15 -0
  50. package/templates/ops/prompts/daily.md +82 -0
  51. package/templates/ops/prompts/mention.md +53 -0
  52. package/templates/ops/prompts/pr-mention.md +57 -0
  53. package/templates/ops/runner-plan.mjs +65 -0
  54. package/templates/portal/css/base.css +104 -0
  55. package/templates/portal/css/brain.css +106 -0
  56. package/templates/portal/css/diff.css +28 -0
  57. package/templates/portal/css/graph.css +34 -0
  58. package/templates/portal/css/health.css +41 -0
  59. package/templates/portal/css/inbox.css +79 -0
  60. package/templates/portal/css/layout.css +98 -0
  61. package/templates/portal/css/markdown.css +54 -0
  62. package/templates/portal/css/setup.css +106 -0
  63. package/templates/portal/index.html +55 -0
  64. package/templates/portal/js/api.js +74 -0
  65. package/templates/portal/js/app.js +282 -0
  66. package/templates/portal/js/dialog.js +70 -0
  67. package/templates/portal/js/dom.js +106 -0
  68. package/templates/portal/js/icons.js +94 -0
  69. package/templates/portal/js/md.js +386 -0
  70. package/templates/portal/js/refresh.js +59 -0
  71. package/templates/portal/js/router.js +20 -0
  72. package/templates/portal/js/state.js +160 -0
  73. package/templates/portal/js/textdiff.js +96 -0
  74. package/templates/portal/js/views/app.js +128 -0
  75. package/templates/portal/js/views/brain.js +260 -0
  76. package/templates/portal/js/views/changed.js +157 -0
  77. package/templates/portal/js/views/checklist.js +87 -0
  78. package/templates/portal/js/views/docs.js +84 -0
  79. package/templates/portal/js/views/files.js +95 -0
  80. package/templates/portal/js/views/graph.js +436 -0
  81. package/templates/portal/js/views/health.js +158 -0
  82. package/templates/portal/js/views/inbox.js +549 -0
  83. package/templates/portal/js/views/memory.js +135 -0
  84. package/templates/portal/js/views/org.js +175 -0
  85. package/templates/portal/js/views/paste.js +142 -0
  86. package/templates/portal/js/views/prompt.js +412 -0
  87. package/templates/portal/js/views/repos.js +92 -0
  88. package/templates/portal/js/views/setup.js +344 -0
  89. package/templates/portal/js/views/staff.js +290 -0
@@ -0,0 +1,333 @@
1
+ name: roster session
2
+
3
+ # One staff member's unattended working session. Called by a ~25-line workflow in their own brain
4
+ # repo, which is the only file that has to exist over there.
5
+ #
6
+ # Reusable workflows in a private repo can only be called from inside the same organisation, which
7
+ # is exactly why this lives in the tenant's ops repo rather than in the framework. Nothing here
8
+ # depends on npm, on a network fetch, or on an org the tenant does not control.
9
+
10
+ on:
11
+ workflow_call:
12
+ inputs:
13
+ staff:
14
+ description: "Staff handle, as it appears in org.yaml"
15
+ required: true
16
+ type: string
17
+ kind:
18
+ description: "daily | mention | pr-mention"
19
+ required: false
20
+ default: daily
21
+ type: string
22
+ ops_repo:
23
+ description: "owner/name of the ops repo holding org.yaml and the prompts"
24
+ required: true
25
+ type: string
26
+ model:
27
+ required: false
28
+ default: claude-opus-5
29
+ type: string
30
+ timeout_minutes:
31
+ required: false
32
+ default: 60
33
+ type: number
34
+ allowed_tools:
35
+ required: false
36
+ default: "Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch"
37
+ type: string
38
+ # Trigger context. Empty on a scheduled run; set when a comment or a PR woke us.
39
+ issue_number:
40
+ required: false
41
+ default: ""
42
+ type: string
43
+ comment_id:
44
+ required: false
45
+ default: ""
46
+ type: string
47
+ pr_number:
48
+ required: false
49
+ default: ""
50
+ type: string
51
+ secrets:
52
+ APP_ID:
53
+ required: true
54
+ APP_PRIVATE_KEY:
55
+ required: true
56
+ PUBLIC_APP_ID:
57
+ required: false
58
+ PUBLIC_APP_PRIVATE_KEY:
59
+ required: false
60
+ # The agent's credential. Which one a caller passes depends on the agent it runs:
61
+ # AGENT_TOKEN is the general form, CLAUDE_CODE_OAUTH_TOKEN the name the reference runner
62
+ # has always used. Neither is required on its own, and exactly one has to be present.
63
+ AGENT_TOKEN:
64
+ required: false
65
+ CLAUDE_CODE_OAUTH_TOKEN:
66
+ required: false
67
+
68
+ permissions:
69
+ contents: read
70
+
71
+ jobs:
72
+ session:
73
+ runs-on: ubuntu-latest
74
+ timeout-minutes: ${{ inputs.timeout_minutes }}
75
+
76
+ # secrets are not usable in a step-level `if`, so the presence check is hoisted here.
77
+ env:
78
+ HAS_PUBLIC_APP: ${{ secrets.PUBLIC_APP_ID != '' }}
79
+
80
+ steps:
81
+ - name: Mint the private-tracker token
82
+ id: private
83
+ uses: actions/create-github-app-token@v2
84
+ with:
85
+ app-id: ${{ secrets.APP_ID }}
86
+ private-key: ${{ secrets.APP_PRIVATE_KEY }}
87
+ owner: ${{ github.repository_owner }}
88
+
89
+ # A PR opened with the default GITHUB_TOKEN does not trigger pull_request workflows, so CI
90
+ # would never run and the gate would be empty. The App token is not optional here.
91
+ - name: Mint the public-repo token
92
+ id: public
93
+ if: env.HAS_PUBLIC_APP == 'true'
94
+ uses: actions/create-github-app-token@v2
95
+ with:
96
+ app-id: ${{ secrets.PUBLIC_APP_ID }}
97
+ private-key: ${{ secrets.PUBLIC_APP_PRIVATE_KEY }}
98
+ owner: ${{ github.repository_owner }}
99
+
100
+ # Tell the human it was heard. This is the only signal between the request and the reply
101
+ # several minutes later, and without it there is no way to tell "working" from "the trigger
102
+ # missed". It sits here, before any checkout, so the eyes land in seconds rather than after
103
+ # the clones.
104
+ #
105
+ # `mention` only. A pr-mention is already acknowledged by the forwarder in the public product
106
+ # repo, on the comment the human actually left, and reacting again here would put two on it.
107
+ # A daily run has nothing to react to.
108
+ #
109
+ # An `issues` payload carries no comment, so the eyes go on the issue itself. That is the
110
+ # route where a mention is typed straight into the body of a new issue.
111
+ #
112
+ # Never fatal: a missing reaction is cosmetic, and failing the job over one would cost the
113
+ # answer as well.
114
+ - name: React to the request
115
+ if: inputs.kind == 'mention' && (inputs.comment_id != '' || inputs.issue_number != '')
116
+ continue-on-error: true
117
+ env:
118
+ GH_TOKEN: ${{ steps.private.outputs.token }}
119
+ TARGET: ${{ inputs.comment_id != ''
120
+ && format('issues/comments/{0}', inputs.comment_id)
121
+ || format('issues/{0}', inputs.issue_number) }}
122
+ run: |
123
+ gh api --method POST "repos/${{ github.repository }}/$TARGET/reactions" \
124
+ -f content=eyes --silent
125
+
126
+ # The ops repo is the only thing we can clone without having read a manifest, so it goes
127
+ # first and then tells us everything else.
128
+ - name: Check out the org brain
129
+ uses: actions/checkout@v7
130
+ with:
131
+ repository: ${{ inputs.ops_repo }}
132
+ path: roster-ops
133
+ token: ${{ steps.private.outputs.token }}
134
+
135
+ - name: Work out what to check out
136
+ id: plan
137
+ run: node roster-ops/runner-plan.mjs --staff "${{ inputs.staff }}" --ops roster-ops >> "$GITHUB_OUTPUT"
138
+
139
+ - name: Check out the brain
140
+ uses: actions/checkout@v7
141
+ with:
142
+ repository: ${{ steps.plan.outputs.brain_repo }}
143
+ path: ${{ steps.plan.outputs.brain_dir }}
144
+ token: ${{ steps.private.outputs.token }}
145
+ fetch-depth: 0
146
+
147
+ # Peers and product repos vary per staff member, so they are cloned rather than declared.
148
+ - name: Check out peers and product repos
149
+ env:
150
+ PRIVATE_TOKEN: ${{ steps.private.outputs.token }}
151
+ PUBLIC_TOKEN: ${{ steps.public.outputs.token }}
152
+ PEERS: ${{ steps.plan.outputs.peers }}
153
+ PRODUCTS: ${{ steps.plan.outputs.products }}
154
+ run: |
155
+ set -euo pipefail
156
+ for spec in $PEERS; do
157
+ repo="${spec%%:*}"; dir="${spec##*:}"
158
+ git clone --quiet --depth 1 "https://x-access-token:${PRIVATE_TOKEN}@github.com/${repo}.git" "$dir"
159
+ echo "peer: $repo -> $dir"
160
+ done
161
+ for spec in $PRODUCTS; do
162
+ IFS=':' read -r repo dir depth <<< "$spec"
163
+ token="${PUBLIC_TOKEN:-$PRIVATE_TOKEN}"
164
+ args=(--quiet)
165
+ [ "$depth" != "0" ] && args+=(--depth "$depth")
166
+ git clone "${args[@]}" "https://x-access-token:${token}@github.com/${repo}.git" "$dir"
167
+ echo "product: $repo -> $dir"
168
+ done
169
+
170
+ # Commits read as the bot, not as a human, so the git history stays legible.
171
+ - name: Set git identity
172
+ env:
173
+ PRIVATE_SLUG: ${{ steps.private.outputs.app-slug }}
174
+ PUBLIC_SLUG: ${{ steps.public.outputs.app-slug }}
175
+ BRAIN_DIR: ${{ steps.plan.outputs.brain_dir }}
176
+ PEERS: ${{ steps.plan.outputs.peers }}
177
+ PRODUCTS: ${{ steps.plan.outputs.products }}
178
+ run: |
179
+ set -euo pipefail
180
+ identify() {
181
+ git -C "$1" config user.name "$2[bot]"
182
+ git -C "$1" config user.email "$2[bot]@users.noreply.github.com"
183
+ }
184
+ identify roster-ops "$PRIVATE_SLUG"
185
+ identify "$BRAIN_DIR" "$PRIVATE_SLUG"
186
+ for spec in $PEERS; do
187
+ dir="${spec##*:}"; [ -d "$dir" ] && identify "$dir" "$PRIVATE_SLUG"
188
+ done
189
+ for spec in $PRODUCTS; do
190
+ dir=$(echo "$spec" | cut -d: -f2)
191
+ [ -d "$dir" ] && identify "$dir" "${PUBLIC_SLUG:-$PRIVATE_SLUG}"
192
+ done
193
+
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
+ - uses: pnpm/action-setup@v6
209
+ if: steps.plan.outputs.needs_node == 'true'
210
+ with:
211
+ package_json_file: ${{ steps.plan.outputs.package_json }}
212
+
213
+ - name: Use Node.js 22
214
+ if: steps.plan.outputs.needs_node == 'true'
215
+ uses: actions/setup-node@v7
216
+ with:
217
+ node-version: 22
218
+ cache: pnpm
219
+ cache-dependency-path: ${{ steps.plan.outputs.package_json }}
220
+
221
+ # Composed at run time rather than baked in, so editing org/voice.md reaches every staff
222
+ # member on their next run without regenerating anything.
223
+ - name: Compose the prompt
224
+ id: compose
225
+ env:
226
+ ROSTER_CONTEXT: >-
227
+ {"issue_number":"${{ inputs.issue_number }}",
228
+ "comment_id":"${{ inputs.comment_id }}",
229
+ "pr_number":"${{ inputs.pr_number }}",
230
+ "repo":"${{ github.repository }}",
231
+ "actor":"${{ github.actor }}"}
232
+ run: |
233
+ node roster-ops/compose.mjs --staff "${{ inputs.staff }}" --kind "${{ inputs.kind }}" --ops roster-ops --brains . > .roster-prompt.txt
234
+ {
235
+ echo 'text<<ROSTER_PROMPT_5f3a9c'
236
+ cat .roster-prompt.txt
237
+ echo 'ROSTER_PROMPT_5f3a9c'
238
+ } >> "$GITHUB_OUTPUT"
239
+
240
+ # A caller may pass its credential under either name. Checked here so a missing one is an
241
+ # immediate, obvious failure rather than an authentication error from inside the agent,
242
+ # forty lines into a log, after the checkout has already happened.
243
+ - name: Check the agent has a credential
244
+ env:
245
+ A: ${{ secrets.AGENT_TOKEN }}
246
+ B: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
247
+ run: |
248
+ if [ -z "$A" ] && [ -z "$B" ]; then
249
+ echo "::error::the caller passed no agent credential. Expected AGENT_TOKEN." >&2
250
+ exit 1
251
+ fi
252
+
253
+ - name: Work out which agent runs this
254
+ id: agent
255
+ run: node roster-ops/agents.mjs --staff "${{ inputs.staff }}" --ops roster-ops --brains . >> "$GITHUB_OUTPUT"
256
+
257
+ # `uses:` cannot be an expression, so an Action-based runner has to be written out
258
+ # literally. This is the reference one; every other agent goes through the step below.
259
+ - name: Run the session
260
+ if: steps.agent.outputs.kind == 'action'
261
+ uses: anthropics/claude-code-action@v1
262
+ env:
263
+ GH_TOKEN: ${{ steps.private.outputs.token }}
264
+ 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
+ with:
268
+ claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN || secrets.AGENT_TOKEN }}
269
+ # These repos are private and single-user, so the usual reason to hide a run's output does
270
+ # not apply, and hiding it turns any failure into "is_error: true" with no message.
271
+ # Registered secrets are still masked by the runner.
272
+ show_full_output: true
273
+ github_token: ${{ steps.private.outputs.token }}
274
+ # No --max-turns. A run that legitimately needs more turns should get them: capping it
275
+ # fails the action *after* the work is done and committed, which is a false red rather
276
+ # than a saved penny. timeout-minutes is the real bound.
277
+ claude_args: >-
278
+ --model ${{ inputs.model }}
279
+ --allowedTools "${{ inputs.allowed_tools }}"
280
+ prompt: ${{ steps.compose.outputs.text }}
281
+
282
+ # Any agent with a command line. The prompt is handed over as a file, never as an
283
+ # argument: it is thousands of words containing quotes and backticks, and argv limits and
284
+ # shell quoting fail at 07:00 rather than in review.
285
+ - name: Run the session
286
+ if: steps.agent.outputs.kind == 'cli'
287
+ env:
288
+ GH_TOKEN: ${{ steps.private.outputs.token }}
289
+ PUBLIC_TOKEN: ${{ steps.public.outputs.token }}
290
+ PIPWEB_TOKEN: ${{ steps.public.outputs.token }}
291
+ AGENT_MODEL: ${{ steps.agent.outputs.model || inputs.model }}
292
+ AGENT_TOOLS: ${{ inputs.allowed_tools }}
293
+ AGENT_TOKEN: ${{ secrets.AGENT_TOKEN }}
294
+ FALLBACK_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
295
+ TOKEN_ENV: ${{ steps.agent.outputs.token_env }}
296
+ INSTALL: ${{ steps.agent.outputs.install }}
297
+ RUN: ${{ steps.agent.outputs.run }}
298
+ run: |
299
+ set -euo pipefail
300
+ TOKEN="${AGENT_TOKEN:-$FALLBACK_TOKEN}"
301
+ if [ -z "$TOKEN" ]; then
302
+ echo "::error::no credential. Pass AGENT_TOKEN from the caller." >&2
303
+ exit 1
304
+ fi
305
+ # The env var name is the agent's, so it is set here rather than declared above:
306
+ # an `env:` key cannot be an expression.
307
+ export "$TOKEN_ENV=$TOKEN"
308
+ export AGENT_PROMPT_FILE="$PWD/.roster-prompt.txt"
309
+
310
+ if [ -n "$INSTALL" ]; then
311
+ echo "::group::install ${{ steps.agent.outputs.id }}"
312
+ eval "$INSTALL"
313
+ echo "::endgroup::"
314
+ fi
315
+ eval "$RUN"
316
+
317
+ # A failed unattended run is otherwise a red X in a tab nobody opens. New in the roster
318
+ # migration, and deliberate: every failure path should end somewhere a human reads.
319
+ - name: Say so if the run did not finish
320
+ if: failure() || cancelled()
321
+ env:
322
+ GH_TOKEN: ${{ steps.private.outputs.token }}
323
+ BRAIN_DIR: ${{ steps.plan.outputs.brain_dir }}
324
+ BRAIN_REPO: ${{ steps.plan.outputs.brain_repo }}
325
+ run: |
326
+ set -euo pipefail
327
+ issue="${{ inputs.issue_number }}"
328
+ if [ -z "$issue" ]; then
329
+ issue=$(sed -n 's/^status_issue:[[:space:]]*//p' "$BRAIN_DIR/staff.yaml" | head -1 || true)
330
+ fi
331
+ [ -n "$issue" ] || { echo "no status_issue in staff.yaml; nothing to comment on"; exit 0; }
332
+ gh issue comment "$issue" --repo "$BRAIN_REPO" --body \
333
+ "This run did not finish, so there is no answer coming. [Run ${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) has the error."
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Which coding agent actually runs a session, and how to invoke it.
4
+ *
5
+ * VENDORED into every tenant's ops repo, for the same reason compose.mjs is: a scheduled run at
6
+ * 07:00 must not depend on npm, on a network fetch, or on an org the tenant does not control.
7
+ *
8
+ * roster knows nothing about any particular agent. A runner is three shell-level facts — how to
9
+ * install it, how to run it, and which environment variable carries its credential — and the
10
+ * presets below are conveniences, not a closed list. Anything with a command line that accepts a
11
+ * prompt works; see `agent:` in org.yaml.
12
+ *
13
+ * The prompt is always handed over as a file, never as an argument. It is thousands of words,
14
+ * it contains quotes and backticks, and argv limits and shell quoting are exactly the sort of
15
+ * thing that fails at 07:00 on a Tuesday rather than in review.
16
+ */
17
+
18
+ import { readFileSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { parseYaml } from "./compose.mjs";
21
+
22
+ /**
23
+ * `action` is a GitHub Action step, which has to be written into session.yaml literally —
24
+ * `uses:` cannot be an expression. Only the reference Claude runner is one of these.
25
+ * `cli` is everything else: install a package, run a command. That path is open-ended.
26
+ */
27
+ export const PRESETS = {
28
+ // The reference runner, and the default. Uses Anthropic's own action, which handles tool
29
+ // permissions and output for us.
30
+ "claude-code-action": {
31
+ kind: "action",
32
+ token_env: "CLAUDE_CODE_OAUTH_TOKEN",
33
+ model: "claude-opus-5",
34
+ },
35
+
36
+ // The same agent through its plain CLI, for anyone who would rather not depend on the action.
37
+ claude: {
38
+ kind: "cli",
39
+ install: "npm install -g @anthropic-ai/claude-code",
40
+ run: 'claude -p --model "$AGENT_MODEL" --allowedTools "$AGENT_TOOLS" < "$AGENT_PROMPT_FILE"',
41
+ token_env: "CLAUDE_CODE_OAUTH_TOKEN",
42
+ model: "claude-opus-5",
43
+ },
44
+
45
+ codex: {
46
+ kind: "cli",
47
+ install: "npm install -g @openai/codex",
48
+ // `exec -` reads the prompt from stdin. The sandbox has to be opened up because the whole
49
+ // point of a session is that it edits the checkout and pushes.
50
+ run: 'codex exec - --model "$AGENT_MODEL" --sandbox danger-full-access < "$AGENT_PROMPT_FILE"',
51
+ token_env: "CODEX_API_KEY",
52
+ model: "gpt-5-codex",
53
+ },
54
+
55
+ nanocoder: {
56
+ kind: "cli",
57
+ install: "npm install -g @nanocollective/nanocoder",
58
+ // `run` is its non-interactive mode; --trust-directory skips the first-run prompt that
59
+ // would otherwise hang a runner, and --plain avoids the TUI. The prompt is an argument
60
+ // here rather than stdin, so it is read out of the file.
61
+ run: 'nanocoder --model "$AGENT_MODEL" --mode yolo --trust-directory --plain run "$(cat "$AGENT_PROMPT_FILE")"',
62
+ token_env: "NANOCODER_API_KEY",
63
+ model: "",
64
+ },
65
+ };
66
+
67
+ /**
68
+ * The runner for a staff member: the org's choice, overridden per staff member, overridden by
69
+ * anything written out longhand. A tenant that needs an agent nobody has heard of writes
70
+ * `install`, `run` and `token_env` and never touches this file.
71
+ */
72
+ export function resolveAgent(org, staff = {}) {
73
+ const asked = staff.agent ?? org.agent ?? {};
74
+ const spec = typeof asked === "string" ? { id: asked } : asked;
75
+ const id = spec.id ?? "claude-code-action";
76
+ const preset = PRESETS[id];
77
+
78
+ if (!preset && !(spec.install && spec.run)) {
79
+ throw new Error(
80
+ `unknown agent "${id}", and no install/run given.\n` +
81
+ ` Known: ${Object.keys(PRESETS).join(", ")}\n` +
82
+ ` Or describe your own in org.yaml:\n` +
83
+ ` agent:\n id: ${id}\n install: <shell>\n run: <shell>\n token_env: <VAR>`,
84
+ );
85
+ }
86
+
87
+ const merged = { kind: "cli", ...(preset ?? {}), ...strip(spec) };
88
+ if (merged.kind === "cli" && !merged.run) throw new Error(`agent "${id}" has no run command`);
89
+ if (!merged.token_env) throw new Error(`agent "${id}" does not say which env var carries its credential`);
90
+
91
+ return {
92
+ id,
93
+ kind: merged.kind,
94
+ install: merged.install ?? "",
95
+ run: merged.run ?? "",
96
+ token_env: merged.token_env,
97
+ // The staff member's own model wins; then the agent's default. Empty means "the agent's".
98
+ model: staff.model ?? merged.model ?? "",
99
+ };
100
+ }
101
+
102
+ function strip(o) {
103
+ const out = {};
104
+ for (const [k, v] of Object.entries(o)) if (v !== undefined && v !== null && v !== "") out[k] = v;
105
+ delete out.id;
106
+ return out;
107
+ }
108
+
109
+ /* --------------------------------- the runner --------------------------------- */
110
+
111
+ if (import.meta.url === `file://${process.argv[1]}`) {
112
+ const args = process.argv.slice(2);
113
+ const at = (flag) => { const i = args.indexOf(flag); return i === -1 ? undefined : args[i + 1]; };
114
+ const opsDir = at("--ops") ?? "roster-ops";
115
+ const brainsDir = at("--brains") ?? ".";
116
+ const handle = at("--staff");
117
+
118
+ const org = parseYaml(readFileSync(join(opsDir, "org.yaml"), "utf8"), "org.yaml");
119
+ const entry = (org.staff ?? []).find((s) => s.handle === handle);
120
+ if (!entry) {
121
+ process.stderr.write(`agents: no staff member "${handle}" in org.yaml\n`);
122
+ process.exit(2);
123
+ }
124
+
125
+ let manifest = {};
126
+ try {
127
+ const dir = entry.dir ?? entry.handle;
128
+ manifest = parseYaml(readFileSync(join(brainsDir, dir, "staff.yaml"), "utf8"), "staff.yaml");
129
+ } catch {
130
+ /* A staff member with no manifest is doctor's problem; the org's default still resolves. */
131
+ }
132
+
133
+ const agent = resolveAgent(org, manifest);
134
+ const out = [
135
+ `kind=${agent.kind}`,
136
+ `id=${agent.id}`,
137
+ `token_env=${agent.token_env}`,
138
+ `model=${agent.model}`,
139
+ `install<<AGENT_EOF_9c1f\n${agent.install}\nAGENT_EOF_9c1f`,
140
+ `run<<AGENT_EOF_9c1f\n${agent.run}\nAGENT_EOF_9c1f`,
141
+ ].join("\n");
142
+ process.stdout.write(out + "\n");
143
+ }