@nanocollective/roster 0.1.0-alpha.2 → 0.1.0-alpha.4

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 (68) hide show
  1. package/dist/cli.js +848 -164
  2. package/docs/README.md +10 -5
  3. package/docs/agents.md +320 -9
  4. package/docs/commands.md +5 -6
  5. package/docs/concepts.md +27 -9
  6. package/docs/cost.md +3 -2
  7. package/docs/doctor-codes.md +13 -4
  8. package/docs/export.md +2 -1
  9. package/docs/extending.md +11 -2
  10. package/docs/getting-started.md +89 -84
  11. package/docs/images/brain.jpg +0 -0
  12. package/docs/images/org.jpg +0 -0
  13. package/docs/images/prompt.jpg +0 -0
  14. package/docs/images/setup-org.jpg +0 -0
  15. package/docs/images/setup-plan.jpg +0 -0
  16. package/docs/images/staff.jpg +0 -0
  17. package/docs/manual-steps.md +36 -13
  18. package/docs/memory.md +9 -6
  19. package/docs/org-yaml.md +37 -9
  20. package/docs/portal.md +197 -31
  21. package/docs/prompts.md +50 -11
  22. package/docs/security.md +19 -7
  23. package/docs/session-workflow.md +8 -10
  24. package/docs/staff-yaml.md +3 -5
  25. package/docs/troubleshooting.md +17 -14
  26. package/docs/writing-a-charter.md +18 -17
  27. package/package.json +1 -1
  28. package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +1 -0
  29. package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +10 -4
  30. package/templates/brain/staff.yaml +0 -1
  31. package/templates/ops/.github/workflows/session.yaml +9 -26
  32. package/templates/ops/agents.mjs +121 -6
  33. package/templates/ops/compose.mjs +61 -4
  34. package/templates/ops/org/operating.md +0 -6
  35. package/templates/ops/prompts/_identity.md +8 -1
  36. package/templates/ops/prompts/mention.md +16 -2
  37. package/templates/portal/css/base.css +122 -8
  38. package/templates/portal/css/brain.css +8 -1
  39. package/templates/portal/css/diff.css +6 -2
  40. package/templates/portal/css/health.css +21 -2
  41. package/templates/portal/css/inbox.css +93 -5
  42. package/templates/portal/css/layout.css +26 -4
  43. package/templates/portal/css/markdown.css +23 -3
  44. package/templates/portal/css/setup.css +11 -6
  45. package/templates/portal/index.html +7 -1
  46. package/templates/portal/js/api.js +33 -0
  47. package/templates/portal/js/app.js +33 -7
  48. package/templates/portal/js/dialog.js +47 -4
  49. package/templates/portal/js/dom.js +25 -0
  50. package/templates/portal/js/icons.js +8 -1
  51. package/templates/portal/js/lightbox.js +273 -0
  52. package/templates/portal/js/md.js +23 -6
  53. package/templates/portal/js/mention.js +264 -0
  54. package/templates/portal/js/refresh.js +136 -6
  55. package/templates/portal/js/state.js +47 -5
  56. package/templates/portal/js/views/checklist.js +20 -7
  57. package/templates/portal/js/views/docs.js +94 -4
  58. package/templates/portal/js/views/files.js +58 -14
  59. package/templates/portal/js/views/health.js +163 -35
  60. package/templates/portal/js/views/inbox.js +882 -96
  61. package/templates/portal/js/views/memory.js +16 -1
  62. package/templates/portal/js/views/org.js +142 -62
  63. package/templates/portal/js/views/prompt.js +50 -63
  64. package/templates/portal/js/views/setup.js +37 -13
  65. package/templates/portal/js/views/staff.js +62 -2
  66. package/templates/portal/js/yaml.js +134 -0
  67. package/templates/brain/.github/workflows/%%STAFF%%-pr-mention.yaml +0 -50
  68. package/templates/ops/prompts/pr-mention.md +0 -57
@@ -15,26 +15,27 @@ particular, which takes longer to notice than no work at all.
15
15
 
16
16
  ## Write it with your own AI
17
17
 
18
- ```bash
19
- roster brief charter <handle>
20
- ```
18
+ **Write the charter**, on that staff member's card on the [Staff screen](portal.md#staff), does
19
+ the whole round trip. It copies a brief with every file it refers to already inside it, so a chat
20
+ window with no filesystem is as useful as an agent standing in the repo. Paste the reply back
21
+ into the box and you get a diff and a save button, never a silent write.
22
+
23
+ It runs about 19,000 characters, on purpose: one paste into a large-context model beats six
24
+ rounds of it asking for files it will never get.
21
25
 
22
- That prints a self-contained brief. Paste it into whatever agent you use, or pipe it:
23
- `roster brief charter cto | pbcopy`. In Claude Code, `cd <staff-dir> && claude` then
24
- `/charter` runs the same text, because `roster hire` generates the slash command from it.
26
+ **The peers' charters are in there**, along with `org/business.md` and the shared operating
27
+ layer. That is the part that matters most, because without them the model writes a second copy
28
+ of whoever it was shown. The brief then interviews you, drafts from your answers, and tells you
29
+ what it cut and why.
25
30
 
26
- **Or do the whole round trip in the portal.** *Write the charter* on that staff member's card
27
- copies the same brief with every file it refers to already inside it, including the peers'
28
- charters, so a chat window with no filesystem can do it. Paste the reply back and you get a diff
29
- and a save. See [the portal](portal.md).
31
+ From a terminal, the same brief:
30
32
 
31
- The brief tells the agent to read `org/business.md`, the shared operating
32
- layer, and every peer's charter, then interviews you and drafts from your answers. It also tells
33
- you what it cut and why.
33
+ ```bash
34
+ roster brief charter <handle> # or: roster brief charter cto | pbcopy
35
+ ```
34
36
 
35
- There is nothing agent-specific in it. Claude Code gets a slash command because it is the
36
- reference runner and the shape happens to fit; everything else gets the same words from
37
- `roster brief`.
37
+ In Claude Code, `cd <staff-dir> && claude` then `/charter` runs the same text, because `roster
38
+ hire` generates the slash command from it. There is nothing agent-specific in any of it.
38
39
 
39
40
  ## What goes in it, and what does not
40
41
 
@@ -73,7 +74,7 @@ status issue, and the surfaces the manifest declares.
73
74
 
74
75
  ## Keep it agreeing with the manifest
75
76
 
76
- `roster lint` fails if the charter and `staff.yaml` disagree. The manifest is the
77
+ Health, and `roster lint`, fail if the charter and `staff.yaml` disagree. The manifest is the
77
78
  machine-readable half of the same document: handle, schedule, peers, surfaces, identities. If
78
79
  the charter says it reviews pull requests on the product repo, `works_in` had better include it.
79
80
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanocollective/roster",
3
- "version": "0.1.0-alpha.2",
3
+ "version": "0.1.0-alpha.4",
4
4
  "description": "An agent-run org, powered by GitHub. Scaffold AI staff members whose brain is a repo.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,6 +25,7 @@ jobs:
25
25
  ops_repo: %%OPS_REPO%%
26
26
  model: %%MODEL%%
27
27
  timeout_minutes: %%TIMEOUT%%
28
+ allowed_tools: "%%ALLOWED_TOOLS%%"
28
29
  secrets:
29
30
  APP_ID: ${{ secrets.%%SECRET_PREFIX%%_APP_ID }}
30
31
  APP_PRIVATE_KEY: ${{ secrets.%%SECRET_PREFIX%%_APP_PRIVATE_KEY }}
@@ -30,20 +30,25 @@ concurrency:
30
30
 
31
31
  jobs:
32
32
  answer:
33
- # Only the human, only on a real mention. A bot quoting the phrase must never wake the loop.
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 the human and then edited by this staff member on every run, so an
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 == '%%HUMAN%%' &&
48
+ contains(fromJSON('%%HUMAN_LOGINS%%'), github.event.sender.login) &&
44
49
  (
45
50
  (github.event_name == 'issue_comment' &&
46
- github.event.comment.user.login == '%%HUMAN%%' &&
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%%'))
@@ -55,6 +60,7 @@ jobs:
55
60
  ops_repo: %%OPS_REPO%%
56
61
  model: %%MODEL%%
57
62
  timeout_minutes: %%MENTION_TIMEOUT%%
63
+ allowed_tools: "%%ALLOWED_TOOLS%%"
58
64
  issue_number: ${{ github.event.issue.number }}
59
65
  comment_id: ${{ github.event.comment.id }}
60
66
  secrets:
@@ -13,7 +13,6 @@ schedule: "%%SCHEDULE%%"
13
13
  model: %%MODEL%%
14
14
  timeout_minutes: %%TIMEOUT%%
15
15
  mention_timeout_minutes: %%MENTION_TIMEOUT%%
16
- pr_mention_timeout_minutes: %%PR_MENTION_TIMEOUT%%
17
16
 
18
17
  bot: %%APP%%[bot]
19
18
  public_bot: %%PUBLIC_APP%%[bot]
@@ -15,7 +15,7 @@ on:
15
15
  required: true
16
16
  type: string
17
17
  kind:
18
- description: "daily | mention | pr-mention"
18
+ description: "daily | mention"
19
19
  required: false
20
20
  default: daily
21
21
  type: string
@@ -29,13 +29,13 @@ on:
29
29
  type: string
30
30
  timeout_minutes:
31
31
  required: false
32
- default: 60
32
+ default: 90
33
33
  type: number
34
34
  allowed_tools:
35
35
  required: false
36
36
  default: "Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch"
37
37
  type: string
38
- # Trigger context. Empty on a scheduled run; set when a comment or a PR woke us.
38
+ # Trigger context. Empty on a scheduled run; set when a comment woke us.
39
39
  issue_number:
40
40
  required: false
41
41
  default: ""
@@ -44,10 +44,6 @@ on:
44
44
  required: false
45
45
  default: ""
46
46
  type: string
47
- pr_number:
48
- required: false
49
- default: ""
50
- type: string
51
47
  secrets:
52
48
  APP_ID:
53
49
  required: true
@@ -102,9 +98,7 @@ jobs:
102
98
  # missed". It sits here, before any checkout, so the eyes land in seconds rather than after
103
99
  # the clones.
104
100
  #
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.
101
+ # `mention` only: a daily run has nothing to react to.
108
102
  #
109
103
  # An `issues` payload carries no comment, so the eyes go on the issue itself. That is the
110
104
  # route where a mention is typed straight into the body of a new issue.
@@ -191,20 +185,6 @@ jobs:
191
185
  [ -d "$dir" ] && identify "$dir" "${PUBLIC_SLUG:-$PRIVATE_SLUG}"
192
186
  done
193
187
 
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
188
  - uses: pnpm/action-setup@v6
209
189
  if: steps.plan.outputs.needs_node == 'true'
210
190
  with:
@@ -226,7 +206,6 @@ jobs:
226
206
  ROSTER_CONTEXT: >-
227
207
  {"issue_number":"${{ inputs.issue_number }}",
228
208
  "comment_id":"${{ inputs.comment_id }}",
229
- "pr_number":"${{ inputs.pr_number }}",
230
209
  "repo":"${{ github.repository }}",
231
210
  "actor":"${{ github.actor }}"}
232
211
  run: |
@@ -274,9 +253,11 @@ jobs:
274
253
  # No --max-turns. A run that legitimately needs more turns should get them: capping it
275
254
  # fails the action *after* the work is done and committed, which is a false red rather
276
255
  # than a saved penny. timeout-minutes is the real bound.
256
+ # The permission flags come from agents.mjs, which is the only thing that knows
257
+ # which agent is running and therefore how to say "may write" to it.
277
258
  claude_args: >-
278
259
  --model ${{ inputs.model }}
279
- --allowedTools "${{ inputs.allowed_tools }}"
260
+ ${{ steps.agent.outputs.flags }}
280
261
  prompt: ${{ steps.compose.outputs.text }}
281
262
 
282
263
  # Any agent with a command line. The prompt is handed over as a file, never as an
@@ -289,6 +270,8 @@ jobs:
289
270
  PUBLIC_TOKEN: ${{ steps.public.outputs.token }}
290
271
  PIPWEB_TOKEN: ${{ steps.public.outputs.token }}
291
272
  AGENT_MODEL: ${{ steps.agent.outputs.model || inputs.model }}
273
+ AGENT_FLAGS: ${{ steps.agent.outputs.flags }}
274
+ # Kept for a custom `run` written before permissions existed.
292
275
  AGENT_TOOLS: ${{ inputs.allowed_tools }}
293
276
  AGENT_TOKEN: ${{ secrets.AGENT_TOKEN }}
294
277
  FALLBACK_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
@@ -24,6 +24,19 @@ 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.
@@ -31,15 +44,19 @@ export const PRESETS = {
31
44
  kind: "action",
32
45
  token_env: "CLAUDE_CODE_OAUTH_TOKEN",
33
46
  model: "claude-opus-5",
47
+ permissions: CLAUDE_TOOLS,
48
+ option: (k, v) => `--${k} ${shellArg(v)}`,
34
49
  },
35
50
 
36
51
  // The same agent through its plain CLI, for anyone who would rather not depend on the action.
37
52
  claude: {
38
53
  kind: "cli",
39
54
  install: "npm install -g @anthropic-ai/claude-code",
40
- run: 'claude -p --model "$AGENT_MODEL" --allowedTools "$AGENT_TOOLS" < "$AGENT_PROMPT_FILE"',
55
+ run: 'claude -p --model "$AGENT_MODEL" $AGENT_FLAGS < "$AGENT_PROMPT_FILE"',
41
56
  token_env: "CLAUDE_CODE_OAUTH_TOKEN",
42
57
  model: "claude-opus-5",
58
+ permissions: CLAUDE_TOOLS,
59
+ option: (k, v) => `--${k} ${shellArg(v)}`,
43
60
  },
44
61
 
45
62
  codex: {
@@ -47,20 +64,66 @@ export const PRESETS = {
47
64
  install: "npm install -g @openai/codex",
48
65
  // `exec -` reads the prompt from stdin. The sandbox has to be opened up because the whole
49
66
  // 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"',
67
+ run: 'codex exec - --model "$AGENT_MODEL" $AGENT_FLAGS < "$AGENT_PROMPT_FILE"',
51
68
  token_env: "CODEX_API_KEY",
52
69
  model: "gpt-5-codex",
70
+ /* Codex spells freedom as a sandbox plus an approval policy, and both have to be said:
71
+ a sandbox that allows writes still stops to ask by default, and a run that stops to ask
72
+ at 07:00 is a run that times out having done nothing. */
73
+ permissions: {
74
+ full: '--sandbox danger-full-access -c approval_policy="never"',
75
+ workspace: '--sandbox workspace-write -c approval_policy="never"',
76
+ "read-only": '--sandbox read-only -c approval_policy="never"',
77
+ },
78
+ // `-c key=value` is its highest-precedence override, so anything else goes through it.
79
+ option: (k, v) => `-c ${k}=${shellArg(JSON.stringify(v))}`,
53
80
  },
54
81
 
55
82
  nanocoder: {
56
83
  kind: "cli",
57
84
  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")"',
85
+ /* `run` is its non-interactive mode; --trust-directory skips the first-run prompt that
86
+ would otherwise hang a runner, and --plain avoids the TUI. The prompt is an argument
87
+ here rather than stdin, so it is read out of the file.
88
+
89
+ NANOCODER_PROVIDERS_FILE is the part that makes it work unattended. Nanocoder is a
90
+ client, not a model: it reads its providers from `agents.config.json` found in the
91
+ working directory. In a session that directory is the workspace root — the place the
92
+ repos are checked out *into* — which belongs to no repo, so a committed config would
93
+ never be found. Pointing at the ops repo's copy gives every staff member the same
94
+ providers from a file that is version controlled. A missing file is ignored, so this is
95
+ safe when somebody has configured it another way. */
96
+ run:
97
+ 'NANOCODER_PROVIDERS_FILE="${NANOCODER_PROVIDERS_FILE:-roster-ops/agents.config.json}" ' +
98
+ 'nanocoder --model "$AGENT_MODEL" $AGENT_FLAGS --trust-directory --plain run "$(cat "$AGENT_PROMPT_FILE")"',
62
99
  token_env: "NANOCODER_API_KEY",
63
100
  model: "",
101
+ /* Its development modes. `plan` is genuinely read-only: it reasons and proposes and edits
102
+ nothing, which is the right answer for a staff member you are not ready to trust yet. */
103
+ permissions: {
104
+ full: "--mode yolo",
105
+ workspace: "--mode auto-accept",
106
+ "read-only": "--mode plan",
107
+ },
108
+ option: (k, v) => `--${k} ${shellArg(v)}`,
109
+ /* A client rather than a model, so it cannot run until it has been told whose model to
110
+ call. Written on init and reported by doctor when it is missing, because the failure
111
+ without it is a run that installs, starts, finds no provider and exits. */
112
+ config: {
113
+ path: "agents.config.json",
114
+ contents: {
115
+ nanocoder: {
116
+ providers: [
117
+ {
118
+ name: "openrouter",
119
+ baseUrl: "https://openrouter.ai/api/v1",
120
+ apiKey: "${NANOCODER_API_KEY}",
121
+ models: ["FILL IN: a model this provider serves, and set it as `model` in org.yaml"],
122
+ },
123
+ ],
124
+ },
125
+ },
126
+ },
64
127
  },
65
128
  };
66
129
 
@@ -96,9 +159,60 @@ export function resolveAgent(org, staff = {}) {
96
159
  token_env: merged.token_env,
97
160
  // The staff member's own model wins; then the agent's default. Empty means "the agent's".
98
161
  model: staff.model ?? merged.model ?? "",
162
+ flags: flagsFor(merged, spec, org, staff),
163
+ config: merged.config ?? null,
99
164
  };
100
165
  }
101
166
 
167
+ /** The three levels, in the order a person would climb them. */
168
+ export const LEVELS = ["read-only", "workspace", "full"];
169
+
170
+ /**
171
+ * What the agent is allowed to do, in its own words.
172
+ *
173
+ * org.yaml says `permissions: full` and every agent hears something different: Claude a list
174
+ * of tool names, Codex a sandbox and an approval policy, nanocoder a development mode. The
175
+ * translation lives here because it is the only place that knows which agent is running, and
176
+ * because the alternative is a config file written in one tool's vocabulary that quietly means
177
+ * nothing to the other two.
178
+ *
179
+ * `options` is the escape hatch, in that agent's own vocabulary, spelled onto its command line
180
+ * by the preset. Anything roster does not model is still reachable without waiting for us.
181
+ */
182
+ function flagsFor(merged, spec, org, staff) {
183
+ const out = [];
184
+ const asked = staff.permissions ?? spec.permissions ?? org.permissions ?? "full";
185
+ if (!LEVELS.includes(asked)) {
186
+ throw new Error(`unknown permissions "${asked}". One of: ${LEVELS.join(", ")}`);
187
+ }
188
+
189
+ /* `allowed_tools` predates the levels and is Claude's own vocabulary, so it still wins for
190
+ an agent that takes a tool list. Nothing translates it for the others: a list written for
191
+ one tool is not a permission level for another, and guessing would be worse than saying so. */
192
+ const tools = staff.allowed_tools ?? org.defaults?.allowed_tools;
193
+ const table = merged.permissions;
194
+ if (tools && table === CLAUDE_TOOLS) {
195
+ const list = Array.isArray(tools) ? tools.join(",") : String(tools);
196
+ out.push(`--allowedTools "${list.replace(/\s+/g, "")}"`);
197
+ } else if (table) {
198
+ out.push(table[asked]);
199
+ }
200
+
201
+ const options = { ...(spec.options ?? {}), ...(staff.options ?? {}) };
202
+ const speller = merged.option;
203
+ for (const [k, v] of Object.entries(options)) {
204
+ if (v === undefined || v === null || v === "") continue;
205
+ if (!speller) {
206
+ throw new Error(
207
+ `agent "${merged.id ?? "custom"}" has options but no way to spell them.\n` +
208
+ " A custom agent takes its options in its own `run` command.",
209
+ );
210
+ }
211
+ out.push(speller(k, v));
212
+ }
213
+ return out.filter(Boolean).join(" ");
214
+ }
215
+
102
216
  function strip(o) {
103
217
  const out = {};
104
218
  for (const [k, v] of Object.entries(o)) if (v !== undefined && v !== null && v !== "") out[k] = v;
@@ -138,6 +252,7 @@ if (import.meta.url === `file://${process.argv[1]}`) {
138
252
  `model=${agent.model}`,
139
253
  `install<<AGENT_EOF_9c1f\n${agent.install}\nAGENT_EOF_9c1f`,
140
254
  `run<<AGENT_EOF_9c1f\n${agent.run}\nAGENT_EOF_9c1f`,
255
+ `flags<<AGENT_EOF_9c1f\n${agent.flags}\nAGENT_EOF_9c1f`,
141
256
  ].join("\n");
142
257
  process.stdout.write(out + "\n");
143
258
  }
@@ -212,9 +212,9 @@ export function render(template, ctx, readPartial, depth = 0) {
212
212
  if (path.startsWith("event.")) {
213
213
  throw new Error(
214
214
  `{{${path}}} needs trigger context, which arrives as ROSTER_CONTEXT.\n` +
215
- ` A "mention" or "pr-mention" prompt is written for a comment that woke it, so it cannot\n` +
215
+ ` A "mention" prompt is written for the comment that woke it, so it cannot\n` +
216
216
  ` be composed without one. To see it locally:\n` +
217
- ` ROSTER_CONTEXT='{"issue_number":"1","comment_id":"1","pr_number":"1","repo":"o/r"}' \\\n` +
217
+ ` ROSTER_CONTEXT='{"issue_number":"1","comment_id":"1","repo":"o/r"}' \\\n` +
218
218
  ` node compose.mjs --staff <handle> --kind <kind>`,
219
219
  );
220
220
  }
@@ -234,6 +234,50 @@ function truthy(v) {
234
234
  return !(v === undefined || v === null || v === false || v === "" || (Array.isArray(v) && v.length === 0));
235
235
  }
236
236
 
237
+ // ---------------------------------------------------------------------------
238
+ // Who the staff answer to
239
+ //
240
+ // One person was written as `human:`. More than one is a `humans:` list. Both are read, the
241
+ // singular still works, and the first entry is the one the prose addresses. Duplicated in
242
+ // roster's own src/lib/humans.ts rather than imported, for the same reason the YAML parser
243
+ // above is: a run must not depend on npm or on a network call.
244
+ // ---------------------------------------------------------------------------
245
+
246
+ export function readHumans(org) {
247
+ const listed = Array.isArray(org?.humans) ? org.humans : org?.humans ? [org.humans] : [];
248
+ const out = [];
249
+ for (const entry of [...listed, org?.human].filter(Boolean)) {
250
+ const github = String(entry.github ?? entry.login ?? "").trim();
251
+ const name = String(entry.name ?? github).trim();
252
+ if (!github && !name) continue;
253
+ if (github && out.some((h) => h.github.toLowerCase() === github.toLowerCase())) continue;
254
+ out.push({
255
+ github,
256
+ name: name || github,
257
+ marker: String(entry.marker ?? "").trim() || defaultMarker(name || github),
258
+ role: entry.role ?? undefined,
259
+ });
260
+ }
261
+ return out;
262
+ }
263
+
264
+ /** "Will (@will-lamerton) and Sam (@sam)", for a sentence in a prompt. */
265
+ export function humanSentence(humans) {
266
+ const parts = humans.map((h) => (h.github ? `${h.name} (@${h.github})` : h.name));
267
+ if (parts.length < 2) return parts[0] ?? "";
268
+ return `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`;
269
+ }
270
+
271
+ function defaultMarker(name) {
272
+ return (
273
+ String(name)
274
+ .trim()
275
+ .split(/[\s-]+/)[0]
276
+ .toLowerCase()
277
+ .replace(/[^a-z0-9]/g, "") || "human"
278
+ );
279
+ }
280
+
237
281
  // ---------------------------------------------------------------------------
238
282
  // Composition
239
283
  // ---------------------------------------------------------------------------
@@ -268,10 +312,18 @@ export function compose({ opsDir, brainsDir, staff, kind }) {
268
312
  }
269
313
  }
270
314
 
315
+ // The `issues` route carries no comment: the ask is the body of a new issue. There is no
316
+ // `{{#unless}}`, and a prompt that tells an agent to read comment `` as its first instruction
317
+ // sends it to a 404 before it has read anything. So the absence is a value of its own.
318
+ if (Object.keys(event).length) event = { ...event, no_comment: !event.comment_id };
319
+
320
+ const humans = readHumans(org);
271
321
  const ctx = {
272
322
  org,
273
323
  event,
274
- human: org.human,
324
+ // The first is who the prose addresses; `humans` is everybody the mention gate accepts.
325
+ human: humans[0] ?? org.human,
326
+ humans,
275
327
  ops: { dir: org.ops_dir ?? "roster-ops" },
276
328
  // `dir` lives in the org registry (it is where the checkout lands), everything
277
329
  // else lives in the staff member's own manifest.
@@ -288,6 +340,11 @@ export function compose({ opsDir, brainsDir, staff, kind }) {
288
340
  // Convenience strings the fragments lean on, computed once here so a
289
341
  // fragment never has to do string work.
290
342
  peer_list: peers.map((p) => `- \`${p.dir}/\` - the ${p.name}'s brain`).join("\n"),
343
+ // Everybody who can wake this staff member and rule on their work, and the same list
344
+ // without the one the prose already names. Empty when there is only one, which is what
345
+ // makes `{{#if humans_extra}}` the right way to mention the others at all.
346
+ human_list: humanSentence(humans),
347
+ humans_extra: humanSentence(humans.slice(1)),
291
348
  };
292
349
 
293
350
  // "staff:foo.md" resolves inside the staff member's own brain repo, which is how a
@@ -315,7 +372,7 @@ function main(argv) {
315
372
  args[argv[i].slice(2)] = argv[i + 1];
316
373
  }
317
374
  if (!args.staff || !args.kind) {
318
- console.error("usage: node compose.mjs --staff <handle> --kind <daily|mention|pr-mention> [--ops DIR] [--brains DIR]");
375
+ console.error("usage: node compose.mjs --staff <handle> --kind <daily|mention> [--ops DIR] [--brains DIR]");
319
376
  process.exit(2);
320
377
  }
321
378
  const opsDir = resolve(args.ops ?? HERE);
@@ -26,12 +26,6 @@ question instead of doing work has wasted its slot.
26
26
  flight.** Stage explicit paths. Doing otherwise has swept someone else's uncommitted work into an
27
27
  unrelated commit.
28
28
 
29
- ## Notifications, and why they matter
30
-
31
- **Anything that needs {{human.name}} must @-mention `@{{human.github}}`.** They get no notification
32
- otherwise, and GitHub does not notify you about your own comments. Assigning also notifies: on a
33
- `decision` issue, do both.
34
-
35
29
  ## The repo is your memory
36
30
 
37
31
  Your chat history is wiped between runs. Your persona, your plan and everything you have learned
@@ -4,7 +4,14 @@ You post as **`{{staff.bot}}`** on the private trackers, not as {{human.name}}.
4
4
  authenticated as that app via `GH_TOKEN`.
5
5
 
6
6
  **Anything that needs {{human.name}} must @-mention `@{{human.github}}`.** They get no notification
7
- otherwise. Assigning also notifies; do both on `decision` issues.
7
+ otherwise, and GitHub does not notify you about your own comments. Assigning also notifies; do both
8
+ on `decision` issues.
9
+
10
+ {{#if humans_extra}}
11
+ {{humans_extra}} can also wake you and rule on your work, and a ruling from any of them is as
12
+ binding as one from {{human.name}}. Reply to whoever asked; address a new `decision` issue to the
13
+ person it is actually for.
14
+ {{/if}}
8
15
 
9
16
  {{#if staff.product}}
10
17
  For anything touching **`{{staff.product.repo}}`**, which is public, use the anonymous identity
@@ -1,5 +1,5 @@
1
- You are **{{staff.name}}** at {{org.name}}. {{human.name}} has asked you something directly, in a
2
- comment on your tracker. **Your reply in that thread is the only thing they will see.**
1
+ You are **{{staff.name}}** at {{org.name}}. {{human.name}} has asked you something directly, on
2
+ your tracker. **Your reply in that thread is the only thing they will see.**
3
3
 
4
4
  **This is not a session.** No boot ritual, no handoff, no rewriting #{{staff.status_issue}}. Answer
5
5
  the question or do the small thing asked, reply, stop.
@@ -10,6 +10,7 @@ the question or do the small thing asked, reply, stop.
10
10
 
11
11
  ## The request
12
12
 
13
+ {{#if event.comment_id}}
13
14
  **Issue #{{event.issue_number}} on `{{event.repo}}`, comment `{{event.comment_id}}`.** Read it
14
15
  first, in full, including the thread around it - a request that looks simple usually has the real
15
16
  ask two comments up.
@@ -18,6 +19,19 @@ ask two comments up.
18
19
  gh api repos/{{event.repo}}/issues/comments/{{event.comment_id}} --jq .body
19
20
  gh api repos/{{event.repo}}/issues/{{event.issue_number}}/comments --jq '.[] | .user.login + ": " + .body'
20
21
  ```
22
+ {{/if}}
23
+ {{#if event.no_comment}}
24
+ **Issue #{{event.issue_number}} on `{{event.repo}}`.** The ask is in the body of the issue
25
+ itself - nobody has commented. Read it first, in full, and read any thread under it.
26
+
27
+ ```
28
+ gh api repos/{{event.repo}}/issues/{{event.issue_number}} --jq .body
29
+ gh api repos/{{event.repo}}/issues/{{event.issue_number}}/comments --jq '.[] | .user.login + ": " + .body'
30
+ ```
31
+
32
+ An issue opened this way often carries a pull request somewhere else, and says where to answer.
33
+ **If it does, that instruction wins over everything below about replying here.**
34
+ {{/if}}
21
35
 
22
36
  `gh issue view --comments` is broken; use `gh api` as above.
23
37