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 +2 -2
- package/dist/budgets/gate.d.ts +9 -5
- package/dist/budgets/gate.js +38 -7
- package/dist/budgets/readings.d.ts +22 -0
- package/dist/budgets/readings.js +37 -0
- package/dist/commands/add.js +2 -2
- package/dist/commands/up.js +4 -3
- package/dist/commands/watch.js +24 -2
- package/dist/profiles/quota.d.ts +7 -2
- package/dist/profiles/quota.js +15 -14
- package/dist/state.d.ts +3 -1
- package/dist/watch/pass.d.ts +13 -1
- package/dist/watch/pass.js +13 -8
- package/dist/watch/screen-core.d.ts +6 -0
- package/dist/watch/screen-core.js +62 -26
- package/dist/watch/screen.d.ts +4 -0
- package/dist/watch/screen.js +10 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ Node 22 or later runs the built command.
|
|
|
18
18
|
|
|
19
19
|
```sh
|
|
20
20
|
npm install -g team # or run it without installing: npx team
|
|
21
|
-
team --version # 0.1.
|
|
21
|
+
team --version # 0.1.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.
|
|
252
|
+
team --version # 0.1.1
|
|
253
253
|
```
|
|
254
254
|
|
|
255
255
|
Bun builds and tests the sources:
|
package/dist/budgets/gate.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Seat, TeamFile } from '../file/types.ts';
|
|
2
|
-
import { type Seen } from './readings.ts';
|
|
2
|
+
import { type Seen, type SpendReading } from './readings.ts';
|
|
3
3
|
export type LaunchDecision = {
|
|
4
4
|
kind: 'clear';
|
|
5
5
|
} | {
|
|
@@ -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
|
-
/**
|
|
20
|
-
|
|
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[];
|
package/dist/budgets/gate.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
56
|
-
|
|
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
|
|
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. */
|
package/dist/budgets/readings.js
CHANGED
|
@@ -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,
|
package/dist/commands/add.js
CHANGED
|
@@ -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
|
package/dist/commands/up.js
CHANGED
|
@@ -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,
|
package/dist/commands/watch.js
CHANGED
|
@@ -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(
|
|
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) {
|
package/dist/profiles/quota.d.ts
CHANGED
|
@@ -18,5 +18,10 @@ export type QuotaFigure = {
|
|
|
18
18
|
used: number;
|
|
19
19
|
resets: string | null;
|
|
20
20
|
};
|
|
21
|
-
/**
|
|
22
|
-
|
|
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[];
|
package/dist/profiles/quota.js
CHANGED
|
@@ -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
|
-
/**
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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;
|
package/dist/watch/pass.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/watch/pass.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
339
|
+
const lower = line.toLowerCase();
|
|
340
|
+
if (FLOOR_PHRASES.some((phrase) => lower.includes(phrase)))
|
|
305
341
|
return true;
|
|
306
342
|
}
|
|
307
343
|
return false;
|
package/dist/watch/screen.d.ts
CHANGED
|
@@ -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;
|
package/dist/watch/screen.js
CHANGED
|
@@ -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.
|
|
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": "
|
|
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": {
|