team 0.1.0 → 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 +12 -7
- package/dist/budgets/gate.js +52 -17
- package/dist/budgets/readings.d.ts +71 -5
- package/dist/budgets/readings.js +136 -9
- package/dist/budgets/table.d.ts +2 -2
- package/dist/budgets/table.js +24 -11
- package/dist/commands/add.js +12 -5
- 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 +12 -7
- package/dist/commands/watch.d.ts +2 -0
- package/dist/commands/watch.js +94 -16
- 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/profiles/quota.d.ts +7 -2
- package/dist/profiles/quota.js +15 -14
- package/dist/state.d.ts +8 -3
- 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 +14 -1
- package/dist/watch/pass.js +64 -28
- package/dist/watch/screen-core.d.ts +7 -1
- package/dist/watch/screen-core.js +146 -54
- package/dist/watch/screen-data.d.ts +9 -0
- package/dist/watch/screen-file.js +25 -7
- package/dist/watch/screen.d.ts +5 -0
- package/dist/watch/screen.js +16 -2
- package/examples/team.yaml +8 -0
- package/package.json +2 -2
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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Seat, TeamFile } from '../file/types.ts';
|
|
2
|
-
import { type Seen } from './readings.ts';
|
|
2
|
+
import { type Seen, type SpendReading } from './readings.ts';
|
|
3
3
|
export type LaunchDecision = {
|
|
4
4
|
kind: 'clear';
|
|
5
5
|
} | {
|
|
@@ -11,10 +11,15 @@ export type LaunchDecision = {
|
|
|
11
11
|
why: string;
|
|
12
12
|
};
|
|
13
13
|
/**
|
|
14
|
-
* The seat's account is its
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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.
|
|
17
19
|
*/
|
|
18
|
-
export declare function seatBudget(budgets: TeamFile['budgets'], readings: readonly Seen[], seat: Seat, now: number): LaunchDecision;
|
|
19
|
-
/**
|
|
20
|
-
|
|
20
|
+
export declare function seatBudget(budgets: TeamFile['budgets'], readings: readonly Seen[], seat: Seat, now: number, spend?: readonly SpendReading[]): LaunchDecision;
|
|
21
|
+
/**
|
|
22
|
+
* Accounts whose counted figures leave room: a subscription with every counted window outside
|
|
23
|
+
* its reserve, a spend account with a fresh reading above its floor.
|
|
24
|
+
*/
|
|
25
|
+
export declare function accountsWithRoom(budgets: TeamFile['budgets'], readings: readonly Seen[], now: number, spend?: readonly SpendReading[]): string[];
|
package/dist/budgets/gate.js
CHANGED
|
@@ -1,17 +1,34 @@
|
|
|
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
|
-
*
|
|
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.
|
|
7
9
|
*/
|
|
8
|
-
export function seatBudget(budgets, readings, seat, now) {
|
|
9
|
-
const name = seat.vendor;
|
|
10
|
+
export function seatBudget(budgets, readings, seat, now, spend = []) {
|
|
11
|
+
const name = seat.account ?? seat.vendor;
|
|
10
12
|
const account = budgets.accounts[name];
|
|
11
13
|
if (!account)
|
|
12
14
|
return { kind: 'clear' };
|
|
13
|
-
if (account.kind === 'spend')
|
|
14
|
-
|
|
15
|
+
if (account.kind === 'spend') {
|
|
16
|
+
const floor = account.floor;
|
|
17
|
+
const reading = spend.find((item) => item.account === name);
|
|
18
|
+
// § 5's freshness rule for a check reading, applied to the floor: a reading with no floor to
|
|
19
|
+
// measure against, one read longer ago than `stale_after`, and one in another currency than
|
|
20
|
+
// the floor's are the same answer — unknown, said, never a refusal.
|
|
21
|
+
if (!floor || !reading || reading.currency !== floor.currency || now - reading.at >= budgets.staleAfter * 1000) {
|
|
22
|
+
return { kind: 'unknown', account: name, text: `${name} is unknown` };
|
|
23
|
+
}
|
|
24
|
+
if (reading.amount > floor.amount)
|
|
25
|
+
return { kind: 'clear' };
|
|
26
|
+
const room = accountsWithRoom(budgets, readings, now, spend).filter((accountName) => accountName !== name);
|
|
27
|
+
return {
|
|
28
|
+
kind: 'refuse',
|
|
29
|
+
why: `${name} spend ${money(reading.amount)} ${reading.currency}, at or below its ${money(floor.amount)} ${floor.currency} floor, read ${age(now - reading.at)} ago; accounts with room: ${room.length ? room.join(', ') : 'none'}`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
15
32
|
const staleAfterMs = budgets.staleAfter * 1000;
|
|
16
33
|
const mine = readings.filter((item) => item.account === name);
|
|
17
34
|
let unknown = mine.length === 0;
|
|
@@ -19,7 +36,9 @@ export function seatBudget(budgets, readings, seat, now) {
|
|
|
19
36
|
let counted = false;
|
|
20
37
|
let worst = null;
|
|
21
38
|
for (const group of windowsOf(mine)) {
|
|
22
|
-
|
|
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);
|
|
23
42
|
if (result.kind === 'unknown') {
|
|
24
43
|
unknown = true;
|
|
25
44
|
continue;
|
|
@@ -30,8 +49,7 @@ export function seatBudget(budgets, readings, seat, now) {
|
|
|
30
49
|
continue;
|
|
31
50
|
}
|
|
32
51
|
counted = true;
|
|
33
|
-
|
|
34
|
-
if (!inside && result.kind !== 'refusing')
|
|
52
|
+
if (account.reserve === null || result.reading.left > account.reserve)
|
|
35
53
|
continue;
|
|
36
54
|
if (!worst
|
|
37
55
|
|| result.reading.left < worst.left
|
|
@@ -41,9 +59,11 @@ export function seatBudget(budgets, readings, seat, now) {
|
|
|
41
59
|
}
|
|
42
60
|
if (worst && account.reserve !== null) {
|
|
43
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';
|
|
44
64
|
return {
|
|
45
65
|
kind: 'refuse',
|
|
46
|
-
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'}`,
|
|
47
67
|
};
|
|
48
68
|
}
|
|
49
69
|
if (unknown)
|
|
@@ -52,22 +72,33 @@ export function seatBudget(budgets, readings, seat, now) {
|
|
|
52
72
|
return { kind: 'unknown', account: name, text: `${name}: first sight only, not yet counted` };
|
|
53
73
|
return { kind: 'clear' };
|
|
54
74
|
}
|
|
55
|
-
/**
|
|
56
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Accounts whose counted figures leave room: a subscription with every counted window outside
|
|
77
|
+
* its reserve, a spend account with a fresh reading above its floor.
|
|
78
|
+
*/
|
|
79
|
+
export function accountsWithRoom(budgets, readings, now, spend = []) {
|
|
57
80
|
const staleAfterMs = budgets.staleAfter * 1000;
|
|
58
81
|
const names = [];
|
|
59
82
|
for (const [name, account] of Object.entries(budgets.accounts)) {
|
|
60
|
-
if (account.kind
|
|
83
|
+
if (account.kind === 'spend') {
|
|
84
|
+
const floor = account.floor;
|
|
85
|
+
const reading = spend.find((item) => item.account === name);
|
|
86
|
+
if (floor && reading && reading.currency === floor.currency && now - reading.at < staleAfterMs && reading.amount > floor.amount) {
|
|
87
|
+
names.push(name);
|
|
88
|
+
}
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (account.reserve === null)
|
|
61
92
|
continue;
|
|
62
93
|
const groups = windowsOf(readings.filter((item) => item.account === name));
|
|
63
94
|
let counted = 0;
|
|
64
95
|
let inside = false;
|
|
65
96
|
for (const group of groups) {
|
|
66
|
-
const result =
|
|
97
|
+
const result = countedFor(account.sources, screenOf(group), checkOf(group), now, staleAfterMs, account.reserve);
|
|
67
98
|
if (result.kind === 'unknown' || result.kind === 'unconfirmed')
|
|
68
99
|
continue;
|
|
69
100
|
counted += 1;
|
|
70
|
-
if (result.
|
|
101
|
+
if (result.reading.left <= account.reserve)
|
|
71
102
|
inside = true;
|
|
72
103
|
}
|
|
73
104
|
if (counted > 0 && !inside)
|
|
@@ -94,3 +125,7 @@ function age(ms) {
|
|
|
94
125
|
return `${hours}h`;
|
|
95
126
|
return `${minutes}m`;
|
|
96
127
|
}
|
|
128
|
+
/** A money figure in a refusal: the cents are shown even when the file wrote none. */
|
|
129
|
+
function money(amount) {
|
|
130
|
+
return amount.toFixed(2);
|
|
131
|
+
}
|