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

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 (97) hide show
  1. package/README.md +70 -84
  2. package/dist/cli.js +4562 -2744
  3. package/docs/README.md +9 -6
  4. package/docs/agents.md +24 -20
  5. package/docs/architecture.md +13 -5
  6. package/docs/charters/analyst.md +65 -0
  7. package/docs/charters/cmo.md +69 -0
  8. package/docs/charters/community.md +63 -0
  9. package/docs/charters/cto.md +71 -0
  10. package/docs/charters/designer.md +65 -0
  11. package/docs/charters/devops.md +65 -0
  12. package/docs/charters/pm.md +70 -0
  13. package/docs/charters/qa.md +65 -0
  14. package/docs/charters/support.md +60 -0
  15. package/docs/charters/writer.md +63 -0
  16. package/docs/commands.md +90 -5
  17. package/docs/concepts.md +48 -14
  18. package/docs/cost.md +36 -1
  19. package/docs/developing.md +16 -21
  20. package/docs/doctor-codes.md +8 -2
  21. package/docs/export.md +2 -0
  22. package/docs/extending.md +2 -2
  23. package/docs/getting-started.md +126 -77
  24. package/docs/images/brain.jpg +0 -0
  25. package/docs/images/org.jpg +0 -0
  26. package/docs/images/prompt.jpg +0 -0
  27. package/docs/images/setup-org.jpg +0 -0
  28. package/docs/images/setup-plan.jpg +0 -0
  29. package/docs/images/staff.jpg +0 -0
  30. package/docs/manual-steps.md +94 -123
  31. package/docs/memory.md +21 -3
  32. package/docs/org-yaml.md +39 -2
  33. package/docs/portal.md +119 -44
  34. package/docs/prompts.md +31 -4
  35. package/docs/security.md +37 -5
  36. package/docs/session-workflow.md +49 -17
  37. package/docs/staff-yaml.md +14 -2
  38. package/docs/troubleshooting.md +8 -8
  39. package/docs/upgrading.md +9 -3
  40. package/docs/writing-a-charter.md +28 -0
  41. package/package.json +1 -1
  42. package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +6 -0
  43. package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +6 -0
  44. package/templates/brain/CHARTER.md +3 -3
  45. package/templates/brain/README.md +1 -0
  46. package/templates/brain/log/decisions.md +3 -0
  47. package/templates/brain/strategy/ideas.md +7 -0
  48. package/templates/briefs/priorities.md +46 -0
  49. package/templates/ops/.github/workflows/session.yaml +108 -14
  50. package/templates/ops/agents.mjs +7 -3
  51. package/templates/ops/compose.mjs +16 -3
  52. package/templates/ops/inflight.mjs +157 -0
  53. package/templates/ops/org/operating.md +21 -1
  54. package/templates/ops/org/voice.md +9 -0
  55. package/templates/ops/prompts/_inflight.md +14 -0
  56. package/templates/ops/prompts/_paths.md +2 -1
  57. package/templates/ops/prompts/daily.md +16 -7
  58. package/templates/ops/prompts/mention.md +2 -0
  59. package/templates/ops/run-record.mjs +144 -0
  60. package/templates/portal/css/base.css +146 -73
  61. package/templates/portal/css/brain.css +23 -20
  62. package/templates/portal/css/diff.css +10 -9
  63. package/templates/portal/css/graph.css +12 -7
  64. package/templates/portal/css/health.css +13 -11
  65. package/templates/portal/css/inbox.css +57 -25
  66. package/templates/portal/css/layout.css +96 -41
  67. package/templates/portal/css/markdown.css +36 -14
  68. package/templates/portal/css/runs.css +13 -0
  69. package/templates/portal/css/setup.css +116 -34
  70. package/templates/portal/index.html +18 -2
  71. package/templates/portal/js/api.js +41 -4
  72. package/templates/portal/js/app.js +132 -9
  73. package/templates/portal/js/dialog.js +82 -0
  74. package/templates/portal/js/icons.js +37 -0
  75. package/templates/portal/js/inflight.js +18 -0
  76. package/templates/portal/js/md.js +5 -2
  77. package/templates/portal/js/mdedit.js +84 -0
  78. package/templates/portal/js/readiness.js +35 -0
  79. package/templates/portal/js/refresh.js +2 -1
  80. package/templates/portal/js/state.js +17 -4
  81. package/templates/portal/js/views/app.js +24 -7
  82. package/templates/portal/js/views/checklist.js +10 -4
  83. package/templates/portal/js/views/credential.js +84 -0
  84. package/templates/portal/js/views/graph.js +1 -1
  85. package/templates/portal/js/views/health.js +17 -4
  86. package/templates/portal/js/views/hire.js +583 -0
  87. package/templates/portal/js/views/inbox.js +248 -81
  88. package/templates/portal/js/views/org.js +46 -106
  89. package/templates/portal/js/views/orgedit.js +234 -0
  90. package/templates/portal/js/views/paste.js +87 -21
  91. package/templates/portal/js/views/prompt.js +11 -4
  92. package/templates/portal/js/views/repos.js +20 -15
  93. package/templates/portal/js/views/runonce.js +94 -0
  94. package/templates/portal/js/views/runs.js +170 -0
  95. package/templates/portal/js/views/setup.js +257 -75
  96. package/templates/portal/js/views/staff.js +158 -243
  97. package/templates/portal/js/views/todo.js +62 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanocollective/roster",
3
- "version": "0.1.0-alpha.4",
3
+ "version": "0.1.0-alpha.40",
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": {
@@ -18,6 +18,12 @@ concurrency:
18
18
 
19
19
  jobs:
20
20
  session:
21
+ # The ceiling on what session.yaml's job token may do: reading the checkout, and the one
22
+ # comment that says a run failed when the App that would normally say so is what broke.
23
+ # A called workflow cannot raise these, so they have to be granted here.
24
+ permissions:
25
+ contents: read
26
+ issues: write
21
27
  uses: %%OPS_REPO%%/.github/workflows/session.yaml@main
22
28
  with:
23
29
  staff: %%STAFF%%
@@ -53,6 +53,12 @@ jobs:
53
53
  (github.event_name == 'issues' &&
54
54
  contains(github.event.issue.body, '%%MENTION%%'))
55
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
56
62
  uses: %%OPS_REPO%%/.github/workflows/session.yaml@main
57
63
  with:
58
64
  staff: %%STAFF%%
@@ -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
- cd %%DIR%% && claude
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.
@@ -25,7 +25,7 @@ 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
@@ -69,11 +69,22 @@ jobs:
69
69
  runs-on: ubuntu-latest
70
70
  timeout-minutes: ${{ inputs.timeout_minutes }}
71
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
+
72
79
  # secrets are not usable in a step-level `if`, so the presence check is hoisted here.
73
80
  env:
74
81
  HAS_PUBLIC_APP: ${{ secrets.PUBLIC_APP_ID != '' }}
75
82
 
76
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
+
77
88
  - name: Mint the private-tracker token
78
89
  id: private
79
90
  uses: actions/create-github-app-token@v2
@@ -161,6 +172,36 @@ jobs:
161
172
  echo "product: $repo -> $dir"
162
173
  done
163
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
+
164
205
  # Commits read as the bot, not as a human, so the git history stays legible.
165
206
  - name: Set git identity
166
207
  env:
@@ -236,13 +277,12 @@ jobs:
236
277
  # `uses:` cannot be an expression, so an Action-based runner has to be written out
237
278
  # literally. This is the reference one; every other agent goes through the step below.
238
279
  - name: Run the session
280
+ id: session_action
239
281
  if: steps.agent.outputs.kind == 'action'
240
282
  uses: anthropics/claude-code-action@v1
241
283
  env:
242
284
  GH_TOKEN: ${{ steps.private.outputs.token }}
243
285
  PUBLIC_TOKEN: ${{ steps.public.outputs.token }}
244
- # Kept as an alias while charters and memory still name it. Retire once they do not.
245
- PIPWEB_TOKEN: ${{ steps.public.outputs.token }}
246
286
  with:
247
287
  claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN || secrets.AGENT_TOKEN }}
248
288
  # These repos are private and single-user, so the usual reason to hide a run's output does
@@ -264,11 +304,11 @@ jobs:
264
304
  # argument: it is thousands of words containing quotes and backticks, and argv limits and
265
305
  # shell quoting fail at 07:00 rather than in review.
266
306
  - name: Run the session
307
+ id: session_cli
267
308
  if: steps.agent.outputs.kind == 'cli'
268
309
  env:
269
310
  GH_TOKEN: ${{ steps.private.outputs.token }}
270
311
  PUBLIC_TOKEN: ${{ steps.public.outputs.token }}
271
- PIPWEB_TOKEN: ${{ steps.public.outputs.token }}
272
312
  AGENT_MODEL: ${{ steps.agent.outputs.model || inputs.model }}
273
313
  AGENT_FLAGS: ${{ steps.agent.outputs.flags }}
274
314
  # Kept for a custom `run` written before permissions existed.
@@ -289,6 +329,10 @@ jobs:
289
329
  # an `env:` key cannot be an expression.
290
330
  export "$TOKEN_ENV=$TOKEN"
291
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"
292
336
 
293
337
  if [ -n "$INSTALL" ]; then
294
338
  echo "::group::install ${{ steps.agent.outputs.id }}"
@@ -297,20 +341,70 @@ jobs:
297
341
  fi
298
342
  eval "$RUN"
299
343
 
300
- # A failed unattended run is otherwise a red X in a tab nobody opens. New in the roster
301
- # migration, and deliberate: every failure path should end somewhere a human reads.
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.
302
378
  - name: Say so if the run did not finish
303
379
  if: failure() || cancelled()
304
380
  env:
305
- GH_TOKEN: ${{ steps.private.outputs.token }}
381
+ APP_TOKEN: ${{ steps.private.outputs.token }}
382
+ JOB_TOKEN: ${{ github.token }}
306
383
  BRAIN_DIR: ${{ steps.plan.outputs.brain_dir }}
307
- 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 }}
308
387
  run: |
309
- set -euo pipefail
310
- issue="${{ inputs.issue_number }}"
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:]#]*}"
311
399
  if [ -z "$issue" ]; then
312
- issue=$(sed -n 's/^status_issue:[[:space:]]*//p' "$BRAIN_DIR/staff.yaml" | head -1 || true)
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
313
407
  fi
314
- [ -n "$issue" ] || { echo "no status_issue in staff.yaml; nothing to comment on"; exit 0; }
315
- gh issue comment "$issue" --repo "$BRAIN_REPO" --body \
316
- "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."
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."
@@ -43,7 +43,7 @@ export const PRESETS = {
43
43
  "claude-code-action": {
44
44
  kind: "action",
45
45
  token_env: "CLAUDE_CODE_OAUTH_TOKEN",
46
- model: "claude-opus-5",
46
+ model: "claude-opus-5-5",
47
47
  permissions: CLAUDE_TOOLS,
48
48
  option: (k, v) => `--${k} ${shellArg(v)}`,
49
49
  },
@@ -52,9 +52,13 @@ export const PRESETS = {
52
52
  claude: {
53
53
  kind: "cli",
54
54
  install: "npm install -g @anthropic-ai/claude-code",
55
- run: 'claude -p --model "$AGENT_MODEL" $AGENT_FLAGS < "$AGENT_PROMPT_FILE"',
55
+ // JSON rather than text so the run record can read turns and cost off it. The log still
56
+ // carries the answer, inside the `result` field.
57
+ run:
58
+ 'claude -p --model "$AGENT_MODEL" $AGENT_FLAGS --output-format json < "$AGENT_PROMPT_FILE"' +
59
+ ' | tee "$AGENT_RESULT_FILE"',
56
60
  token_env: "CLAUDE_CODE_OAUTH_TOKEN",
57
- model: "claude-opus-5",
61
+ model: "claude-opus-5-5",
58
62
  permissions: CLAUDE_TOOLS,
59
63
  option: (k, v) => `--${k} ${shellArg(v)}`,
60
64
  },
@@ -186,6 +186,12 @@ function parseScalar(raw, line) {
186
186
 
187
187
  const MAX_INCLUDE_DEPTH = 8;
188
188
 
189
+ /* A partial's output is scanned again by the template that included it, so a value containing
190
+ `{{` would be rendered as though somebody had written it into a prompt: a PR title saying
191
+ `{{nope}}` failed the run, and one saying `{{> some/file}}` read that file in. Values are
192
+ fenced off with a character no template can contain, and let back out once, at the end. */
193
+ const FENCE = "\u0000";
194
+
189
195
  export function render(template, ctx, readPartial, depth = 0) {
190
196
  if (depth > MAX_INCLUDE_DEPTH) throw new Error("include depth exceeded; a partial probably includes itself");
191
197
 
@@ -220,10 +226,10 @@ export function render(template, ctx, readPartial, depth = 0) {
220
226
  }
221
227
  throw new Error(`unknown or empty placeholder: {{${path}}}`);
222
228
  }
223
- return String(v);
229
+ return String(v).replace(/\{\{/g, FENCE);
224
230
  });
225
231
 
226
- return out;
232
+ return depth === 0 ? out.replaceAll(FENCE, "{{") : out;
227
233
  }
228
234
 
229
235
  function lookup(ctx, path) {
@@ -282,7 +288,7 @@ function defaultMarker(name) {
282
288
  // Composition
283
289
  // ---------------------------------------------------------------------------
284
290
 
285
- export function compose({ opsDir, brainsDir, staff, kind }) {
291
+ export function compose({ opsDir, brainsDir, staff, kind, runDir }) {
286
292
  const org = parseYaml(readFileSync(join(opsDir, "org.yaml"), "utf8"), "org.yaml");
287
293
 
288
294
  const entry = (org.staff ?? []).find((s) => s.handle === staff);
@@ -317,6 +323,12 @@ export function compose({ opsDir, brainsDir, staff, kind }) {
317
323
  // sends it to a 404 before it has read anything. So the absence is a value of its own.
318
324
  if (Object.keys(event).length) event = { ...event, no_comment: !event.comment_id };
319
325
 
326
+ // What people have open on the product repos, gathered by inflight.mjs just before this runs.
327
+ // Read as a value and never rendered as a template: it is PR titles, which are a person's
328
+ // words, and a `{{` in one must not be able to break composition.
329
+ const inflightFile = join(runDir ?? join(brainsDir, ".roster-run"), "inflight.md");
330
+ const inflight = existsSync(inflightFile) ? readFileSync(inflightFile, "utf8").trim() : "";
331
+
320
332
  const humans = readHumans(org);
321
333
  const ctx = {
322
334
  org,
@@ -344,6 +356,7 @@ export function compose({ opsDir, brainsDir, staff, kind }) {
344
356
  // without the one the prose already names. Empty when there is only one, which is what
345
357
  // makes `{{#if humans_extra}}` the right way to mention the others at all.
346
358
  human_list: humanSentence(humans),
359
+ inflight,
347
360
  humans_extra: humanSentence(humans.slice(1)),
348
361
  };
349
362
 
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env node
2
+ // Finds the pull requests people have open on a staff member's product repos, before the prompt
3
+ // is composed, so the agent knows what a human is in the middle of changing.
4
+ //
5
+ // Written because nothing told them. While a person had a long branch open rewriting a product's
6
+ // copy, the staff opened five pull requests and nine issues chasing that same copy, and four of
7
+ // those pull requests were overtaken by the branch. Each one was reasonable on its own; none of
8
+ // them could see the branch.
9
+ //
10
+ // Vendored alongside compose.mjs for the same reason: it runs on the runner, and must not depend
11
+ // on npm. It asks GitHub through `gh`, which every runner has, and it never fails a run: with no
12
+ // answer the prompt simply has no section about it.
13
+ //
14
+ // Usage: node roster-ops/inflight.mjs --staff cto --ops roster-ops --brains . --out .roster-run/inflight.md
15
+
16
+ import { execFileSync } from "node:child_process";
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { dirname, join, resolve } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { parseYaml } from "./compose.mjs";
21
+
22
+ /** Enough to see an overlap without reading a 37,000-line branch into the prompt. */
23
+ const MAX_PRS = 10;
24
+ const MAX_FILES = 20;
25
+ const MAX_DIRS = 6;
26
+
27
+ /**
28
+ * A person, rather than one of this org's Apps or anybody else's automation.
29
+ *
30
+ * `gh` marks an App author as a bot and names it `app/<slug>`; a bot's own login ends `[bot]`.
31
+ * A machine user is none of those, which is why the org's own bot logins are passed in too.
32
+ */
33
+ export function isHuman(author, bots = []) {
34
+ if (!author) return false;
35
+ const login = String(author.login ?? "");
36
+ if (!login || author.is_bot) return false;
37
+ if (login.startsWith("app/") || /\[bot\]$/i.test(login)) return false;
38
+ const bare = (s) => String(s).toLowerCase().replace(/\[bot\]$/, "").replace(/^app\//, "");
39
+ return !bots.some((b) => bare(b) === bare(login));
40
+ }
41
+
42
+ /** The repos a staff member works in: their own `works_in`, or else every product repo. */
43
+ export function productRepos(org, manifest) {
44
+ const own = (manifest?.works_in ?? []).map((w) => String(w?.repo ?? "")).filter(Boolean);
45
+ if (own.length) return own;
46
+ return (org.repos ?? []).filter((r) => r.role === "product").map((r) => `${org.org}/${r.name}`);
47
+ }
48
+
49
+ /** The top directories a change touches, so a wide one reads as "the copy" rather than a list. */
50
+ function directories(paths) {
51
+ const counts = new Map();
52
+ for (const p of paths) {
53
+ const parts = p.split("/");
54
+ const dir = parts.length === 1 ? "(root)" : parts.slice(0, Math.min(2, parts.length - 1)).join("/") + "/";
55
+ counts.set(dir, (counts.get(dir) ?? 0) + 1);
56
+ }
57
+ return [...counts].sort((a, b) => b[1] - a[1]).slice(0, MAX_DIRS);
58
+ }
59
+
60
+ function days(iso, now) {
61
+ const d = Math.floor((now - new Date(iso).getTime()) / 86400_000);
62
+ return d <= 0 ? "today" : d === 1 ? "1 day" : `${d} days`;
63
+ }
64
+
65
+ /**
66
+ * The markdown the prompt carries, or "" for nothing in flight.
67
+ *
68
+ * Titles are a person's words and go in as data: compose.mjs substitutes this file as a value,
69
+ * never renders it as a template, so a `{{` in a title cannot break a run.
70
+ */
71
+ export function describe(found, now = Date.now()) {
72
+ const lines = [];
73
+ for (const { repo, prs } of found) {
74
+ for (const pr of prs.slice(0, MAX_PRS)) {
75
+ const paths = (pr.files ?? []).map((f) => f.path).filter(Boolean);
76
+ const total = Math.max(pr.changedFiles ?? 0, paths.length);
77
+ const title = String(pr.title ?? "").replace(/\s+/g, " ").trim();
78
+ lines.push(
79
+ `- **${repo}#${pr.number}** "${title}" by @${pr.author.login}, open ${days(pr.createdAt, now)}` +
80
+ `, branch \`${pr.headRefName}\`${pr.isDraft ? ", draft" : ""}, ${total} file${total === 1 ? "" : "s"}`,
81
+ );
82
+ if (!paths.length) continue;
83
+ if (total > MAX_FILES) {
84
+ lines.push(` - mostly under ${directories(paths).map(([d, n]) => `\`${d}\` (${n})`).join(", ")}`);
85
+ }
86
+ const shown = paths.slice(0, MAX_FILES).map((p) => `\`${p}\``).join(", ");
87
+ const more = total - Math.min(paths.length, MAX_FILES);
88
+ lines.push(` - ${shown}${more > 0 ? `, and ${more} more` : ""}`);
89
+ }
90
+ if (prs.length > MAX_PRS) lines.push(`- and ${prs.length - MAX_PRS} more open on ${repo}`);
91
+ }
92
+ return lines.length ? lines.join("\n") + "\n" : "";
93
+ }
94
+
95
+ /** Everything open by a person, per repo. `gh` is passed in so a test never needs the network. */
96
+ export function gather({ org, manifest, gh }) {
97
+ const bots = [manifest?.bot, manifest?.public_bot].filter(Boolean);
98
+ const out = [];
99
+ for (const repo of productRepos(org, manifest)) {
100
+ let raw;
101
+ try {
102
+ raw = gh([
103
+ "pr",
104
+ "list",
105
+ "--repo",
106
+ repo,
107
+ "--state",
108
+ "open",
109
+ "--limit",
110
+ "50",
111
+ "--json",
112
+ "number,title,author,createdAt,headRefName,isDraft,changedFiles,files",
113
+ ]);
114
+ } catch (err) {
115
+ // One unreadable repo is not a reason to say nothing about the others.
116
+ console.error(`::warning::inflight: ${repo}: ${String(err.message).split("\n")[0]}`);
117
+ continue;
118
+ }
119
+ const prs = JSON.parse(raw || "[]")
120
+ .filter((pr) => isHuman(pr.author, bots))
121
+ // Oldest first: the long-lived branch is the one most likely to be overtaking everybody.
122
+ .sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
123
+ if (prs.length) out.push({ repo, prs });
124
+ }
125
+ return out;
126
+ }
127
+
128
+ function main(argv) {
129
+ const args = {};
130
+ for (let i = 0; i < argv.length; i += 2) args[argv[i].replace(/^--/, "")] = argv[i + 1];
131
+ if (!args.staff) throw new Error("--staff is required");
132
+ const opsDir = resolve(args.ops ?? ".");
133
+ const org = parseYaml(readFileSync(join(opsDir, "org.yaml"), "utf8"), "org.yaml");
134
+ const entry = (org.staff ?? []).find((s) => s.handle === args.staff);
135
+ if (!entry) throw new Error(`org.yaml has no staff member "${args.staff}"`);
136
+ const manifestPath = join(resolve(args.brains ?? ".."), entry.dir ?? entry.handle, "staff.yaml");
137
+ const manifest = existsSync(manifestPath)
138
+ ? parseYaml(readFileSync(manifestPath, "utf8"), "staff.yaml")
139
+ : {};
140
+
141
+ const gh = (a) => execFileSync("gh", a, { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
142
+ const text = describe(gather({ org, manifest, gh }));
143
+ const out = resolve(args.out ?? ".roster-run/inflight.md");
144
+ mkdirSync(dirname(out), { recursive: true });
145
+ writeFileSync(out, text);
146
+ process.stdout.write(text || "no human pull requests open on the product repos\n");
147
+ }
148
+
149
+ if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
150
+ try {
151
+ main(process.argv.slice(2));
152
+ } catch (err) {
153
+ // Never the reason a run fails. Without it the prompt has no section, which is how every
154
+ // run went before this existed.
155
+ console.error(`::warning::inflight: ${err.message}`);
156
+ }
157
+ }
@@ -15,13 +15,33 @@ question instead of doing work has wasted its slot.
15
15
  - **Never end a run blocked.** If everything on the list is genuinely blocked, do the most useful
16
16
  unblocked thing you can find and say so in the report.
17
17
 
18
+ ## Choosing work
19
+
20
+ - **Serve the priorities.** Work that serves none of the ranked priorities in `org/priorities.md`
21
+ waits, unless something is broken. Say which priority a PR serves.
22
+ - **Product before process.** Guards, checks, claim-policing and measuring your own output earn a
23
+ run when they protect something that has shipped. Most runs should move the product forward; if
24
+ your last few went on meta-work, this one does not.
25
+
26
+ ## Keeping your tracker clean
27
+
28
+ {{human.name}} should never have to ask whether an issue can be closed.
29
+
30
+ - **Close your own issues** when the work is done or superseded, with one line naming what closed
31
+ it: the PR, the commit, or the issue that replaced it. Do not leave one open "in case".
32
+ - **Sweep them on every daily run.** Read every open issue you opened; close what is finished or
33
+ stale, and fold duplicates into one.
34
+ - **Ideas live in your brain, not on the tracker.** Park a speculative idea as one line in
35
+ `strategy/ideas.md`. Open an `IDEA:` issue only when it needs a ruling, and never more than one
36
+ at a time.
37
+
18
38
  ## What you may not do
19
39
 
20
40
  - **You cannot ship to the outside world.** Anything public goes through {{human.name}}. The gate is
21
41
  mechanical rather than a promise: protected branches mean you open a PR and their merge is the
22
42
  approval. **Do not look for a way around it.** Being unable to ship unreviewed is what earns the
23
43
  autonomy.
24
- - **Never close a `decision` issue.** Those are {{human.name}}'s rulings to close.
44
+ - **Never close a `decision` issue**, even in a sweep. Those are {{human.name}}'s rulings to close.
25
45
  - **Never `git add -A` in another staff member's repo, or in a repo where a human may have work in
26
46
  flight.** Stage explicit paths. Doing otherwise has swept someone else's uncommitted work into an
27
47
  unrelated commit.
@@ -12,6 +12,15 @@ bodies, run reports, briefs to other staff, and how you talk to them in a sessio
12
12
  - **An issue title is the ask, not the topic.**
13
13
  - **Say the default** on anything needing a ruling: what you do if they say nothing.
14
14
 
15
+ **Length ceilings.** Lead with the outcome or the ask, then stop at:
16
+
17
+ - **A comment or reply: 100 words.** Most need three lines.
18
+ - **An issue or PR body: 200 words.**
19
+ - **The pinned status issue: 300 words**, readable on one phone screen.
20
+
21
+ Past the ceiling, the detail goes in a file in your brain and the comment links to it in one
22
+ line. A status {{human.name}} has to rewrite before they can use it has cost more than it saved.
23
+
15
24
  **Cut on sight:**
16
25
 
17
26
  - Context they already have. They founded this; it does not need explaining to them.
@@ -0,0 +1,14 @@
1
+ {{#if inflight}}
2
+ ## Human work in flight
3
+
4
+ People have these pull requests open on the product repos, and each one is theirs. A long-lived
5
+ branch is usually rewriting what you would otherwise be about to change.
6
+
7
+ - **Do not open competing work on files they touch**: no pull request, and no issue asking for a
8
+ change there. It gets overtaken when their branch lands, and it costs them a review first.
9
+ - **If you have something to say about that work, say it on their pull request**, briefly, and
10
+ leave the decision to them.
11
+ - If today's task sits on those files, say so in your report and take the next thing.
12
+
13
+ {{inflight}}
14
+ {{/if}}
@@ -5,7 +5,8 @@ repos sit side by side inside it:
5
5
 
6
6
  - `{{staff.dir}}/` - your brain. **Start by reading it.**
7
7
  - `{{ops.dir}}/` - the org's shared brain: `org/operating.md`, `org/voice.md`, `org/guardrails.md`,
8
- `org/business.md`. **Read-only to you.** Propose a change as a PR; do not edit it in place.
8
+ `org/business.md`, `org/priorities.md`. **Read-only to you.** Propose a change as a PR; do not
9
+ edit it in place.
9
10
  {{#if peers}}
10
11
  {{peer_list}}
11
12
  {{/if}}