team 0.1.0 → 0.1.1

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 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.1
22
22
  ```
23
23
 
24
24
  ## The file is private to each clone
@@ -249,7 +249,7 @@ cd team
249
249
  bun install
250
250
  bun run build
251
251
  npm install -g . # puts `team` on the PATH
252
- team --version # 0.1.0
252
+ team --version # 0.1.1
253
253
  ```
254
254
 
255
255
  Bun builds and tests the sources:
@@ -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
  } | {
@@ -13,8 +13,12 @@ export type LaunchDecision = {
13
13
  /**
14
14
  * The seat's account is its vendor. There is no separate account field on a seat. `budgets` is
15
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.
16
+ * and an account only the unapproved edit names is not an account at all. `spend` holds the
17
+ * stored spend check readings; a subscription account never looks at them.
17
18
  */
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[];
19
+ export declare function seatBudget(budgets: TeamFile['budgets'], readings: readonly Seen[], seat: Seat, now: number, spend?: readonly SpendReading[]): LaunchDecision;
20
+ /**
21
+ * Accounts whose counted figures leave room: a subscription with every counted window outside
22
+ * its reserve, a spend account with a fresh reading above its floor.
23
+ */
24
+ export declare function accountsWithRoom(budgets: TeamFile['budgets'], readings: readonly Seen[], now: number, spend?: readonly SpendReading[]): string[];
@@ -3,15 +3,31 @@ const RANK = { session: 0, daily: 1, weekly: 2 };
3
3
  /**
4
4
  * The seat's account is its vendor. There is no separate account field on a seat. `budgets` is
5
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.
6
+ * and an account only the unapproved edit names is not an account at all. `spend` holds the
7
+ * stored spend check readings; a subscription account never looks at them.
7
8
  */
8
- export function seatBudget(budgets, readings, seat, now) {
9
+ export function seatBudget(budgets, readings, seat, now, spend = []) {
9
10
  const name = seat.vendor;
10
11
  const account = budgets.accounts[name];
11
12
  if (!account)
12
13
  return { kind: 'clear' };
13
- if (account.kind === 'spend')
14
- return { kind: 'unknown', account: name, text: `${name} is unknown` };
14
+ if (account.kind === 'spend') {
15
+ const floor = account.floor;
16
+ const reading = spend.find((item) => item.account === name);
17
+ // § 5's freshness rule for a check reading, applied to the floor: a reading with no floor to
18
+ // measure against, one read longer ago than `stale_after`, and one in another currency than
19
+ // the floor's are the same answer — unknown, said, never a refusal.
20
+ if (!floor || !reading || reading.currency !== floor.currency || now - reading.at >= budgets.staleAfter * 1000) {
21
+ return { kind: 'unknown', account: name, text: `${name} is unknown` };
22
+ }
23
+ if (reading.amount > floor.amount)
24
+ return { kind: 'clear' };
25
+ const room = accountsWithRoom(budgets, readings, now, spend).filter((accountName) => accountName !== name);
26
+ return {
27
+ kind: 'refuse',
28
+ 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'}`,
29
+ };
30
+ }
15
31
  const staleAfterMs = budgets.staleAfter * 1000;
16
32
  const mine = readings.filter((item) => item.account === name);
17
33
  let unknown = mine.length === 0;
@@ -52,12 +68,23 @@ export function seatBudget(budgets, readings, seat, now) {
52
68
  return { kind: 'unknown', account: name, text: `${name}: first sight only, not yet counted` };
53
69
  return { kind: 'clear' };
54
70
  }
55
- /** Subscription accounts whose counted windows are all outside the reserve. */
56
- export function accountsWithRoom(budgets, readings, now) {
71
+ /**
72
+ * Accounts whose counted figures leave room: a subscription with every counted window outside
73
+ * its reserve, a spend account with a fresh reading above its floor.
74
+ */
75
+ export function accountsWithRoom(budgets, readings, now, spend = []) {
57
76
  const staleAfterMs = budgets.staleAfter * 1000;
58
77
  const names = [];
59
78
  for (const [name, account] of Object.entries(budgets.accounts)) {
60
- if (account.kind !== 'subscription' || account.reserve === null)
79
+ if (account.kind === 'spend') {
80
+ const floor = account.floor;
81
+ const reading = spend.find((item) => item.account === name);
82
+ if (floor && reading && reading.currency === floor.currency && now - reading.at < staleAfterMs && reading.amount > floor.amount) {
83
+ names.push(name);
84
+ }
85
+ continue;
86
+ }
87
+ if (account.reserve === null)
61
88
  continue;
62
89
  const groups = windowsOf(readings.filter((item) => item.account === name));
63
90
  let counted = 0;
@@ -94,3 +121,7 @@ function age(ms) {
94
121
  return `${hours}h`;
95
122
  return `${minutes}m`;
96
123
  }
124
+ /** A money figure in a refusal: the cents are shown even when the file wrote none. */
125
+ function money(amount) {
126
+ return amount.toFixed(2);
127
+ }
@@ -25,6 +25,20 @@ export type Verdict = {
25
25
  } | {
26
26
  kind: 'unknown';
27
27
  };
28
+ /** A spend check reading (RFC 0003 § 5): the money the account still holds. */
29
+ export type SpendReading = {
30
+ account: string;
31
+ amount: number;
32
+ currency: string;
33
+ /** When the check ran: a spend line carries no time of its own (§ 5). */
34
+ at: number;
35
+ };
36
+ export type StoredSpend = {
37
+ account: string;
38
+ amount: number;
39
+ currency: string;
40
+ at: string;
41
+ };
28
42
  /** Fold one seat's figure into the readings kept for this pass. */
29
43
  export declare function observe(list: readonly Seen[], figure: QuotaFigure, seat: string, now: number): Seen[];
30
44
  /**
@@ -41,6 +55,14 @@ export declare function recall(stored: Record<string, StoredReading> | undefined
41
55
  /** Write the readings that still count. The state file is the per-project cache. */
42
56
  export declare function saveReadings(dir: string, session: string, list: readonly Seen[], now?: number): void;
43
57
  export declare function loadReadings(dir: string, session: string): Seen[];
58
+ /**
59
+ * Write the spend readings a pass's checks read, merging by account: an account whose check did
60
+ * not run this pass keeps the reading the state already holds. Nothing read, nothing written.
61
+ */
62
+ export declare function saveSpendReadings(dir: string, session: string, list: readonly SpendReading[]): void;
63
+ export declare function loadSpendReadings(dir: string, session: string): SpendReading[];
64
+ export declare function storeSpend(reading: SpendReading): StoredSpend;
65
+ export declare function recallSpend(stored: Record<string, StoredSpend> | undefined): SpendReading[];
44
66
  export declare function store(reading: Seen): StoredReading;
45
67
  export declare function revive(stored: StoredReading): Seen;
46
68
  /** When a duration such as `114h4m` ends, counted from a time. Null for no or an odd duration. */
@@ -82,6 +82,43 @@ export function saveReadings(dir, session, list, now = Date.now()) {
82
82
  export function loadReadings(dir, session) {
83
83
  return recall(readState(dir).sessions[session]?.budgets);
84
84
  }
85
+ /**
86
+ * Write the spend readings a pass's checks read, merging by account: an account whose check did
87
+ * not run this pass keeps the reading the state already holds. Nothing read, nothing written.
88
+ */
89
+ export function saveSpendReadings(dir, session, list) {
90
+ if (!list.length)
91
+ return;
92
+ updateState(dir, (state) => {
93
+ const current = state.sessions[session] ?? emptySession();
94
+ const spend = current.spend ?? {};
95
+ for (const reading of list)
96
+ spend[reading.account] = storeSpend(reading);
97
+ current.spend = spend;
98
+ state.sessions[session] = current;
99
+ });
100
+ }
101
+ export function loadSpendReadings(dir, session) {
102
+ return recallSpend(readState(dir).sessions[session]?.spend);
103
+ }
104
+ export function storeSpend(reading) {
105
+ return {
106
+ account: reading.account,
107
+ amount: reading.amount,
108
+ currency: reading.currency,
109
+ at: new Date(reading.at).toISOString(),
110
+ };
111
+ }
112
+ export function recallSpend(stored) {
113
+ if (!stored)
114
+ return [];
115
+ return Object.values(stored).map((one) => ({
116
+ account: one.account,
117
+ amount: one.amount,
118
+ currency: one.currency,
119
+ at: Date.parse(one.at),
120
+ }));
121
+ }
85
122
  export function store(reading) {
86
123
  return {
87
124
  account: reading.account,
@@ -6,7 +6,7 @@ import { readArgs } from "../args.js";
6
6
  import { callerOf, describeCaller, isOwner, mayChangeTeam } from "../caller.js";
7
7
  import { blocksLaunch, doctorFindings, realSources as doctorSources } from "../commands/doctor.js";
8
8
  import { seatBudget } from "../budgets/gate.js";
9
- import { loadReadings } from "../budgets/readings.js";
9
+ import { loadReadings, loadSpendReadings } from "../budgets/readings.js";
10
10
  import { rulesOf } from "../commands/up.js";
11
11
  import { branchPresent, readMerge } from "../end/condition.js";
12
12
  import { clearStopped, hasSeat, restoreSeat, seatIsStopped } from "../file/lines.js";
@@ -198,7 +198,7 @@ export async function runAdd(argv, io, sources = realSources) {
198
198
  // The gate reads the approved budgets, never the edited file's: the seat this `add` inserts
199
199
  // changes no section of its own, and no unapproved reserve may unblock a launch (#50).
200
200
  const budgets = budgetsInForce(prepared.team, root, sources.home);
201
- const decision = seatBudget(budgets, loadReadings(dir, session), built.seat, sources.now().getTime());
201
+ const decision = seatBudget(budgets, loadReadings(dir, session), built.seat, sources.now().getTime(), loadSpendReadings(dir, session));
202
202
  const stray = unnamedIn(built.seat.label, agents, workspaces);
203
203
  const starting = seatPlan(prepared.team, built.seat, start);
204
204
  const planned = stray
@@ -18,7 +18,7 @@ import { profileFor } from "../profiles/index.js";
18
18
  import { readApproval, storePath } from "../store/store.js";
19
19
  import { emptySession, readState, updateState } from "../state.js";
20
20
  import { seatBudget } from "../budgets/gate.js";
21
- import { loadReadings } from "../budgets/readings.js";
21
+ import { loadReadings, loadSpendReadings } from "../budgets/readings.js";
22
22
  import { blocksLaunch, doctorFindings, realSources as doctorSources } from "./doctor.js";
23
23
  import { launchLimit, readMachine } from "../watch/machine.js";
24
24
  import { readScreen } from "../watch/screen.js";
@@ -209,6 +209,7 @@ export async function runUp(argv, io, sources) {
209
209
  const recorded = readState(dir).sessions[session];
210
210
  const workspaces = sources.workspaces?.(session) ?? null;
211
211
  const readings = loadReadings(dir, session);
212
+ const spend = loadSpendReadings(dir, session);
212
213
  const seats = [];
213
214
  const refused = new Set();
214
215
  for (const seat of team.seats) {
@@ -221,7 +222,7 @@ export async function runUp(argv, io, sources) {
221
222
  }
222
223
  const placed = planned.stage === undefined || !planned.pane;
223
224
  if (!placed) {
224
- const budget = seatBudget(budgets, readings, seat, readAt());
225
+ const budget = seatBudget(budgets, readings, seat, readAt(), spend);
225
226
  seats.push({ ...planned, ...(budget.kind === 'clear' ? {} : { budget }) });
226
227
  continue;
227
228
  }
@@ -233,7 +234,7 @@ export async function runUp(argv, io, sources) {
233
234
  }
234
235
  continue;
235
236
  }
236
- const budget = seatBudget(budgets, readings, seat, readAt());
237
+ const budget = seatBudget(budgets, readings, seat, readAt(), spend);
237
238
  seats.push({
238
239
  ...planned,
239
240
  cwd: start.cwd,
@@ -1,6 +1,6 @@
1
1
  import { readArgs } from "../args.js";
2
2
  import { budgetsInForce, watchInForce } from "../approve/approval.js";
3
- import { saveReadings } from "../budgets/readings.js";
3
+ import { saveReadings, saveSpendReadings } from "../budgets/readings.js";
4
4
  import { runChecks } from "../budgets/run.js";
5
5
  import { currentTeam } from "../file/current.js";
6
6
  import { homedir } from 'node:os';
@@ -131,6 +131,10 @@ export async function runWatch(argv, io, sources) {
131
131
  if (checksAt === null || now - checksAt >= budget.checkEvery * 1000) {
132
132
  checksAt = now;
133
133
  outcomes = sources.readChecks(team, root, now);
134
+ // The money a spend check counted is kept, like the pass's screen readings: the launch
135
+ // gate of `up` and `add` reads it later, and one that is stale by then reads unknown
136
+ // (§ 5). An account whose check did not run keeps its stored reading.
137
+ saveSpendReadings(dir, session, spendOf(outcomes));
134
138
  for (const outcome of outcomes) {
135
139
  const key = `check:${outcome.account}`;
136
140
  if (outcome.state === 'read') {
@@ -145,7 +149,10 @@ export async function runWatch(argv, io, sources) {
145
149
  : `the check for ${outcome.account} is unapproved; that account reads unknown`, false);
146
150
  }
147
151
  }
148
- const result = pass(team, state, live, sources.machine(root), now, memory, sources.approval(team, root), inForce, outcomes, budget);
152
+ const result = pass({
153
+ team, state, live, machine: sources.machine(root), now, memory,
154
+ approval: sources.approval(team, root), watch: inForce, outcomes, budgets: budget,
155
+ });
149
156
  for (const report of result.reports)
150
157
  say(report.text, true);
151
158
  saveReadings(dir, session, result.readings, now);
@@ -174,6 +181,21 @@ export async function runWatch(argv, io, sources) {
174
181
  }
175
182
  return 0;
176
183
  }
184
+ /** The spend readings among a pass's check outcomes, for the state. */
185
+ function spendOf(outcomes) {
186
+ const list = [];
187
+ for (const outcome of outcomes) {
188
+ if (outcome.state !== 'read' || outcome.reading.kind !== 'spend')
189
+ continue;
190
+ list.push({
191
+ account: outcome.account,
192
+ amount: outcome.reading.amount,
193
+ currency: outcome.reading.currency,
194
+ at: outcome.reading.at,
195
+ });
196
+ }
197
+ return list;
198
+ }
177
199
  // Types the nudge, after reading the operator's screen once more: the pass saw it free, and a
178
200
  // prompt may have appeared since. Anything but an empty idle prompt keeps the nudge pending.
179
201
  function deliver(nudge, team, session, sources, memory, say) {
@@ -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;
@@ -37,6 +37,8 @@ export type SessionState = {
37
37
  };
38
38
  /** The last screen readings for this project, keyed by account, window and seat. */
39
39
  budgets?: Record<string, StoredReading>;
40
+ /** The last spend check readings for this project, keyed by account. */
41
+ spend?: Record<string, StoredSpend>;
40
42
  };
41
43
  export type State = {
42
44
  format: 1;
@@ -27,4 +27,16 @@ 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
+ };
42
+ export declare function pass({ team, state, live, machine, now, memory, approval, watch, outcomes, budgets, }: PassInput): PassResult;
@@ -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
  }
@@ -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 = team.watch, outcomes = [], budgets = team.budgets, }) {
60
65
  const reports = [];
61
66
  const current = new Set();
62
67
  // Reported when it starts, and again only after it has cleared.
@@ -141,7 +146,7 @@ export function pass(team, state, live, machine, now, memory, approval, watch =
141
146
  screen,
142
147
  cli,
143
148
  vendor,
144
- quota: pane === undefined ? [] : figuresOf(quotaFor(cli), pane),
149
+ quota: quotaOf(cli, screen, pane),
145
150
  running: true,
146
151
  quiet,
147
152
  working,
@@ -5,6 +5,12 @@ 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
  /**
@@ -1,7 +1,44 @@
1
1
  const BUDGET_MS = 100;
2
2
  // The rule a `below_last_rule` pattern has to sit under. It is the core's, not the profile's.
3
3
  const RULE_LINE = /^\s*[─━]{8,}\s*$/;
4
- const FLOOR_PHRASES = ['Do you want to', 'Esc to cancel', 'enter Confirm', 'enter continue', 'esc quit', 'esc skip', '↑/↓'];
4
+ // The safety floor's markers, matched without case: a dialog's wording is not always title-case
5
+ // (`Press enter to confirm or esc to cancel`). RFC 0002 § 4.1 permits it.
6
+ const FLOOR_PHRASES = ['do you want to', 'esc to cancel', 'enter confirm', 'enter continue', 'esc quit', 'esc skip', '↑/↓'];
7
+ /**
8
+ * The index of the composer's status line in the window, or -1. A `status-last` composer pins it
9
+ * as the last non-blank line; a `status-then-one` composer allows one chrome line below it. The
10
+ * classification and the quota read both take their line from here, so the two cannot disagree
11
+ * about which line the composer's status line is.
12
+ */
13
+ function statusIndex(composer, lines, allowOneTrailing, tick) {
14
+ for (let i = lines.length - 1; i >= 0; i--) {
15
+ if (tick())
16
+ return 'stop';
17
+ if (!composer.statusLine.test(lines[i] ?? ''))
18
+ continue;
19
+ const trailing = lines.slice(i + 1).filter((line) => line.trim());
20
+ if (allowOneTrailing) {
21
+ if (trailing.length > 0 && (trailing.length > 1 || composer.prompt.test(trailing[0] ?? '')))
22
+ return -1;
23
+ }
24
+ else if (trailing.length > 0)
25
+ return -1;
26
+ return i;
27
+ }
28
+ return -1;
29
+ }
30
+ /**
31
+ * The one line a quota figure may be read from: the composer's own status row, or null when the
32
+ * window shows none. A dialog, a transcript line, the input box — anything the seat printed or
33
+ * typed — is never the row. The caller still has to know the screen is a composer screen.
34
+ */
35
+ export function statusRowOf(data, lines) {
36
+ const composer = data.composer;
37
+ if (composer.mode !== 'status-last' && composer.mode !== 'status-then-one')
38
+ return null;
39
+ const at = statusIndex(composer, lines, composer.mode === 'status-then-one', () => false);
40
+ return at === 'stop' || at < 0 ? null : (lines[at] ?? null);
41
+ }
5
42
  /** `lines` is already the window: the last 20 lines, each trimmed at the end. */
6
43
  export function classifyLines(data, lines, clock) {
7
44
  const now = clock?.now ?? Date.now;
@@ -19,7 +56,7 @@ export function classifyLines(data, lines, clock) {
19
56
  if (!stage)
20
57
  continue;
21
58
  for (const rule of stage.rules) {
22
- const hit = ruleMatches(lines, rule, tick);
59
+ const hit = ruleMatches(data, lines, rule, tick);
23
60
  if (hit === 'stop')
24
61
  return { kind: 'unknown' };
25
62
  if (hit)
@@ -47,7 +84,7 @@ export function composeLines(data, lines, clock) {
47
84
  const tick = () => now() - start > budget;
48
85
  if (data.unknown) {
49
86
  for (const rule of data.unknown.rules) {
50
- const hit = ruleMatches(lines, rule, tick);
87
+ const hit = ruleMatches(data, lines, rule, tick);
51
88
  if (hit === 'stop')
52
89
  return { kind: 'unknown' };
53
90
  if (hit)
@@ -64,7 +101,7 @@ export function composeLines(data, lines, clock) {
64
101
  return { kind: 'unknown' };
65
102
  return { kind: composed.kind };
66
103
  }
67
- function ruleMatches(lines, rule, tick) {
104
+ function ruleMatches(data, lines, rule, tick) {
68
105
  if (rule.any) {
69
106
  const hit = anyLine(lines, rule.any, tick);
70
107
  if (hit === 'stop')
@@ -82,8 +119,7 @@ function ruleMatches(lines, rule, tick) {
82
119
  }
83
120
  }
84
121
  if (rule.footer !== undefined) {
85
- const last = [...lines].reverse().find((line) => line.trim());
86
- if ((last ?? '').trim() !== rule.footer)
122
+ if (footerLine(data, lines) !== rule.footer)
87
123
  return false;
88
124
  }
89
125
  if (rule.belowLastRule) {
@@ -137,6 +173,21 @@ function lineSomewhere(lines, pattern, tick) {
137
173
  }
138
174
  return false;
139
175
  }
176
+ /**
177
+ * The line a rule's footer must be: the last non-blank one. The composer's own chrome is the one
178
+ * thing allowed below it — a `status-last` mode pins the status line to the pane's last line, and
179
+ * a dialog drawn above it is still the dialog its footer belongs to. Any other line below the
180
+ * footer means the footer is not the dialog's, and the rule doesn't match.
181
+ */
182
+ function footerLine(data, lines) {
183
+ const nonBlank = lines.filter((line) => line.trim());
184
+ const last = nonBlank[nonBlank.length - 1] ?? '';
185
+ const composer = data.composer;
186
+ if (composer.mode === 'status-last' && composer.statusLine.test(last)) {
187
+ return (nonBlank[nonBlank.length - 2] ?? '').trim();
188
+ }
189
+ return last.trim();
190
+ }
140
191
  function matches(line, pattern, tick) {
141
192
  if (tick())
142
193
  return 'stop';
@@ -185,27 +236,11 @@ function boxToRule(lines, composer, tick) {
185
236
  return { kind: placeholder(typed, composer.placeholders) ? 'idle' : 'unsent', from, input };
186
237
  }
187
238
  function statusLast(lines, composer, tick, allowOneTrailing) {
188
- let status = -1;
189
- for (let i = lines.length - 1; i >= 0; i--) {
190
- if (tick())
191
- return { kind: 'stop' };
192
- if (composer.statusLine.test(lines[i] ?? '')) {
193
- status = i;
194
- break;
195
- }
196
- }
239
+ const status = statusIndex(composer, lines, allowOneTrailing, tick);
240
+ if (status === 'stop')
241
+ return { kind: 'stop' };
197
242
  if (status < 0)
198
243
  return { kind: 'unknown' };
199
- const trailing = [];
200
- for (let i = status + 1; i < lines.length; i++)
201
- if ((lines[i] ?? '').trim())
202
- trailing.push(lines[i] ?? '');
203
- if (allowOneTrailing) {
204
- if (trailing.length > 1 || trailing.some((line) => composer.prompt.test(line)))
205
- return { kind: 'unknown' };
206
- }
207
- else if (trailing.length > 0)
208
- return { kind: 'unknown' };
209
244
  let input = status - 1;
210
245
  while (input >= 0 && !composer.prompt.test(lines[input] ?? ''))
211
246
  input--;
@@ -301,7 +336,8 @@ function floorHits(lines, from, input, chrome, tick) {
301
336
  continue;
302
337
  if (i !== input && choiceLine(line) && lines.slice(i + 1).some((later) => twoLine(later)))
303
338
  return true;
304
- if (FLOOR_PHRASES.some((phrase) => line.includes(phrase)))
339
+ const lower = line.toLowerCase();
340
+ if (FLOOR_PHRASES.some((phrase) => lower.includes(phrase)))
305
341
  return true;
306
342
  }
307
343
  return false;
@@ -18,3 +18,7 @@ export declare function classify(cli: string, lines: string[]): Screen;
18
18
  /** The composer alone. A running turn would otherwise hide an empty input box. */
19
19
  export declare function classifyComposer(cli: string, lines: string[]): Screen;
20
20
  export declare function readScreen(cli: string, screen: string | undefined): Screen;
21
+ /** The one line a quota figure may come from: the composer's own status row, in the pane's last
22
+ * 20 lines. Null for a CLI with no status line, and for a window that shows no status row — a
23
+ * dialog, a question, a trust screen, a shell or an unknown one reads no figures. */
24
+ export declare function statusRow(cli: string, screen: string | undefined): string | null;
@@ -1,7 +1,7 @@
1
1
  // What a pane's visible text shows, read against the shapes of its CLI. A screen that matches no
2
2
  // shape is "unknown": never ready, never idle, and never grounds for typing anything.
3
3
  import { readFileSync } from 'node:fs';
4
- import { classifyLines, composeLines } from "./screen-core.js";
4
+ import { classifyLines, composeLines, statusRowOf } from "./screen-core.js";
5
5
  import { loadScreen } from "./screen-file.js";
6
6
  function load(name) {
7
7
  return loadScreen(readFileSync(new URL(`../profiles/${name}.yaml`, import.meta.url), 'utf8'));
@@ -36,3 +36,12 @@ export function readScreen(cli, screen) {
36
36
  return { kind: 'unknown' };
37
37
  return classify(cli, screen.split('\n'));
38
38
  }
39
+ /** The one line a quota figure may come from: the composer's own status row, in the pane's last
40
+ * 20 lines. Null for a CLI with no status line, and for a window that shows no status row — a
41
+ * dialog, a question, a trust screen, a shell or an unknown one reads no figures. */
42
+ export function statusRow(cli, screen) {
43
+ const data = DATA[cli];
44
+ if (data === undefined || screen === undefined)
45
+ return null;
46
+ return statusRowOf(data, windowOf(screen.split('\n')));
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "team",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Set up, change and watch a project's team of AI agents from one file",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -23,7 +23,7 @@
23
23
  "typecheck": "tsc --noEmit",
24
24
  "test": "bun test",
25
25
  "e2e": "bun scripts/e2e.ts",
26
- "build": "tsc -p tsconfig.build.json && mkdir -p dist/profiles && cp src/profiles/*.yaml dist/profiles/",
26
+ "build": "bun scripts/build.ts",
27
27
  "ci": "bun run typecheck && bun test && bun run build && node dist/cli.js --version"
28
28
  },
29
29
  "devDependencies": {