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.
Files changed (57) hide show
  1. package/README.md +20 -5
  2. package/dist/ansi.d.ts +13 -0
  3. package/dist/ansi.js +75 -0
  4. package/dist/approve/approval.d.ts +11 -0
  5. package/dist/approve/approval.js +75 -8
  6. package/dist/approve/fingerprint.d.ts +15 -4
  7. package/dist/approve/fingerprint.js +37 -12
  8. package/dist/budgets/gate.d.ts +12 -7
  9. package/dist/budgets/gate.js +52 -17
  10. package/dist/budgets/readings.d.ts +71 -5
  11. package/dist/budgets/readings.js +136 -9
  12. package/dist/budgets/table.d.ts +2 -2
  13. package/dist/budgets/table.js +24 -11
  14. package/dist/commands/add.js +12 -5
  15. package/dist/commands/doctor.d.ts +3 -1
  16. package/dist/commands/doctor.js +4 -2
  17. package/dist/commands/down.d.ts +2 -2
  18. package/dist/commands/down.js +8 -1
  19. package/dist/commands/remove.d.ts +2 -0
  20. package/dist/commands/remove.js +21 -2
  21. package/dist/commands/status.js +3 -2
  22. package/dist/commands/up.d.ts +3 -1
  23. package/dist/commands/up.js +12 -7
  24. package/dist/commands/watch.d.ts +2 -0
  25. package/dist/commands/watch.js +94 -16
  26. package/dist/file/types.d.ts +5 -0
  27. package/dist/file/validate.js +31 -11
  28. package/dist/herdr.d.ts +4 -0
  29. package/dist/herdr.js +40 -11
  30. package/dist/launch/agent.d.ts +1 -0
  31. package/dist/launch/agent.js +12 -0
  32. package/dist/launch/deliver.d.ts +3 -1
  33. package/dist/launch/deliver.js +10 -1
  34. package/dist/launch/execute.d.ts +2 -2
  35. package/dist/launch/execute.js +15 -2
  36. package/dist/launch/rules.d.ts +4 -2
  37. package/dist/launch/rules.js +17 -3
  38. package/dist/profiles/antigravity.yaml +13 -0
  39. package/dist/profiles/claude-code.yaml +30 -0
  40. package/dist/profiles/cursor.yaml +4 -1
  41. package/dist/profiles/quota.d.ts +7 -2
  42. package/dist/profiles/quota.js +15 -14
  43. package/dist/state.d.ts +8 -3
  44. package/dist/state.js +37 -0
  45. package/dist/status/statusline.js +3 -1
  46. package/dist/watch/check.d.ts +1 -0
  47. package/dist/watch/checks/budget.js +4 -1
  48. package/dist/watch/pass.d.ts +14 -1
  49. package/dist/watch/pass.js +64 -28
  50. package/dist/watch/screen-core.d.ts +7 -1
  51. package/dist/watch/screen-core.js +146 -54
  52. package/dist/watch/screen-data.d.ts +9 -0
  53. package/dist/watch/screen-file.js +25 -7
  54. package/dist/watch/screen.d.ts +5 -0
  55. package/dist/watch/screen.js +16 -2
  56. package/examples/team.yaml +8 -0
  57. 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.0
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 `check` command is resolved to a file and hashed when the
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` turn those off | anyone, one per session; it types only its fixed nudge, into an empty idle prompt |
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.0
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
  /**
@@ -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
- if (!checked.ok)
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: { ...fingerprints(checked.team).sections, ...stored.sections }, seats: stored.seats };
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
- if (approvedFingerprints(record).sections['watch'] === fingerprints(team).sections['watch'])
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
- return copy.ok ? copy.team.watch : defaultWatch();
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, parked or stopped needs no new approval.
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` reads it to
39
- * keep the checks running until the owner approves an edit that would turn one off: nothing is
40
- * turned off until the owner approves (RFC 0002 § 4.2).
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: what `remove --keep` and
42
- * `add` set, where the seat sits in the file, and how its entry is written
43
- * (`count: 3` becoming `count: 2`, or explicit seats, when one is taken out).
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(['parked', 'stopped', 'line', 'declared', 'count', 'instance']);
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
- const fields = Object.fromEntries(Object.entries(seat).filter(([key]) => !SEAT_FREE_FIELDS.has(key)));
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, parked or stopped needs no new approval.
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` reads it to
98
- * keep the checks running until the owner approves an edit that would turn one off: nothing is
99
- * turned off until the owner approves (RFC 0002 § 4.2).
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
+ }
@@ -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 vendor. There is no separate account field on a seat. `budgets` is
15
- * the section in force: an unapproved edit to a reserve refuses no one until it is approved,
16
- * and an account only the unapproved edit names is not an account at all.
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
- /** Subscription accounts whose counted windows are all outside the reserve. */
20
- export declare function accountsWithRoom(budgets: TeamFile['budgets'], readings: readonly Seen[], now: number): string[];
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[];
@@ -1,17 +1,34 @@
1
- import { verdict } from "./readings.js";
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 vendor. There is no separate account field on a seat. `budgets` is
5
- * the section in force: an unapproved edit to a reserve refuses no one until it is approved,
6
- * and an account only the unapproved edit names is not an account at all.
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
- return { kind: 'unknown', account: name, text: `${name} is unknown` };
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
- const result = verdict(group, now, staleAfterMs, account.reserve);
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
- const inside = account.reserve !== null && result.reading.left <= account.reserve;
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, changed ${age(now - worst.changedAt)} ago; accounts with room: ${room.length ? room.join(', ') : 'none'}`,
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
- /** Subscription accounts whose counted windows are all outside the reserve. */
56
- export function accountsWithRoom(budgets, readings, now) {
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 !== 'subscription' || account.reserve === null)
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 = verdict(group, now, staleAfterMs, account.reserve);
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.kind === 'refusing' || result.reading.left <= account.reserve)
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
+ }