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.
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 +20 -0
  14. package/dist/budgets/gate.js +96 -0
  15. package/dist/budgets/readings.d.ts +47 -0
  16. package/dist/budgets/readings.js +119 -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 +361 -0
  55. package/dist/commands/watch.d.ts +35 -0
  56. package/dist/commands/watch.js +302 -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 +22 -0
  104. package/dist/profiles/quota.js +56 -0
  105. package/dist/state.d.ts +59 -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 +30 -0
  154. package/dist/watch/pass.js +237 -0
  155. package/dist/watch/screen-core.d.ts +14 -0
  156. package/dist/watch/screen-core.js +320 -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 +20 -0
  162. package/dist/watch/screen.js +38 -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,60 @@
1
+ import { type QuotaPattern } from './quota.ts';
2
+ /**
3
+ * A launch profile: what `team` knows about one CLI, so a seat can't start
4
+ * blocked. Keyed by the seat's `cli`.
5
+ */
6
+ export interface Profile {
7
+ cli: string;
8
+ /** The CLI's process names, for the caller check. */
9
+ processNames: readonly string[];
10
+ /** The binary whose version `doctor` reads, with `--version`. */
11
+ binary: string;
12
+ /** The range this profile was tested with, both ends included. */
13
+ tested: {
14
+ from: string;
15
+ to: string;
16
+ };
17
+ /** Options that make the CLI run without approvals, added to the launch. */
18
+ unattended: readonly string[];
19
+ /** The option that carries the rules, or null when they go as a first message. */
20
+ rulesOption: string | null;
21
+ /** Arguments that exit 0 only when the owner is logged in, or null when the CLI has none. */
22
+ loginCheck: readonly string[] | null;
23
+ /** The command the owner runs to log in. */
24
+ loginHint: string;
25
+ /** What is typed into an idle prompt to make the CLI exit. */
26
+ exit: string;
27
+ /** Seconds to wait for the idle prompt after a launch. */
28
+ idleTimeout: number;
29
+ /** Seconds to wait for the pane's shell after the exit command. */
30
+ exitTimeout: number;
31
+ /** The model and version a launch line's model id means, or null when unknown. */
32
+ modelOf(launch: string): {
33
+ model: string;
34
+ version: string;
35
+ } | null;
36
+ }
37
+ /** A version as numbers: `2.1.288 (Claude Code)` is [2, 1, 288]. Null when the text holds none. */
38
+ export declare function parseVersion(text: string): number[] | null;
39
+ export type VersionVerdict = 'tested' | 'older' | 'newer' | 'unread';
40
+ /** Where a CLI's version sits against the range a profile was tested with. */
41
+ export declare function versionVerdict(text: string, tested: Profile['tested']): VersionVerdict;
42
+ /** A word quoted for a POSIX shell. */
43
+ export declare function shellQuote(word: string): string;
44
+ /**
45
+ * The command a seat's pane runs: the unattended flag in its environment, the
46
+ * file's launch line, then the profile's options and, where the CLI takes
47
+ * them at launch, the rules.
48
+ */
49
+ export declare function launchCommand(profile: Profile, launch: string, rules: string): string;
50
+ /** The profiles this version launches. A `cli` without one is reported and left out. */
51
+ export declare function profileFor(cli: string): Profile | null;
52
+ /** The quota patterns shipped with a CLI. An unknown CLI, or one with none, has an empty list. */
53
+ export declare function quotaFor(cli: string): readonly QuotaPattern[];
54
+ /** Patterns from a YAML list, for a profile snippet. A bad pattern throws. */
55
+ export declare function quotaPatterns(text: string): QuotaPattern[];
56
+ /** The model a status line names. `unreadable` is a line the rules claim that does not name one. */
57
+ export declare function statusOnLine(cli: string, line: string): {
58
+ model: string;
59
+ version: string;
60
+ } | 'unreadable' | null;
@@ -0,0 +1,313 @@
1
+ // A launch profile, read from the CLI's YAML file. The screen is loaded separately.
2
+ import { readFileSync } from 'node:fs';
3
+ import { DialectError, compilePattern } from "../watch/dialect.js";
4
+ import { WINDOWS } from "./quota.js";
5
+ import { YamlError, parseYaml } from "../yaml.js";
6
+ /** A version as numbers: `2.1.288 (Claude Code)` is [2, 1, 288]. Null when the text holds none. */
7
+ export function parseVersion(text) {
8
+ const match = /\d+(?:\.\d+)*/.exec(text);
9
+ return match ? match[0].split('.').map(Number) : null;
10
+ }
11
+ function order(a, b) {
12
+ for (let index = 0; index < Math.max(a.length, b.length); index++) {
13
+ const difference = (a[index] ?? 0) - (b[index] ?? 0);
14
+ if (difference !== 0)
15
+ return Math.sign(difference);
16
+ }
17
+ return 0;
18
+ }
19
+ /** Where a CLI's version sits against the range a profile was tested with. */
20
+ export function versionVerdict(text, tested) {
21
+ const version = parseVersion(text);
22
+ const from = parseVersion(tested.from);
23
+ const to = parseVersion(tested.to);
24
+ if (!version || !from || !to)
25
+ return 'unread';
26
+ if (order(version, from) < 0)
27
+ return 'older';
28
+ if (order(version, to) > 0)
29
+ return 'newer';
30
+ return 'tested';
31
+ }
32
+ /** A word quoted for a POSIX shell. */
33
+ export function shellQuote(word) {
34
+ if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(word))
35
+ return word;
36
+ return `'${word.replaceAll("'", `'\\''`)}'`;
37
+ }
38
+ /**
39
+ * The command a seat's pane runs: the unattended flag in its environment, the
40
+ * file's launch line, then the profile's options and, where the CLI takes
41
+ * them at launch, the rules.
42
+ */
43
+ export function launchCommand(profile, launch, rules) {
44
+ const options = [...profile.unattended];
45
+ if (profile.rulesOption !== null)
46
+ options.push(profile.rulesOption, rules);
47
+ return ['AGENT_UNATTENDED=1', launch.trim(), ...options.map(shellQuote)].join(' ');
48
+ }
49
+ const NAMES = ['claude-code', 'codex', 'cursor', 'antigravity'];
50
+ const LAUNCH_KEYS = ['binary', 'process_names', 'tested', 'unattended', 'rules', 'login', 'exit', 'timeouts', 'models', 'status_model'];
51
+ const SHIPPED = loadShipped();
52
+ /** The profiles this version launches. A `cli` without one is reported and left out. */
53
+ export function profileFor(cli) {
54
+ return Object.hasOwn(SHIPPED, cli) ? (SHIPPED[cli]?.profile ?? null) : null;
55
+ }
56
+ /** The quota patterns shipped with a CLI. An unknown CLI, or one with none, has an empty list. */
57
+ export function quotaFor(cli) {
58
+ return SHIPPED[cli]?.quota ?? [];
59
+ }
60
+ /** Patterns from a YAML list, for a profile snippet. A bad pattern throws. */
61
+ export function quotaPatterns(text) {
62
+ return quotaOf(parseYaml(text));
63
+ }
64
+ /** The model a status line names. `unreadable` is a line the rules claim that does not name one. */
65
+ export function statusOnLine(cli, line) {
66
+ const rules = SHIPPED[cli]?.status;
67
+ if (!rules)
68
+ return null;
69
+ return apply(rules, line);
70
+ }
71
+ function loadShipped() {
72
+ const out = {};
73
+ for (const name of NAMES) {
74
+ const text = readFileSync(new URL(`./${name}.yaml`, import.meta.url), 'utf8');
75
+ const shipped = launchOf(parseYaml(text));
76
+ if (out[shipped.profile.cli])
77
+ fail(1, `two profiles are named "${shipped.profile.cli}"`);
78
+ out[shipped.profile.cli] = shipped;
79
+ }
80
+ return out;
81
+ }
82
+ function launchOf(root) {
83
+ const entries = mapping(root, 'a profile');
84
+ const format = required(entries, 'format', root.line);
85
+ if (format.value.kind !== 'scalar' || format.value.value !== 1)
86
+ fail(format.line, '"format" must be 1');
87
+ only(entries, ['format', 'cli', 'screen', 'quota', ...LAUNCH_KEYS]);
88
+ const cli = text(required(entries, 'cli', root.line), 'cli');
89
+ required(entries, 'screen', root.line);
90
+ const quotaEntry = optional(entries, 'quota');
91
+ for (const key of LAUNCH_KEYS)
92
+ required(entries, key, root.line);
93
+ const login = loginOf(required(entries, 'login', root.line).value);
94
+ const timeouts = required(entries, 'timeouts', root.line).value;
95
+ const models = modelsOf(required(entries, 'models', root.line).value);
96
+ return {
97
+ profile: {
98
+ cli,
99
+ binary: text(required(entries, 'binary', root.line), 'binary'),
100
+ processNames: strings(required(entries, 'process_names', root.line).value, 'process_names'),
101
+ tested: testedOf(required(entries, 'tested', root.line).value),
102
+ unattended: strings(required(entries, 'unattended', root.line).value, 'unattended'),
103
+ rulesOption: rulesOf(required(entries, 'rules', root.line).value),
104
+ loginCheck: login.check,
105
+ loginHint: login.hint,
106
+ exit: text(required(entries, 'exit', root.line), 'exit'),
107
+ idleTimeout: seconds(timeouts, 'idle'),
108
+ exitTimeout: seconds(timeouts, 'exit'),
109
+ modelOf: (launch) => modelOf(models, launch),
110
+ },
111
+ status: modelRules(required(entries, 'status_model', root.line).value, 'status_model'),
112
+ quota: quotaEntry ? quotaOf(quotaEntry.value) : [],
113
+ };
114
+ }
115
+ function quotaOf(node) {
116
+ if (node.kind !== 'seq' || node.items.length === 0)
117
+ fail(node.line, '"quota" must be a non-empty list');
118
+ return node.items.map((item) => {
119
+ const entries = mapping(item, 'a quota pattern');
120
+ only(entries, ['account', 'match', 'left', 'used', 'resets', 'window']);
121
+ const left = optional(entries, 'left');
122
+ const used = optional(entries, 'used');
123
+ if (left && used)
124
+ fail(left.line, 'a quota pattern has left or used, not both');
125
+ if (!left && !used)
126
+ fail(item.line, 'a quota pattern needs left or used');
127
+ const side = left ? 'left' : 'used';
128
+ const figure = text((left ?? used), side);
129
+ template(figure, (left ?? used)?.line ?? item.line);
130
+ const resetsEntry = optional(entries, 'resets');
131
+ const resets = resetsEntry ? text(resetsEntry, 'resets') : null;
132
+ if (resets !== null)
133
+ template(resets, resetsEntry?.line ?? item.line);
134
+ const window = text(required(entries, 'window', item.line), 'window');
135
+ if (!WINDOWS.includes(window))
136
+ fail(item.line, '"window" must be session, daily or weekly');
137
+ const match = required(entries, 'match', item.line);
138
+ return {
139
+ account: text(required(entries, 'account', item.line), 'account'),
140
+ window: window,
141
+ match: pattern(text(match, 'match'), false, match.line),
142
+ side,
143
+ figure,
144
+ resets,
145
+ };
146
+ });
147
+ }
148
+ // The flag's value is the next token, after "=" or any whitespace. The last flag in the line wins.
149
+ function modelOf(models, launch) {
150
+ let bestAt = -1;
151
+ let id = null;
152
+ for (const option of models.option) {
153
+ const found = flagValue(launch, option);
154
+ if (found && found.at > bestAt) {
155
+ bestAt = found.at;
156
+ id = found.id;
157
+ }
158
+ }
159
+ if (id === null)
160
+ return null;
161
+ const hit = apply(models.ids, id);
162
+ return hit === 'unreadable' ? null : hit;
163
+ }
164
+ function flagValue(launch, option) {
165
+ const escaped = option.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
166
+ const pattern = new RegExp(`(?:^|\\s)${escaped}(?:=|\\s+)(\\S+)`, 'gu');
167
+ let found = null;
168
+ for (const match of launch.matchAll(pattern)) {
169
+ if (match[1] === undefined || match.index === undefined)
170
+ continue;
171
+ found = { at: match.index, id: match[1] };
172
+ }
173
+ return found;
174
+ }
175
+ function apply(rules, text) {
176
+ for (const rule of rules) {
177
+ const match = rule.match.exec(text);
178
+ if (!match)
179
+ continue;
180
+ const model = fill(rule.model, match);
181
+ const version = fill(rule.version, match);
182
+ // A match that cannot fill its template is a line of this shape that names no model.
183
+ if (model === null || version === null)
184
+ return 'unreadable';
185
+ return { model, version };
186
+ }
187
+ return null;
188
+ }
189
+ function fill(template, match) {
190
+ let missing = false;
191
+ const out = template.replace(/\{(\d+)(?::(title))?\}/g, (_whole, number, mode) => {
192
+ const value = match[Number(number)];
193
+ if (value === undefined) {
194
+ missing = true;
195
+ return '';
196
+ }
197
+ return mode === 'title' ? `${value.slice(0, 1).toUpperCase()}${value.slice(1).toLowerCase()}` : value;
198
+ });
199
+ return missing ? null : out;
200
+ }
201
+ function modelsOf(node) {
202
+ const entries = mapping(node, 'models');
203
+ only(entries, ['option', 'ids']);
204
+ return {
205
+ option: strings(required(entries, 'option', node.line).value, 'option'),
206
+ ids: modelRules(required(entries, 'ids', node.line).value, 'ids'),
207
+ };
208
+ }
209
+ function modelRules(node, key) {
210
+ if (node.kind !== 'seq' || node.items.length === 0)
211
+ fail(node.line, `"${key}" must be a non-empty list`);
212
+ return node.items.map((item) => {
213
+ const entries = mapping(item, 'a model rule');
214
+ only(entries, ['match', 'ignore_case', 'model', 'version']);
215
+ const flag = optional(entries, 'ignore_case');
216
+ const ignoreCase = flag ? boolOf(flag.value, 'ignore_case') : false;
217
+ const match = required(entries, 'match', item.line);
218
+ const model = text(required(entries, 'model', item.line), 'model');
219
+ const version = text(required(entries, 'version', item.line), 'version');
220
+ template(model, match.line);
221
+ template(version, match.line);
222
+ return { match: pattern(text(match, 'match'), ignoreCase, match.line), model, version };
223
+ });
224
+ }
225
+ function template(text, line) {
226
+ const rest = text.replace(/\{(\d+)(?::title)?\}/g, '');
227
+ if (rest.includes('{') || rest.includes('}'))
228
+ fail(line, 'a template is "{n}" or "{n:title}"');
229
+ }
230
+ function rulesOf(node) {
231
+ if (node.kind === 'scalar' && node.value === 'first-message')
232
+ return null;
233
+ const entries = mapping(node, 'rules');
234
+ only(entries, ['option']);
235
+ return text(required(entries, 'option', node.line), 'option');
236
+ }
237
+ function loginOf(node) {
238
+ const entries = mapping(node, 'login');
239
+ only(entries, ['check', 'hint']);
240
+ return {
241
+ check: strings(required(entries, 'check', node.line).value, 'check'),
242
+ hint: text(required(entries, 'hint', node.line), 'hint'),
243
+ };
244
+ }
245
+ function testedOf(node) {
246
+ const entries = mapping(node, 'tested');
247
+ only(entries, ['from', 'to']);
248
+ return { from: text(required(entries, 'from', node.line), 'from'), to: text(required(entries, 'to', node.line), 'to') };
249
+ }
250
+ function seconds(node, key) {
251
+ const entries = mapping(node, 'timeouts');
252
+ only(entries, ['idle', 'exit']);
253
+ const value = text(required(entries, key, node.line), key);
254
+ const match = /^([0-9]+)s$/.exec(value);
255
+ if (!match || match[1] === undefined)
256
+ fail(node.line, `"${key}" is a number of seconds, written "90s"`);
257
+ return Number(match[1]);
258
+ }
259
+ function pattern(source, ignoreCase, line) {
260
+ try {
261
+ return compilePattern(source, ignoreCase);
262
+ }
263
+ catch (error) {
264
+ if (error instanceof DialectError)
265
+ fail(line, error.message);
266
+ throw error;
267
+ }
268
+ }
269
+ function strings(node, key) {
270
+ if (node.kind !== 'seq' || node.items.length === 0)
271
+ fail(node.line, `"${key}" must be a non-empty list`);
272
+ return node.items.map((item) => {
273
+ const value = stringOf(item);
274
+ if (!value)
275
+ fail(item.line, `"${key}" entries must be non-empty strings`);
276
+ return value;
277
+ });
278
+ }
279
+ function text(entry, key) {
280
+ const value = stringOf(entry.value);
281
+ if (!value)
282
+ fail(entry.line, `"${key}" must be a non-empty string`);
283
+ return value;
284
+ }
285
+ function boolOf(node, key) {
286
+ if (node.kind !== 'scalar' || typeof node.value !== 'boolean')
287
+ fail(node.line, `"${key}" must be true or false`);
288
+ return node.value;
289
+ }
290
+ function stringOf(node) {
291
+ if (node.kind !== 'scalar' || typeof node.value !== 'string' || node.value === '')
292
+ return null;
293
+ return node.value;
294
+ }
295
+ function mapping(node, what) {
296
+ if (node.kind !== 'map')
297
+ fail(node.line, `${what} must be a map`);
298
+ return node.entries;
299
+ }
300
+ function only(entries, allowed) {
301
+ for (const entry of entries)
302
+ if (!allowed.includes(entry.key))
303
+ fail(entry.line, `unknown key "${entry.key}"`);
304
+ }
305
+ function required(entries, key, line) {
306
+ return optional(entries, key) ?? fail(line, `missing "${key}"`);
307
+ }
308
+ function optional(entries, key) {
309
+ return entries.find((entry) => entry.key === key);
310
+ }
311
+ function fail(line, message) {
312
+ throw new YamlError(line, message);
313
+ }
@@ -0,0 +1,22 @@
1
+ export declare const WINDOWS: readonly ["session", "daily", "weekly"];
2
+ export type WindowName = (typeof WINDOWS)[number];
3
+ export type QuotaPattern = {
4
+ account: string;
5
+ window: WindowName;
6
+ match: RegExp;
7
+ side: 'left' | 'used';
8
+ /** A template such as `{1}%`. */
9
+ figure: string;
10
+ /** A template for a duration such as `114h4m`, or null when the line has none. */
11
+ resets: string | null;
12
+ };
13
+ export type QuotaFigure = {
14
+ account: string;
15
+ window: WindowName;
16
+ /** 0 to 100. `left` and `used` add to 100. */
17
+ left: number;
18
+ used: number;
19
+ resets: string | null;
20
+ };
21
+ /** Figures from the last six lines. A cut line, or a figure over 100, is absent. */
22
+ export declare function figuresOf(patterns: readonly QuotaPattern[], screen: string): QuotaFigure[];
@@ -0,0 +1,56 @@
1
+ // A profile's quota patterns. They are read after a screen is classified, and they
2
+ // only produce figures: they never change what the screen is.
3
+ export const WINDOWS = ['session', 'daily', 'weekly'];
4
+ const RESET = /^(?:[0-9]+h(?:[0-9]+m)?|[0-9]+m)$/;
5
+ const PERCENT = /^([0-9]+)%$/;
6
+ /** Figures from the last six lines. A cut line, or a figure over 100, is absent. */
7
+ export function figuresOf(patterns, screen) {
8
+ const lines = screen.split('\n').map((line) => line.trimEnd()).slice(-6);
9
+ const found = [];
10
+ for (const pattern of patterns) {
11
+ let hit = null;
12
+ for (const line of lines) {
13
+ const match = pattern.match.exec(line);
14
+ if (!match)
15
+ continue;
16
+ const next = figureOf(pattern, match);
17
+ if (next)
18
+ hit = next;
19
+ }
20
+ if (hit)
21
+ found.push(hit);
22
+ }
23
+ return found;
24
+ }
25
+ function figureOf(pattern, match) {
26
+ const filled = fill(pattern.figure, match);
27
+ const percent = filled === null ? null : PERCENT.exec(filled);
28
+ const value = percent?.[1] === undefined ? null : Number(percent[1]);
29
+ if (value === null || value > 100)
30
+ return null;
31
+ let resets = null;
32
+ if (pattern.resets !== null) {
33
+ resets = fill(pattern.resets, match);
34
+ if (resets === null || !RESET.test(resets))
35
+ return null;
36
+ }
37
+ return {
38
+ account: pattern.account,
39
+ window: pattern.window,
40
+ left: pattern.side === 'left' ? value : 100 - value,
41
+ used: pattern.side === 'used' ? value : 100 - value,
42
+ resets,
43
+ };
44
+ }
45
+ function fill(template, match) {
46
+ let missing = false;
47
+ const out = template.replace(/\{(\d+)\}/g, (_whole, number) => {
48
+ const value = match[Number(number)];
49
+ if (value === undefined) {
50
+ missing = true;
51
+ return '';
52
+ }
53
+ return value;
54
+ });
55
+ return missing ? null : out;
56
+ }
@@ -0,0 +1,59 @@
1
+ import type { StoredReading } from './budgets/readings.ts';
2
+ export type SeatState = {
3
+ stage: 'launched' | 'named' | 'ready';
4
+ pane?: string;
5
+ /** The herdr workspace, so a later command can close it without listing agents. */
6
+ workspace?: string;
7
+ cli_version?: string;
8
+ rules?: 'option' | 'message' | 'undelivered';
9
+ worked?: boolean;
10
+ temporary?: {
11
+ like: string;
12
+ until: string;
13
+ task?: string;
14
+ own_commits?: boolean;
15
+ };
16
+ };
17
+ export type WorktreeState = {
18
+ path: string;
19
+ branch: string;
20
+ own_commits?: boolean;
21
+ seat?: string;
22
+ setup: 'ok' | 'failed';
23
+ };
24
+ export type SessionState = {
25
+ started?: {
26
+ at: string;
27
+ by: string;
28
+ };
29
+ seats: Record<string, SeatState>;
30
+ worktrees: Record<string, WorktreeState>;
31
+ watch?: {
32
+ pid: number;
33
+ heartbeat: string;
34
+ };
35
+ nudge?: {
36
+ pending_since: string | null;
37
+ };
38
+ /** The last screen readings for this project, keyed by account, window and seat. */
39
+ budgets?: Record<string, StoredReading>;
40
+ };
41
+ export type State = {
42
+ format: 1;
43
+ last_valid?: {
44
+ read_at: string;
45
+ file: string;
46
+ };
47
+ sessions: Record<string, SessionState>;
48
+ };
49
+ export declare const STATE_FILE = "team.state.json";
50
+ export declare const LOCK_FILE = "team.lock";
51
+ export declare const LOG_FILE = "team.log";
52
+ export declare function emptySession(): SessionState;
53
+ export declare function readState(dir: string): State;
54
+ export declare function updateState<T>(dir: string, change: (state: State) => T): T;
55
+ export declare function writeAtomic(path: string, text: string): void;
56
+ export declare function withLock<T>(dir: string, work: () => T, options?: {
57
+ waitMs?: number;
58
+ onStale?: (pid: number) => void;
59
+ }): T;
package/dist/state.js ADDED
@@ -0,0 +1,127 @@
1
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ export const STATE_FILE = 'team.state.json';
4
+ export const LOCK_FILE = 'team.lock';
5
+ export const LOG_FILE = 'team.log';
6
+ export function emptySession() {
7
+ return { seats: {}, worktrees: {} };
8
+ }
9
+ // The state in `dir` (the folder of the team file). A missing file is an empty state; a file
10
+ // that can't be read as a state is an error, never silently replaced.
11
+ export function readState(dir) {
12
+ const path = join(dir, STATE_FILE);
13
+ if (!existsSync(path))
14
+ return { format: 1, sessions: {} };
15
+ let parsed;
16
+ try {
17
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
18
+ }
19
+ catch {
20
+ throw new Error(`${path} is not valid JSON; move it aside and run the command again`);
21
+ }
22
+ const state = parsed;
23
+ if (!state || typeof state !== 'object' || state.format !== 1 || typeof state.sessions !== 'object' || !state.sessions) {
24
+ throw new Error(`${path} is not a team state of format 1; move it aside and run the command again`);
25
+ }
26
+ for (const session of Object.values(state.sessions)) {
27
+ session.seats ??= {};
28
+ session.worktrees ??= {};
29
+ }
30
+ return state;
31
+ }
32
+ // Reads the state, lets `change` edit it, and writes it back, all under the lock.
33
+ export function updateState(dir, change) {
34
+ return withLock(dir, () => {
35
+ const state = readState(dir);
36
+ const result = change(state);
37
+ writeAtomic(join(dir, STATE_FILE), `${JSON.stringify(state, null, 2)}\n`);
38
+ return result;
39
+ });
40
+ }
41
+ export function writeAtomic(path, text) {
42
+ const temporary = `${path}.${process.pid}.tmp`;
43
+ writeFileSync(temporary, text);
44
+ renameSync(temporary, path);
45
+ }
46
+ function alive(pid) {
47
+ try {
48
+ process.kill(pid, 0);
49
+ return true;
50
+ }
51
+ catch (error) {
52
+ return error.code === 'EPERM';
53
+ }
54
+ }
55
+ function pause(ms) {
56
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
57
+ }
58
+ // Removes a lock whose holder is dead. Two commands may see the same dead holder: the lock is
59
+ // moved aside first, which only one of them can do, and put back if it turns out to be a fresh
60
+ // one that another command took in between.
61
+ function takeOver(path, holder) {
62
+ const aside = `${path}.${process.pid}.stale`;
63
+ try {
64
+ renameSync(path, aside);
65
+ }
66
+ catch {
67
+ return false;
68
+ }
69
+ const found = Number(readFileSync(aside, 'utf8').trim());
70
+ if (found !== holder && Number.isInteger(found) && found > 0 && alive(found)) {
71
+ try {
72
+ renameSync(aside, path);
73
+ }
74
+ catch { }
75
+ return false;
76
+ }
77
+ unlinkSync(aside);
78
+ return true;
79
+ }
80
+ // One writer at a time. The lock file holds its holder's pid; a lock whose holder is dead is
81
+ // taken over, and `onStale` is told.
82
+ export function withLock(dir, work, options = {}) {
83
+ mkdirSync(dir, { recursive: true });
84
+ const path = join(dir, LOCK_FILE);
85
+ const deadline = Date.now() + (options.waitMs ?? 5000);
86
+ for (;;) {
87
+ try {
88
+ const fd = openSync(path, 'wx');
89
+ writeFileSync(fd, `${process.pid}\n`);
90
+ closeSync(fd);
91
+ break;
92
+ }
93
+ catch (error) {
94
+ if (error.code !== 'EEXIST')
95
+ throw error;
96
+ let holder = NaN;
97
+ try {
98
+ holder = Number(readFileSync(path, 'utf8').trim());
99
+ }
100
+ catch {
101
+ continue; // released between the two calls
102
+ }
103
+ if (Number.isInteger(holder) && holder > 0 && alive(holder)) {
104
+ if (Date.now() > deadline)
105
+ throw new Error(`another team command (pid ${holder}) holds ${path}; try again when it is done`);
106
+ pause(50);
107
+ continue;
108
+ }
109
+ // A dead holder, or a lock file too young to hold its pid yet.
110
+ if (!Number.isInteger(holder) && Date.now() - statSync(path).mtimeMs < 1000) {
111
+ pause(20);
112
+ continue;
113
+ }
114
+ if (takeOver(path, holder))
115
+ options.onStale?.(holder);
116
+ }
117
+ }
118
+ try {
119
+ return work();
120
+ }
121
+ finally {
122
+ try {
123
+ unlinkSync(path);
124
+ }
125
+ catch { }
126
+ }
127
+ }
@@ -0,0 +1,26 @@
1
+ import type { TeamFile } from '../file/types.ts';
2
+ import type { HerdrAgent, HerdrWorkspace } from '../herdr.ts';
3
+ import type { SessionState } from '../state.ts';
4
+ export type Live = {
5
+ running: boolean;
6
+ agents: HerdrAgent[];
7
+ workspaces: HerdrWorkspace[];
8
+ screens: Record<string, string>;
9
+ };
10
+ export type Row = {
11
+ name: string;
12
+ state: string;
13
+ model: string;
14
+ pane: string;
15
+ };
16
+ export type Difference = {
17
+ what: string;
18
+ repair: string;
19
+ };
20
+ export type Comparison = {
21
+ rows: Row[];
22
+ differences: Difference[];
23
+ notes: string[];
24
+ };
25
+ export declare const WATCH_LABEL = "watchdog";
26
+ export declare function compare(team: TeamFile, session: string, state: SessionState, live: Live, now?: Date, watch?: TeamFile['watch']): Comparison;