team 0.1.1 → 0.2.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/README.md +38 -12
- package/dist/ansi.d.ts +13 -0
- package/dist/ansi.js +75 -0
- package/dist/approve/approval.d.ts +11 -0
- package/dist/approve/approval.js +80 -8
- package/dist/approve/fingerprint.d.ts +29 -6
- package/dist/approve/fingerprint.js +57 -32
- package/dist/budgets/gate.d.ts +5 -4
- package/dist/budgets/gate.js +23 -14
- package/dist/budgets/readings.d.ts +57 -9
- package/dist/budgets/readings.js +134 -18
- package/dist/budgets/run.js +8 -2
- package/dist/budgets/table.d.ts +4 -2
- package/dist/budgets/table.js +29 -11
- package/dist/cli.d.ts +2 -1
- package/dist/cli.js +8 -5
- package/dist/commands/add.d.ts +0 -1
- package/dist/commands/add.js +18 -9
- package/dist/commands/approve.js +17 -1
- package/dist/commands/doctor.d.ts +17 -1
- package/dist/commands/doctor.js +128 -8
- package/dist/commands/down.d.ts +6 -2
- package/dist/commands/down.js +40 -14
- package/dist/commands/init.d.ts +1 -1
- package/dist/commands/init.js +4 -2
- package/dist/commands/remove.d.ts +2 -0
- package/dist/commands/remove.js +26 -4
- package/dist/commands/status.d.ts +2 -0
- package/dist/commands/status.js +22 -3
- package/dist/commands/up.d.ts +3 -1
- package/dist/commands/up.js +12 -6
- package/dist/commands/watch.d.ts +4 -0
- package/dist/commands/watch.js +99 -19
- package/dist/conformance/adapter.d.ts +5 -0
- package/dist/conformance/adapter.js +44 -0
- package/dist/file/check.d.ts +20 -0
- package/dist/file/check.js +112 -0
- package/dist/file/lines.js +13 -2
- package/dist/file/sections/budgets.d.ts +15 -0
- package/dist/file/sections/budgets.js +180 -0
- package/dist/file/sections/coordinator.d.ts +2 -0
- package/dist/file/sections/coordinator.js +11 -0
- package/dist/file/sections/format.d.ts +2 -0
- package/dist/file/sections/format.js +13 -0
- package/dist/file/sections/identity.d.ts +2 -0
- package/dist/file/sections/identity.js +103 -0
- package/dist/file/sections/index.d.ts +9 -0
- package/dist/file/sections/index.js +43 -0
- package/dist/file/sections/lead.d.ts +4 -0
- package/dist/file/sections/lead.js +18 -0
- package/dist/file/sections/limits.d.ts +2 -0
- package/dist/file/sections/limits.js +43 -0
- package/dist/file/sections/machine.d.ts +2 -0
- package/dist/file/sections/machine.js +38 -0
- package/dist/file/sections/operator.d.ts +2 -0
- package/dist/file/sections/operator.js +11 -0
- package/dist/file/sections/project.d.ts +2 -0
- package/dist/file/sections/project.js +13 -0
- package/dist/file/sections/rules.d.ts +2 -0
- package/dist/file/sections/rules.js +9 -0
- package/dist/file/sections/seats.d.ts +7 -0
- package/dist/file/sections/seats.js +161 -0
- package/dist/file/sections/section.d.ts +32 -0
- package/dist/file/sections/section.js +7 -0
- package/dist/file/sections/session.d.ts +2 -0
- package/dist/file/sections/session.js +16 -0
- package/dist/file/sections/tools.d.ts +2 -0
- package/dist/file/sections/tools.js +43 -0
- package/dist/file/sections/trust.d.ts +2 -0
- package/dist/file/sections/trust.js +20 -0
- package/dist/file/sections/units.d.ts +16 -0
- package/dist/file/sections/units.js +8 -0
- package/dist/file/sections/visibility.d.ts +2 -0
- package/dist/file/sections/visibility.js +12 -0
- package/dist/file/sections/watch-checks.d.ts +9 -0
- package/dist/file/sections/watch-checks.js +14 -0
- package/dist/file/sections/watch.d.ts +14 -0
- package/dist/file/sections/watch.js +111 -0
- package/dist/file/sections/workspace.d.ts +4 -0
- package/dist/file/sections/workspace.js +77 -0
- package/dist/file/types.d.ts +6 -0
- package/dist/file/validate.d.ts +4 -12
- package/dist/file/validate.js +45 -668
- package/dist/herdr.d.ts +5 -0
- package/dist/herdr.js +51 -12
- package/dist/launch/agent.d.ts +1 -0
- package/dist/launch/agent.js +13 -0
- package/dist/launch/deliver.d.ts +7 -1
- package/dist/launch/deliver.js +159 -7
- package/dist/launch/execute.d.ts +4 -2
- package/dist/launch/execute.js +36 -13
- package/dist/launch/plan.js +16 -2
- package/dist/launch/rules.d.ts +4 -2
- package/dist/launch/rules.js +17 -3
- package/dist/profiles/antigravity.yaml +25 -2
- package/dist/profiles/claude-code.yaml +51 -6
- package/dist/profiles/codex.yaml +14 -1
- package/dist/profiles/cursor.yaml +46 -3
- package/dist/profiles/overrides.d.ts +67 -0
- package/dist/profiles/overrides.js +190 -0
- package/dist/profiles/profile.d.ts +3 -0
- package/dist/profiles/profile.js +5 -1
- package/dist/state.d.ts +9 -4
- package/dist/state.js +37 -0
- package/dist/status/compare.js +7 -4
- package/dist/status/statusline.js +3 -1
- package/dist/store/store.d.ts +5 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +7 -0
- package/dist/watch/check.d.ts +1 -0
- package/dist/watch/checks/budget.js +50 -51
- package/dist/watch/dialect.d.ts +6 -1
- package/dist/watch/dialect.js +68 -12
- package/dist/watch/pass.d.ts +10 -2
- package/dist/watch/pass.js +70 -29
- package/dist/watch/screen-core.d.ts +41 -4
- package/dist/watch/screen-core.js +510 -89
- package/dist/watch/screen-data.d.ts +34 -0
- package/dist/watch/screen-file.d.ts +8 -2
- package/dist/watch/screen-file.js +344 -39
- package/dist/watch/screen-profile.d.ts +19 -0
- package/dist/watch/screen-profile.js +1 -0
- package/dist/watch/screen.d.ts +25 -6
- package/dist/watch/screen.js +48 -9
- package/examples/team.yaml +8 -0
- package/package.json +9 -3
- package/schema/team.schema.json +498 -0
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ Node 22 or later runs the built command.
|
|
|
18
18
|
|
|
19
19
|
```sh
|
|
20
20
|
npm install -g team # or run it without installing: npx team
|
|
21
|
-
team --version # 0.
|
|
21
|
+
team --version # 0.2.0
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
## The file is private to each clone
|
|
@@ -34,13 +34,14 @@ yourself.
|
|
|
34
34
|
A documented subset of YAML, read by the library's own parser: maps, lists, one-line `{ }` and
|
|
35
35
|
`[ ]`, plain and quoted values, comments. Anchors, aliases, tags, block scalars, several documents
|
|
36
36
|
in one file and duplicate keys are refused, with the line number. The file starts with `format: 1`.
|
|
37
|
+
The package ships the JSON Schema at `schema/team.schema.json`, and `team init` writes a `# yaml-language-server: $schema=…` line at the top of the file so editors validate it.
|
|
37
38
|
By example:
|
|
38
39
|
|
|
39
40
|
```yaml
|
|
40
41
|
format: 1 # the only format this version reads
|
|
41
42
|
project: hello
|
|
42
|
-
coordinator:
|
|
43
|
-
operator:
|
|
43
|
+
coordinator: coordinator # the seat that dispatches work
|
|
44
|
+
operator: coordinator # the seat the watch reports to
|
|
44
45
|
|
|
45
46
|
identity:
|
|
46
47
|
signature:
|
|
@@ -48,7 +49,10 @@ identity:
|
|
|
48
49
|
position: trailer # last-line | trailer | anywhere
|
|
49
50
|
exempt: [merge] # merge commits need no signature
|
|
50
51
|
|
|
51
|
-
rules: # lines added to every seat's rules at launch
|
|
52
|
+
rules: # lines added to every seat's rules at launch. Rules delivered as a launch
|
|
53
|
+
# option (claude-code) close with "These are standing rules, not a task.";
|
|
54
|
+
# rules typed as a first message (codex, cursor, antigravity) close with
|
|
55
|
+
# "These are standing rules, not a task: reply ready and wait for your brief."
|
|
52
56
|
- Run the tests your change touches, not the whole suite.
|
|
53
57
|
|
|
54
58
|
workspace:
|
|
@@ -56,7 +60,7 @@ workspace:
|
|
|
56
60
|
|
|
57
61
|
seats:
|
|
58
62
|
- role: coordinator
|
|
59
|
-
name:
|
|
63
|
+
name: coordinator
|
|
60
64
|
cli: claude-code # the launch profile
|
|
61
65
|
vendor: anthropic # the model's maker
|
|
62
66
|
model: Claude Opus # the model's name, without its version
|
|
@@ -64,9 +68,10 @@ seats:
|
|
|
64
68
|
launch: claude --model claude-opus-5-5 # no approval flags: the profile adds them
|
|
65
69
|
|
|
66
70
|
- role: implementer
|
|
67
|
-
name:
|
|
71
|
+
name: implementer
|
|
68
72
|
cli: codex
|
|
69
73
|
vendor: openai
|
|
74
|
+
account: openai-hello # the seat's account, when one vendor has two; absent, its vendor
|
|
70
75
|
model: GPT Sol
|
|
71
76
|
version: "6"
|
|
72
77
|
display: GPT-6 Sol # the vendor's spelling, for the signature
|
|
@@ -74,23 +79,30 @@ seats:
|
|
|
74
79
|
parked: true # running, and not reported while idle
|
|
75
80
|
|
|
76
81
|
- role: implementer
|
|
77
|
-
name: deepseek
|
|
82
|
+
name: implementer-deepseek
|
|
78
83
|
cli: claude-code # DeepSeek's model, run by Claude Code
|
|
79
84
|
vendor: deepseek
|
|
80
85
|
model: DeepSeek Flash
|
|
81
86
|
version: "V4.1"
|
|
82
87
|
display: DeepSeek V4.1 Flash
|
|
83
88
|
launch: team-deepseek # a launcher on the PATH, holding the account's key and endpoint
|
|
84
|
-
count: 2 # deepseek
|
|
89
|
+
count: 2 # implementer-deepseek and implementer-deepseek-2
|
|
85
90
|
|
|
86
91
|
- role: reviewer
|
|
87
|
-
name:
|
|
92
|
+
name: reviewer
|
|
88
93
|
cli: grok
|
|
89
94
|
vendor: xai
|
|
90
95
|
model: Grok
|
|
91
96
|
version: "4.7"
|
|
92
97
|
launch: grok --model grok-4.7
|
|
93
98
|
stopped: true # kept in the file; `up` doesn't start it
|
|
99
|
+
|
|
100
|
+
budgets: # the owner's: reserve or floor per account, marks, freshness
|
|
101
|
+
accounts:
|
|
102
|
+
openai-hello: # the account implementer spends
|
|
103
|
+
kind: subscription
|
|
104
|
+
reserve: 10% # refuse a launch on a figure inside it
|
|
105
|
+
sources: [status_line] # the figure comes off Codex's status line
|
|
94
106
|
```
|
|
95
107
|
|
|
96
108
|
- `session` names the herdr session and defaults to `project`; `--session` overrides it.
|
|
@@ -104,6 +116,8 @@ seats:
|
|
|
104
116
|
and session links are always refused.
|
|
105
117
|
- `seats[*].cli` picks the launch profile; `claude-code`, `codex`, `cursor` and `antigravity` are available, and `team
|
|
106
118
|
doctor` says what the others still need. `vendor`, `model` and `version` spell one seat's model.
|
|
119
|
+
`account` names the budget account the seat spends when one vendor has two; without it, the seat
|
|
120
|
+
spends its `vendor`, and changing either is an edit the owner re-approves.
|
|
107
121
|
- `launch` is the plain command, without approval flags: the profile adds them. `count: 2` makes the
|
|
108
122
|
numbered names; `parked` keeps a seat out of idle reports, `stopped` keeps it out of `up`.
|
|
109
123
|
- `workspace.mode` is `shared` (every seat in the project) or `worktree` (each task in its own
|
|
@@ -112,6 +126,15 @@ seats:
|
|
|
112
126
|
`trust` and outside every protected checkout — never in the project root; `up` and `add` refuse a
|
|
113
127
|
seat whose folder, lobby included, would be protected or untrusted.
|
|
114
128
|
|
|
129
|
+
### Naming seats
|
|
130
|
+
|
|
131
|
+
The herdr session carries the project, so a seat's name is its role: `coordinator`, `implementer`,
|
|
132
|
+
`reviewer`. When a role is used twice, the model is added: `implementer-deepseek`. The label is the
|
|
133
|
+
herdr workspace title. Left out of the file, it is the model and version in lowercase
|
|
134
|
+
(`claude opus 5.5`), taken from that seat's own fields, so a model change retitles the pane. A
|
|
135
|
+
label written in the file is kept. `team doctor` warns, and does not refuse the file, when a name
|
|
136
|
+
or a label repeats the project or the session.
|
|
137
|
+
|
|
115
138
|
The Codex profile is tested with CLI 0.157.0. Its status line is read for a weekly figure
|
|
116
139
|
(`weekly N% left`) when the pane is wide enough to show the number; a cut line is not a figure.
|
|
117
140
|
It adds `-a never -s danger-full-access`
|
|
@@ -134,7 +157,10 @@ under ~/.cursor/projects for that folder; team writes no trust (.workspace-trust
|
|
|
134
157
|
config.
|
|
135
158
|
|
|
136
159
|
`budgets` is the owner's: marks (percent used), how long a figure stays fresh, and each
|
|
137
|
-
account's reserve or floor.
|
|
160
|
+
account's reserve or floor. An account's `shared` key is informational; `team` does not act
|
|
161
|
+
on it. A seat spends its own `account:` when the file names one, its `vendor`
|
|
162
|
+
when it doesn't, so one vendor's two accounts are two buckets; a pattern names the account it
|
|
163
|
+
measures, not the seat's. A `check` command is resolved to a file and hashed when the
|
|
138
164
|
owner approves. A change to that file leaves that account's check unapproved: it is
|
|
139
165
|
not run, and the account reads unknown, until the owner approves again. The rest of
|
|
140
166
|
the file still runs. `watch.quota_marks` is still read, with a warning, until you move it to
|
|
@@ -197,7 +223,7 @@ the commands below read.
|
|
|
197
223
|
| `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
224
|
| `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
225
|
| `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`
|
|
226
|
+
| `team watch` | watches the session, reports idle seats and nudges the operator; `--no-nudge` and `--no-notify` are the owner's and do not silence a report addressed to the owner | anyone, one per session; it types only its fixed nudge, into an empty idle prompt |
|
|
201
227
|
| `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
228
|
| `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
229
|
| `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 |
|
|
@@ -249,7 +275,7 @@ cd team
|
|
|
249
275
|
bun install
|
|
250
276
|
bun run build
|
|
251
277
|
npm install -g . # puts `team` on the PATH
|
|
252
|
-
team --version # 0.
|
|
278
|
+
team --version # 0.2.0
|
|
253
279
|
```
|
|
254
280
|
|
|
255
281
|
Bun builds and tests the sources:
|
package/dist/ansi.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare function sgrDim(sequence: string, faint: boolean): boolean;
|
|
2
|
+
/** The text of a styled pane read, without its escape sequences. */
|
|
3
|
+
export declare function stripSgr(text: string): string;
|
|
4
|
+
/** Whether the text carries any SGR styling at all — a colour, faint, bold, anything. */
|
|
5
|
+
export declare function hasSgr(text: string): boolean;
|
|
6
|
+
/**
|
|
7
|
+
* Whether every visible character after the first `skip` ones is faint, so the line holds a
|
|
8
|
+
* greyed suggestion rather than text. Whitespace is not read, mirroring the typed text the
|
|
9
|
+
* caller trims; an empty remainder is dim, as an empty box is. A character without any
|
|
10
|
+
* styling is plain text, so a plain source answers false and the placeholder list alone
|
|
11
|
+
* decides — the fallback for an herdr without `--format ansi`.
|
|
12
|
+
*/
|
|
13
|
+
export declare function allDimAfter(styled: string, skip: number): boolean;
|
package/dist/ansi.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// ANSI styling as herdr's `pane read --format ansi` reads it off a pane: the escape sequences
|
|
2
|
+
// a CLI wrapped around its text. Matching always runs on the plain form; the styling answers
|
|
3
|
+
// one question, the composer's — is the input line's text all dim, a greyed suggestion rather
|
|
4
|
+
// than something typed. Nothing here moves the cursor or writes to a pane.
|
|
5
|
+
//
|
|
6
|
+
// Observed on Claude Code 2.1.289 (test/fixtures/claude-code/2.1.289/README.md): a greyed
|
|
7
|
+
// suggestion is `ESC[0m ESC[2m` … `ESC[0m` — faint — while typed text carries no styling at
|
|
8
|
+
// all. Faint is the only placeholder style: a colour, however grey it renders, is a colour,
|
|
9
|
+
// and reading one as a placeholder was a guess this module no longer makes.
|
|
10
|
+
// A CSI sequence (an SGR ends in `m`), or an OSC title string. Visible output holds little
|
|
11
|
+
// else, and what it does hold is not text.
|
|
12
|
+
const ESCAPES = /\x1b\[[0-9;:]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
13
|
+
// The faint state one SGR sequence leaves the pen in. `0` and `22` lift it; `2` sets it. The
|
|
14
|
+
// extended colours — foreground, background (48), underline (58) — carry their payload in
|
|
15
|
+
// sub-parameters (5;n or 2;r;g;b) whose numbers are never the faint switch.
|
|
16
|
+
export function sgrDim(sequence, faint) {
|
|
17
|
+
const params = sequence.slice(2, -1).split(';').map((part) => (part === '' ? '0' : part));
|
|
18
|
+
let faintNow = faint;
|
|
19
|
+
for (let i = 0; i < params.length; i++) {
|
|
20
|
+
const n = Number(params[i]);
|
|
21
|
+
if (!Number.isInteger(n))
|
|
22
|
+
continue;
|
|
23
|
+
if (n === 0 || n === 22)
|
|
24
|
+
faintNow = false;
|
|
25
|
+
if (n === 2)
|
|
26
|
+
faintNow = true;
|
|
27
|
+
if (n === 38 || n === 48 || n === 58) {
|
|
28
|
+
const mode = Number(params[i + 1]);
|
|
29
|
+
if (mode === 5)
|
|
30
|
+
i += 2;
|
|
31
|
+
else if (mode === 2)
|
|
32
|
+
i += 4;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return faintNow;
|
|
36
|
+
}
|
|
37
|
+
/** The text of a styled pane read, without its escape sequences. */
|
|
38
|
+
export function stripSgr(text) {
|
|
39
|
+
return text.replace(ESCAPES, '');
|
|
40
|
+
}
|
|
41
|
+
/** Whether the text carries any SGR styling at all — a colour, faint, bold, anything. */
|
|
42
|
+
export function hasSgr(text) {
|
|
43
|
+
return /\x1b\[[0-9;:]*m/.test(text);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Whether every visible character after the first `skip` ones is faint, so the line holds a
|
|
47
|
+
* greyed suggestion rather than text. Whitespace is not read, mirroring the typed text the
|
|
48
|
+
* caller trims; an empty remainder is dim, as an empty box is. A character without any
|
|
49
|
+
* styling is plain text, so a plain source answers false and the placeholder list alone
|
|
50
|
+
* decides — the fallback for an herdr without `--format ansi`.
|
|
51
|
+
*/
|
|
52
|
+
export function allDimAfter(styled, skip) {
|
|
53
|
+
let passed = 0;
|
|
54
|
+
let faint = false;
|
|
55
|
+
let cursor = 0;
|
|
56
|
+
// The characters before an escape, then the escape; then the rest after the last one.
|
|
57
|
+
const read = (chunk) => {
|
|
58
|
+
for (const ch of chunk) {
|
|
59
|
+
if (/\s/.test(ch))
|
|
60
|
+
continue;
|
|
61
|
+
passed++;
|
|
62
|
+
if (passed > skip && !faint)
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
};
|
|
67
|
+
for (const match of styled.matchAll(ESCAPES)) {
|
|
68
|
+
if (!read(styled.slice(cursor, match.index)))
|
|
69
|
+
return false;
|
|
70
|
+
if (match[0].endsWith('m'))
|
|
71
|
+
faint = sgrDim(match[0], faint);
|
|
72
|
+
cursor = match.index + match[0].length;
|
|
73
|
+
}
|
|
74
|
+
return read(styled.slice(cursor));
|
|
75
|
+
}
|
|
@@ -14,6 +14,13 @@ export declare function approvalOf(team: TeamFile, root: string, now?: Date, che
|
|
|
14
14
|
* it is, and the section reads as a difference.
|
|
15
15
|
*/
|
|
16
16
|
export declare function approvedFingerprints(record: ApprovalRecord): Fingerprints;
|
|
17
|
+
/**
|
|
18
|
+
* `remove --keep` and `add` write `stopped` and nothing else. The new digest is
|
|
19
|
+
* recorded only when putting `stopped` back to its approved value makes the
|
|
20
|
+
* seat match the approval. A launch line or a `parked` flag edited beside the
|
|
21
|
+
* mark stays drift. A stored copy that can't be read records nothing.
|
|
22
|
+
*/
|
|
23
|
+
export declare function recordSeatDigest(team: TeamFile, root: string, name: string, home?: string): void;
|
|
17
24
|
/**
|
|
18
25
|
* What in the file the owner has not approved on this machine, one line per
|
|
19
26
|
* difference. Empty when the file is the approved one; null when nothing was
|
|
@@ -25,6 +32,10 @@ export declare function approvalDifferences(team: TeamFile, root: string, home?:
|
|
|
25
32
|
* only once the owner has approved it: a file never approved runs with the defaults, and a file
|
|
26
33
|
* whose `watch` section differs from the approved one runs with the values of the approved copy.
|
|
27
34
|
* An edit to a threshold changes nothing until `approve`.
|
|
35
|
+
*
|
|
36
|
+
* `watch.checks` is the finer line inside that section. Whenever its digest differs, the checks
|
|
37
|
+
* in force are the approved copy's — or none, when that copy can't be read — even when the
|
|
38
|
+
* timings themselves are unchanged and the rest of the section is the file's.
|
|
28
39
|
*/
|
|
29
40
|
export declare function watchInForce(team: TeamFile, root: string, home?: string): TeamFile['watch'];
|
|
30
41
|
/**
|
package/dist/approve/approval.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { homedir } from 'node:os';
|
|
2
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";
|
|
3
|
+
import { readApproval, storePath, writeApproval } from "../store/store.js";
|
|
4
|
+
import { compare, describe, fingerprints, legacyLabelDigests, legacySeatDigests, OWNER_SECTIONS } from "./fingerprint.js";
|
|
5
5
|
/** The ceilings an approval fixes: `up` and `add` read them from the record, never from the file. */
|
|
6
6
|
export function ceilingsOf(team) {
|
|
7
7
|
return { seats: team.limits.seats, temporary: team.limits.temporary, vendors: { ...team.limits.vendors } };
|
|
@@ -26,12 +26,74 @@ export function approvalOf(team, root, now = new Date(), checks = {}) {
|
|
|
26
26
|
*/
|
|
27
27
|
export function approvedFingerprints(record) {
|
|
28
28
|
const stored = record.approval.fingerprints;
|
|
29
|
-
if (OWNER_SECTIONS.every((name) => stored.sections[name] !== undefined))
|
|
30
|
-
return stored;
|
|
31
29
|
const checked = validateTeamFile(record.file);
|
|
32
|
-
|
|
30
|
+
let sections = stored.sections;
|
|
31
|
+
if (!OWNER_SECTIONS.every((name) => stored.sections[name] !== undefined) && checked.ok) {
|
|
32
|
+
sections = { ...fingerprints(checked.team).sections, ...stored.sections };
|
|
33
|
+
}
|
|
34
|
+
const seats = checked.ok ? adoptLegacyDigests(stored.seats, checked.team) : stored.seats;
|
|
35
|
+
if (sections === stored.sections && seats === stored.seats)
|
|
33
36
|
return stored;
|
|
34
|
-
return { sections
|
|
37
|
+
return { sections, seats };
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A record from before `parked` and `stopped` were in the digest, or from when
|
|
41
|
+
* an omitted label was the seat's name, still names the seat as it was
|
|
42
|
+
* approved, read from the stored copy. A digest already in the new shape is
|
|
43
|
+
* left alone, so a later edit of the file is not adopted from a stale copy.
|
|
44
|
+
* A copy that can't be read is left alone.
|
|
45
|
+
*/
|
|
46
|
+
function adoptLegacyDigests(stored, team) {
|
|
47
|
+
const current = fingerprints(team).seats;
|
|
48
|
+
const legacyFlags = legacySeatDigests(team);
|
|
49
|
+
const legacyLabels = legacyLabelDigests(team);
|
|
50
|
+
let changed = false;
|
|
51
|
+
const next = { ...stored };
|
|
52
|
+
for (const [name, previous] of Object.entries(stored)) {
|
|
53
|
+
const adopted = current[name];
|
|
54
|
+
const old = previous === legacyFlags[name] ||
|
|
55
|
+
previous === legacyLabels.named[name] ||
|
|
56
|
+
previous === legacyLabels.namedWithoutFlags[name];
|
|
57
|
+
if (adopted !== undefined && old && previous !== adopted) {
|
|
58
|
+
next[name] = adopted;
|
|
59
|
+
changed = true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return changed ? next : stored;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* `remove --keep` and `add` write `stopped` and nothing else. The new digest is
|
|
66
|
+
* recorded only when putting `stopped` back to its approved value makes the
|
|
67
|
+
* seat match the approval. A launch line or a `parked` flag edited beside the
|
|
68
|
+
* mark stays drift. A stored copy that can't be read records nothing.
|
|
69
|
+
*/
|
|
70
|
+
export function recordSeatDigest(team, root, name, home = homedir()) {
|
|
71
|
+
const store = storePath(team.project, root, home);
|
|
72
|
+
const record = readApproval(store);
|
|
73
|
+
if (record === null)
|
|
74
|
+
return;
|
|
75
|
+
if (!validateTeamFile(record.file).ok)
|
|
76
|
+
return;
|
|
77
|
+
const approved = approvedFingerprints(record).seats[name];
|
|
78
|
+
const digest = fingerprints(team).seats[name];
|
|
79
|
+
if (approved === undefined || digest === undefined || record.approval.fingerprints.seats[name] === digest)
|
|
80
|
+
return;
|
|
81
|
+
const withStopped = (stopped) => fingerprints({
|
|
82
|
+
...team,
|
|
83
|
+
seats: team.seats.map((seat) => (seat.name === name ? { ...seat, stopped } : seat)),
|
|
84
|
+
}).seats[name];
|
|
85
|
+
if (withStopped(true) !== approved && withStopped(false) !== approved)
|
|
86
|
+
return;
|
|
87
|
+
writeApproval(store, {
|
|
88
|
+
approval: {
|
|
89
|
+
...record.approval,
|
|
90
|
+
fingerprints: {
|
|
91
|
+
...record.approval.fingerprints,
|
|
92
|
+
seats: { ...record.approval.fingerprints.seats, [name]: digest },
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
file: record.file,
|
|
96
|
+
}, []);
|
|
35
97
|
}
|
|
36
98
|
/**
|
|
37
99
|
* What in the file the owner has not approved on this machine, one line per
|
|
@@ -49,15 +111,25 @@ export function approvalDifferences(team, root, home = homedir()) {
|
|
|
49
111
|
* only once the owner has approved it: a file never approved runs with the defaults, and a file
|
|
50
112
|
* whose `watch` section differs from the approved one runs with the values of the approved copy.
|
|
51
113
|
* An edit to a threshold changes nothing until `approve`.
|
|
114
|
+
*
|
|
115
|
+
* `watch.checks` is the finer line inside that section. Whenever its digest differs, the checks
|
|
116
|
+
* in force are the approved copy's — or none, when that copy can't be read — even when the
|
|
117
|
+
* timings themselves are unchanged and the rest of the section is the file's.
|
|
52
118
|
*/
|
|
53
119
|
export function watchInForce(team, root, home = homedir()) {
|
|
54
120
|
const record = readApproval(storePath(team.project, root, home));
|
|
55
121
|
if (record === null)
|
|
56
122
|
return defaultWatch();
|
|
57
|
-
|
|
123
|
+
const differences = compare(approvedFingerprints(record), fingerprints(team));
|
|
124
|
+
const timingsDiffer = differences.some((difference) => difference.kind === 'section' && difference.name === 'watch');
|
|
125
|
+
const checksDiffer = differences.some((difference) => difference.kind === 'section' && difference.name === 'watch.checks');
|
|
126
|
+
if (!timingsDiffer && !checksDiffer)
|
|
58
127
|
return team.watch;
|
|
59
128
|
const copy = validateTeamFile(record.file);
|
|
60
|
-
|
|
129
|
+
const approved = copy.ok ? copy.team.watch : defaultWatch();
|
|
130
|
+
if (!timingsDiffer)
|
|
131
|
+
return { ...team.watch, checks: approved.checks };
|
|
132
|
+
return approved;
|
|
61
133
|
}
|
|
62
134
|
/**
|
|
63
135
|
* The budget values in force. The `budgets` section is the owner's like the watch's, so what a
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The sections only the owner changes. An edit to any of them needs a new
|
|
3
|
-
* approval before the file runs.
|
|
3
|
+
* approval before the file runs. Generated from the section modules, in their
|
|
4
|
+
* list's order — the order feeds the approval digest, so the list keeps it:
|
|
5
|
+
* `watch` last with `watch.checks`, the line inside it, after it.
|
|
4
6
|
*/
|
|
5
|
-
export declare const OWNER_SECTIONS: readonly [
|
|
7
|
+
export declare const OWNER_SECTIONS: readonly string[];
|
|
6
8
|
/** A validated team file, as far as an approval reads it. */
|
|
7
9
|
export type Approvable = Record<string, unknown> & {
|
|
8
10
|
seats: readonly (Record<string, unknown> & {
|
|
@@ -18,6 +20,21 @@ export interface Fingerprints {
|
|
|
18
20
|
export declare function canonical(value: unknown): string;
|
|
19
21
|
/** A fingerprint of each owner-only section and of each seat. */
|
|
20
22
|
export declare function fingerprints(team: Approvable): Fingerprints;
|
|
23
|
+
/**
|
|
24
|
+
* Seat digests as a record written before `parked` and `stopped` were part of
|
|
25
|
+
* them. An approval from then still matches a file that has not changed.
|
|
26
|
+
*/
|
|
27
|
+
export declare function legacySeatDigests(team: Approvable): Record<string, string>;
|
|
28
|
+
/**
|
|
29
|
+
* Seat digests as a record written when an omitted label was the seat's name.
|
|
30
|
+
* `v0.1.2` hashed `parked` and `stopped`; `v0.1.1` left them out. A written
|
|
31
|
+
* label hashes the same title in every shape, so these equal a stored digest
|
|
32
|
+
* only where the title was the name.
|
|
33
|
+
*/
|
|
34
|
+
export declare function legacyLabelDigests(team: Approvable): {
|
|
35
|
+
named: Record<string, string>;
|
|
36
|
+
namedWithoutFlags: Record<string, string>;
|
|
37
|
+
};
|
|
21
38
|
export type Difference = {
|
|
22
39
|
kind: 'section';
|
|
23
40
|
name: string;
|
|
@@ -31,14 +48,20 @@ export type Difference = {
|
|
|
31
48
|
/**
|
|
32
49
|
* What in the file the owner has not approved. The file passes when every
|
|
33
50
|
* section matches and every seat in it matches an approved seat: a seat taken
|
|
34
|
-
* out
|
|
51
|
+
* out needs no new approval. Parking or stopping one does.
|
|
35
52
|
*/
|
|
36
53
|
export declare function compare(approved: Fingerprints, current: Fingerprints): Difference[];
|
|
37
54
|
/**
|
|
38
|
-
* The line `describe` prints when `watch.checks` itself is the difference. `pass`
|
|
39
|
-
*
|
|
40
|
-
* turned off
|
|
55
|
+
* The line `describe` prints when `watch.checks` itself is the difference. `pass` does not read
|
|
56
|
+
* it. The list in force is what turns checks off: while the edit is unapproved, the approved
|
|
57
|
+
* list stays in force, so nothing new is turned off and an approved-off check stays off.
|
|
41
58
|
*/
|
|
42
59
|
export declare const WATCH_CHECKS_CHANGED = "`watch.checks` changed";
|
|
43
60
|
/** One line per difference, as `status`, `doctor` and the refusals print it. */
|
|
44
61
|
export declare function describe(difference: Difference): string;
|
|
62
|
+
/**
|
|
63
|
+
* The seat a difference line names, when it names one — the inverse of `describe`, kept beside it
|
|
64
|
+
* so the two can't drift. `pass` reads it to keep a seat the owner has not approved out of the
|
|
65
|
+
* readings fold; `null` for a section difference.
|
|
66
|
+
*/
|
|
67
|
+
export declare function seatNamed(line: string): string | null;
|
|
@@ -1,28 +1,12 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { SECTIONS } from "../file/sections/index.js";
|
|
2
3
|
/**
|
|
3
4
|
* The sections only the owner changes. An edit to any of them needs a new
|
|
4
|
-
* approval before the file runs.
|
|
5
|
+
* approval before the file runs. Generated from the section modules, in their
|
|
6
|
+
* list's order — the order feeds the approval digest, so the list keeps it:
|
|
7
|
+
* `watch` last with `watch.checks`, the line inside it, after it.
|
|
5
8
|
*/
|
|
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
|
-
];
|
|
9
|
+
export const OWNER_SECTIONS = SECTIONS.filter((section) => section.owner).map((section) => section.name);
|
|
26
10
|
/** One owner section, read from the file; the two watch sections sit inside `watch`, not at the top. */
|
|
27
11
|
function sectionOf(team, name) {
|
|
28
12
|
if (name === 'watch.checks')
|
|
@@ -38,11 +22,15 @@ function sectionOf(team, name) {
|
|
|
38
22
|
return team[name];
|
|
39
23
|
}
|
|
40
24
|
/**
|
|
41
|
-
* Seat fields that change without a new approval:
|
|
42
|
-
*
|
|
43
|
-
*
|
|
25
|
+
* Seat fields that change without a new approval: where the seat sits in the
|
|
26
|
+
* file, and how its entry is written (`count: 3` becoming `count: 2`, or
|
|
27
|
+
* explicit seats, when one is taken out). `parked` and `stopped` stay in the
|
|
28
|
+
* digest: a seat that can edit the file must not silence itself. `remove --keep`
|
|
29
|
+
* and `add` record the new digest with the edit.
|
|
44
30
|
*/
|
|
45
|
-
const SEAT_FREE_FIELDS = new Set(['
|
|
31
|
+
const SEAT_FREE_FIELDS = new Set(['line', 'declared', 'count', 'instance']);
|
|
32
|
+
/** The free set from before `parked` and `stopped` joined the digest. */
|
|
33
|
+
const LEGACY_SEAT_FREE_FIELDS = new Set(['parked', 'stopped', 'line', 'declared', 'count', 'instance']);
|
|
46
34
|
/** JSON with every object's keys in order, so equal values give equal text. */
|
|
47
35
|
export function canonical(value) {
|
|
48
36
|
if (Array.isArray(value))
|
|
@@ -64,16 +52,44 @@ export function fingerprints(team) {
|
|
|
64
52
|
for (const section of OWNER_SECTIONS)
|
|
65
53
|
sections[section] = digest(sectionOf(team, section));
|
|
66
54
|
const seats = {};
|
|
55
|
+
for (const seat of team.seats)
|
|
56
|
+
seats[seat.name] = seatDigest(seat, SEAT_FREE_FIELDS);
|
|
57
|
+
return { sections, seats };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Seat digests as a record written before `parked` and `stopped` were part of
|
|
61
|
+
* them. An approval from then still matches a file that has not changed.
|
|
62
|
+
*/
|
|
63
|
+
export function legacySeatDigests(team) {
|
|
64
|
+
const seats = {};
|
|
65
|
+
for (const seat of team.seats)
|
|
66
|
+
seats[seat.name] = seatDigest(seat, LEGACY_SEAT_FREE_FIELDS);
|
|
67
|
+
return seats;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Seat digests as a record written when an omitted label was the seat's name.
|
|
71
|
+
* `v0.1.2` hashed `parked` and `stopped`; `v0.1.1` left them out. A written
|
|
72
|
+
* label hashes the same title in every shape, so these equal a stored digest
|
|
73
|
+
* only where the title was the name.
|
|
74
|
+
*/
|
|
75
|
+
export function legacyLabelDigests(team) {
|
|
76
|
+
const named = {};
|
|
77
|
+
const namedWithoutFlags = {};
|
|
67
78
|
for (const seat of team.seats) {
|
|
68
|
-
const
|
|
69
|
-
|
|
79
|
+
const titled = { ...seat, label: seat.name };
|
|
80
|
+
named[seat.name] = seatDigest(titled, SEAT_FREE_FIELDS);
|
|
81
|
+
namedWithoutFlags[seat.name] = seatDigest(titled, LEGACY_SEAT_FREE_FIELDS);
|
|
70
82
|
}
|
|
71
|
-
return {
|
|
83
|
+
return { named, namedWithoutFlags };
|
|
84
|
+
}
|
|
85
|
+
function seatDigest(seat, free) {
|
|
86
|
+
const fields = Object.fromEntries(Object.entries(seat).filter(([key]) => !free.has(key)));
|
|
87
|
+
return digest(fields);
|
|
72
88
|
}
|
|
73
89
|
/**
|
|
74
90
|
* What in the file the owner has not approved. The file passes when every
|
|
75
91
|
* section matches and every seat in it matches an approved seat: a seat taken
|
|
76
|
-
* out
|
|
92
|
+
* out needs no new approval. Parking or stopping one does.
|
|
77
93
|
*/
|
|
78
94
|
export function compare(approved, current) {
|
|
79
95
|
const differences = [];
|
|
@@ -94,9 +110,9 @@ export function compare(approved, current) {
|
|
|
94
110
|
return differences;
|
|
95
111
|
}
|
|
96
112
|
/**
|
|
97
|
-
* The line `describe` prints when `watch.checks` itself is the difference. `pass`
|
|
98
|
-
*
|
|
99
|
-
* turned off
|
|
113
|
+
* The line `describe` prints when `watch.checks` itself is the difference. `pass` does not read
|
|
114
|
+
* it. The list in force is what turns checks off: while the edit is unapproved, the approved
|
|
115
|
+
* list stays in force, so nothing new is turned off and an approved-off check stays off.
|
|
100
116
|
*/
|
|
101
117
|
export const WATCH_CHECKS_CHANGED = '`watch.checks` changed';
|
|
102
118
|
/** One line per difference, as `status`, `doctor` and the refusals print it. */
|
|
@@ -107,3 +123,12 @@ export function describe(difference) {
|
|
|
107
123
|
return `seat ${difference.name} is not in the approved file`;
|
|
108
124
|
return `seat ${difference.name} changed`;
|
|
109
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* The seat a difference line names, when it names one — the inverse of `describe`, kept beside it
|
|
128
|
+
* so the two can't drift. `pass` reads it to keep a seat the owner has not approved out of the
|
|
129
|
+
* readings fold; `null` for a section difference.
|
|
130
|
+
*/
|
|
131
|
+
export function seatNamed(line) {
|
|
132
|
+
const match = /^seat (.+) (?:changed|is not in the approved file)$/.exec(line);
|
|
133
|
+
return match?.[1] ?? null;
|
|
134
|
+
}
|
package/dist/budgets/gate.d.ts
CHANGED
|
@@ -11,10 +11,11 @@ export type LaunchDecision = {
|
|
|
11
11
|
why: string;
|
|
12
12
|
};
|
|
13
13
|
/**
|
|
14
|
-
* The seat's account is its
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* The seat's account is its own `account:` when the file names one — one vendor with two accounts
|
|
15
|
+
* is two buckets (§ 3b) — and its vendor otherwise. `budgets` is the section in force: an
|
|
16
|
+
* unapproved edit to a reserve refuses no one until it is approved, and an account only the
|
|
17
|
+
* unapproved edit names is not an account at all. `spend` holds the stored spend check readings;
|
|
18
|
+
* a subscription account never looks at them.
|
|
18
19
|
*/
|
|
19
20
|
export declare function seatBudget(budgets: TeamFile['budgets'], readings: readonly Seen[], seat: Seat, now: number, spend?: readonly SpendReading[]): LaunchDecision;
|
|
20
21
|
/**
|