team 0.0.1 → 0.1.0
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/LICENSE +21 -0
- package/README.md +310 -0
- package/dist/approve/approval.d.ts +38 -0
- package/dist/approve/approval.js +78 -0
- package/dist/approve/diff.d.ts +13 -0
- package/dist/approve/diff.js +47 -0
- package/dist/approve/fingerprint.d.ts +44 -0
- package/dist/approve/fingerprint.js +109 -0
- package/dist/args.d.ts +7 -0
- package/dist/args.js +22 -0
- package/dist/budgets/checks.d.ts +35 -0
- package/dist/budgets/checks.js +77 -0
- package/dist/budgets/gate.d.ts +20 -0
- package/dist/budgets/gate.js +96 -0
- package/dist/budgets/readings.d.ts +47 -0
- package/dist/budgets/readings.js +119 -0
- package/dist/budgets/run.d.ts +66 -0
- package/dist/budgets/run.js +119 -0
- package/dist/budgets/table.d.ts +24 -0
- package/dist/budgets/table.js +90 -0
- package/dist/caller.d.ts +48 -0
- package/dist/caller.js +126 -0
- package/dist/check/config.d.ts +51 -0
- package/dist/check/config.js +50 -0
- package/dist/check/git.d.ts +27 -0
- package/dist/check/git.js +97 -0
- package/dist/check/message.d.ts +27 -0
- package/dist/check/message.js +101 -0
- package/dist/check/run.d.ts +31 -0
- package/dist/check/run.js +77 -0
- package/dist/check/signature.d.ts +10 -0
- package/dist/check/signature.js +22 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +76 -0
- package/dist/clis.d.ts +7 -0
- package/dist/clis.js +9 -0
- package/dist/commands/add.d.ts +23 -0
- package/dist/commands/add.js +445 -0
- package/dist/commands/approve.d.ts +11 -0
- package/dist/commands/approve.js +146 -0
- package/dist/commands/check.d.ts +17 -0
- package/dist/commands/check.js +103 -0
- package/dist/commands/doctor.d.ts +38 -0
- package/dist/commands/doctor.js +232 -0
- package/dist/commands/down.d.ts +37 -0
- package/dist/commands/down.js +258 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.js +138 -0
- package/dist/commands/remove.d.ts +23 -0
- package/dist/commands/remove.js +235 -0
- package/dist/commands/status.d.ts +41 -0
- package/dist/commands/status.js +147 -0
- package/dist/commands/up.d.ts +49 -0
- package/dist/commands/up.js +361 -0
- package/dist/commands/watch.d.ts +35 -0
- package/dist/commands/watch.js +302 -0
- package/dist/commands/worktree.d.ts +12 -0
- package/dist/commands/worktree.js +388 -0
- package/dist/end/condition.d.ts +28 -0
- package/dist/end/condition.js +77 -0
- package/dist/file/current.d.ts +13 -0
- package/dist/file/current.js +53 -0
- package/dist/file/lines.d.ts +25 -0
- package/dist/file/lines.js +277 -0
- package/dist/file/load.d.ts +7 -0
- package/dist/file/load.js +90 -0
- package/dist/file/paths.d.ts +6 -0
- package/dist/file/paths.js +77 -0
- package/dist/file/secrets.d.ts +6 -0
- package/dist/file/secrets.js +54 -0
- package/dist/file/signature.d.ts +13 -0
- package/dist/file/signature.js +36 -0
- package/dist/file/types.d.ts +130 -0
- package/dist/file/types.js +1 -0
- package/dist/file/validate.d.ts +13 -0
- package/dist/file/validate.js +702 -0
- package/dist/file/write.d.ts +11 -0
- package/dist/file/write.js +13 -0
- package/dist/herdr.d.ts +38 -0
- package/dist/herdr.js +227 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +6 -0
- package/dist/io.d.ts +10 -0
- package/dist/io.js +1 -0
- package/dist/launch/deliver.d.ts +10 -0
- package/dist/launch/deliver.js +39 -0
- package/dist/launch/execute.d.ts +46 -0
- package/dist/launch/execute.js +284 -0
- package/dist/launch/plan.d.ts +185 -0
- package/dist/launch/plan.js +280 -0
- package/dist/launch/rules.d.ts +25 -0
- package/dist/launch/rules.js +36 -0
- package/dist/log.d.ts +1 -0
- package/dist/log.js +24 -0
- package/dist/profiles/antigravity.yaml +66 -0
- package/dist/profiles/claude-code.yaml +69 -0
- package/dist/profiles/codex.yaml +77 -0
- package/dist/profiles/cursor.yaml +68 -0
- package/dist/profiles/index.d.ts +1 -0
- package/dist/profiles/index.js +1 -0
- package/dist/profiles/profile.d.ts +60 -0
- package/dist/profiles/profile.js +313 -0
- package/dist/profiles/quota.d.ts +22 -0
- package/dist/profiles/quota.js +56 -0
- package/dist/state.d.ts +59 -0
- package/dist/state.js +127 -0
- package/dist/status/compare.d.ts +26 -0
- package/dist/status/compare.js +110 -0
- package/dist/status/statusline.d.ts +9 -0
- package/dist/status/statusline.js +26 -0
- package/dist/store/store.d.ts +62 -0
- package/dist/store/store.js +116 -0
- package/dist/watch/check.d.ts +82 -0
- package/dist/watch/check.js +29 -0
- package/dist/watch/checks/approval.d.ts +2 -0
- package/dist/watch/checks/approval.js +16 -0
- package/dist/watch/checks/attention.d.ts +2 -0
- package/dist/watch/checks/attention.js +23 -0
- package/dist/watch/checks/budget.d.ts +2 -0
- package/dist/watch/checks/budget.js +141 -0
- package/dist/watch/checks/disk.d.ts +2 -0
- package/dist/watch/checks/disk.js +13 -0
- package/dist/watch/checks/extra.d.ts +2 -0
- package/dist/watch/checks/extra.js +18 -0
- package/dist/watch/checks/idle.d.ts +2 -0
- package/dist/watch/checks/idle.js +32 -0
- package/dist/watch/checks/load.d.ts +2 -0
- package/dist/watch/checks/load.js +11 -0
- package/dist/watch/checks/memory.d.ts +2 -0
- package/dist/watch/checks/memory.js +11 -0
- package/dist/watch/checks/missing.d.ts +2 -0
- package/dist/watch/checks/missing.js +12 -0
- package/dist/watch/checks/model-drift.d.ts +2 -0
- package/dist/watch/checks/model-drift.js +14 -0
- package/dist/watch/checks/swap-free.d.ts +2 -0
- package/dist/watch/checks/swap-free.js +12 -0
- package/dist/watch/checks/swap-growth.d.ts +2 -0
- package/dist/watch/checks/swap-growth.js +18 -0
- package/dist/watch/checks/team-idle.d.ts +2 -0
- package/dist/watch/checks/team-idle.js +18 -0
- package/dist/watch/checks/unsent.d.ts +2 -0
- package/dist/watch/checks/unsent.js +18 -0
- package/dist/watch/close.d.ts +19 -0
- package/dist/watch/close.js +40 -0
- package/dist/watch/dialect.d.ts +5 -0
- package/dist/watch/dialect.js +430 -0
- package/dist/watch/end.d.ts +11 -0
- package/dist/watch/end.js +17 -0
- package/dist/watch/machine.d.ts +38 -0
- package/dist/watch/machine.js +137 -0
- package/dist/watch/notify.d.ts +1 -0
- package/dist/watch/notify.js +15 -0
- package/dist/watch/pass.d.ts +30 -0
- package/dist/watch/pass.js +237 -0
- package/dist/watch/screen-core.d.ts +14 -0
- package/dist/watch/screen-core.js +320 -0
- package/dist/watch/screen-data.d.ts +65 -0
- package/dist/watch/screen-data.js +1 -0
- package/dist/watch/screen-file.d.ts +2 -0
- package/dist/watch/screen-file.js +269 -0
- package/dist/watch/screen.d.ts +20 -0
- package/dist/watch/screen.js +38 -0
- package/dist/worktree/place.d.ts +18 -0
- package/dist/worktree/place.js +126 -0
- package/dist/yaml.d.ts +30 -0
- package/dist/yaml.js +380 -0
- package/examples/checks/codex-quota +217 -0
- package/examples/team.yaml +76 -0
- package/package.json +29 -10
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// One pass of the watch: the core. It builds one observation per seat before any check runs,
|
|
2
|
+
// runs the registered checks in the order it owns, applies the report-once rule, and dials the
|
|
3
|
+
// nudge. A check never reads herdr, a screen or the file itself — only the observation it is
|
|
4
|
+
// handed (RFC 0002 § 4.2).
|
|
5
|
+
import { WATCH_CHECKS_CHANGED } from "../approve/fingerprint.js";
|
|
6
|
+
import { observe, recall } from "../budgets/readings.js";
|
|
7
|
+
import { quotaFor } from "../profiles/profile.js";
|
|
8
|
+
import { figuresOf } from "../profiles/quota.js";
|
|
9
|
+
import { seatModel } from "../status/statusline.js";
|
|
10
|
+
import { ALWAYS_ON, } from "./check.js";
|
|
11
|
+
import { minutes } from "./check.js";
|
|
12
|
+
import { attention } from "./checks/attention.js";
|
|
13
|
+
import { approval } from "./checks/approval.js";
|
|
14
|
+
import { budget } from "./checks/budget.js";
|
|
15
|
+
import { disk } from "./checks/disk.js";
|
|
16
|
+
import { extra } from "./checks/extra.js";
|
|
17
|
+
import { idle } from "./checks/idle.js";
|
|
18
|
+
import { load } from "./checks/load.js";
|
|
19
|
+
import { memory as memoryCheck } from "./checks/memory.js";
|
|
20
|
+
import { missing } from "./checks/missing.js";
|
|
21
|
+
import { modelDrift } from "./checks/model-drift.js";
|
|
22
|
+
import { swapFree } from "./checks/swap-free.js";
|
|
23
|
+
import { swapGrowth } from "./checks/swap-growth.js";
|
|
24
|
+
import { teamIdle } from "./checks/team-idle.js";
|
|
25
|
+
import { unsent } from "./checks/unsent.js";
|
|
26
|
+
import { readScreen } from "./screen.js";
|
|
27
|
+
export function newMemory() {
|
|
28
|
+
return { history: {}, slots: {}, active: new Set(), pending: [], pendingSince: null };
|
|
29
|
+
}
|
|
30
|
+
// The one line the watch types into the operator's pane. Fixed, and free of report text: a dialog
|
|
31
|
+
// can open between the screen read and the typing, and a digit in the line would answer it. The
|
|
32
|
+
// reports go to the log and to the desktop notification; the line only says where they are.
|
|
33
|
+
export const NUDGE_TEXT = 'Team watch: reports are waiting in .agents/team.log';
|
|
34
|
+
// The seat checks, in the order their reports land in the log; the team checks, after them. The
|
|
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.
|
|
38
|
+
export const SEAT_CHECKS = [missing, modelDrift, attention, unsent, idle];
|
|
39
|
+
export const TEAM_CHECKS = [extra, teamIdle, approval, load, memoryCheck, disk, swapFree, swapGrowth, budget];
|
|
40
|
+
// The one exclusive reading of a seat's screen and herdr status. herdr can report a seat at a
|
|
41
|
+
// permission prompt as idle, so the screen decides first.
|
|
42
|
+
function attentionOf(screen, status, quiet) {
|
|
43
|
+
if (screen.kind === 'permission' || screen.kind === 'trust')
|
|
44
|
+
return 'permission';
|
|
45
|
+
if (screen.kind === 'question')
|
|
46
|
+
return 'question';
|
|
47
|
+
if (status === 'blocked')
|
|
48
|
+
return 'blocked';
|
|
49
|
+
if (!quiet && status !== 'working')
|
|
50
|
+
return 'unknown';
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
// 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) {
|
|
60
|
+
const reports = [];
|
|
61
|
+
const current = new Set();
|
|
62
|
+
// Reported when it starts, and again only after it has cleared.
|
|
63
|
+
const once = (key, text, to = 'operator') => {
|
|
64
|
+
current.add(key);
|
|
65
|
+
return memory.active.has(key) ? null : { key, text, to };
|
|
66
|
+
};
|
|
67
|
+
const known = new Set();
|
|
68
|
+
const workers = [];
|
|
69
|
+
const observations = [];
|
|
70
|
+
// A stopped seat is kept in the file and not started; should its owner start one by hand, herdr
|
|
71
|
+
// shows it running, and the watch gives it a parked seat's treatment: attention (permission,
|
|
72
|
+
// trust, question, blocked) and unsent text are reported, idle is not — and it is never an agent
|
|
73
|
+
// the file doesn't hold. A stopped seat that is not running is the normal state, and draws nothing.
|
|
74
|
+
const seats = [
|
|
75
|
+
...team.seats.map((seat) => ({
|
|
76
|
+
name: seat.name,
|
|
77
|
+
cli: seat.cli,
|
|
78
|
+
vendor: seat.vendor,
|
|
79
|
+
parked: seat.parked || seat.stopped,
|
|
80
|
+
stopped: seat.stopped,
|
|
81
|
+
seat,
|
|
82
|
+
})),
|
|
83
|
+
...Object.entries(state.seats).filter(([, recorded]) => recorded.temporary).map(([name, recorded]) => {
|
|
84
|
+
// A temporary seat signs like the seat it is like: its CLI, and its account.
|
|
85
|
+
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 };
|
|
87
|
+
}),
|
|
88
|
+
];
|
|
89
|
+
for (const { name, cli, vendor, parked, stopped, seat } of seats) {
|
|
90
|
+
const agent = live.agents.find((candidate) => candidate.name === name);
|
|
91
|
+
const lead = name === team.coordinator || name === team.operator;
|
|
92
|
+
if (!agent) {
|
|
93
|
+
delete memory.history[name];
|
|
94
|
+
observations.push({
|
|
95
|
+
name,
|
|
96
|
+
seat,
|
|
97
|
+
agent: undefined,
|
|
98
|
+
herdr: live.running,
|
|
99
|
+
screen: { kind: 'unknown' },
|
|
100
|
+
cli,
|
|
101
|
+
vendor,
|
|
102
|
+
quota: [],
|
|
103
|
+
running: false,
|
|
104
|
+
quiet: false,
|
|
105
|
+
working: false,
|
|
106
|
+
prompt: false,
|
|
107
|
+
lead,
|
|
108
|
+
parked,
|
|
109
|
+
stopped,
|
|
110
|
+
attention: null,
|
|
111
|
+
model: null,
|
|
112
|
+
history: { lastWorking: undefined, idleSince: undefined },
|
|
113
|
+
});
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
known.add(agent.pane);
|
|
117
|
+
const pane = live.screens[agent.pane];
|
|
118
|
+
const screen = readScreen(cli, pane);
|
|
119
|
+
const quiet = agent.status === 'idle' || agent.status === 'done';
|
|
120
|
+
// Working is herdr's word or the screen's: a seat mid-turn is never idle, whatever its
|
|
121
|
+
// status says.
|
|
122
|
+
const working = agent.status === 'working' || screen.kind === 'working';
|
|
123
|
+
const prompt = screen.kind === 'permission' || screen.kind === 'trust' || screen.kind === 'question';
|
|
124
|
+
if (!lead && !parked)
|
|
125
|
+
workers.push({ idle: quiet && !working && !prompt });
|
|
126
|
+
// The history the idle clock counts from. A seat seen working starts it again; a seat quiet
|
|
127
|
+
// with no work behind it was already idle when the watch started, and counts from then.
|
|
128
|
+
const history = (memory.history[name] ??= { lastWorking: undefined, idleSince: undefined });
|
|
129
|
+
if (working) {
|
|
130
|
+
history.lastWorking = now;
|
|
131
|
+
delete history.idleSince;
|
|
132
|
+
}
|
|
133
|
+
else if (quiet && !prompt && !lead && !parked && history.lastWorking === undefined) {
|
|
134
|
+
history.idleSince ??= now;
|
|
135
|
+
}
|
|
136
|
+
observations.push({
|
|
137
|
+
name,
|
|
138
|
+
seat,
|
|
139
|
+
agent,
|
|
140
|
+
herdr: live.running,
|
|
141
|
+
screen,
|
|
142
|
+
cli,
|
|
143
|
+
vendor,
|
|
144
|
+
quota: pane === undefined ? [] : figuresOf(quotaFor(cli), pane),
|
|
145
|
+
running: true,
|
|
146
|
+
quiet,
|
|
147
|
+
working,
|
|
148
|
+
prompt,
|
|
149
|
+
lead,
|
|
150
|
+
parked,
|
|
151
|
+
stopped,
|
|
152
|
+
attention: attentionOf(screen, agent.status, quiet),
|
|
153
|
+
model: seat ? seatModel(seat, live.screens[agent.pane]) : null,
|
|
154
|
+
history: { lastWorking: history.lastWorking, idleSince: history.idleSince },
|
|
155
|
+
});
|
|
156
|
+
}
|
|
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);
|
|
161
|
+
for (const seat of observations) {
|
|
162
|
+
if (!seat.running)
|
|
163
|
+
continue;
|
|
164
|
+
for (const figure of seat.quota) {
|
|
165
|
+
if (budgets.accounts[figure.account]?.sources.includes('status_line')) {
|
|
166
|
+
readings = observe(readings, figure, seat.name, now);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const teamObservation = {
|
|
171
|
+
team,
|
|
172
|
+
state,
|
|
173
|
+
live,
|
|
174
|
+
machine,
|
|
175
|
+
limits: team.machine,
|
|
176
|
+
seats: observations,
|
|
177
|
+
known,
|
|
178
|
+
workers,
|
|
179
|
+
approval,
|
|
180
|
+
readings,
|
|
181
|
+
outcomes,
|
|
182
|
+
};
|
|
183
|
+
const ctx = {
|
|
184
|
+
now,
|
|
185
|
+
watch,
|
|
186
|
+
budgets,
|
|
187
|
+
once,
|
|
188
|
+
memory: (kind, start) => {
|
|
189
|
+
if (!Object.hasOwn(memory.slots, kind))
|
|
190
|
+
memory.slots[kind] = start();
|
|
191
|
+
return memory.slots[kind];
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
// The four § 4.2 keeps out of `watch.checks` run whatever the file says: the validation refuses
|
|
195
|
+
// them, and this holds even for a file that reached memory another way.
|
|
196
|
+
//
|
|
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();
|
|
203
|
+
const enabled = (name) => ALWAYS_ON.includes(name) || !off.has(name);
|
|
204
|
+
for (const seat of observations) {
|
|
205
|
+
for (const check of SEAT_CHECKS)
|
|
206
|
+
if (enabled(check.name))
|
|
207
|
+
reports.push(...check.run(seat, ctx));
|
|
208
|
+
}
|
|
209
|
+
for (const check of TEAM_CHECKS)
|
|
210
|
+
if (enabled(check.name))
|
|
211
|
+
reports.push(...check.run(teamObservation, ctx));
|
|
212
|
+
memory.active = current;
|
|
213
|
+
// The nudge: kept until the operator is free, never typed into anything but an empty idle prompt.
|
|
214
|
+
for (const report of reports)
|
|
215
|
+
if (report.to === 'operator')
|
|
216
|
+
memory.pending.push(report.text);
|
|
217
|
+
let nudge = null;
|
|
218
|
+
let fallback = null;
|
|
219
|
+
if (memory.pending.length) {
|
|
220
|
+
memory.pendingSince ??= now;
|
|
221
|
+
const operator = live.agents.find((agent) => agent.name === team.operator);
|
|
222
|
+
const cli = team.seats.find((seat) => seat.name === team.operator)?.cli ?? '';
|
|
223
|
+
const free = operator !== undefined && (operator.status === 'idle' || operator.status === 'done')
|
|
224
|
+
&& readScreen(cli, live.screens[operator.pane]).kind === 'idle';
|
|
225
|
+
if (operator && free) {
|
|
226
|
+
nudge = { pane: operator.pane, text: NUDGE_TEXT, pending: memory.pending.slice() };
|
|
227
|
+
}
|
|
228
|
+
else if (now - memory.pendingSince >= watch.nudgeWait * 1000) {
|
|
229
|
+
fallback = `the operator could not be nudged for ${minutes(now - memory.pendingSince)} minutes; ${memory.pending.length} report(s) wait: ${memory.pending.join('; ')}`;
|
|
230
|
+
}
|
|
231
|
+
if (nudge || fallback) {
|
|
232
|
+
memory.pending = [];
|
|
233
|
+
memory.pendingSince = null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return { reports, nudge, fallback, readings };
|
|
237
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Screen } from './screen.ts';
|
|
2
|
+
import type { ScreenData } from './screen-data.ts';
|
|
3
|
+
export type { Composer, FallbackRule, LinePattern, Placeholder, Rule, ScreenData, Stage } from './screen-data.ts';
|
|
4
|
+
export type ReadClock = {
|
|
5
|
+
now(): number;
|
|
6
|
+
budgetMs: number;
|
|
7
|
+
};
|
|
8
|
+
/** `lines` is already the window: the last 20 lines, each trimmed at the end. */
|
|
9
|
+
export declare function classifyLines(data: ScreenData, lines: string[], clock?: ReadClock): Screen;
|
|
10
|
+
/**
|
|
11
|
+
* The composer alone, for a turn that is still running. The unknown stage is
|
|
12
|
+
* read first, as in the full classification, and the floor still applies.
|
|
13
|
+
*/
|
|
14
|
+
export declare function composeLines(data: ScreenData, lines: string[], clock?: ReadClock): Screen;
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
const BUDGET_MS = 100;
|
|
2
|
+
// The rule a `below_last_rule` pattern has to sit under. It is the core's, not the profile's.
|
|
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', '↑/↓'];
|
|
5
|
+
/** `lines` is already the window: the last 20 lines, each trimmed at the end. */
|
|
6
|
+
export function classifyLines(data, lines, clock) {
|
|
7
|
+
const now = clock?.now ?? Date.now;
|
|
8
|
+
const budget = clock?.budgetMs ?? BUDGET_MS;
|
|
9
|
+
const start = now();
|
|
10
|
+
const tick = () => now() - start > budget;
|
|
11
|
+
const order = [
|
|
12
|
+
['unknown', data.unknown],
|
|
13
|
+
['trust', data.trust],
|
|
14
|
+
['permission', data.permission],
|
|
15
|
+
['question', data.question],
|
|
16
|
+
['working', data.working],
|
|
17
|
+
];
|
|
18
|
+
for (const [kind, stage] of order) {
|
|
19
|
+
if (!stage)
|
|
20
|
+
continue;
|
|
21
|
+
for (const rule of stage.rules) {
|
|
22
|
+
const hit = ruleMatches(lines, rule, tick);
|
|
23
|
+
if (hit === 'stop')
|
|
24
|
+
return { kind: 'unknown' };
|
|
25
|
+
if (hit)
|
|
26
|
+
return { kind };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const composed = compose(data, lines, tick);
|
|
30
|
+
if (composed.kind === 'stop' || composed.kind === 'unknown')
|
|
31
|
+
return { kind: 'unknown' };
|
|
32
|
+
if (composed.kind !== 'idle' && composed.kind !== 'unsent')
|
|
33
|
+
return { kind: composed.kind };
|
|
34
|
+
const marked = floorHits(lines, composed.from, composed.input, data.chrome, tick);
|
|
35
|
+
if (marked === 'stop' || marked)
|
|
36
|
+
return { kind: 'unknown' };
|
|
37
|
+
return { kind: composed.kind };
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The composer alone, for a turn that is still running. The unknown stage is
|
|
41
|
+
* read first, as in the full classification, and the floor still applies.
|
|
42
|
+
*/
|
|
43
|
+
export function composeLines(data, lines, clock) {
|
|
44
|
+
const now = clock?.now ?? Date.now;
|
|
45
|
+
const budget = clock?.budgetMs ?? BUDGET_MS;
|
|
46
|
+
const start = now();
|
|
47
|
+
const tick = () => now() - start > budget;
|
|
48
|
+
if (data.unknown) {
|
|
49
|
+
for (const rule of data.unknown.rules) {
|
|
50
|
+
const hit = ruleMatches(lines, rule, tick);
|
|
51
|
+
if (hit === 'stop')
|
|
52
|
+
return { kind: 'unknown' };
|
|
53
|
+
if (hit)
|
|
54
|
+
return { kind: 'unknown' };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const composed = compose(data, lines, tick);
|
|
58
|
+
if (composed.kind === 'stop' || composed.kind === 'unknown')
|
|
59
|
+
return { kind: 'unknown' };
|
|
60
|
+
if (composed.kind !== 'idle' && composed.kind !== 'unsent')
|
|
61
|
+
return { kind: composed.kind };
|
|
62
|
+
const marked = floorHits(lines, composed.from, composed.input, data.chrome, tick);
|
|
63
|
+
if (marked === 'stop' || marked)
|
|
64
|
+
return { kind: 'unknown' };
|
|
65
|
+
return { kind: composed.kind };
|
|
66
|
+
}
|
|
67
|
+
function ruleMatches(lines, rule, tick) {
|
|
68
|
+
if (rule.any) {
|
|
69
|
+
const hit = anyLine(lines, rule.any, tick);
|
|
70
|
+
if (hit === 'stop')
|
|
71
|
+
return 'stop';
|
|
72
|
+
if (!hit)
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
if (rule.all) {
|
|
76
|
+
for (const pattern of rule.all) {
|
|
77
|
+
const hit = lineSomewhere(lines, pattern, tick);
|
|
78
|
+
if (hit === 'stop')
|
|
79
|
+
return 'stop';
|
|
80
|
+
if (!hit)
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (rule.footer !== undefined) {
|
|
85
|
+
const last = [...lines].reverse().find((line) => line.trim());
|
|
86
|
+
if ((last ?? '').trim() !== rule.footer)
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
if (rule.belowLastRule) {
|
|
90
|
+
if (tick())
|
|
91
|
+
return 'stop';
|
|
92
|
+
// No rule line counts as below one: today's check is `index > lastIndex`, and
|
|
93
|
+
// lastIndex is -1 when the window has no rule. A later rule still hides a quoted dialog.
|
|
94
|
+
const ruleAt = findLast(lines, (line) => RULE_LINE.test(line));
|
|
95
|
+
const at = findLast(lines, (line) => rule.belowLastRule?.test(line) ?? false);
|
|
96
|
+
if (at < 0 || at <= ruleAt)
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
if (rule.noneAfter) {
|
|
100
|
+
let anchor = -1;
|
|
101
|
+
for (let i = 0; i < lines.length; i++) {
|
|
102
|
+
const hit = matches(lines[i] ?? '', rule.noneAfter.anchor, tick);
|
|
103
|
+
if (hit === 'stop')
|
|
104
|
+
return 'stop';
|
|
105
|
+
if (hit)
|
|
106
|
+
anchor = i;
|
|
107
|
+
}
|
|
108
|
+
if (anchor < 0)
|
|
109
|
+
return false;
|
|
110
|
+
for (let i = anchor + 1; i < lines.length; i++) {
|
|
111
|
+
const later = anyLine([lines[i] ?? ''], rule.noneAfter.patterns, tick);
|
|
112
|
+
if (later === 'stop')
|
|
113
|
+
return 'stop';
|
|
114
|
+
if (later)
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
function anyLine(lines, patterns, tick) {
|
|
121
|
+
for (const pattern of patterns) {
|
|
122
|
+
const hit = lineSomewhere(lines, pattern, tick);
|
|
123
|
+
if (hit === 'stop')
|
|
124
|
+
return 'stop';
|
|
125
|
+
if (hit)
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
function lineSomewhere(lines, pattern, tick) {
|
|
131
|
+
for (const line of lines) {
|
|
132
|
+
const hit = matches(line, pattern, tick);
|
|
133
|
+
if (hit === 'stop')
|
|
134
|
+
return 'stop';
|
|
135
|
+
if (hit)
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
function matches(line, pattern, tick) {
|
|
141
|
+
if (tick())
|
|
142
|
+
return 'stop';
|
|
143
|
+
if (!pattern.match.test(line))
|
|
144
|
+
return false;
|
|
145
|
+
for (const except of pattern.except) {
|
|
146
|
+
if (tick())
|
|
147
|
+
return 'stop';
|
|
148
|
+
if (except.test(line))
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
function compose(data, lines, tick) {
|
|
154
|
+
const composer = data.composer;
|
|
155
|
+
if (composer.mode === 'box-to-rule')
|
|
156
|
+
return boxToRule(lines, composer, tick);
|
|
157
|
+
if (composer.mode === 'status-last')
|
|
158
|
+
return statusLast(lines, composer, tick, false);
|
|
159
|
+
if (composer.mode === 'status-then-one')
|
|
160
|
+
return statusThenOne(lines, composer, tick);
|
|
161
|
+
return twoRules(lines, composer, tick);
|
|
162
|
+
}
|
|
163
|
+
function boxToRule(lines, composer, tick) {
|
|
164
|
+
let input = -1;
|
|
165
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
166
|
+
if (tick())
|
|
167
|
+
return { kind: 'stop' };
|
|
168
|
+
if (composer.prompt.test(lines[i] ?? '')) {
|
|
169
|
+
input = i;
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (input < 0)
|
|
174
|
+
return { kind: 'unknown' };
|
|
175
|
+
const from = boundaryBefore(lines, input, composer.rule);
|
|
176
|
+
for (let i = input + 1; i < lines.length; i++) {
|
|
177
|
+
if (tick())
|
|
178
|
+
return { kind: 'stop' };
|
|
179
|
+
if (composer.rule.test(lines[i] ?? ''))
|
|
180
|
+
break;
|
|
181
|
+
if ((lines[i] ?? '').trim())
|
|
182
|
+
return { kind: 'unsent', from, input };
|
|
183
|
+
}
|
|
184
|
+
const typed = (lines[input] ?? '').replace(composer.prompt, '').trim();
|
|
185
|
+
return { kind: placeholder(typed, composer.placeholders) ? 'idle' : 'unsent', from, input };
|
|
186
|
+
}
|
|
187
|
+
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
|
+
}
|
|
197
|
+
if (status < 0)
|
|
198
|
+
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
|
+
let input = status - 1;
|
|
210
|
+
while (input >= 0 && !composer.prompt.test(lines[input] ?? ''))
|
|
211
|
+
input--;
|
|
212
|
+
if (input < 0)
|
|
213
|
+
return { kind: 'unknown' };
|
|
214
|
+
for (let i = input + 1; i < status; i++)
|
|
215
|
+
if ((lines[i] ?? '').trim())
|
|
216
|
+
return { kind: 'unsent', from: input, input };
|
|
217
|
+
return { kind: placeholder(stripTyped(lines[input] ?? '', composer), composer.placeholders) ? 'idle' : 'unsent', from: input, input };
|
|
218
|
+
}
|
|
219
|
+
function statusThenOne(lines, composer, tick) {
|
|
220
|
+
const found = statusLast(lines, composer, tick, true);
|
|
221
|
+
if (found.kind !== 'unknown')
|
|
222
|
+
return found;
|
|
223
|
+
// No status line: the fallback rules decide. Anything they don't name is unknown.
|
|
224
|
+
const hasStatus = lines.some((line) => composer.statusLine.test(line));
|
|
225
|
+
if (hasStatus)
|
|
226
|
+
return { kind: 'unknown' };
|
|
227
|
+
for (const rule of composer.fallback) {
|
|
228
|
+
let ok = true;
|
|
229
|
+
for (const pattern of rule.all) {
|
|
230
|
+
const hit = lineSomewhere(lines, pattern, tick);
|
|
231
|
+
if (hit === 'stop')
|
|
232
|
+
return { kind: 'stop' };
|
|
233
|
+
if (!hit) {
|
|
234
|
+
ok = false;
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (ok) {
|
|
239
|
+
// The floor's region starts at the prompt, the composer's top boundary.
|
|
240
|
+
// A phrase in the transcript above it is not the floor's to read.
|
|
241
|
+
const input = lines.findIndex((line) => composer.prompt.test(line));
|
|
242
|
+
return { kind: rule.kind, from: input < 0 ? 0 : input, input };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return { kind: 'unknown' };
|
|
246
|
+
}
|
|
247
|
+
function twoRules(lines, composer, tick) {
|
|
248
|
+
let bottom = -1;
|
|
249
|
+
let top = -1;
|
|
250
|
+
let input = -1;
|
|
251
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
252
|
+
if (tick())
|
|
253
|
+
return { kind: 'stop' };
|
|
254
|
+
const line = lines[i] ?? '';
|
|
255
|
+
if (composer.rule.test(line)) {
|
|
256
|
+
if (bottom === -1)
|
|
257
|
+
bottom = i;
|
|
258
|
+
else if (top === -1 && input !== -1) {
|
|
259
|
+
top = i;
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
else if (bottom !== -1 && input === -1 && composer.prompt.test(line))
|
|
264
|
+
input = i;
|
|
265
|
+
}
|
|
266
|
+
if (top < 0 || bottom < 0 || input <= top || input >= bottom)
|
|
267
|
+
return { kind: 'unknown' };
|
|
268
|
+
let footer = false;
|
|
269
|
+
for (let i = bottom + 1; i < lines.length; i++) {
|
|
270
|
+
if (tick())
|
|
271
|
+
return { kind: 'stop' };
|
|
272
|
+
if (composer.footers.some((pattern) => pattern.test(lines[i] ?? '')))
|
|
273
|
+
footer = true;
|
|
274
|
+
}
|
|
275
|
+
if (!footer)
|
|
276
|
+
return { kind: 'unknown' };
|
|
277
|
+
if (lines.slice(input + 1, bottom).some((line) => line.trim()))
|
|
278
|
+
return { kind: 'unsent', from: top, input };
|
|
279
|
+
const typed = (lines[input] ?? '').replace(composer.prompt, '').trim();
|
|
280
|
+
return { kind: placeholder(typed, composer.placeholders) ? 'idle' : 'unsent', from: top, input };
|
|
281
|
+
}
|
|
282
|
+
function boundaryBefore(lines, input, rule) {
|
|
283
|
+
for (let i = input - 1; i >= 0; i--)
|
|
284
|
+
if (rule.test(lines[i] ?? ''))
|
|
285
|
+
return i;
|
|
286
|
+
return input;
|
|
287
|
+
}
|
|
288
|
+
function stripTyped(line, composer) {
|
|
289
|
+
const stripped = composer.stripSuffix ? line.replace(composer.prompt, '').replace(composer.stripSuffix, '') : line.replace(composer.prompt, '');
|
|
290
|
+
return stripped.trim();
|
|
291
|
+
}
|
|
292
|
+
function placeholder(typed, list) {
|
|
293
|
+
return list.some((entry) => ('equals' in entry ? typed === entry.equals : typed.startsWith(entry.prefix)));
|
|
294
|
+
}
|
|
295
|
+
function floorHits(lines, from, input, chrome, tick) {
|
|
296
|
+
for (let i = Math.max(0, from); i < lines.length; i++) {
|
|
297
|
+
if (tick())
|
|
298
|
+
return 'stop';
|
|
299
|
+
const line = lines[i] ?? '';
|
|
300
|
+
if (chrome.some((pattern) => pattern.test(line)))
|
|
301
|
+
continue;
|
|
302
|
+
if (i !== input && choiceLine(line) && lines.slice(i + 1).some((later) => twoLine(later)))
|
|
303
|
+
return true;
|
|
304
|
+
if (FLOOR_PHRASES.some((phrase) => line.includes(phrase)))
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
function choiceLine(line) {
|
|
310
|
+
return /^\s*[❯›>]\s*1\.\s/.test(line);
|
|
311
|
+
}
|
|
312
|
+
function twoLine(line) {
|
|
313
|
+
return /^\s*[❯›>]?\s*2\.\s/.test(line);
|
|
314
|
+
}
|
|
315
|
+
function findLast(lines, test) {
|
|
316
|
+
for (let i = lines.length - 1; i >= 0; i--)
|
|
317
|
+
if (test(lines[i] ?? ''))
|
|
318
|
+
return i;
|
|
319
|
+
return -1;
|
|
320
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Screen } from './screen.ts';
|
|
2
|
+
export type LinePattern = {
|
|
3
|
+
match: RegExp;
|
|
4
|
+
except: RegExp[];
|
|
5
|
+
};
|
|
6
|
+
export type Rule = {
|
|
7
|
+
any?: LinePattern[];
|
|
8
|
+
all?: LinePattern[];
|
|
9
|
+
footer?: string;
|
|
10
|
+
belowLastRule?: RegExp;
|
|
11
|
+
noneAfter?: {
|
|
12
|
+
anchor: LinePattern;
|
|
13
|
+
patterns: LinePattern[];
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
export type Stage = {
|
|
17
|
+
rules: Rule[];
|
|
18
|
+
};
|
|
19
|
+
export type Placeholder = {
|
|
20
|
+
equals: string;
|
|
21
|
+
} | {
|
|
22
|
+
prefix: string;
|
|
23
|
+
};
|
|
24
|
+
export type FallbackRule = {
|
|
25
|
+
all: LinePattern[];
|
|
26
|
+
kind: Screen['kind'];
|
|
27
|
+
};
|
|
28
|
+
type Box = {
|
|
29
|
+
mode: 'box-to-rule';
|
|
30
|
+
prompt: RegExp;
|
|
31
|
+
rule: RegExp;
|
|
32
|
+
placeholders: Placeholder[];
|
|
33
|
+
};
|
|
34
|
+
type StatusLast = {
|
|
35
|
+
mode: 'status-last';
|
|
36
|
+
statusLine: RegExp;
|
|
37
|
+
prompt: RegExp;
|
|
38
|
+
placeholders: Placeholder[];
|
|
39
|
+
};
|
|
40
|
+
type StatusThenOne = {
|
|
41
|
+
mode: 'status-then-one';
|
|
42
|
+
statusLine: RegExp;
|
|
43
|
+
prompt: RegExp;
|
|
44
|
+
placeholders: Placeholder[];
|
|
45
|
+
stripSuffix: RegExp | null;
|
|
46
|
+
fallback: FallbackRule[];
|
|
47
|
+
};
|
|
48
|
+
type TwoRules = {
|
|
49
|
+
mode: 'two-rules-footer-below';
|
|
50
|
+
prompt: RegExp;
|
|
51
|
+
rule: RegExp;
|
|
52
|
+
footers: RegExp[];
|
|
53
|
+
placeholders: Placeholder[];
|
|
54
|
+
};
|
|
55
|
+
export type Composer = Box | StatusLast | StatusThenOne | TwoRules;
|
|
56
|
+
export type ScreenData = {
|
|
57
|
+
chrome: RegExp[];
|
|
58
|
+
unknown?: Stage;
|
|
59
|
+
trust?: Stage;
|
|
60
|
+
permission?: Stage;
|
|
61
|
+
question?: Stage;
|
|
62
|
+
working?: Stage;
|
|
63
|
+
composer: Composer;
|
|
64
|
+
};
|
|
65
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|