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
@@ -30,7 +30,21 @@ export function seatRules(input) {
30
30
  }
31
31
  return [...own, ...input.rules, ...signing, ...working];
32
32
  }
33
- /** The rules as the one text a launch option or a first message carries. */
34
- export function rulesText(input) {
35
- return ['Rules for this session, from the team file:', ...seatRules(input).map((rule) => `- ${rule}`)].join('\n');
33
+ /**
34
+ * The line each form ends with. The message says what the rules are and never that they are not a
35
+ * task, so some models start complying by investigating instead of waiting for a brief. A first
36
+ * message is answered once, so it can ask for the ready reply; a system prompt (a launch option)
37
+ * stays in force on every later turn, so it must not: a Claude seat could answer "ready" to its
38
+ * real brief.
39
+ */
40
+ const CLOSING = {
41
+ message: 'These are standing rules, not a task: reply ready and wait for your brief.',
42
+ option: 'These are standing rules, not a task.',
43
+ };
44
+ /** The rules as the one text a launch option or a first message carries, the closing line last. */
45
+ export function rulesText(input, delivery) {
46
+ // A file whose own rules already carry the form's closing line gets it once: as the closing line.
47
+ const closing = CLOSING[delivery];
48
+ const rules = seatRules(input).filter((rule) => rule !== closing);
49
+ return ['Rules for this session, from the team file:', ...rules.map((rule) => `- ${rule}`), closing].join('\n');
36
50
  }
@@ -43,6 +43,12 @@ screen:
43
43
  - 'Do you trust the contents of this project\?'
44
44
  - '>\s*Yes, I trust this folder'
45
45
  below_last_rule: 'enter Confirm'
46
+ # The captured trust dialog fills the pane: there is no composer rule.
47
+ - all:
48
+ - 'Do you trust the contents of this project\?'
49
+ - '>\s*Yes, I trust this folder'
50
+ - 'enter Confirm'
51
+ without_rule: true
46
52
  permission:
47
53
  ignore_case: true
48
54
  rules:
@@ -50,6 +56,13 @@ screen:
50
56
  - 'Requesting permission for:'
51
57
  - '^\s*>\s*[0-9]\.\s+Yes\b'
52
58
  below_last_rule: '↑/↓ Navigate'
59
+ # A long command pushes the one rule out of the window. The dialog is the
60
+ # three lines, with no composer rule left to sit below.
61
+ - all:
62
+ - 'Requesting permission for:'
63
+ - '^\s*>\s*[0-9]\.\s+Yes\b'
64
+ - '↑/↓ Navigate'
65
+ without_rule: true
53
66
  working:
54
67
  - any: ['\bGenerating\.\.\.']
55
68
  composer:
@@ -50,12 +50,38 @@ screen:
50
50
  footer: 'Esc to cancel · Tab to amend'
51
51
  below_last_rule: 'Esc to cancel · Tab to amend'
52
52
  - below_last_rule: '^\s*❯?\s*[0-9]\. (Yes|No)\b'
53
+ # A pane with no composer rule whose choice is the dialog, including the
54
+ # unmarked "1. Yes" while up is running. A choice quoted above an empty
55
+ # prompt is a transcript: the empty prompt comes after it.
56
+ - all:
57
+ - '^\s*❯?\s*[0-9]\. (Yes|No)\b'
58
+ without_rule: true
59
+ none_after:
60
+ anchor: '^\s*❯?\s*[0-9]\. (Yes|No)\b'
61
+ patterns:
62
+ - '^\s*[❯›>]\s*$'
53
63
  question:
64
+ # The phrase is the dialog's last line, as a permission footer is. Prose
65
+ # above the box is not a question.
66
+ - any:
67
+ - 'Enter to select'
68
+ - '↑/↓ to navigate'
69
+ - 'Enter to confirm'
70
+ - 'Esc to cancel'
71
+ on_footer: true
72
+ - below_last_rule: 'Enter to select|↑/↓ to navigate|Enter to confirm|Esc to cancel'
73
+ # The footer has a status line under it and the rule is out of the window.
74
+ # A phrase quoted above an empty prompt is a transcript.
54
75
  - any:
55
76
  - 'Enter to select'
56
77
  - '↑/↓ to navigate'
57
78
  - 'Enter to confirm'
58
79
  - 'Esc to cancel'
80
+ without_rule: true
81
+ none_after:
82
+ anchor: 'Enter to select|↑/↓ to navigate|Enter to confirm|Esc to cancel'
83
+ patterns:
84
+ - '^\s*[❯›>]\s*$'
59
85
  working:
60
86
  - any:
61
87
  - '\besc to interrupt\b'
@@ -64,6 +90,10 @@ screen:
64
90
  mode: box-to-rule
65
91
  prompt: '^\s*[❯›>]'
66
92
  rule: '^\s*[─━]{8,}'
93
+ # Claude Code greys every suggestion it shows — "Try …", "Resume briefly.", any other —
94
+ # with faint styling, while typed text carries none. The list below stays the fallback
95
+ # for a source without styling.
96
+ placeholder_style: dim
67
97
  placeholders:
68
98
  - equals: ''
69
99
  - prefix: 'Try "'
@@ -48,8 +48,11 @@ screen:
48
48
  - '^\s*→'
49
49
  - '^\s+Grok\s+[0-9]'
50
50
  working:
51
+ # The running turn is the braille spinner, or the prompt line ending in
52
+ # "ctrl+c to stop". Idle and rules-accepted captures do not paint that suffix.
53
+ # The same words quoted in the transcript are not a turn.
51
54
  - any:
52
- - 'ctrl\+c to stop'
55
+ - '^\s*→.*\s{2,}ctrl\+c to stop\s*$'
53
56
  - '^\s*[⠀-⣿]+\s+(Working|Thinking)\b'
54
57
  composer:
55
58
  mode: status-then-one
@@ -18,5 +18,10 @@ export type QuotaFigure = {
18
18
  used: number;
19
19
  resets: string | null;
20
20
  };
21
- /** Figures from the last six lines. A cut line, or a figure over 100, is absent. */
22
- export declare function figuresOf(patterns: readonly QuotaPattern[], screen: string): QuotaFigure[];
21
+ /**
22
+ * Figures from one line: the composer's own status row, handed over by the caller (`statusRow`
23
+ * in the watch's screen core). Nothing else on the pane is ever read — a seat can print or type
24
+ * a line that only looks like a quota, and such a line is not the row. A row that is null, and
25
+ * a figure over 100, gives no figure.
26
+ */
27
+ export declare function figuresOf(patterns: readonly QuotaPattern[], row: string | null): QuotaFigure[];
@@ -3,22 +3,23 @@
3
3
  export const WINDOWS = ['session', 'daily', 'weekly'];
4
4
  const RESET = /^(?:[0-9]+h(?:[0-9]+m)?|[0-9]+m)$/;
5
5
  const PERCENT = /^([0-9]+)%$/;
6
- /** Figures from the last six lines. A cut line, or a figure over 100, is absent. */
7
- export function figuresOf(patterns, screen) {
8
- const lines = screen.split('\n').map((line) => line.trimEnd()).slice(-6);
6
+ /**
7
+ * Figures from one line: the composer's own status row, handed over by the caller (`statusRow`
8
+ * in the watch's screen core). Nothing else on the pane is ever read — a seat can print or type
9
+ * a line that only looks like a quota, and such a line is not the row. A row that is null, and
10
+ * a figure over 100, gives no figure.
11
+ */
12
+ export function figuresOf(patterns, row) {
13
+ if (row === null)
14
+ return [];
9
15
  const found = [];
10
16
  for (const pattern of patterns) {
11
- let hit = null;
12
- for (const line of lines) {
13
- const match = pattern.match.exec(line);
14
- if (!match)
15
- continue;
16
- const next = figureOf(pattern, match);
17
- if (next)
18
- hit = next;
19
- }
20
- if (hit)
21
- found.push(hit);
17
+ const match = pattern.match.exec(row);
18
+ if (!match)
19
+ continue;
20
+ const next = figureOf(pattern, match);
21
+ if (next)
22
+ found.push(next);
22
23
  }
23
24
  return found;
24
25
  }
package/dist/state.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { StoredReading } from './budgets/readings.ts';
1
+ import type { StoredReading, StoredSpend } from './budgets/readings.ts';
2
2
  export type SeatState = {
3
3
  stage: 'launched' | 'named' | 'ready';
4
4
  pane?: string;
@@ -35,8 +35,6 @@ export type SessionState = {
35
35
  nudge?: {
36
36
  pending_since: string | null;
37
37
  };
38
- /** The last screen readings for this project, keyed by account, window and seat. */
39
- budgets?: Record<string, StoredReading>;
40
38
  };
41
39
  export type State = {
42
40
  format: 1;
@@ -44,6 +42,13 @@ export type State = {
44
42
  read_at: string;
45
43
  file: string;
46
44
  };
45
+ /**
46
+ * The project's last readings, whatever session saw them: a screen reading keyed by account,
47
+ * window and seat, a check reading by account and window (§ 4.4).
48
+ */
49
+ budgets?: Record<string, StoredReading>;
50
+ /** The project's last spend check readings, keyed by account. */
51
+ spend?: Record<string, StoredSpend>;
47
52
  sessions: Record<string, SessionState>;
48
53
  };
49
54
  export declare const STATE_FILE = "team.state.json";
package/dist/state.js CHANGED
@@ -26,9 +26,46 @@ export function readState(dir) {
26
26
  for (const session of Object.values(state.sessions)) {
27
27
  session.seats ??= {};
28
28
  session.worktrees ??= {};
29
+ // Read old, write new: readings used to be held under a session. They are the project's
30
+ // (§ 4.4), so every session's records are merged to the top level here — no reader loses one,
31
+ // and the next write persists the new shape.
32
+ const held = session;
33
+ if (held.budgets) {
34
+ state.budgets = mergeReadings(state.budgets, held.budgets);
35
+ delete held.budgets;
36
+ }
37
+ if (held.spend) {
38
+ state.spend = mergeSpend(state.spend, held.spend);
39
+ delete held.spend;
40
+ }
29
41
  }
30
42
  return state;
31
43
  }
44
+ /**
45
+ * One record per slot when two sessions hold the same account, window and seat: the reading that
46
+ * counts wins — a confirmed one, and among those the newest change (§ 4.3).
47
+ */
48
+ function mergeReadings(top, held) {
49
+ const merged = top ?? {};
50
+ for (const [key, reading] of Object.entries(held)) {
51
+ const current = merged[key];
52
+ merged[key] = !current
53
+ || (reading.confirmed && !current.confirmed)
54
+ || (reading.confirmed === current.confirmed && Date.parse(reading.changedAt) > Date.parse(current.changedAt))
55
+ ? reading
56
+ : current;
57
+ }
58
+ return merged;
59
+ }
60
+ /** The same for spend: one record per account, the reading whose check ran last winning. */
61
+ function mergeSpend(top, held) {
62
+ const merged = top ?? {};
63
+ for (const [key, reading] of Object.entries(held)) {
64
+ const current = merged[key];
65
+ merged[key] = !current || Date.parse(reading.at) > Date.parse(current.at) ? reading : current;
66
+ }
67
+ return merged;
68
+ }
32
69
  // Reads the state, lets `change` edit it, and writes it back, all under the lock.
33
70
  export function updateState(dir, change) {
34
71
  return withLock(dir, () => {
@@ -2,10 +2,12 @@
2
2
  // visible text into the file's two fields, `model` and `version`, or null when the text doesn't
3
3
  // show them: "unread" is never a mismatch. The last six lines are the window. A line the rules
4
4
  // claim but cannot name clears an earlier match; that is how an unknown Codex footer stays unread.
5
+ import { stripSgr } from "../ansi.js";
5
6
  import { statusOnLine } from "../profiles/profile.js";
6
7
  export function runningModel(cli, screen) {
7
8
  let found = null;
8
- for (const line of screen.split('\n').slice(-6)) {
9
+ // The pane's text may keep its ANSI styling; the model is read from the plain form.
10
+ for (const line of stripSgr(screen).split('\n').slice(-6)) {
9
11
  const hit = statusOnLine(cli, line);
10
12
  if (hit === 'unreadable')
11
13
  found = null;
@@ -29,6 +29,7 @@ export type SeatObservation = {
29
29
  screen: Screen;
30
30
  cli: string;
31
31
  vendor: string;
32
+ account: string;
32
33
  quota: QuotaFigure[];
33
34
  running: boolean;
34
35
  quiet: boolean;
@@ -67,6 +67,9 @@ function countedOf(name, account, team, staleMs, now) {
67
67
  for (const reading of team.readings) {
68
68
  if (reading.account !== name)
69
69
  continue;
70
+ // The check readings of the same account live in this slot too: this source reads screens.
71
+ if (reading.source !== 'status_line')
72
+ continue;
70
73
  const group = groups.get(reading.window) ?? [];
71
74
  group.push(reading);
72
75
  groups.set(reading.window, group);
@@ -98,7 +101,7 @@ function countedOf(name, account, team, staleMs, now) {
98
101
  }
99
102
  /** The account reads unknown while a seat that spends it is running: the operator can stop it. */
100
103
  function unknown(name, team, ctx) {
101
- const on = team.seats.filter((seat) => seat.running && seat.vendor === name).map((seat) => seat.name);
104
+ const on = team.seats.filter((seat) => seat.running && seat.account === name).map((seat) => seat.name);
102
105
  if (!on.length)
103
106
  return null;
104
107
  const verb = on.length === 1 ? 'runs' : 'run';
@@ -27,4 +27,17 @@ export type PassResult = {
27
27
  };
28
28
  export declare const SEAT_CHECKS: SeatCheck[];
29
29
  export declare const TEAM_CHECKS: TeamCheck[];
30
- export declare function pass(team: TeamFile, state: SessionState, live: Live, machine: Machine, now: number, memory: Memory, approval?: string[] | null, watch?: TeamFile['watch'], outcomes?: readonly CheckOutcome[], budgets?: TeamFile['budgets']): PassResult;
30
+ export type PassInput = {
31
+ team: TeamFile;
32
+ state: SessionState;
33
+ live: Live;
34
+ machine: Machine;
35
+ now: number;
36
+ memory: Memory;
37
+ approval?: string[] | null;
38
+ watch: TeamFile['watch'];
39
+ outcomes?: readonly CheckOutcome[];
40
+ budgets?: TeamFile['budgets'];
41
+ readings?: readonly Seen[];
42
+ };
43
+ export declare function pass({ team, state, live, machine, now, memory, approval, watch, outcomes, budgets, readings: stored, }: PassInput): PassResult;
@@ -2,8 +2,8 @@
2
2
  // runs the registered checks in the order it owns, applies the report-once rule, and dials the
3
3
  // nudge. A check never reads herdr, a screen or the file itself — only the observation it is
4
4
  // handed (RFC 0002 § 4.2).
5
- import { WATCH_CHECKS_CHANGED } from "../approve/fingerprint.js";
6
- import { observe, recall } from "../budgets/readings.js";
5
+ import { seatNamed } from "../approve/fingerprint.js";
6
+ import { observe, observeCheck } from "../budgets/readings.js";
7
7
  import { quotaFor } from "../profiles/profile.js";
8
8
  import { figuresOf } from "../profiles/quota.js";
9
9
  import { seatModel } from "../status/statusline.js";
@@ -23,7 +23,7 @@ import { swapFree } from "./checks/swap-free.js";
23
23
  import { swapGrowth } from "./checks/swap-growth.js";
24
24
  import { teamIdle } from "./checks/team-idle.js";
25
25
  import { unsent } from "./checks/unsent.js";
26
- import { readScreen } from "./screen.js";
26
+ import { readScreen, statusRow } from "./screen.js";
27
27
  export function newMemory() {
28
28
  return { history: {}, slots: {}, active: new Set(), pending: [], pendingSince: null };
29
29
  }
@@ -33,8 +33,8 @@ export function newMemory() {
33
33
  export const NUDGE_TEXT = 'Team watch: reports are waiting in .agents/team.log';
34
34
  // The seat checks, in the order their reports land in the log; the team checks, after them. The
35
35
  // order is the core's: a seat's wrong model is reported before its permission prompt, and every
36
- // seat before the team. Disabling a check in the file's `watch.checks` takes it out of the run,
37
- // except the four that can't be turned off.
36
+ // seat before the team. Disabling a check in the `watch` section in force takes it out of the
37
+ // run, except the four that can't be turned off.
38
38
  export const SEAT_CHECKS = [missing, modelDrift, attention, unsent, idle];
39
39
  export const TEAM_CHECKS = [extra, teamIdle, approval, load, memoryCheck, disk, swapFree, swapGrowth, budget];
40
40
  // The one exclusive reading of a seat's screen and herdr status. herdr can report a seat at a
@@ -50,13 +50,18 @@ function attentionOf(screen, status, quiet) {
50
50
  return 'unknown';
51
51
  return null;
52
52
  }
53
+ // The figures off a seat's own pane: only a composer screen — idle, unsent or working — shows a
54
+ // status row at all, and only that row's line is read. A dialog, a question, a trust screen or an
55
+ // unknown one gives no figures, and neither does a line the seat printed or typed.
56
+ function quotaOf(cli, screen, pane) {
57
+ if (pane === undefined)
58
+ return [];
59
+ if (screen.kind !== 'idle' && screen.kind !== 'unsent' && screen.kind !== 'working')
60
+ return [];
61
+ return figuresOf(quotaFor(cli), statusRow(cli, pane));
62
+ }
53
63
  // One pass of the watch. Pure: it reads what it is handed and changes only `memory`.
54
- // `approval` is how the file differs from the approved one: [] when it doesn't, null when it was
55
- // never approved, undefined when that wasn't looked at. `watch` and `budgets` are the sections in
56
- // force — the approved ones, or the defaults while the file's own are not approved — and the
57
- // checks read those, never the file's. `outcomes` is what the accounts' check commands read
58
- // outside the pass, when the watch last ran them.
59
- export function pass(team, state, live, machine, now, memory, approval, watch = team.watch, outcomes = [], budgets = team.budgets) {
64
+ export function pass({ team, state, live, machine, now, memory, approval, watch, outcomes = [], budgets = team.budgets, readings: stored = [], }) {
60
65
  const reports = [];
61
66
  const current = new Set();
62
67
  // Reported when it starts, and again only after it has cleared.
@@ -76,6 +81,7 @@ export function pass(team, state, live, machine, now, memory, approval, watch =
76
81
  name: seat.name,
77
82
  cli: seat.cli,
78
83
  vendor: seat.vendor,
84
+ account: seat.account ?? seat.vendor,
79
85
  parked: seat.parked || seat.stopped,
80
86
  stopped: seat.stopped,
81
87
  seat,
@@ -83,10 +89,18 @@ export function pass(team, state, live, machine, now, memory, approval, watch =
83
89
  ...Object.entries(state.seats).filter(([, recorded]) => recorded.temporary).map(([name, recorded]) => {
84
90
  // A temporary seat signs like the seat it is like: its CLI, and its account.
85
91
  const like = team.seats.find((seat) => seat.name === recorded.temporary?.like);
86
- return { name, cli: like?.cli ?? '', vendor: like?.vendor ?? '', parked: false, stopped: false, seat: undefined };
92
+ return {
93
+ name,
94
+ cli: like?.cli ?? '',
95
+ vendor: like?.vendor ?? '',
96
+ account: like?.account ?? like?.vendor ?? '',
97
+ parked: false,
98
+ stopped: false,
99
+ seat: undefined,
100
+ };
87
101
  }),
88
102
  ];
89
- for (const { name, cli, vendor, parked, stopped, seat } of seats) {
103
+ for (const { name, cli, vendor, account, parked, stopped, seat } of seats) {
90
104
  const agent = live.agents.find((candidate) => candidate.name === name);
91
105
  const lead = name === team.coordinator || name === team.operator;
92
106
  if (!agent) {
@@ -99,6 +113,7 @@ export function pass(team, state, live, machine, now, memory, approval, watch =
99
113
  screen: { kind: 'unknown' },
100
114
  cli,
101
115
  vendor,
116
+ account,
102
117
  quota: [],
103
118
  running: false,
104
119
  quiet: false,
@@ -141,7 +156,8 @@ export function pass(team, state, live, machine, now, memory, approval, watch =
141
156
  screen,
142
157
  cli,
143
158
  vendor,
144
- quota: pane === undefined ? [] : figuresOf(quotaFor(cli), pane),
159
+ account,
160
+ quota: quotaOf(cli, screen, pane),
145
161
  running: true,
146
162
  quiet,
147
163
  working,
@@ -154,19 +170,40 @@ export function pass(team, state, live, machine, now, memory, approval, watch =
154
170
  history: { lastWorking: history.lastWorking, idleSince: history.idleSince },
155
171
  });
156
172
  }
157
- // The figures this pass saw, folded into the readings the state keeps (§ 4.3). Only an account
158
- // whose `sources` name `status_line` takes a screen reading: a check-only account never records
159
- // one here, and neither does an account the budgets in force don't name (#50).
160
- let readings = recall(state.budgets);
173
+ // The figures this pass saw, folded into the readings the project keeps (§ 4.3, § 4.4). Only an
174
+ // account whose `sources` name `status_line` takes a screen reading: a check-only account never
175
+ // records one here, and neither does an account the budgets in force don't name (#50). A figure
176
+ // names the account its pattern measures (§ 3b): one that measures this seat's vendor is this
177
+ // seat's account — its own `account:` when the file names one, so two accounts of one vendor
178
+ // keep two buckets — while a figure on another account (a launcher showing the vendor it really
179
+ // runs on) stays that account's, whichever seat's screen showed it.
180
+ //
181
+ // A seat the approval lists as changed — or as not in the approved file — is drift, and its
182
+ // figures are read as they are but none is folded: an unapproved edit to its `account:` must
183
+ // not move its figure into another account's bucket, where `up` and `add` would count it.
184
+ // Nothing is saved for that seat until the owner approves.
185
+ const drift = new Set((Array.isArray(approval) ? approval : []).map(seatNamed).filter((name) => name !== null));
186
+ let readings = stored.slice();
161
187
  for (const seat of observations) {
162
- if (!seat.running)
188
+ if (!seat.running || drift.has(seat.name))
163
189
  continue;
164
190
  for (const figure of seat.quota) {
165
- if (budgets.accounts[figure.account]?.sources.includes('status_line')) {
166
- readings = observe(readings, figure, seat.name, now);
191
+ const account = figure.account === seat.vendor ? seat.account : figure.account;
192
+ if (budgets.accounts[account]?.sources.includes('status_line')) {
193
+ readings = observe(readings, { ...figure, account }, seat.name, now);
167
194
  }
168
195
  }
169
196
  }
197
+ // A check reading lands in the same slot (§ 5): what an approved check read outlives the watch,
198
+ // so `up` and `add` count it after the watch has exited. Only an account whose sources name
199
+ // `check`, and only when its check actually read this pass.
200
+ for (const outcome of outcomes) {
201
+ if (outcome.state !== 'read' || outcome.reading.kind !== 'subscription')
202
+ continue;
203
+ if (!budgets.accounts[outcome.account]?.sources.includes('check'))
204
+ continue;
205
+ readings = observeCheck(readings, outcome.account, outcome.reading.windows);
206
+ }
170
207
  const teamObservation = {
171
208
  team,
172
209
  state,
@@ -191,15 +228,14 @@ export function pass(team, state, live, machine, now, memory, approval, watch =
191
228
  return memory.slots[kind];
192
229
  },
193
230
  };
194
- // The four § 4.2 keeps out of `watch.checks` run whatever the file says: the validation refuses
231
+ // The four § 4.2 keeps out of `watch.checks` run whatever the list says: the validation refuses
195
232
  // them, and this holds even for a file that reached memory another way.
196
233
  //
197
- // And nothing is turned off until the owner approves (RFC 0002 § 4.2): a file whose
198
- // `watch.checks` differs from the approved one, a file never approved, and an approval that
199
- // wasn't looked at keep every check running and report the difference instead. A difference in
200
- // some other section leaves the checks turned off as approved — the owner owns both questions.
201
- const approved = Array.isArray(approval) && !approval.includes(WATCH_CHECKS_CHANGED);
202
- const off = approved ? new Set(team.watch.checks) : new Set();
234
+ // The off list is the section in force, which the caller passes as `watch`. A file that was
235
+ // approved — `approval` is a list, empty or not — runs that list. An unapproved `watch.checks`
236
+ // edit does not turn anything new off, and a check the approved list turned off stays off.
237
+ // A file never approved, or whose approval was not looked at, turns nothing off.
238
+ const off = Array.isArray(approval) ? new Set(watch.checks) : new Set();
203
239
  const enabled = (name) => ALWAYS_ON.includes(name) || !off.has(name);
204
240
  for (const seat of observations) {
205
241
  for (const check of SEAT_CHECKS)
@@ -1,10 +1,16 @@
1
1
  import type { Screen } from './screen.ts';
2
2
  import type { ScreenData } from './screen-data.ts';
3
- export type { Composer, FallbackRule, LinePattern, Placeholder, Rule, ScreenData, Stage } from './screen-data.ts';
3
+ export type { Composer, FallbackRule, LinePattern, Placeholder, PlaceholderStyle, Rule, ScreenData, Stage } from './screen-data.ts';
4
4
  export type ReadClock = {
5
5
  now(): number;
6
6
  budgetMs: number;
7
7
  };
8
+ /**
9
+ * The one line a quota figure may be read from: the composer's own status row, or null when the
10
+ * window shows none. A dialog, a transcript line, the input box — anything the seat printed or
11
+ * typed — is never the row. The caller still has to know the screen is a composer screen.
12
+ */
13
+ export declare function statusRowOf(data: ScreenData, lines: string[]): string | null;
8
14
  /** `lines` is already the window: the last 20 lines, each trimmed at the end. */
9
15
  export declare function classifyLines(data: ScreenData, lines: string[], clock?: ReadClock): Screen;
10
16
  /**