@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.
- package/dist/cli.js +848 -164
- package/docs/README.md +10 -5
- package/docs/agents.md +320 -9
- package/docs/commands.md +5 -6
- package/docs/concepts.md +27 -9
- package/docs/cost.md +3 -2
- package/docs/doctor-codes.md +13 -4
- package/docs/export.md +2 -1
- package/docs/extending.md +11 -2
- package/docs/getting-started.md +89 -84
- 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 +36 -13
- package/docs/memory.md +9 -6
- package/docs/org-yaml.md +37 -9
- package/docs/portal.md +197 -31
- package/docs/prompts.md +50 -11
- package/docs/security.md +19 -7
- package/docs/session-workflow.md +8 -10
- package/docs/staff-yaml.md +3 -5
- package/docs/troubleshooting.md +17 -14
- package/docs/writing-a-charter.md +18 -17
- package/package.json +1 -1
- package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +1 -0
- package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +10 -4
- package/templates/brain/staff.yaml +0 -1
- package/templates/ops/.github/workflows/session.yaml +9 -26
- package/templates/ops/agents.mjs +121 -6
- package/templates/ops/compose.mjs +61 -4
- package/templates/ops/org/operating.md +0 -6
- package/templates/ops/prompts/_identity.md +8 -1
- package/templates/ops/prompts/mention.md +16 -2
- package/templates/portal/css/base.css +122 -8
- package/templates/portal/css/brain.css +8 -1
- package/templates/portal/css/diff.css +6 -2
- package/templates/portal/css/health.css +21 -2
- package/templates/portal/css/inbox.css +93 -5
- package/templates/portal/css/layout.css +26 -4
- package/templates/portal/css/markdown.css +23 -3
- package/templates/portal/css/setup.css +11 -6
- package/templates/portal/index.html +7 -1
- package/templates/portal/js/api.js +33 -0
- package/templates/portal/js/app.js +33 -7
- package/templates/portal/js/dialog.js +47 -4
- package/templates/portal/js/dom.js +25 -0
- package/templates/portal/js/icons.js +8 -1
- package/templates/portal/js/lightbox.js +273 -0
- package/templates/portal/js/md.js +23 -6
- package/templates/portal/js/mention.js +264 -0
- package/templates/portal/js/refresh.js +136 -6
- package/templates/portal/js/state.js +47 -5
- package/templates/portal/js/views/checklist.js +20 -7
- package/templates/portal/js/views/docs.js +94 -4
- package/templates/portal/js/views/files.js +58 -14
- package/templates/portal/js/views/health.js +163 -35
- package/templates/portal/js/views/inbox.js +882 -96
- package/templates/portal/js/views/memory.js +16 -1
- package/templates/portal/js/views/org.js +142 -62
- package/templates/portal/js/views/prompt.js +50 -63
- package/templates/portal/js/views/setup.js +37 -13
- package/templates/portal/js/views/staff.js +62 -2
- 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
|
@@ -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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
```bash
|
|
34
|
+
roster brief charter <handle> # or: roster brief charter cto | pbcopy
|
|
35
|
+
```
|
|
34
36
|
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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
|
@@ -30,20 +30,25 @@ 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%%'))
|
|
@@ -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:
|
|
@@ -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
|
|
@@ -29,13 +29,13 @@ on:
|
|
|
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
|
|
@@ -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
|
|
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
|
-
|
|
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 }}
|
package/templates/ops/agents.mjs
CHANGED
|
@@ -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"
|
|
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"
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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"
|
|
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","
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
2
|
-
|
|
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
|
|