team 0.1.1 → 0.1.2
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 +20 -5
- 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 +75 -8
- package/dist/approve/fingerprint.d.ts +15 -4
- package/dist/approve/fingerprint.js +37 -12
- package/dist/budgets/gate.d.ts +5 -4
- package/dist/budgets/gate.js +16 -12
- package/dist/budgets/readings.d.ts +51 -7
- package/dist/budgets/readings.js +106 -16
- package/dist/budgets/table.d.ts +2 -2
- package/dist/budgets/table.js +24 -11
- package/dist/commands/add.js +11 -4
- package/dist/commands/doctor.d.ts +3 -1
- package/dist/commands/doctor.js +4 -2
- package/dist/commands/down.d.ts +2 -2
- package/dist/commands/down.js +8 -1
- package/dist/commands/remove.d.ts +2 -0
- package/dist/commands/remove.js +21 -2
- package/dist/commands/status.js +3 -2
- package/dist/commands/up.d.ts +3 -1
- package/dist/commands/up.js +9 -5
- package/dist/commands/watch.d.ts +2 -0
- package/dist/commands/watch.js +73 -17
- package/dist/file/types.d.ts +5 -0
- package/dist/file/validate.js +31 -11
- package/dist/herdr.d.ts +4 -0
- package/dist/herdr.js +40 -11
- package/dist/launch/agent.d.ts +1 -0
- package/dist/launch/agent.js +12 -0
- package/dist/launch/deliver.d.ts +3 -1
- package/dist/launch/deliver.js +10 -1
- package/dist/launch/execute.d.ts +2 -2
- package/dist/launch/execute.js +15 -2
- package/dist/launch/rules.d.ts +4 -2
- package/dist/launch/rules.js +17 -3
- package/dist/profiles/antigravity.yaml +13 -0
- package/dist/profiles/claude-code.yaml +30 -0
- package/dist/profiles/cursor.yaml +4 -1
- package/dist/state.d.ts +7 -4
- package/dist/state.js +37 -0
- package/dist/status/statusline.js +3 -1
- package/dist/watch/check.d.ts +1 -0
- package/dist/watch/checks/budget.js +4 -1
- package/dist/watch/pass.d.ts +3 -2
- package/dist/watch/pass.js +52 -21
- package/dist/watch/screen-core.d.ts +1 -1
- package/dist/watch/screen-core.js +89 -33
- package/dist/watch/screen-data.d.ts +9 -0
- package/dist/watch/screen-file.js +25 -7
- package/dist/watch/screen.d.ts +3 -2
- package/dist/watch/screen.js +9 -4
- package/examples/team.yaml +8 -0
- package/package.json +1 -1
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.1.
|
|
21
|
+
team --version # 0.1.2
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
## The file is private to each clone
|
|
@@ -48,7 +48,10 @@ identity:
|
|
|
48
48
|
position: trailer # last-line | trailer | anywhere
|
|
49
49
|
exempt: [merge] # merge commits need no signature
|
|
50
50
|
|
|
51
|
-
rules: # lines added to every seat's rules at launch
|
|
51
|
+
rules: # lines added to every seat's rules at launch. Rules delivered as a launch
|
|
52
|
+
# option (claude-code) close with "These are standing rules, not a task.";
|
|
53
|
+
# rules typed as a first message (codex, cursor, antigravity) close with
|
|
54
|
+
# "These are standing rules, not a task: reply ready and wait for your brief."
|
|
52
55
|
- Run the tests your change touches, not the whole suite.
|
|
53
56
|
|
|
54
57
|
workspace:
|
|
@@ -67,6 +70,7 @@ seats:
|
|
|
67
70
|
name: codex-hello
|
|
68
71
|
cli: codex
|
|
69
72
|
vendor: openai
|
|
73
|
+
account: openai-hello # the seat's account, when one vendor has two; absent, its vendor
|
|
70
74
|
model: GPT Sol
|
|
71
75
|
version: "6"
|
|
72
76
|
display: GPT-6 Sol # the vendor's spelling, for the signature
|
|
@@ -91,6 +95,13 @@ seats:
|
|
|
91
95
|
version: "4.7"
|
|
92
96
|
launch: grok --model grok-4.7
|
|
93
97
|
stopped: true # kept in the file; `up` doesn't start it
|
|
98
|
+
|
|
99
|
+
budgets: # the owner's: reserve or floor per account, marks, freshness
|
|
100
|
+
accounts:
|
|
101
|
+
openai-hello: # the account codex-hello spends
|
|
102
|
+
kind: subscription
|
|
103
|
+
reserve: 10% # refuse a launch on a figure inside it
|
|
104
|
+
sources: [status_line] # the figure comes off Codex's status line
|
|
94
105
|
```
|
|
95
106
|
|
|
96
107
|
- `session` names the herdr session and defaults to `project`; `--session` overrides it.
|
|
@@ -104,6 +115,8 @@ seats:
|
|
|
104
115
|
and session links are always refused.
|
|
105
116
|
- `seats[*].cli` picks the launch profile; `claude-code`, `codex`, `cursor` and `antigravity` are available, and `team
|
|
106
117
|
doctor` says what the others still need. `vendor`, `model` and `version` spell one seat's model.
|
|
118
|
+
`account` names the budget account the seat spends when one vendor has two; without it, the seat
|
|
119
|
+
spends its `vendor`, and changing either is an edit the owner re-approves.
|
|
107
120
|
- `launch` is the plain command, without approval flags: the profile adds them. `count: 2` makes the
|
|
108
121
|
numbered names; `parked` keeps a seat out of idle reports, `stopped` keeps it out of `up`.
|
|
109
122
|
- `workspace.mode` is `shared` (every seat in the project) or `worktree` (each task in its own
|
|
@@ -134,7 +147,9 @@ under ~/.cursor/projects for that folder; team writes no trust (.workspace-trust
|
|
|
134
147
|
config.
|
|
135
148
|
|
|
136
149
|
`budgets` is the owner's: marks (percent used), how long a figure stays fresh, and each
|
|
137
|
-
account's reserve or floor. A
|
|
150
|
+
account's reserve or floor. A seat spends its own `account:` when the file names one, its `vendor`
|
|
151
|
+
when it doesn't, so one vendor's two accounts are two buckets; a pattern names the account it
|
|
152
|
+
measures, not the seat's. A `check` command is resolved to a file and hashed when the
|
|
138
153
|
owner approves. A change to that file leaves that account's check unapproved: it is
|
|
139
154
|
not run, and the account reads unknown, until the owner approves again. The rest of
|
|
140
155
|
the file still runs. `watch.quota_marks` is still read, with a warning, until you move it to
|
|
@@ -197,7 +212,7 @@ the commands below read.
|
|
|
197
212
|
| `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
213
|
| `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
214
|
| `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`
|
|
215
|
+
| `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
216
|
| `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
217
|
| `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
218
|
| `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 +264,7 @@ cd team
|
|
|
249
264
|
bun install
|
|
250
265
|
bun run build
|
|
251
266
|
npm install -g . # puts `team` on the PATH
|
|
252
|
-
team --version # 0.1.
|
|
267
|
+
team --version # 0.1.2
|
|
253
268
|
```
|
|
254
269
|
|
|
255
270
|
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, 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,69 @@ 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 ? adoptFlagDigests(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 still names
|
|
41
|
+
* the seat as it was approved, flags included, read from the stored copy. A
|
|
42
|
+
* digest already in the new shape is left alone, so a later edit of the file
|
|
43
|
+
* is not adopted from a stale copy. A copy that can't be read is left alone.
|
|
44
|
+
*/
|
|
45
|
+
function adoptFlagDigests(stored, team) {
|
|
46
|
+
const current = fingerprints(team).seats;
|
|
47
|
+
const legacy = legacySeatDigests(team);
|
|
48
|
+
let changed = false;
|
|
49
|
+
const next = { ...stored };
|
|
50
|
+
for (const [name, previous] of Object.entries(stored)) {
|
|
51
|
+
const adopted = current[name];
|
|
52
|
+
if (adopted !== undefined && previous === legacy[name] && previous !== adopted) {
|
|
53
|
+
next[name] = adopted;
|
|
54
|
+
changed = true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return changed ? next : stored;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* `remove --keep` and `add` write `stopped` and nothing else. The new digest is
|
|
61
|
+
* recorded only when putting `stopped` back to its approved value makes the
|
|
62
|
+
* seat match the approval. A launch line or a `parked` flag edited beside the
|
|
63
|
+
* mark stays drift. A stored copy that can't be read records nothing.
|
|
64
|
+
*/
|
|
65
|
+
export function recordSeatDigest(team, root, name, home = homedir()) {
|
|
66
|
+
const store = storePath(team.project, root, home);
|
|
67
|
+
const record = readApproval(store);
|
|
68
|
+
if (record === null)
|
|
69
|
+
return;
|
|
70
|
+
if (!validateTeamFile(record.file).ok)
|
|
71
|
+
return;
|
|
72
|
+
const approved = approvedFingerprints(record).seats[name];
|
|
73
|
+
const digest = fingerprints(team).seats[name];
|
|
74
|
+
if (approved === undefined || digest === undefined || record.approval.fingerprints.seats[name] === digest)
|
|
75
|
+
return;
|
|
76
|
+
const withStopped = (stopped) => fingerprints({
|
|
77
|
+
...team,
|
|
78
|
+
seats: team.seats.map((seat) => (seat.name === name ? { ...seat, stopped } : seat)),
|
|
79
|
+
}).seats[name];
|
|
80
|
+
if (withStopped(true) !== approved && withStopped(false) !== approved)
|
|
81
|
+
return;
|
|
82
|
+
writeApproval(store, {
|
|
83
|
+
approval: {
|
|
84
|
+
...record.approval,
|
|
85
|
+
fingerprints: {
|
|
86
|
+
...record.approval.fingerprints,
|
|
87
|
+
seats: { ...record.approval.fingerprints.seats, [name]: digest },
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
file: record.file,
|
|
91
|
+
}, []);
|
|
35
92
|
}
|
|
36
93
|
/**
|
|
37
94
|
* What in the file the owner has not approved on this machine, one line per
|
|
@@ -49,15 +106,25 @@ export function approvalDifferences(team, root, home = homedir()) {
|
|
|
49
106
|
* only once the owner has approved it: a file never approved runs with the defaults, and a file
|
|
50
107
|
* whose `watch` section differs from the approved one runs with the values of the approved copy.
|
|
51
108
|
* An edit to a threshold changes nothing until `approve`.
|
|
109
|
+
*
|
|
110
|
+
* `watch.checks` is the finer line inside that section. Whenever its digest differs, the checks
|
|
111
|
+
* in force are the approved copy's — or none, when that copy can't be read — even when the
|
|
112
|
+
* timings themselves are unchanged and the rest of the section is the file's.
|
|
52
113
|
*/
|
|
53
114
|
export function watchInForce(team, root, home = homedir()) {
|
|
54
115
|
const record = readApproval(storePath(team.project, root, home));
|
|
55
116
|
if (record === null)
|
|
56
117
|
return defaultWatch();
|
|
57
|
-
|
|
118
|
+
const differences = compare(approvedFingerprints(record), fingerprints(team));
|
|
119
|
+
const timingsDiffer = differences.some((difference) => difference.kind === 'section' && difference.name === 'watch');
|
|
120
|
+
const checksDiffer = differences.some((difference) => difference.kind === 'section' && difference.name === 'watch.checks');
|
|
121
|
+
if (!timingsDiffer && !checksDiffer)
|
|
58
122
|
return team.watch;
|
|
59
123
|
const copy = validateTeamFile(record.file);
|
|
60
|
-
|
|
124
|
+
const approved = copy.ok ? copy.team.watch : defaultWatch();
|
|
125
|
+
if (!timingsDiffer)
|
|
126
|
+
return { ...team.watch, checks: approved.checks };
|
|
127
|
+
return approved;
|
|
61
128
|
}
|
|
62
129
|
/**
|
|
63
130
|
* The budget values in force. The `budgets` section is the owner's like the watch's, so what a
|
|
@@ -18,6 +18,11 @@ export interface Fingerprints {
|
|
|
18
18
|
export declare function canonical(value: unknown): string;
|
|
19
19
|
/** A fingerprint of each owner-only section and of each seat. */
|
|
20
20
|
export declare function fingerprints(team: Approvable): Fingerprints;
|
|
21
|
+
/**
|
|
22
|
+
* Seat digests as a record written before `parked` and `stopped` were part of
|
|
23
|
+
* them. An approval from then still matches a file that has not changed.
|
|
24
|
+
*/
|
|
25
|
+
export declare function legacySeatDigests(team: Approvable): Record<string, string>;
|
|
21
26
|
export type Difference = {
|
|
22
27
|
kind: 'section';
|
|
23
28
|
name: string;
|
|
@@ -31,14 +36,20 @@ export type Difference = {
|
|
|
31
36
|
/**
|
|
32
37
|
* What in the file the owner has not approved. The file passes when every
|
|
33
38
|
* section matches and every seat in it matches an approved seat: a seat taken
|
|
34
|
-
* out
|
|
39
|
+
* out needs no new approval. Parking or stopping one does.
|
|
35
40
|
*/
|
|
36
41
|
export declare function compare(approved: Fingerprints, current: Fingerprints): Difference[];
|
|
37
42
|
/**
|
|
38
|
-
* The line `describe` prints when `watch.checks` itself is the difference. `pass`
|
|
39
|
-
*
|
|
40
|
-
* turned off
|
|
43
|
+
* The line `describe` prints when `watch.checks` itself is the difference. `pass` does not read
|
|
44
|
+
* it. The list in force is what turns checks off: while the edit is unapproved, the approved
|
|
45
|
+
* list stays in force, so nothing new is turned off and an approved-off check stays off.
|
|
41
46
|
*/
|
|
42
47
|
export declare const WATCH_CHECKS_CHANGED = "`watch.checks` changed";
|
|
43
48
|
/** One line per difference, as `status`, `doctor` and the refusals print it. */
|
|
44
49
|
export declare function describe(difference: Difference): string;
|
|
50
|
+
/**
|
|
51
|
+
* The seat a difference line names, when it names one — the inverse of `describe`, kept beside it
|
|
52
|
+
* so the two can't drift. `pass` reads it to keep a seat the owner has not approved out of the
|
|
53
|
+
* readings fold; `null` for a section difference.
|
|
54
|
+
*/
|
|
55
|
+
export declare function seatNamed(line: string): string | null;
|
|
@@ -38,11 +38,15 @@ function sectionOf(team, name) {
|
|
|
38
38
|
return team[name];
|
|
39
39
|
}
|
|
40
40
|
/**
|
|
41
|
-
* Seat fields that change without a new approval:
|
|
42
|
-
*
|
|
43
|
-
*
|
|
41
|
+
* Seat fields that change without a new approval: where the seat sits in the
|
|
42
|
+
* file, and how its entry is written (`count: 3` becoming `count: 2`, or
|
|
43
|
+
* explicit seats, when one is taken out). `parked` and `stopped` stay in the
|
|
44
|
+
* digest: a seat that can edit the file must not silence itself. `remove --keep`
|
|
45
|
+
* and `add` record the new digest with the edit.
|
|
44
46
|
*/
|
|
45
|
-
const SEAT_FREE_FIELDS = new Set(['
|
|
47
|
+
const SEAT_FREE_FIELDS = new Set(['line', 'declared', 'count', 'instance']);
|
|
48
|
+
/** The free set from before `parked` and `stopped` joined the digest. */
|
|
49
|
+
const LEGACY_SEAT_FREE_FIELDS = new Set(['parked', 'stopped', 'line', 'declared', 'count', 'instance']);
|
|
46
50
|
/** JSON with every object's keys in order, so equal values give equal text. */
|
|
47
51
|
export function canonical(value) {
|
|
48
52
|
if (Array.isArray(value))
|
|
@@ -64,16 +68,28 @@ export function fingerprints(team) {
|
|
|
64
68
|
for (const section of OWNER_SECTIONS)
|
|
65
69
|
sections[section] = digest(sectionOf(team, section));
|
|
66
70
|
const seats = {};
|
|
67
|
-
for (const seat of team.seats)
|
|
68
|
-
|
|
69
|
-
seats[seat.name] = digest(fields);
|
|
70
|
-
}
|
|
71
|
+
for (const seat of team.seats)
|
|
72
|
+
seats[seat.name] = seatDigest(seat, SEAT_FREE_FIELDS);
|
|
71
73
|
return { sections, seats };
|
|
72
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Seat digests as a record written before `parked` and `stopped` were part of
|
|
77
|
+
* them. An approval from then still matches a file that has not changed.
|
|
78
|
+
*/
|
|
79
|
+
export function legacySeatDigests(team) {
|
|
80
|
+
const seats = {};
|
|
81
|
+
for (const seat of team.seats)
|
|
82
|
+
seats[seat.name] = seatDigest(seat, LEGACY_SEAT_FREE_FIELDS);
|
|
83
|
+
return seats;
|
|
84
|
+
}
|
|
85
|
+
function seatDigest(seat, free) {
|
|
86
|
+
const fields = Object.fromEntries(Object.entries(seat).filter(([key]) => !free.has(key)));
|
|
87
|
+
return digest(fields);
|
|
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
|
/**
|
package/dist/budgets/gate.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { checkOf, countedFor, screenOf } from "./readings.js";
|
|
2
2
|
const RANK = { session: 0, daily: 1, weekly: 2 };
|
|
3
3
|
/**
|
|
4
|
-
* The seat's account is its
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* The seat's account is its own `account:` when the file names one — one vendor with two accounts
|
|
5
|
+
* is two buckets (§ 3b) — and its vendor otherwise. `budgets` is the section in force: an
|
|
6
|
+
* unapproved edit to a reserve refuses no one until it is approved, and an account only the
|
|
7
|
+
* unapproved edit names is not an account at all. `spend` holds the stored spend check readings;
|
|
8
|
+
* a subscription account never looks at them.
|
|
8
9
|
*/
|
|
9
10
|
export function seatBudget(budgets, readings, seat, now, spend = []) {
|
|
10
|
-
const name = seat.vendor;
|
|
11
|
+
const name = seat.account ?? seat.vendor;
|
|
11
12
|
const account = budgets.accounts[name];
|
|
12
13
|
if (!account)
|
|
13
14
|
return { kind: 'clear' };
|
|
@@ -35,7 +36,9 @@ export function seatBudget(budgets, readings, seat, now, spend = []) {
|
|
|
35
36
|
let counted = false;
|
|
36
37
|
let worst = null;
|
|
37
38
|
for (const group of windowsOf(mine)) {
|
|
38
|
-
|
|
39
|
+
// The window's reading from the sources the account names, in order (§ 3, § 5): a check
|
|
40
|
+
// reading counts by its own freshness, ahead of a screen reading below it.
|
|
41
|
+
const result = countedFor(account.sources, screenOf(group), checkOf(group), now, staleAfterMs, account.reserve);
|
|
39
42
|
if (result.kind === 'unknown') {
|
|
40
43
|
unknown = true;
|
|
41
44
|
continue;
|
|
@@ -46,8 +49,7 @@ export function seatBudget(budgets, readings, seat, now, spend = []) {
|
|
|
46
49
|
continue;
|
|
47
50
|
}
|
|
48
51
|
counted = true;
|
|
49
|
-
|
|
50
|
-
if (!inside && result.kind !== 'refusing')
|
|
52
|
+
if (account.reserve === null || result.reading.left > account.reserve)
|
|
51
53
|
continue;
|
|
52
54
|
if (!worst
|
|
53
55
|
|| result.reading.left < worst.left
|
|
@@ -57,9 +59,11 @@ export function seatBudget(budgets, readings, seat, now, spend = []) {
|
|
|
57
59
|
}
|
|
58
60
|
if (worst && account.reserve !== null) {
|
|
59
61
|
const room = accountsWithRoom(budgets, readings, now).filter((accountName) => accountName !== name);
|
|
62
|
+
// A check reading was measured, not changed on a screen: § 5's word for it.
|
|
63
|
+
const when = worst.source === 'check' ? 'read' : 'changed';
|
|
60
64
|
return {
|
|
61
65
|
kind: 'refuse',
|
|
62
|
-
why: `${name} ${worst.window} left ${worst.left}%, inside its ${account.reserve}% reserve,
|
|
66
|
+
why: `${name} ${worst.window} left ${worst.left}%, inside its ${account.reserve}% reserve, ${when} ${age(now - worst.changedAt)} ago; accounts with room: ${room.length ? room.join(', ') : 'none'}`,
|
|
63
67
|
};
|
|
64
68
|
}
|
|
65
69
|
if (unknown)
|
|
@@ -90,11 +94,11 @@ export function accountsWithRoom(budgets, readings, now, spend = []) {
|
|
|
90
94
|
let counted = 0;
|
|
91
95
|
let inside = false;
|
|
92
96
|
for (const group of groups) {
|
|
93
|
-
const result =
|
|
97
|
+
const result = countedFor(account.sources, screenOf(group), checkOf(group), now, staleAfterMs, account.reserve);
|
|
94
98
|
if (result.kind === 'unknown' || result.kind === 'unconfirmed')
|
|
95
99
|
continue;
|
|
96
100
|
counted += 1;
|
|
97
|
-
if (result.
|
|
101
|
+
if (result.reading.left <= account.reserve)
|
|
98
102
|
inside = true;
|
|
99
103
|
}
|
|
100
104
|
if (counted > 0 && !inside)
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { QuotaFigure, WindowName } from '../profiles/quota.ts';
|
|
2
|
+
import type { CheckWindow } from './run.ts';
|
|
3
|
+
/** Where a reading came from: a pane's own status line, or an approved check command (§ 3). */
|
|
4
|
+
export type ReadingSource = 'status_line' | 'check';
|
|
2
5
|
export type Seen = {
|
|
3
6
|
account: string;
|
|
4
7
|
window: WindowName;
|
|
@@ -6,7 +9,9 @@ export type Seen = {
|
|
|
6
9
|
used: number;
|
|
7
10
|
changedAt: number;
|
|
8
11
|
resetsAt: number | null;
|
|
9
|
-
seat
|
|
12
|
+
/** The seat whose screen showed it; null for a check reading, which belongs to no seat. */
|
|
13
|
+
seat: string | null;
|
|
14
|
+
source: ReadingSource;
|
|
10
15
|
confirmed: boolean;
|
|
11
16
|
};
|
|
12
17
|
export type StoredReading = {
|
|
@@ -16,7 +21,9 @@ export type StoredReading = {
|
|
|
16
21
|
used: number;
|
|
17
22
|
changedAt: string;
|
|
18
23
|
resetsAt: string | null;
|
|
19
|
-
seat: string;
|
|
24
|
+
seat: string | null;
|
|
25
|
+
/** Absent in state files written before sources were recorded: a screen reading then. */
|
|
26
|
+
source?: ReadingSource;
|
|
20
27
|
confirmed: boolean;
|
|
21
28
|
};
|
|
22
29
|
export type Verdict = {
|
|
@@ -41,6 +48,15 @@ export type StoredSpend = {
|
|
|
41
48
|
};
|
|
42
49
|
/** Fold one seat's figure into the readings kept for this pass. */
|
|
43
50
|
export declare function observe(list: readonly Seen[], figure: QuotaFigure, seat: string, now: number): Seen[];
|
|
51
|
+
/**
|
|
52
|
+
* Fold a check command's windows into the readings kept for this pass (§ 5). A check reading is
|
|
53
|
+
* confirmed at first sight and belongs to no seat; one is kept per account and window, so a new
|
|
54
|
+
* check replaces the last, and the screen readings of the same window are left alone.
|
|
55
|
+
*/
|
|
56
|
+
export declare function observeCheck(list: readonly Seen[], account: string, windows: readonly CheckWindow[]): Seen[];
|
|
57
|
+
/** The screen readings in a list, and the check readings: the two slots of the same account. */
|
|
58
|
+
export declare function screenOf(list: readonly Seen[]): Seen[];
|
|
59
|
+
export declare function checkOf(list: readonly Seen[]): Seen[];
|
|
44
60
|
/**
|
|
45
61
|
* The reading that counts. A figure from before a known reset is dropped.
|
|
46
62
|
* Among confirmed live readings, the newest change wins. A first sight counts
|
|
@@ -49,18 +65,46 @@ export declare function observe(list: readonly Seen[], figure: QuotaFigure, seat
|
|
|
49
65
|
* with no reset time it is unknown.
|
|
50
66
|
*/
|
|
51
67
|
export declare function verdict(list: readonly Seen[], now: number, staleAfterMs: number, reserve: number | null): Verdict;
|
|
68
|
+
export type CountedReading = {
|
|
69
|
+
kind: 'counted';
|
|
70
|
+
reading: Seen;
|
|
71
|
+
} | {
|
|
72
|
+
kind: 'unconfirmed';
|
|
73
|
+
reading: Seen;
|
|
74
|
+
} | {
|
|
75
|
+
kind: 'unknown';
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* The reading that counts for one window, from the sources the account names in order (§ 3, § 5):
|
|
79
|
+
* a lower source is used only when every higher one is failed, unknown or stale. A check reading
|
|
80
|
+
* counts while it is fresh by § 5's own time and its reset has not passed; a screen reading falls
|
|
81
|
+
* through when it is unknown, and a bare first sight falls through too, so a fresh check below it
|
|
82
|
+
* still counts (§ 4.3 rule 5). A stale reading inside its reserve with its reset ahead is kept as
|
|
83
|
+
* the fallback, whichever source it came from, so rule 4 still refuses when nothing below counts.
|
|
84
|
+
*/
|
|
85
|
+
export declare function countedFor(sources: readonly ReadingSource[], screen: readonly Seen[], checks: readonly Seen[], now: number, staleAfterMs: number, reserve: number | null): CountedReading;
|
|
52
86
|
/** Readings whose reset has passed are left out. One with no reset time is kept. */
|
|
53
87
|
export declare function remember(list: readonly Seen[], now: number): Record<string, StoredReading>;
|
|
54
88
|
export declare function recall(stored: Record<string, StoredReading> | undefined): Seen[];
|
|
55
|
-
/** Write the readings that still count. The state file is the per-project cache. */
|
|
56
|
-
export declare function saveReadings(dir: string,
|
|
57
|
-
|
|
89
|
+
/** Write the readings that still count. The state file is the per-project cache (§ 4.4). */
|
|
90
|
+
export declare function saveReadings(dir: string, list: readonly Seen[], now?: number): void;
|
|
91
|
+
/**
|
|
92
|
+
* One pass's fold, read and written under the state lock (§ 4.4): `fold` is handed the readings
|
|
93
|
+
* the state holds and returns the list to keep, so a watch folding while another watch writes
|
|
94
|
+
* folds onto what was written, never over a list it read before it. Whatever the fold returns as
|
|
95
|
+
* its `value` is handed back.
|
|
96
|
+
*/
|
|
97
|
+
export declare function updateReadings<T>(dir: string, now: number, fold: (stored: Seen[]) => {
|
|
98
|
+
readings: Seen[];
|
|
99
|
+
value: T;
|
|
100
|
+
}): T;
|
|
101
|
+
export declare function loadReadings(dir: string): Seen[];
|
|
58
102
|
/**
|
|
59
103
|
* Write the spend readings a pass's checks read, merging by account: an account whose check did
|
|
60
104
|
* not run this pass keeps the reading the state already holds. Nothing read, nothing written.
|
|
61
105
|
*/
|
|
62
|
-
export declare function saveSpendReadings(dir: string,
|
|
63
|
-
export declare function loadSpendReadings(dir: string
|
|
106
|
+
export declare function saveSpendReadings(dir: string, list: readonly SpendReading[]): void;
|
|
107
|
+
export declare function loadSpendReadings(dir: string): SpendReading[];
|
|
64
108
|
export declare function storeSpend(reading: SpendReading): StoredSpend;
|
|
65
109
|
export declare function recallSpend(stored: Record<string, StoredSpend> | undefined): SpendReading[];
|
|
66
110
|
export declare function store(reading: Seen): StoredReading;
|