team 0.0.1 → 0.1.0
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/LICENSE +21 -0
- package/README.md +310 -0
- package/dist/approve/approval.d.ts +38 -0
- package/dist/approve/approval.js +78 -0
- package/dist/approve/diff.d.ts +13 -0
- package/dist/approve/diff.js +47 -0
- package/dist/approve/fingerprint.d.ts +44 -0
- package/dist/approve/fingerprint.js +109 -0
- package/dist/args.d.ts +7 -0
- package/dist/args.js +22 -0
- package/dist/budgets/checks.d.ts +35 -0
- package/dist/budgets/checks.js +77 -0
- package/dist/budgets/gate.d.ts +20 -0
- package/dist/budgets/gate.js +96 -0
- package/dist/budgets/readings.d.ts +47 -0
- package/dist/budgets/readings.js +119 -0
- package/dist/budgets/run.d.ts +66 -0
- package/dist/budgets/run.js +119 -0
- package/dist/budgets/table.d.ts +24 -0
- package/dist/budgets/table.js +90 -0
- package/dist/caller.d.ts +48 -0
- package/dist/caller.js +126 -0
- package/dist/check/config.d.ts +51 -0
- package/dist/check/config.js +50 -0
- package/dist/check/git.d.ts +27 -0
- package/dist/check/git.js +97 -0
- package/dist/check/message.d.ts +27 -0
- package/dist/check/message.js +101 -0
- package/dist/check/run.d.ts +31 -0
- package/dist/check/run.js +77 -0
- package/dist/check/signature.d.ts +10 -0
- package/dist/check/signature.js +22 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +76 -0
- package/dist/clis.d.ts +7 -0
- package/dist/clis.js +9 -0
- package/dist/commands/add.d.ts +23 -0
- package/dist/commands/add.js +445 -0
- package/dist/commands/approve.d.ts +11 -0
- package/dist/commands/approve.js +146 -0
- package/dist/commands/check.d.ts +17 -0
- package/dist/commands/check.js +103 -0
- package/dist/commands/doctor.d.ts +38 -0
- package/dist/commands/doctor.js +232 -0
- package/dist/commands/down.d.ts +37 -0
- package/dist/commands/down.js +258 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.js +138 -0
- package/dist/commands/remove.d.ts +23 -0
- package/dist/commands/remove.js +235 -0
- package/dist/commands/status.d.ts +41 -0
- package/dist/commands/status.js +147 -0
- package/dist/commands/up.d.ts +49 -0
- package/dist/commands/up.js +361 -0
- package/dist/commands/watch.d.ts +35 -0
- package/dist/commands/watch.js +302 -0
- package/dist/commands/worktree.d.ts +12 -0
- package/dist/commands/worktree.js +388 -0
- package/dist/end/condition.d.ts +28 -0
- package/dist/end/condition.js +77 -0
- package/dist/file/current.d.ts +13 -0
- package/dist/file/current.js +53 -0
- package/dist/file/lines.d.ts +25 -0
- package/dist/file/lines.js +277 -0
- package/dist/file/load.d.ts +7 -0
- package/dist/file/load.js +90 -0
- package/dist/file/paths.d.ts +6 -0
- package/dist/file/paths.js +77 -0
- package/dist/file/secrets.d.ts +6 -0
- package/dist/file/secrets.js +54 -0
- package/dist/file/signature.d.ts +13 -0
- package/dist/file/signature.js +36 -0
- package/dist/file/types.d.ts +130 -0
- package/dist/file/types.js +1 -0
- package/dist/file/validate.d.ts +13 -0
- package/dist/file/validate.js +702 -0
- package/dist/file/write.d.ts +11 -0
- package/dist/file/write.js +13 -0
- package/dist/herdr.d.ts +38 -0
- package/dist/herdr.js +227 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +6 -0
- package/dist/io.d.ts +10 -0
- package/dist/io.js +1 -0
- package/dist/launch/deliver.d.ts +10 -0
- package/dist/launch/deliver.js +39 -0
- package/dist/launch/execute.d.ts +46 -0
- package/dist/launch/execute.js +284 -0
- package/dist/launch/plan.d.ts +185 -0
- package/dist/launch/plan.js +280 -0
- package/dist/launch/rules.d.ts +25 -0
- package/dist/launch/rules.js +36 -0
- package/dist/log.d.ts +1 -0
- package/dist/log.js +24 -0
- package/dist/profiles/antigravity.yaml +66 -0
- package/dist/profiles/claude-code.yaml +69 -0
- package/dist/profiles/codex.yaml +77 -0
- package/dist/profiles/cursor.yaml +68 -0
- package/dist/profiles/index.d.ts +1 -0
- package/dist/profiles/index.js +1 -0
- package/dist/profiles/profile.d.ts +60 -0
- package/dist/profiles/profile.js +313 -0
- package/dist/profiles/quota.d.ts +22 -0
- package/dist/profiles/quota.js +56 -0
- package/dist/state.d.ts +59 -0
- package/dist/state.js +127 -0
- package/dist/status/compare.d.ts +26 -0
- package/dist/status/compare.js +110 -0
- package/dist/status/statusline.d.ts +9 -0
- package/dist/status/statusline.js +26 -0
- package/dist/store/store.d.ts +62 -0
- package/dist/store/store.js +116 -0
- package/dist/watch/check.d.ts +82 -0
- package/dist/watch/check.js +29 -0
- package/dist/watch/checks/approval.d.ts +2 -0
- package/dist/watch/checks/approval.js +16 -0
- package/dist/watch/checks/attention.d.ts +2 -0
- package/dist/watch/checks/attention.js +23 -0
- package/dist/watch/checks/budget.d.ts +2 -0
- package/dist/watch/checks/budget.js +141 -0
- package/dist/watch/checks/disk.d.ts +2 -0
- package/dist/watch/checks/disk.js +13 -0
- package/dist/watch/checks/extra.d.ts +2 -0
- package/dist/watch/checks/extra.js +18 -0
- package/dist/watch/checks/idle.d.ts +2 -0
- package/dist/watch/checks/idle.js +32 -0
- package/dist/watch/checks/load.d.ts +2 -0
- package/dist/watch/checks/load.js +11 -0
- package/dist/watch/checks/memory.d.ts +2 -0
- package/dist/watch/checks/memory.js +11 -0
- package/dist/watch/checks/missing.d.ts +2 -0
- package/dist/watch/checks/missing.js +12 -0
- package/dist/watch/checks/model-drift.d.ts +2 -0
- package/dist/watch/checks/model-drift.js +14 -0
- package/dist/watch/checks/swap-free.d.ts +2 -0
- package/dist/watch/checks/swap-free.js +12 -0
- package/dist/watch/checks/swap-growth.d.ts +2 -0
- package/dist/watch/checks/swap-growth.js +18 -0
- package/dist/watch/checks/team-idle.d.ts +2 -0
- package/dist/watch/checks/team-idle.js +18 -0
- package/dist/watch/checks/unsent.d.ts +2 -0
- package/dist/watch/checks/unsent.js +18 -0
- package/dist/watch/close.d.ts +19 -0
- package/dist/watch/close.js +40 -0
- package/dist/watch/dialect.d.ts +5 -0
- package/dist/watch/dialect.js +430 -0
- package/dist/watch/end.d.ts +11 -0
- package/dist/watch/end.js +17 -0
- package/dist/watch/machine.d.ts +38 -0
- package/dist/watch/machine.js +137 -0
- package/dist/watch/notify.d.ts +1 -0
- package/dist/watch/notify.js +15 -0
- package/dist/watch/pass.d.ts +30 -0
- package/dist/watch/pass.js +237 -0
- package/dist/watch/screen-core.d.ts +14 -0
- package/dist/watch/screen-core.js +320 -0
- package/dist/watch/screen-data.d.ts +65 -0
- package/dist/watch/screen-data.js +1 -0
- package/dist/watch/screen-file.d.ts +2 -0
- package/dist/watch/screen-file.js +269 -0
- package/dist/watch/screen.d.ts +20 -0
- package/dist/watch/screen.js +38 -0
- package/dist/worktree/place.d.ts +18 -0
- package/dist/worktree/place.js +126 -0
- package/dist/yaml.d.ts +30 -0
- package/dist/yaml.js +380 -0
- package/examples/checks/codex-quota +217 -0
- package/examples/team.yaml +76 -0
- 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.0
|
|
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.0
|
|
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
|
+
}
|