team 0.0.1 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +310 -0
  3. package/dist/approve/approval.d.ts +38 -0
  4. package/dist/approve/approval.js +78 -0
  5. package/dist/approve/diff.d.ts +13 -0
  6. package/dist/approve/diff.js +47 -0
  7. package/dist/approve/fingerprint.d.ts +44 -0
  8. package/dist/approve/fingerprint.js +109 -0
  9. package/dist/args.d.ts +7 -0
  10. package/dist/args.js +22 -0
  11. package/dist/budgets/checks.d.ts +35 -0
  12. package/dist/budgets/checks.js +77 -0
  13. package/dist/budgets/gate.d.ts +24 -0
  14. package/dist/budgets/gate.js +127 -0
  15. package/dist/budgets/readings.d.ts +69 -0
  16. package/dist/budgets/readings.js +156 -0
  17. package/dist/budgets/run.d.ts +66 -0
  18. package/dist/budgets/run.js +119 -0
  19. package/dist/budgets/table.d.ts +24 -0
  20. package/dist/budgets/table.js +90 -0
  21. package/dist/caller.d.ts +48 -0
  22. package/dist/caller.js +126 -0
  23. package/dist/check/config.d.ts +51 -0
  24. package/dist/check/config.js +50 -0
  25. package/dist/check/git.d.ts +27 -0
  26. package/dist/check/git.js +97 -0
  27. package/dist/check/message.d.ts +27 -0
  28. package/dist/check/message.js +101 -0
  29. package/dist/check/run.d.ts +31 -0
  30. package/dist/check/run.js +77 -0
  31. package/dist/check/signature.d.ts +10 -0
  32. package/dist/check/signature.js +22 -0
  33. package/dist/cli.d.ts +9 -0
  34. package/dist/cli.js +76 -0
  35. package/dist/clis.d.ts +7 -0
  36. package/dist/clis.js +9 -0
  37. package/dist/commands/add.d.ts +23 -0
  38. package/dist/commands/add.js +445 -0
  39. package/dist/commands/approve.d.ts +11 -0
  40. package/dist/commands/approve.js +146 -0
  41. package/dist/commands/check.d.ts +17 -0
  42. package/dist/commands/check.js +103 -0
  43. package/dist/commands/doctor.d.ts +38 -0
  44. package/dist/commands/doctor.js +232 -0
  45. package/dist/commands/down.d.ts +37 -0
  46. package/dist/commands/down.js +258 -0
  47. package/dist/commands/init.d.ts +9 -0
  48. package/dist/commands/init.js +138 -0
  49. package/dist/commands/remove.d.ts +23 -0
  50. package/dist/commands/remove.js +235 -0
  51. package/dist/commands/status.d.ts +41 -0
  52. package/dist/commands/status.js +147 -0
  53. package/dist/commands/up.d.ts +49 -0
  54. package/dist/commands/up.js +362 -0
  55. package/dist/commands/watch.d.ts +35 -0
  56. package/dist/commands/watch.js +324 -0
  57. package/dist/commands/worktree.d.ts +12 -0
  58. package/dist/commands/worktree.js +388 -0
  59. package/dist/end/condition.d.ts +28 -0
  60. package/dist/end/condition.js +77 -0
  61. package/dist/file/current.d.ts +13 -0
  62. package/dist/file/current.js +53 -0
  63. package/dist/file/lines.d.ts +25 -0
  64. package/dist/file/lines.js +277 -0
  65. package/dist/file/load.d.ts +7 -0
  66. package/dist/file/load.js +90 -0
  67. package/dist/file/paths.d.ts +6 -0
  68. package/dist/file/paths.js +77 -0
  69. package/dist/file/secrets.d.ts +6 -0
  70. package/dist/file/secrets.js +54 -0
  71. package/dist/file/signature.d.ts +13 -0
  72. package/dist/file/signature.js +36 -0
  73. package/dist/file/types.d.ts +130 -0
  74. package/dist/file/types.js +1 -0
  75. package/dist/file/validate.d.ts +13 -0
  76. package/dist/file/validate.js +702 -0
  77. package/dist/file/write.d.ts +11 -0
  78. package/dist/file/write.js +13 -0
  79. package/dist/herdr.d.ts +38 -0
  80. package/dist/herdr.js +227 -0
  81. package/dist/index.d.ts +10 -0
  82. package/dist/index.js +6 -0
  83. package/dist/io.d.ts +10 -0
  84. package/dist/io.js +1 -0
  85. package/dist/launch/deliver.d.ts +10 -0
  86. package/dist/launch/deliver.js +39 -0
  87. package/dist/launch/execute.d.ts +46 -0
  88. package/dist/launch/execute.js +284 -0
  89. package/dist/launch/plan.d.ts +185 -0
  90. package/dist/launch/plan.js +280 -0
  91. package/dist/launch/rules.d.ts +25 -0
  92. package/dist/launch/rules.js +36 -0
  93. package/dist/log.d.ts +1 -0
  94. package/dist/log.js +24 -0
  95. package/dist/profiles/antigravity.yaml +66 -0
  96. package/dist/profiles/claude-code.yaml +69 -0
  97. package/dist/profiles/codex.yaml +77 -0
  98. package/dist/profiles/cursor.yaml +68 -0
  99. package/dist/profiles/index.d.ts +1 -0
  100. package/dist/profiles/index.js +1 -0
  101. package/dist/profiles/profile.d.ts +60 -0
  102. package/dist/profiles/profile.js +313 -0
  103. package/dist/profiles/quota.d.ts +27 -0
  104. package/dist/profiles/quota.js +57 -0
  105. package/dist/state.d.ts +61 -0
  106. package/dist/state.js +127 -0
  107. package/dist/status/compare.d.ts +26 -0
  108. package/dist/status/compare.js +110 -0
  109. package/dist/status/statusline.d.ts +9 -0
  110. package/dist/status/statusline.js +26 -0
  111. package/dist/store/store.d.ts +62 -0
  112. package/dist/store/store.js +116 -0
  113. package/dist/watch/check.d.ts +82 -0
  114. package/dist/watch/check.js +29 -0
  115. package/dist/watch/checks/approval.d.ts +2 -0
  116. package/dist/watch/checks/approval.js +16 -0
  117. package/dist/watch/checks/attention.d.ts +2 -0
  118. package/dist/watch/checks/attention.js +23 -0
  119. package/dist/watch/checks/budget.d.ts +2 -0
  120. package/dist/watch/checks/budget.js +141 -0
  121. package/dist/watch/checks/disk.d.ts +2 -0
  122. package/dist/watch/checks/disk.js +13 -0
  123. package/dist/watch/checks/extra.d.ts +2 -0
  124. package/dist/watch/checks/extra.js +18 -0
  125. package/dist/watch/checks/idle.d.ts +2 -0
  126. package/dist/watch/checks/idle.js +32 -0
  127. package/dist/watch/checks/load.d.ts +2 -0
  128. package/dist/watch/checks/load.js +11 -0
  129. package/dist/watch/checks/memory.d.ts +2 -0
  130. package/dist/watch/checks/memory.js +11 -0
  131. package/dist/watch/checks/missing.d.ts +2 -0
  132. package/dist/watch/checks/missing.js +12 -0
  133. package/dist/watch/checks/model-drift.d.ts +2 -0
  134. package/dist/watch/checks/model-drift.js +14 -0
  135. package/dist/watch/checks/swap-free.d.ts +2 -0
  136. package/dist/watch/checks/swap-free.js +12 -0
  137. package/dist/watch/checks/swap-growth.d.ts +2 -0
  138. package/dist/watch/checks/swap-growth.js +18 -0
  139. package/dist/watch/checks/team-idle.d.ts +2 -0
  140. package/dist/watch/checks/team-idle.js +18 -0
  141. package/dist/watch/checks/unsent.d.ts +2 -0
  142. package/dist/watch/checks/unsent.js +18 -0
  143. package/dist/watch/close.d.ts +19 -0
  144. package/dist/watch/close.js +40 -0
  145. package/dist/watch/dialect.d.ts +5 -0
  146. package/dist/watch/dialect.js +430 -0
  147. package/dist/watch/end.d.ts +11 -0
  148. package/dist/watch/end.js +17 -0
  149. package/dist/watch/machine.d.ts +38 -0
  150. package/dist/watch/machine.js +137 -0
  151. package/dist/watch/notify.d.ts +1 -0
  152. package/dist/watch/notify.js +15 -0
  153. package/dist/watch/pass.d.ts +42 -0
  154. package/dist/watch/pass.js +242 -0
  155. package/dist/watch/screen-core.d.ts +20 -0
  156. package/dist/watch/screen-core.js +356 -0
  157. package/dist/watch/screen-data.d.ts +65 -0
  158. package/dist/watch/screen-data.js +1 -0
  159. package/dist/watch/screen-file.d.ts +2 -0
  160. package/dist/watch/screen-file.js +269 -0
  161. package/dist/watch/screen.d.ts +24 -0
  162. package/dist/watch/screen.js +47 -0
  163. package/dist/worktree/place.d.ts +18 -0
  164. package/dist/worktree/place.js +126 -0
  165. package/dist/yaml.d.ts +30 -0
  166. package/dist/yaml.js +380 -0
  167. package/examples/checks/codex-quota +217 -0
  168. package/examples/team.yaml +76 -0
  169. package/package.json +29 -10
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Floor
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,310 @@
1
+ # team
2
+
3
+ Set up, change and watch a project's team of AI agents from one file.
4
+
5
+ `team` is a small command-line tool with no runtime dependencies. A project declares its team in
6
+ `.agents/team.yaml`: the seats, the model each one runs, how each agent signs its work, the rules it
7
+ works under, the folders it may touch. Commands then check that file against a machine, a session
8
+ and a history, and build and watch the team itself. Version 0.1 runs teams in
9
+ [herdr](https://herdr.dev).
10
+
11
+ **Status: 0.1, early: herdr only; trust is specified, not built yet.** This build parses and
12
+ validates the file, checks who is calling, and holds `add`, `approve`, `check`, `doctor`, `down`,
13
+ `init`, `remove`, `status`, `up`, `watch` and `worktree`.
14
+
15
+ ## Install
16
+
17
+ Node 22 or later runs the built command.
18
+
19
+ ```sh
20
+ npm install -g team # or run it without installing: npx team
21
+ team --version # 0.1.1
22
+ ```
23
+
24
+ ## The file is private to each clone
25
+
26
+ `team init` keeps `.agents/team.yaml` out of git through `.git/info/exclude`, never by editing
27
+ `.gitignore`: a public repository shouldn't carry its roster. A fresh clone therefore has no team
28
+ file. Run `team init` to write one, or `team init --restore` to bring back the copy you last
29
+ approved on this machine. A file you receive from someone else runs nothing until you approve it
30
+ yourself.
31
+
32
+ ## The file's format
33
+
34
+ A documented subset of YAML, read by the library's own parser: maps, lists, one-line `{ }` and
35
+ `[ ]`, plain and quoted values, comments. Anchors, aliases, tags, block scalars, several documents
36
+ in one file and duplicate keys are refused, with the line number. The file starts with `format: 1`.
37
+ By example:
38
+
39
+ ```yaml
40
+ format: 1 # the only format this version reads
41
+ project: hello
42
+ coordinator: claude-coord # the seat that dispatches work
43
+ operator: claude-coord # the seat the watch reports to
44
+
45
+ identity:
46
+ signature:
47
+ commits:
48
+ position: trailer # last-line | trailer | anywhere
49
+ exempt: [merge] # merge commits need no signature
50
+
51
+ rules: # lines added to every seat's rules at launch
52
+ - Run the tests your change touches, not the whole suite.
53
+
54
+ workspace:
55
+ mode: shared # shared | worktree: the default for every seat
56
+
57
+ seats:
58
+ - role: coordinator
59
+ name: claude-coord
60
+ cli: claude-code # the launch profile
61
+ vendor: anthropic # the model's maker
62
+ model: Claude Opus # the model's name, without its version
63
+ version: "5.5" # the release alone, quoted
64
+ launch: claude --model claude-opus-5-5 # no approval flags: the profile adds them
65
+
66
+ - role: implementer
67
+ name: codex-hello
68
+ cli: codex
69
+ vendor: openai
70
+ model: GPT Sol
71
+ version: "6"
72
+ display: GPT-6 Sol # the vendor's spelling, for the signature
73
+ launch: codex -m gpt-6-sol -c model_reasoning_effort=high
74
+ parked: true # running, and not reported while idle
75
+
76
+ - role: implementer
77
+ name: deepseek-hello
78
+ cli: claude-code # DeepSeek's model, run by Claude Code
79
+ vendor: deepseek
80
+ model: DeepSeek Flash
81
+ version: "V4.1"
82
+ display: DeepSeek V4.1 Flash
83
+ launch: team-deepseek # a launcher on the PATH, holding the account's key and endpoint
84
+ count: 2 # deepseek-hello and deepseek-hello-2
85
+
86
+ - role: reviewer
87
+ name: grok-hello
88
+ cli: grok
89
+ vendor: xai
90
+ model: Grok
91
+ version: "4.7"
92
+ launch: grok --model grok-4.7
93
+ stopped: true # kept in the file; `up` doesn't start it
94
+ ```
95
+
96
+ - `session` names the herdr session and defaults to `project`; `--session` overrides it.
97
+ - `coordinator` and `operator` name seats: the coordinator dispatches work, the operator receives
98
+ the watch's reports and nudges.
99
+ - `identity.signature` is the rule `check` enforces: a template, where it must stand, and which
100
+ commits are exempt. Commit signatures read `Agent: {display} · {role}`, pull request bodies
101
+ `**Agent:** {display} · {role}`. Without `display`, the signature reads "model version"; with it,
102
+ the vendor's own spelling. `identity.since` skips an older history, `identity.humans` lists commit
103
+ authors who don't sign, and `identity.forbidden` adds to the defaults — `^Claude-Session:` lines
104
+ and session links are always refused.
105
+ - `seats[*].cli` picks the launch profile; `claude-code`, `codex`, `cursor` and `antigravity` are available, and `team
106
+ doctor` says what the others still need. `vendor`, `model` and `version` spell one seat's model.
107
+ - `launch` is the plain command, without approval flags: the profile adds them. `count: 2` makes the
108
+ numbered names; `parked` keeps a seat out of idle reports, `stopped` keeps it out of `up`.
109
+ - `workspace.mode` is `shared` (every seat in the project) or `worktree` (each task in its own
110
+ checkout, with `path`, `base` and `setup`). Under `worktree`, a seat that isn't `mode: shared`
111
+ starts in the lobby — the parent of `workspace.path` with `.lobby` beside the worktrees, inside
112
+ `trust` and outside every protected checkout — never in the project root; `up` and `add` refuse a
113
+ seat whose folder, lobby included, would be protected or untrusted.
114
+
115
+ The Codex profile is tested with CLI 0.157.0. Its status line is read for a weekly figure
116
+ (`weekly N% left`) when the pane is wide enough to show the number; a cut line is not a figure.
117
+ It adds `-a never -s danger-full-access`
118
+ for unattended execution, plus `--no-daemon --no-alt-screen` for the captured pane mode,
119
+ and checks login with `codex login status`. Rules go as a first message
120
+ only at an empty idle prompt; delivery is recorded after Codex starts working with the input
121
+ empty again. Nothing writes a vendor config or an `AGENTS.md`. `/exit` is sent only to a free
122
+ seat. Update and workspace-trust screens are reported and closed without input; the owner
123
+ handles them before relaunching. Other unrecognised layouts stay unknown.
124
+
125
+ The Antigravity profile is tested with CLI 1.2.16 (`agy`). It adds `--dangerously-skip-permissions`
126
+ for unattended execution, and checks authentication with `agy models`. Rules go as a first message
127
+ only at an empty idle prompt; delivery is recorded after the CLI starts working with the composer
128
+ empty again. Nothing writes a vendor config or an `AGENTS.md`. `/exit` is sent only to a free
129
+ seat. Workspace-trust screens are reported and closed without input; the owner trusts the folder
130
+ before relaunching. Other unrecognised layouts stay unknown.
131
+
132
+ Launching a Cursor seat, like launching cursor-agent by hand, creates Cursor's own project record
133
+ under ~/.cursor/projects for that folder; team writes no trust (.workspace-trusted) and no Cursor
134
+ config.
135
+
136
+ `budgets` is the owner's: marks (percent used), how long a figure stays fresh, and each
137
+ account's reserve or floor. A `check` command is resolved to a file and hashed when the
138
+ owner approves. A change to that file leaves that account's check unapproved: it is
139
+ not run, and the account reads unknown, until the owner approves again. The rest of
140
+ the file still runs. `watch.quota_marks` is still read, with a warning, until you move it to
141
+ `budgets.marks`. A figure first seen on one seat does not count until it changes or a
142
+ second seat shows the same number. It goes stale from the moment it last changed, and
143
+ the last readings are kept in the state file beside the team file. `team status` prints
144
+ them, one row per account and window, when there is an account or a stored reading.
145
+
146
+ `examples/checks/codex-quota` is a check for an openai account. It ships with the package: with a
147
+ global install it is at `$(npm root -g)/team/examples/checks/codex-quota`, and it is
148
+ [examples/checks/codex-quota](https://github.com/floor/team/blob/main/examples/checks/codex-quota)
149
+ in the repository. It is a Bun script — the check needs Bun on `PATH`, whatever runs `team` — and
150
+ it uses only built-in file modules, so there is no package to install beside it. Copy it onto
151
+ `PATH` and name that command:
152
+
153
+ ```yaml
154
+ openai:
155
+ kind: subscription
156
+ reserve: 10%
157
+ sources: [check, status_line]
158
+ check: codex-quota
159
+ ```
160
+
161
+ `team` runs a check with an empty environment plus `PATH` and `HOME`, so a
162
+ `CODEX_HOME` set in the owner's shell never reaches the script. It reads
163
+ `~/.codex/sessions`. When Codex's home is somewhere else, install a two-line
164
+ wrapper and name the wrapper as the check:
165
+
166
+ ```sh
167
+ #!/bin/sh
168
+ CODEX_HOME=/path/to/codex exec /path/to/codex-quota
169
+ ```
170
+
171
+ The wrapper sets `CODEX_HOME` and execs this script. The script takes rollouts
172
+ newest first. The first that has a `token_count` primary window is the one used, and
173
+ at most ten files are opened. A new session that has not recorded a figure yet
174
+ does not hide the last one. The line's `at` is that event's own time, so an
175
+ older figure stays dated. It prints the primary window, and a second line when
176
+ that event's secondary window is a different length, such as
177
+ `session 21% used resets 3h at 1791091200` and
178
+ `weekly 39% used resets 114h4m at 1791091200`. The same length is not printed
179
+ twice. A secondary figure that cannot be written is left off. When the primary
180
+ figure cannot be written, nothing is printed. Five hours (`300` minutes) is
181
+ `session`, a day (`1440`) is `daily`, and a week (`10080`) is `weekly`. Any other
182
+ length, a figure over 100%, or no such rollout prints nothing, so the account
183
+ reads unknown. `team approve` records the command you named. Bun has to be on
184
+ `PATH` when the check runs.
185
+
186
+ More fields exist — `tools`, `trust`, `machine`, `limits`, `watch`, `visibility` — and the comments
187
+ `team init` writes name them; validation refuses what it cannot check, and this build acts on what
188
+ the commands below read.
189
+
190
+ ## Commands
191
+
192
+ | Command | What it does | Who may run it |
193
+ | --- | --- | --- |
194
+ | `team init` | writes the skeleton `.agents/team.yaml` and adds it and its runtime files to `.git/info/exclude` | the owner |
195
+ | `team approve` | reads the whole file back for a last look, then records it, its ceilings and its seats on this machine; `--show` prints it | the owner (`--show`: anyone) |
196
+ | `team check <ref>` | checks one commit, a `a..b` range, or a PR body (`--pr <file>`, `-` reads stdin) against the signature rule; exit 1 when one is refused | anyone; read only |
197
+ | `team doctor` | checks this machine for what the file needs: herdr, each CLI, login, launcher, model, watch heartbeat; `--login` checks only CLI sign-ins | anyone; read only |
198
+ | `team status` | prints the file's seats against the running session, each difference with its repair; `--json` outputs a stable JSON document (`format: 1`) for scripts; exit 1 when they differ | anyone; read only |
199
+ | `team up` / `team down` | starts / stops the session and its seats | `up`: the owner; `down`: the owner, the coordinator or the operator seat |
200
+ | `team watch` | watches the session, reports idle seats and nudges the operator; `--no-nudge` and `--no-notify` turn those off | anyone, one per session; it types only its fixed nudge, into an empty idle prompt |
201
+ | `team add <name>` | starts one declared seat, or puts one back from the approved copy; `--temporary --like <seat> --until <end>` starts a seat the file does not hold | the owner, the coordinator or the operator |
202
+ | `team remove <name>` | stops one seat, then takes it out of the file; `--keep` leaves it stopped; `--abandon` is the owner's, and types nothing | the owner, the coordinator or the operator; only the owner removes the coordinator or the operator |
203
+ | `team worktree new <task>` / `team worktree remove <task>` | creates a task worktree from an up-to-date base, or removes its folder; a failed setup is kept and recorded; the branch is never deleted; ignored files in the worktree are deleted with it | the owner, the coordinator or the operator |
204
+
205
+ Each command has its own page in [docs/commands](https://github.com/floor/team/tree/main/docs/commands): the synopsis, what it reads and
206
+ writes, who may run it, every flag, the refusals with their exact text, the exit codes, and examples
207
+ that `bun run ci` runs against a fixture team.
208
+
209
+ The owner is a terminal outside herdr with no agent process above it: a seat, or a script a seat
210
+ runs, cannot approve a file or start a team. Every command that reads the file also takes
211
+ `--file <path>` for a file other than `.agents/team.yaml`.
212
+
213
+ `team status --json` prints the facts `status` prints as one JSON document (`format: 1`) on stdout:
214
+ `project`, `session`, `rows` (`name`, `state`, `model`, `pane`), `notes`, `differences` (`what`, `repair`), and `notice`.
215
+
216
+ `team doctor --login` checks read-only that each CLI in the file is signed in, using each profile's
217
+ existing login check command (`cursor-agent status`, `agy models`, `codex login status`, `claude auth status`).
218
+ It never answers prompts and performs no sign-in action.
219
+
220
+ `team up`, `team down`, `team add`, `team remove`, `team worktree new` and `team worktree remove`
221
+ run live. `up` and `down` take `--dry-run` to print every command they would run, and every
222
+ refusal, and change nothing. `trust` is specified but not built yet.
223
+
224
+ ## Your first team in five minutes
225
+
226
+ ```sh
227
+ mkdir hello && cd hello && git init
228
+ team init # the owner: writes .agents/team.yaml, private to this clone
229
+ $EDITOR .agents/team.yaml # name your seats — the example above is a working file
230
+ team approve # the owner: read the file it prints, then type the seat count
231
+ team doctor # what this machine still needs
232
+ team up --dry-run # every command it would run, and every refusal
233
+ team up # the owner: starts the session and its seats
234
+ ```
235
+
236
+ `team init` writes a skeleton, one seat and lots of comments; it prints how the file stays private,
237
+ and leaves your first commit as a commented `# since:` line. `team approve` prints the whole file
238
+ back and asks you to type how many seats it holds, so no file approves itself unnoticed. Then
239
+ `doctor` says what is missing on this machine, `up --dry-run` shows every command the launch would
240
+ run — and `up` starts the team, from the owner's terminal outside herdr.
241
+
242
+ ## Development
243
+
244
+ To run this tree's command from a clone instead of npm:
245
+
246
+ ```sh
247
+ git clone https://github.com/floor/team.git
248
+ cd team
249
+ bun install
250
+ bun run build
251
+ npm install -g . # puts `team` on the PATH
252
+ team --version # 0.1.1
253
+ ```
254
+
255
+ Bun builds and tests the sources:
256
+
257
+ ```sh
258
+ bun install
259
+ bun run typecheck
260
+ bun test
261
+ bun run build # dist/, which runs on Node 22 or later
262
+ bun run ci # what CI runs: typecheck, tests, build, then the built command's --version
263
+ ```
264
+
265
+ Sources import each other with `.ts` extensions and use erasable syntax only, so Node can run them
266
+ directly; `tsc` writes `dist/` for the published command. CI also runs `team check` on every pull
267
+ request, against the team file the repository keeps at `.github/team.yaml`.
268
+
269
+ ### The end-to-end run
270
+
271
+ `bun run e2e` drives the real commands — `status`, `watch` (one pass), `add --temporary --like`,
272
+ `remove` and `down` — against fake seats, in a herdr session of its own (`team-test-e2e`) that it
273
+ creates and always stops and deletes. A fake seat is `scripts/fake-seat.ts`: a pane that draws one
274
+ of the screens the commands classify, logs every byte typed at it, and leaves on `/exit` and Enter.
275
+ No model runs and nothing reaches the network. After each command the run checks its exit code, its
276
+ own output, the log lines it wrote to `.agents/team.log`, and the seat records in
277
+ `.agents/team.state.json`.
278
+
279
+ It is local only: CI has no herdr, so `bun run ci` does not run it. It refuses to start when herdr
280
+ is not on the PATH, when the machine is over its gate (load under 60, 25 % memory free, swap not
281
+ growing over a minute), or when a session named `team-test-e2e` already exists. It works in a fresh
282
+ folder under the system's temporary directory — the project, the approval store and the fake seats'
283
+ input logs — and prints that path; it never writes the owner's home. It reads the default herdr
284
+ session's agent list before and after, and fails when the count changes.
285
+
286
+ Every check prints a line; the run ends with `all N checks passed` or `M of N checks failed` and
287
+ exits 2 when it refused to start, 1 when a check failed. A failed step skips the steps after it,
288
+ and the session is stopped and deleted on every path.
289
+
290
+ ## Releasing
291
+
292
+ The owner cuts a release by pushing a version tag: `git tag v0.2.0 && git push origin v0.2.0`. The
293
+ Release workflow (`.github/workflows/release.yml`) refuses a tag that isn't `package.json`'s
294
+ version or whose commit isn't on `main`, runs `bun run ci`, then publishes with npm trusted
295
+ publishing — the workflow's own identity, no token stored — and provenance. A version with a
296
+ hyphen (`0.2.0-next.1`) goes under the `next` dist-tag, any other under `latest`. Once npm has the
297
+ version, the same run creates the GitHub release from the `CHANGELOG.md` section for it, marked a
298
+ pre-release when the version is one. If the GitHub release step fails — a missing changelog
299
+ section, say — use "Re-run failed jobs": a full re-run goes back through `npm publish`, which
300
+ fails because that version is already on npm. A tag ruleset protecting `v*`, so that only the
301
+ owner creates version tags, is recommended.
302
+
303
+ Trusted publishing must be bound once, by the package owner, on npmjs.com: the package `team` →
304
+ Publishing → trusted publishers → GitHub Actions, naming `floor/team` and the workflow file
305
+ `release.yml`; until then the workflow cannot publish. The 0.1.0 release itself is a manual
306
+ `npm publish` from a clean `main`; the workflow covers the releases after it.
307
+
308
+ ## License
309
+
310
+ MIT
@@ -0,0 +1,38 @@
1
+ import type { ApprovedCheck } from '../budgets/checks.ts';
2
+ import type { TeamFile } from '../file/types.ts';
3
+ import { type Approval, type ApprovalRecord, type Ceilings } from '../store/store.ts';
4
+ import { type Fingerprints } from './fingerprint.ts';
5
+ /** The ceilings an approval fixes: `up` and `add` read them from the record, never from the file. */
6
+ export declare function ceilingsOf(team: TeamFile): Ceilings;
7
+ /** The record an approval of this file writes. */
8
+ export declare function approvalOf(team: TeamFile, root: string, now?: Date, checks?: Record<string, ApprovedCheck>): Approval;
9
+ /**
10
+ * An approval's fingerprints, with the sections a record written before them has none for read
11
+ * from the copy it stored: `watch` and `watch.checks` arrived after records did, and the section's
12
+ * arrival alone is not a difference — an approval recorded before it stays valid while the section
13
+ * is unchanged (as `watch.checks` has since #48). A copy that can't be read leaves the record as
14
+ * it is, and the section reads as a difference.
15
+ */
16
+ export declare function approvedFingerprints(record: ApprovalRecord): Fingerprints;
17
+ /**
18
+ * What in the file the owner has not approved on this machine, one line per
19
+ * difference. Empty when the file is the approved one; null when nothing was
20
+ * ever approved for this root. A file runs only when this is empty.
21
+ */
22
+ export declare function approvalDifferences(team: TeamFile, root: string, home?: string): string[] | null;
23
+ /**
24
+ * The watch values in force. The watch section is the owner's, so what a file sets takes effect
25
+ * only once the owner has approved it: a file never approved runs with the defaults, and a file
26
+ * whose `watch` section differs from the approved one runs with the values of the approved copy.
27
+ * An edit to a threshold changes nothing until `approve`.
28
+ */
29
+ export declare function watchInForce(team: TeamFile, root: string, home?: string): TeamFile['watch'];
30
+ /**
31
+ * The budget values in force. The `budgets` section is the owner's like the watch's, so what a
32
+ * file sets takes effect only once the owner has approved it: a file never approved runs with the
33
+ * defaults — no accounts — and a file whose `budgets` section differs from the approved one runs
34
+ * with the values of the approved copy, accounts included. An unapproved edit — a reserve lowered,
35
+ * a mark dropped, `check_every` stretched, an account taken out — silences nothing and unblocks
36
+ * nothing until `approve`.
37
+ */
38
+ export declare function budgetsInForce(team: TeamFile, root: string, home?: string): TeamFile['budgets'];
@@ -0,0 +1,78 @@
1
+ import { homedir } from 'node:os';
2
+ import { defaultBudgets, defaultWatch, validateTeamFile } from "../file/validate.js";
3
+ import { readApproval, storePath } from "../store/store.js";
4
+ import { compare, describe, fingerprints, OWNER_SECTIONS } from "./fingerprint.js";
5
+ /** The ceilings an approval fixes: `up` and `add` read them from the record, never from the file. */
6
+ export function ceilingsOf(team) {
7
+ return { seats: team.limits.seats, temporary: team.limits.temporary, vendors: { ...team.limits.vendors } };
8
+ }
9
+ /** The record an approval of this file writes. */
10
+ export function approvalOf(team, root, now = new Date(), checks = {}) {
11
+ return {
12
+ format: 1,
13
+ approvedAt: now.toISOString(),
14
+ root,
15
+ fingerprints: fingerprints(team),
16
+ ceilings: ceilingsOf(team),
17
+ checks,
18
+ };
19
+ }
20
+ /**
21
+ * An approval's fingerprints, with the sections a record written before them has none for read
22
+ * from the copy it stored: `watch` and `watch.checks` arrived after records did, and the section's
23
+ * arrival alone is not a difference — an approval recorded before it stays valid while the section
24
+ * is unchanged (as `watch.checks` has since #48). A copy that can't be read leaves the record as
25
+ * it is, and the section reads as a difference.
26
+ */
27
+ export function approvedFingerprints(record) {
28
+ const stored = record.approval.fingerprints;
29
+ if (OWNER_SECTIONS.every((name) => stored.sections[name] !== undefined))
30
+ return stored;
31
+ const checked = validateTeamFile(record.file);
32
+ if (!checked.ok)
33
+ return stored;
34
+ return { sections: { ...fingerprints(checked.team).sections, ...stored.sections }, seats: stored.seats };
35
+ }
36
+ /**
37
+ * What in the file the owner has not approved on this machine, one line per
38
+ * difference. Empty when the file is the approved one; null when nothing was
39
+ * ever approved for this root. A file runs only when this is empty.
40
+ */
41
+ export function approvalDifferences(team, root, home = homedir()) {
42
+ const record = readApproval(storePath(team.project, root, home));
43
+ if (record === null)
44
+ return null;
45
+ return compare(approvedFingerprints(record), fingerprints(team)).map(describe);
46
+ }
47
+ /**
48
+ * The watch values in force. The watch section is the owner's, so what a file sets takes effect
49
+ * only once the owner has approved it: a file never approved runs with the defaults, and a file
50
+ * whose `watch` section differs from the approved one runs with the values of the approved copy.
51
+ * An edit to a threshold changes nothing until `approve`.
52
+ */
53
+ export function watchInForce(team, root, home = homedir()) {
54
+ const record = readApproval(storePath(team.project, root, home));
55
+ if (record === null)
56
+ return defaultWatch();
57
+ if (approvedFingerprints(record).sections['watch'] === fingerprints(team).sections['watch'])
58
+ return team.watch;
59
+ const copy = validateTeamFile(record.file);
60
+ return copy.ok ? copy.team.watch : defaultWatch();
61
+ }
62
+ /**
63
+ * The budget values in force. The `budgets` section is the owner's like the watch's, so what a
64
+ * file sets takes effect only once the owner has approved it: a file never approved runs with the
65
+ * defaults — no accounts — and a file whose `budgets` section differs from the approved one runs
66
+ * with the values of the approved copy, accounts included. An unapproved edit — a reserve lowered,
67
+ * a mark dropped, `check_every` stretched, an account taken out — silences nothing and unblocks
68
+ * nothing until `approve`.
69
+ */
70
+ export function budgetsInForce(team, root, home = homedir()) {
71
+ const record = readApproval(storePath(team.project, root, home));
72
+ if (record === null)
73
+ return defaultBudgets();
74
+ if (approvedFingerprints(record).sections['budgets'] === fingerprints(team).sections['budgets'])
75
+ return team.budgets;
76
+ const copy = validateTeamFile(record.file);
77
+ return copy.ok ? copy.team.budgets : defaultBudgets();
78
+ }
@@ -0,0 +1,13 @@
1
+ /** One line of a comparison: kept, taken out of the old text, or added by the new one. */
2
+ export type DiffLine = {
3
+ kind: 'same' | 'removed' | 'added';
4
+ text: string;
5
+ line: number;
6
+ };
7
+ /**
8
+ * The lines of `after` against `before`, by their longest common subsequence.
9
+ * `line` is the line's number in the text it comes from.
10
+ */
11
+ export declare function diffLines(before: string, after: string): DiffLine[];
12
+ /** The changed lines only, as `- 12: old` and `+ 12: new`; empty when the texts are equal. */
13
+ export declare function formatDiff(before: string, after: string): string[];
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The lines of `after` against `before`, by their longest common subsequence.
3
+ * `line` is the line's number in the text it comes from.
4
+ */
5
+ export function diffLines(before, after) {
6
+ const a = before.split('\n');
7
+ const b = after.split('\n');
8
+ // lengths[i][j]: the longest common subsequence of a[i..] and b[j..].
9
+ const lengths = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
10
+ for (let i = a.length - 1; i >= 0; i--) {
11
+ for (let j = b.length - 1; j >= 0; j--) {
12
+ lengths[i][j] =
13
+ a[i] === b[j]
14
+ ? lengths[i + 1][j + 1] + 1
15
+ : Math.max(lengths[i + 1][j], lengths[i][j + 1]);
16
+ }
17
+ }
18
+ const out = [];
19
+ let i = 0;
20
+ let j = 0;
21
+ while (i < a.length && j < b.length) {
22
+ if (a[i] === b[j]) {
23
+ out.push({ kind: 'same', text: b[j], line: j + 1 });
24
+ i++;
25
+ j++;
26
+ }
27
+ else if (lengths[i + 1][j] >= lengths[i][j + 1]) {
28
+ out.push({ kind: 'removed', text: a[i], line: i + 1 });
29
+ i++;
30
+ }
31
+ else {
32
+ out.push({ kind: 'added', text: b[j], line: j + 1 });
33
+ j++;
34
+ }
35
+ }
36
+ for (; i < a.length; i++)
37
+ out.push({ kind: 'removed', text: a[i], line: i + 1 });
38
+ for (; j < b.length; j++)
39
+ out.push({ kind: 'added', text: b[j], line: j + 1 });
40
+ return out;
41
+ }
42
+ /** The changed lines only, as `- 12: old` and `+ 12: new`; empty when the texts are equal. */
43
+ export function formatDiff(before, after) {
44
+ return diffLines(before, after)
45
+ .filter((line) => line.kind !== 'same')
46
+ .map((line) => `${line.kind === 'removed' ? '-' : '+'} ${line.line}: ${line.text}`);
47
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The sections only the owner changes. An edit to any of them needs a new
3
+ * approval before the file runs.
4
+ */
5
+ export declare const OWNER_SECTIONS: readonly ["trust", "limits", "machine", "rules", "identity", "workspace", "coordinator", "operator", "session", "visibility", "tools", "budgets", "watch", "watch.checks"];
6
+ /** A validated team file, as far as an approval reads it. */
7
+ export type Approvable = Record<string, unknown> & {
8
+ seats: readonly (Record<string, unknown> & {
9
+ name: string;
10
+ })[];
11
+ };
12
+ export interface Fingerprints {
13
+ sections: Record<string, string>;
14
+ /** By seat name, after `count` is expanded. */
15
+ seats: Record<string, string>;
16
+ }
17
+ /** JSON with every object's keys in order, so equal values give equal text. */
18
+ export declare function canonical(value: unknown): string;
19
+ /** A fingerprint of each owner-only section and of each seat. */
20
+ export declare function fingerprints(team: Approvable): Fingerprints;
21
+ export type Difference = {
22
+ kind: 'section';
23
+ name: string;
24
+ } | {
25
+ kind: 'seat-changed';
26
+ name: string;
27
+ } | {
28
+ kind: 'seat-new';
29
+ name: string;
30
+ };
31
+ /**
32
+ * What in the file the owner has not approved. The file passes when every
33
+ * section matches and every seat in it matches an approved seat: a seat taken
34
+ * out, parked or stopped needs no new approval.
35
+ */
36
+ export declare function compare(approved: Fingerprints, current: Fingerprints): Difference[];
37
+ /**
38
+ * The line `describe` prints when `watch.checks` itself is the difference. `pass` reads it to
39
+ * keep the checks running until the owner approves an edit that would turn one off: nothing is
40
+ * turned off until the owner approves (RFC 0002 § 4.2).
41
+ */
42
+ export declare const WATCH_CHECKS_CHANGED = "`watch.checks` changed";
43
+ /** One line per difference, as `status`, `doctor` and the refusals print it. */
44
+ export declare function describe(difference: Difference): string;
@@ -0,0 +1,109 @@
1
+ import { createHash } from 'node:crypto';
2
+ /**
3
+ * The sections only the owner changes. An edit to any of them needs a new
4
+ * approval before the file runs.
5
+ */
6
+ export const OWNER_SECTIONS = [
7
+ 'trust',
8
+ 'limits',
9
+ 'machine',
10
+ 'rules',
11
+ 'identity',
12
+ 'workspace',
13
+ 'coordinator',
14
+ 'operator',
15
+ 'session',
16
+ 'visibility',
17
+ 'tools',
18
+ 'budgets',
19
+ // The watch's own timings, thresholds included: a seat allowed to stretch `unsent_after` or
20
+ // `idle_first` could silence the watch itself, so the section is the owner's like the rest.
21
+ 'watch',
22
+ // And turning a check off is the finer line inside it: the digest above leaves the checks out,
23
+ // so turning one off reads as `watch.checks` alone, never as a threshold change too.
24
+ 'watch.checks',
25
+ ];
26
+ /** One owner section, read from the file; the two watch sections sit inside `watch`, not at the top. */
27
+ function sectionOf(team, name) {
28
+ if (name === 'watch.checks')
29
+ return team.watch?.checks ?? [];
30
+ if (name === 'watch') {
31
+ const watch = team.watch;
32
+ if (watch === null || watch === undefined)
33
+ return undefined;
34
+ const rest = { ...watch };
35
+ delete rest.checks;
36
+ return rest;
37
+ }
38
+ return team[name];
39
+ }
40
+ /**
41
+ * Seat fields that change without a new approval: what `remove --keep` and
42
+ * `add` set, where the seat sits in the file, and how its entry is written
43
+ * (`count: 3` becoming `count: 2`, or explicit seats, when one is taken out).
44
+ */
45
+ const SEAT_FREE_FIELDS = new Set(['parked', 'stopped', 'line', 'declared', 'count', 'instance']);
46
+ /** JSON with every object's keys in order, so equal values give equal text. */
47
+ export function canonical(value) {
48
+ if (Array.isArray(value))
49
+ return `[${value.map((item) => canonical(item ?? null)).join(',')}]`;
50
+ if (value !== null && typeof value === 'object') {
51
+ const entries = Object.entries(value)
52
+ .filter(([, item]) => item !== undefined)
53
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
54
+ return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(',')}}`;
55
+ }
56
+ return JSON.stringify(value ?? null);
57
+ }
58
+ function digest(value) {
59
+ return createHash('sha256').update(canonical(value)).digest('hex');
60
+ }
61
+ /** A fingerprint of each owner-only section and of each seat. */
62
+ export function fingerprints(team) {
63
+ const sections = {};
64
+ for (const section of OWNER_SECTIONS)
65
+ sections[section] = digest(sectionOf(team, section));
66
+ const seats = {};
67
+ for (const seat of team.seats) {
68
+ const fields = Object.fromEntries(Object.entries(seat).filter(([key]) => !SEAT_FREE_FIELDS.has(key)));
69
+ seats[seat.name] = digest(fields);
70
+ }
71
+ return { sections, seats };
72
+ }
73
+ /**
74
+ * What in the file the owner has not approved. The file passes when every
75
+ * section matches and every seat in it matches an approved seat: a seat taken
76
+ * out, parked or stopped needs no new approval.
77
+ */
78
+ export function compare(approved, current) {
79
+ const differences = [];
80
+ for (const name of OWNER_SECTIONS) {
81
+ // A record with no fingerprint for a section is read by `approvedFingerprints`, from the copy
82
+ // it stored. What reaches here without one is a record whose copy is gone: it turned nothing
83
+ // off, so `watch.checks` reads as the empty list, and a missing `watch` is a difference.
84
+ const before = approved.sections[name] ?? (name === 'watch.checks' ? digest([]) : undefined);
85
+ if (before !== current.sections[name])
86
+ differences.push({ kind: 'section', name });
87
+ }
88
+ for (const [name, fingerprint] of Object.entries(current.seats)) {
89
+ if (!Object.hasOwn(approved.seats, name))
90
+ differences.push({ kind: 'seat-new', name });
91
+ else if (approved.seats[name] !== fingerprint)
92
+ differences.push({ kind: 'seat-changed', name });
93
+ }
94
+ return differences;
95
+ }
96
+ /**
97
+ * The line `describe` prints when `watch.checks` itself is the difference. `pass` reads it to
98
+ * keep the checks running until the owner approves an edit that would turn one off: nothing is
99
+ * turned off until the owner approves (RFC 0002 § 4.2).
100
+ */
101
+ export const WATCH_CHECKS_CHANGED = '`watch.checks` changed';
102
+ /** One line per difference, as `status`, `doctor` and the refusals print it. */
103
+ export function describe(difference) {
104
+ if (difference.kind === 'section')
105
+ return `\`${difference.name}\` changed`;
106
+ if (difference.kind === 'seat-new')
107
+ return `seat ${difference.name} is not in the approved file`;
108
+ return `seat ${difference.name} changed`;
109
+ }