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