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,702 @@
1
+ import { CLIS } from "../clis.js";
2
+ import { ALWAYS_ON, CHECK_NAMES } from "../watch/check.js";
3
+ import { YamlError, parseYaml } from "../yaml.js";
4
+ import { findSecrets } from "./secrets.js";
5
+ import { DEFAULT_COMMIT_TEMPLATE, DEFAULT_FORBIDDEN, DEFAULT_PR_TEMPLATE, hasVersionToken, isTrailerTemplate, templateProblem, } from "./signature.js";
6
+ import { insideProject, insideTrust, normalize, trustProblem } from "./paths.js";
7
+ const POSITIONS = ['last-line', 'trailer', 'anywhere'];
8
+ const MODES = ['worktree', 'shared'];
9
+ // Validates the text of a team file. Every problem is collected, each with its line.
10
+ export function validateTeamFile(text) {
11
+ let root;
12
+ try {
13
+ root = parseYaml(text);
14
+ }
15
+ catch (error) {
16
+ if (error instanceof YamlError)
17
+ return { ok: false, errors: [{ line: error.line, message: error.message }] };
18
+ throw error;
19
+ }
20
+ const check = new Check();
21
+ const secrets = findSecrets(root);
22
+ check.problems.push(...secrets.refused);
23
+ check.warnings.push(...secrets.warned);
24
+ const team = readTeam(root, check);
25
+ if (check.problems.length || !team) {
26
+ return { ok: false, errors: check.problems.sort((a, b) => a.line - b.line) };
27
+ }
28
+ return { ok: true, team, warnings: check.warnings };
29
+ }
30
+ class Check {
31
+ problems = [];
32
+ warnings = [];
33
+ fail(line, message) {
34
+ this.problems.push({ line, message });
35
+ }
36
+ // The fields of a map, with every field outside `known` reported.
37
+ fields(node, where, known) {
38
+ const fields = new Map();
39
+ if (!node)
40
+ return fields;
41
+ if (node.kind !== 'map') {
42
+ if (!(node.kind === 'scalar' && node.value === null))
43
+ this.fail(node.line, `${where} must be a map of fields`);
44
+ return fields;
45
+ }
46
+ for (const entry of node.entries) {
47
+ if (known.includes(entry.key))
48
+ fields.set(entry.key, entry);
49
+ else
50
+ this.fail(entry.line, `unknown field "${entry.key}" in ${where}`);
51
+ }
52
+ return fields;
53
+ }
54
+ text(entry, name) {
55
+ if (!entry)
56
+ return undefined;
57
+ const node = entry.value;
58
+ if (node.kind === 'scalar' && typeof node.value === 'string' && node.value !== '')
59
+ return node.value;
60
+ this.fail(node.line, `${name} must be text${node.kind === 'scalar' && node.value !== null && node.value !== '' ? ': quote it' : ''}`);
61
+ return undefined;
62
+ }
63
+ required(entry, name, line) {
64
+ if (!entry)
65
+ this.fail(line, `${name} is required`);
66
+ return this.text(entry, name);
67
+ }
68
+ oneOf(entry, name, values) {
69
+ if (!entry)
70
+ return undefined;
71
+ const node = entry.value;
72
+ if (node.kind === 'scalar' && typeof node.value === 'string' && values.includes(node.value)) {
73
+ return node.value;
74
+ }
75
+ this.fail(node.line, `${name} must be one of: ${values.join(', ')}`);
76
+ return undefined;
77
+ }
78
+ flag(entry, name) {
79
+ if (!entry)
80
+ return false;
81
+ const node = entry.value;
82
+ if (node.kind === 'scalar' && typeof node.value === 'boolean' && !node.quoted)
83
+ return node.value;
84
+ this.fail(node.line, `${name} must be true or false`);
85
+ return false;
86
+ }
87
+ whole(entry, name, least) {
88
+ if (!entry)
89
+ return undefined;
90
+ const node = entry.value;
91
+ if (node.kind === 'scalar' && typeof node.value === 'number' && Number.isInteger(node.value) && node.value >= least) {
92
+ return node.value;
93
+ }
94
+ this.fail(node.line, `${name} must be a whole number, ${least} or more`);
95
+ return undefined;
96
+ }
97
+ number(entry, name) {
98
+ if (!entry)
99
+ return undefined;
100
+ const node = entry.value;
101
+ if (node.kind === 'scalar' && typeof node.value === 'number' && Number.isFinite(node.value) && node.value > 0) {
102
+ return node.value;
103
+ }
104
+ this.fail(node.line, `${name} must be a number above 0`);
105
+ return undefined;
106
+ }
107
+ // A value with its unit, such as 120s, 25% or 10GB.
108
+ measure(entry, name, units, example) {
109
+ if (!entry)
110
+ return undefined;
111
+ const node = entry.value;
112
+ const raw = node.kind === 'scalar' ? node.raw : '';
113
+ const match = /^([0-9]+(?:\.[0-9]+)?)([A-Za-z%]+)$/.exec(raw);
114
+ const unit = match?.[2];
115
+ if (match && unit !== undefined && unit in units && node.kind === 'scalar' && !node.quoted) {
116
+ return Number(match[1]) * units[unit];
117
+ }
118
+ this.fail(node.line, `${name} needs a value with its unit (${Object.keys(units).join(', ')}), such as ${example}`);
119
+ return undefined;
120
+ }
121
+ list(entry, name) {
122
+ if (!entry)
123
+ return [];
124
+ const node = entry.value;
125
+ if (node.kind !== 'seq') {
126
+ if (!(node.kind === 'scalar' && node.value === null))
127
+ this.fail(node.line, `${name} must be a list`);
128
+ return [];
129
+ }
130
+ const out = [];
131
+ for (const item of node.items) {
132
+ if (item.kind === 'scalar' && typeof item.value === 'string' && item.value !== '') {
133
+ out.push({ value: item.value, line: item.line });
134
+ }
135
+ else {
136
+ this.fail(item.line, `each item of ${name} must be text${item.kind === 'map' ? ': quote a line that contains ": "' : ''}`);
137
+ }
138
+ }
139
+ return out;
140
+ }
141
+ }
142
+ const DURATION = { s: 1, m: 60, h: 3600 };
143
+ const PERCENT = { '%': 1 };
144
+ const SIZE = { MB: 1e6, GB: 1e9, TB: 1e12 };
145
+ function readTeam(root, check) {
146
+ if (root.kind !== 'map') {
147
+ check.fail(root.line, 'the file must be a map of fields, starting with "format: 1"');
148
+ return null;
149
+ }
150
+ const top = check.fields(root, 'the file', [
151
+ 'format', 'project', 'visibility', 'session', 'coordinator', 'operator', 'tools', 'identity', 'rules',
152
+ 'trust', 'workspace', 'watch', 'machine', 'limits', 'seats', 'budgets',
153
+ ]);
154
+ const format = top.get('format');
155
+ if (!format)
156
+ check.fail(1, 'format is required: "format: 1"');
157
+ else if (!(format.value.kind === 'scalar' && format.value.value === 1 && !format.value.quoted)) {
158
+ check.fail(format.line, 'format must be 1: this version of team reads no other');
159
+ }
160
+ const project = check.required(top.get('project'), 'project', 1) ?? '';
161
+ if (project && !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(project)) {
162
+ check.fail(top.get('project')?.line ?? 1, 'project must be a short name: letters, digits, ".", "_" and "-"');
163
+ }
164
+ const session = check.text(top.get('session'), 'session') ?? project;
165
+ if (session === 'default') {
166
+ check.fail(top.get('session')?.line ?? top.get('project')?.line ?? 1, 'session can\'t be "default", herdr\'s own session: give the team a session of its own');
167
+ }
168
+ const identity = readIdentity(top.get('identity'), check);
169
+ const declaredVisibility = check.oneOf(top.get('visibility'), 'visibility', ['public', 'private']);
170
+ const visibility = declaredVisibility ?? (identity.forbiddenPublic.length ? 'public' : 'private');
171
+ const trustItems = check.list(top.get('trust'), 'trust');
172
+ for (const item of trustItems) {
173
+ const problem = trustProblem(item.value);
174
+ if (problem)
175
+ check.fail(item.line, `trust: "${item.value}" ${problem}`);
176
+ }
177
+ const trust = trustItems.map((item) => item.value);
178
+ const broken = new Set();
179
+ const seats = readSeats(top.get('seats'), check, trust, root.line, broken);
180
+ const workspace = readWorkspace(top.get('workspace'), check, project, trust, seats, root.line);
181
+ for (const seat of seats)
182
+ seat.mode ??= workspace.mode;
183
+ const coordinator = readLead(top.get('coordinator'), 'coordinator', seats, broken, check);
184
+ const operator = readLead(top.get('operator'), 'operator', seats, broken, check);
185
+ const limits = readLimits(top.get('limits'), check, seats.length);
186
+ const watched = readWatch(top.get('watch'), check);
187
+ return {
188
+ format: 1,
189
+ project,
190
+ visibility,
191
+ session,
192
+ coordinator,
193
+ operator,
194
+ tools: readTools(top.get('tools'), check),
195
+ identity,
196
+ rules: check.list(top.get('rules'), 'rules').map((item) => item.value),
197
+ trust,
198
+ workspace,
199
+ watch: watched.watch,
200
+ budgets: readBudgets(top.get('budgets'), check, watched.watch.quotaMarks, watched.legacyMarks),
201
+ machine: readMachine(top.get('machine'), check),
202
+ limits,
203
+ seats: seats,
204
+ };
205
+ }
206
+ function readTools(entry, check) {
207
+ const tools = {};
208
+ if (!entry)
209
+ return tools;
210
+ if (entry.value.kind !== 'map') {
211
+ check.fail(entry.value.line, 'tools must be a map: a name, then its kind and where it is');
212
+ return tools;
213
+ }
214
+ for (const tool of entry.value.entries) {
215
+ const fields = {};
216
+ if (tool.value.kind !== 'map') {
217
+ check.fail(tool.value.line, `tools.${tool.key} must be a map with a kind`);
218
+ continue;
219
+ }
220
+ for (const field of tool.value.entries) {
221
+ const value = check.text(field, `tools.${tool.key}.${field.key}`);
222
+ if (value !== undefined)
223
+ fields[field.key] = value;
224
+ }
225
+ if (!('kind' in fields))
226
+ check.fail(tool.line, `tools.${tool.key} needs a kind`);
227
+ tools[tool.key] = fields;
228
+ }
229
+ return tools;
230
+ }
231
+ function readIdentity(entry, check) {
232
+ const identity = check.fields(entry?.value, 'identity', ['signature', 'humans', 'since', 'forbidden', 'forbidden_public']);
233
+ const signature = check.fields(identity.get('signature')?.value, 'identity.signature', ['template', 'commits', 'pull_requests']);
234
+ const commits = check.fields(signature.get('commits')?.value, 'identity.signature.commits', ['template', 'position', 'exempt']);
235
+ const prs = check.fields(signature.get('pull_requests')?.value, 'identity.signature.pull_requests', ['template', 'position', 'exempt']);
236
+ const base = readTemplate(signature.get('template'), 'identity.signature.template', check) ?? DEFAULT_COMMIT_TEMPLATE;
237
+ const commitTemplate = readTemplate(commits.get('template'), 'identity.signature.commits.template', check) ?? base;
238
+ const prTemplate = readTemplate(prs.get('template'), 'identity.signature.pull_requests.template', check) ?? DEFAULT_PR_TEMPLATE;
239
+ const commitPosition = check.oneOf(commits.get('position'), 'identity.signature.commits.position', POSITIONS) ?? 'trailer';
240
+ const prPosition = check.oneOf(prs.get('position'), 'identity.signature.pull_requests.position', POSITIONS) ?? 'last-line';
241
+ if (commitPosition === 'trailer' && !isTrailerTemplate(commitTemplate)) {
242
+ const line = commits.get('position')?.line ?? commits.get('template')?.line ?? signature.get('template')?.line ?? entry?.line ?? 1;
243
+ check.fail(line, 'position "trailer" needs a template git reads as a trailer: "<token>: <value>", the token without spaces');
244
+ }
245
+ const exemptEntry = commits.get('exempt');
246
+ const exempt = exemptEntry ? check.list(exemptEntry, 'identity.signature.commits.exempt') : [{ value: 'merge', line: 0 }];
247
+ for (const item of exempt) {
248
+ if (item.value !== 'merge')
249
+ check.fail(item.line, `exempt takes only "merge" in this version, found "${item.value}"`);
250
+ }
251
+ const prExempt = prs.get('exempt');
252
+ if (prExempt)
253
+ check.fail(prExempt.line, 'pull_requests has no exempt: it applies to commits only');
254
+ const forbidden = readPatterns(identity.get('forbidden'), 'identity.forbidden', check);
255
+ const forbiddenPublic = readPatterns(identity.get('forbidden_public'), 'identity.forbidden_public', check);
256
+ return {
257
+ signature: {
258
+ commits: { template: commitTemplate, position: commitPosition, exempt: exempt.some((item) => item.value === 'merge') ? ['merge'] : [] },
259
+ pullRequests: { template: prTemplate, position: prPosition },
260
+ },
261
+ humans: check.list(identity.get('humans'), 'identity.humans').map((item) => item.value),
262
+ since: check.text(identity.get('since'), 'identity.since') ?? null,
263
+ forbidden: [...DEFAULT_FORBIDDEN, ...forbidden.filter((pattern) => !DEFAULT_FORBIDDEN.includes(pattern))],
264
+ forbiddenPublic,
265
+ };
266
+ }
267
+ function readTemplate(entry, name, check) {
268
+ const template = check.text(entry, name);
269
+ if (template === undefined || !entry)
270
+ return undefined;
271
+ const problem = templateProblem(template);
272
+ if (problem)
273
+ check.fail(entry.value.line, `${name}: ${problem}`);
274
+ return template;
275
+ }
276
+ function readPatterns(entry, name, check) {
277
+ const out = [];
278
+ for (const item of check.list(entry, name)) {
279
+ try {
280
+ new RegExp(item.value);
281
+ out.push(item.value);
282
+ }
283
+ catch (error) {
284
+ check.fail(item.line, `${name}: not a regular expression (${error.message})`);
285
+ }
286
+ }
287
+ return out;
288
+ }
289
+ // `broken` collects the names of seats left out for their own problems, so that a reference to
290
+ // one is not reported a second time.
291
+ function readSeats(entry, check, trust, topLine, broken) {
292
+ if (!entry) {
293
+ check.fail(topLine, 'seats is required: at least the coordinator\'s and the operator\'s seat');
294
+ return [];
295
+ }
296
+ if (entry.value.kind !== 'seq' || !entry.value.items.length) {
297
+ check.fail(entry.value.line, 'seats must be a list with at least one seat');
298
+ return [];
299
+ }
300
+ const seats = [];
301
+ for (const item of entry.value.items) {
302
+ const fields = check.fields(item, 'a seat', [
303
+ 'role', 'name', 'cli', 'vendor', 'model', 'version', 'display', 'launch', 'cwd', 'label', 'mode',
304
+ 'parked', 'stopped', 'count',
305
+ ]);
306
+ if (item.kind !== 'map')
307
+ continue;
308
+ const line = item.line;
309
+ const role = check.required(fields.get('role'), 'a seat\'s role', line);
310
+ const name = check.required(fields.get('name'), 'a seat\'s name', line);
311
+ const at = name ? `seat "${name}"` : 'a seat';
312
+ if (name && !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) {
313
+ check.fail(fields.get('name')?.line ?? line, `${at}: the name takes letters, digits, ".", "_" and "-"`);
314
+ }
315
+ if (!fields.get('cli'))
316
+ check.fail(line, `${at}: cli is required`);
317
+ const cli = check.oneOf(fields.get('cli'), `${at}: cli`, CLIS);
318
+ const vendor = check.required(fields.get('vendor'), `${at}: vendor`, line);
319
+ const model = check.required(fields.get('model'), `${at}: model`, line);
320
+ const version = readVersion(fields.get('version'), at, line, check);
321
+ const launch = check.required(fields.get('launch'), `${at}: launch`, line);
322
+ const display = check.text(fields.get('display'), `${at}: display`) ?? (model && version ? `${model} ${version}` : undefined);
323
+ if (display && version && fields.get('display') && !hasVersionToken(display, version)) {
324
+ check.fail(fields.get('display')?.line ?? line, `${at}: display must contain the version "${version}" as a token of its own`);
325
+ }
326
+ const cwd = readCwd(fields.get('cwd'), at, trust, check);
327
+ const mode = check.oneOf(fields.get('mode'), `${at}: mode`, MODES);
328
+ const parked = check.flag(fields.get('parked'), `${at}: parked`);
329
+ const stopped = check.flag(fields.get('stopped'), `${at}: stopped`);
330
+ const count = check.whole(fields.get('count'), `${at}: count`, 1) ?? 1;
331
+ const label = check.text(fields.get('label'), `${at}: label`);
332
+ if (!role || !name || !cli || !vendor || !model || !version || !launch || !display) {
333
+ if (name)
334
+ broken.add(name);
335
+ continue;
336
+ }
337
+ for (let instance = 1; instance <= count; instance++) {
338
+ const suffix = instance === 1 ? '' : `-${instance}`;
339
+ seats.push({
340
+ role, cli, vendor, model, version, display, launch, cwd, parked, stopped, line,
341
+ name: name + suffix,
342
+ label: (label ?? name) + suffix,
343
+ declared: name,
344
+ count,
345
+ instance,
346
+ ...(mode ? { mode } : {}),
347
+ });
348
+ }
349
+ }
350
+ reportCollisions(seats, 'name', check);
351
+ reportCollisions(seats, 'label', check);
352
+ return seats;
353
+ }
354
+ function readVersion(entry, at, line, check) {
355
+ if (!entry) {
356
+ check.fail(line, `${at}: version is required`);
357
+ return undefined;
358
+ }
359
+ const node = entry.value;
360
+ if (node.kind === 'scalar' && typeof node.value === 'string' && node.value !== '')
361
+ return node.value;
362
+ if (node.kind === 'scalar' && typeof node.value === 'number') {
363
+ check.fail(node.line, `${at}: version must be quoted ("${node.raw}"): unquoted, YAML reads it as a number and 4.10 becomes 4.1`);
364
+ }
365
+ else
366
+ check.fail(node.line, `${at}: version must be quoted text`);
367
+ return undefined;
368
+ }
369
+ function readCwd(entry, at, trust, check) {
370
+ const cwd = check.text(entry, `${at}: cwd`);
371
+ if (cwd === undefined || !entry)
372
+ return '.';
373
+ if (cwd.startsWith('/') || cwd.startsWith('~') || /^[A-Za-z]:[\\/]/.test(cwd)) {
374
+ check.fail(entry.value.line, `${at}: cwd must be relative to the project, never absolute and never "~"`);
375
+ return '.';
376
+ }
377
+ const path = normalize(cwd);
378
+ if (!insideProject(path) && !insideTrust(path, trust)) {
379
+ check.fail(entry.value.line, `${at}: cwd "${cwd}" is outside the project and matches no trust path`);
380
+ }
381
+ return path;
382
+ }
383
+ function reportCollisions(seats, field, check) {
384
+ const seen = new Map();
385
+ for (const seat of seats) {
386
+ const first = seen.get(seat[field]);
387
+ if (!first)
388
+ seen.set(seat[field], seat);
389
+ else if (first.line !== seat.line || first.instance === seat.instance) {
390
+ check.fail(seat.line, `two seats have the ${field} "${seat[field]}" (the other is on line ${first.line}${first.count > 1 || seat.count > 1 ? ', after count is expanded' : ''})`);
391
+ }
392
+ }
393
+ }
394
+ function readLead(entry, field, seats, broken, check) {
395
+ const name = check.required(entry, field, 1);
396
+ if (!name || !entry)
397
+ return '';
398
+ const seat = seats.find((candidate) => candidate.name === name);
399
+ const line = entry.value.line;
400
+ if (!seat) {
401
+ if (!broken.has(name))
402
+ check.fail(line, `${field} "${name}" names no declared seat`);
403
+ }
404
+ else if (seat.count > 1)
405
+ check.fail(line, `${field} "${name}" can't be a seat with count`);
406
+ else if (seat.parked)
407
+ check.fail(line, `${field} "${name}" can't be a parked seat`);
408
+ else if (seat.stopped)
409
+ check.fail(line, `${field} "${name}" can't be a stopped seat`);
410
+ return name;
411
+ }
412
+ function readWorkspace(entry, check, project, trust, seats, topLine) {
413
+ const fields = check.fields(entry?.value, 'workspace', ['mode', 'path', 'branch', 'base', 'setup', 'remove', 'protected', 'limit']);
414
+ const mode = check.oneOf(fields.get('mode'), 'workspace.mode', MODES) ?? 'worktree';
415
+ const usesWorktrees = seats.some((seat) => (seat.mode ?? mode) === 'worktree');
416
+ const line = entry?.line ?? topLine;
417
+ const path = check.text(fields.get('path'), 'workspace.path') ?? null;
418
+ const base = check.text(fields.get('base'), 'workspace.base') ?? null;
419
+ if (usesWorktrees && !base)
420
+ check.fail(line, 'workspace.base is required when a seat works in worktrees');
421
+ if (usesWorktrees && !path)
422
+ check.fail(line, 'workspace.path is required when a seat works in worktrees');
423
+ if (path) {
424
+ const at = fields.get('path')?.value.line ?? line;
425
+ const problem = worktreePathProblem(path, project, trust);
426
+ if (problem)
427
+ check.fail(at, `workspace.path ${problem}`);
428
+ }
429
+ const protectedPaths = fields.get('protected') ? check.list(fields.get('protected'), 'workspace.protected').map((item) => item.value) : ['.'];
430
+ return {
431
+ mode,
432
+ path,
433
+ branch: check.text(fields.get('branch'), 'workspace.branch') ?? '{task}',
434
+ base,
435
+ setup: check.list(fields.get('setup'), 'workspace.setup').map((item) => item.value),
436
+ remove: check.oneOf(fields.get('remove'), 'workspace.remove', ['on-merge', 'manual']) ?? 'on-merge',
437
+ protected: protectedPaths,
438
+ limit: check.whole(fields.get('limit'), 'workspace.limit', 1) ?? 8,
439
+ };
440
+ }
441
+ // A worktree goes under a fixed folder of its own, and inside a trust path.
442
+ function worktreePathProblem(path, project, trust) {
443
+ if (path.startsWith('/') || path.startsWith('~'))
444
+ return 'must be relative to the project';
445
+ const segments = path.split('/').filter((segment) => segment && segment !== '.');
446
+ const last = segments[segments.length - 1];
447
+ if (last !== '{task}')
448
+ return 'must end with the segment {task}';
449
+ if (segments.slice(0, -1).some((segment) => segment.includes('{task}')))
450
+ return 'takes {task} only as its last segment';
451
+ const folder = segments.slice(0, -1).map((segment) => segment.replaceAll('{repo}', project || 'project'));
452
+ if (folder.every((segment) => segment === '..')) {
453
+ return 'needs a fixed folder of its own before {task}: this one puts worktrees straight into the project or one of its parents';
454
+ }
455
+ const sample = normalize([...folder, 'task'].join('/'));
456
+ if (!insideTrust(sample, trust))
457
+ return 'matches no trust pattern';
458
+ return null;
459
+ }
460
+ /**
461
+ * The watch values a file that sets none runs with — and, since the section is the owner's, the
462
+ * values a file runs with until the owner approves what it sets instead.
463
+ */
464
+ export function defaultWatch() {
465
+ return {
466
+ interval: 120,
467
+ idleFirst: 600,
468
+ idleRepeat: 1200,
469
+ teamIdle: 600,
470
+ nudgeWait: 600,
471
+ unsentAfter: 60,
472
+ quotaMarks: [50, 75, 90],
473
+ checks: [],
474
+ };
475
+ }
476
+ function readWatch(entry, check) {
477
+ const fields = check.fields(entry?.value, 'watch', [
478
+ 'interval', 'idle_first', 'idle_repeat', 'team_idle', 'nudge_wait', 'unsent_after', 'quota_marks', 'checks',
479
+ ]);
480
+ const time = (name, fallback) => check.measure(fields.get(name), `watch.${name}`, DURATION, '120s or 10m') ?? fallback;
481
+ const base = defaultWatch();
482
+ let quotaMarks = base.quotaMarks;
483
+ const marks = fields.get('quota_marks');
484
+ if (marks) {
485
+ const node = marks.value;
486
+ const numbers = node.kind === 'seq' ? node.items.map((item) => (item.kind === 'scalar' ? item.value : null)) : null;
487
+ if (numbers && numbers.every((n) => typeof n === 'number' && n > 0 && n <= 100))
488
+ quotaMarks = numbers;
489
+ else
490
+ check.fail(node.line, 'watch.quota_marks must be a list of percentages, such as [50, 75, 90]');
491
+ check.warnings.push({ line: node.line, message: 'watch.quota_marks is now budgets.marks, and is still read' });
492
+ }
493
+ return {
494
+ legacyMarks: Boolean(marks),
495
+ watch: {
496
+ interval: time('interval', base.interval),
497
+ idleFirst: time('idle_first', base.idleFirst),
498
+ idleRepeat: time('idle_repeat', base.idleRepeat),
499
+ teamIdle: time('team_idle', base.teamIdle),
500
+ nudgeWait: time('nudge_wait', base.nudgeWait),
501
+ unsentAfter: time('unsent_after', base.unsentAfter),
502
+ quotaMarks,
503
+ checks: readChecks(fields.get('checks'), check),
504
+ },
505
+ };
506
+ }
507
+ // The checks a file turns off: a map of check name to `off`. A name that doesn't exist is refused
508
+ // with its line, and so is one of the four that always run (RFC 0002 § 4.2) — the schema refuses
509
+ // them, and `team approve` validates before it fingerprints, so neither can approve them either.
510
+ function readChecks(entry, check) {
511
+ const off = [];
512
+ if (!entry)
513
+ return off;
514
+ const node = entry.value;
515
+ if (node.kind !== 'map') {
516
+ if (!(node.kind === 'scalar' && node.value === null)) {
517
+ check.fail(node.line, 'watch.checks must be a map: a check named, and set off');
518
+ }
519
+ return off;
520
+ }
521
+ for (const item of node.entries) {
522
+ if (!CHECK_NAMES.includes(item.key)) {
523
+ check.fail(item.line, `unknown check "${item.key}" in watch.checks: the checks are ${CHECK_NAMES.join(', ')}`);
524
+ continue;
525
+ }
526
+ if (ALWAYS_ON.includes(item.key)) {
527
+ check.fail(item.line, `watch.checks can't turn off ${item.key}: ${ALWAYS_ON.join(', ')} always run`);
528
+ continue;
529
+ }
530
+ if (item.value.kind === 'scalar' && item.value.value === 'off')
531
+ off.push(item.key);
532
+ else
533
+ check.fail(item.line, `watch.checks.${item.key} must be off: the only setting is off`);
534
+ }
535
+ return off;
536
+ }
537
+ /**
538
+ * The budget values a file that sets none runs with — and, since the section is the owner's, the
539
+ * values a file runs with until the owner approves what it sets instead. No accounts: nothing is
540
+ * budgeted until the owner names one.
541
+ */
542
+ export function defaultBudgets() {
543
+ return { staleAfter: 30 * 60, checkEvery: 10 * 60, marks: defaultWatch().quotaMarks, accounts: {} };
544
+ }
545
+ function readBudgets(entry, check, fallbackMarks, legacyMarks) {
546
+ const base = { ...defaultBudgets(), marks: fallbackMarks };
547
+ if (!entry)
548
+ return base;
549
+ const fields = check.fields(entry.value, 'budgets', ['stale_after', 'check_every', 'marks', 'accounts']);
550
+ const marks = percentList(fields.get('marks'), 'budgets.marks', check);
551
+ if (marks && legacyMarks) {
552
+ check.warnings.push({ line: fields.get('marks')?.line ?? entry.line, message: 'budgets.marks replaces watch.quota_marks' });
553
+ }
554
+ return {
555
+ staleAfter: check.measure(fields.get('stale_after'), 'budgets.stale_after', DURATION, '30m') ?? base.staleAfter,
556
+ checkEvery: check.measure(fields.get('check_every'), 'budgets.check_every', DURATION, '10m') ?? base.checkEvery,
557
+ marks: marks ?? fallbackMarks,
558
+ accounts: readAccounts(fields.get('accounts'), check),
559
+ };
560
+ }
561
+ function percentList(entry, name, check) {
562
+ if (!entry)
563
+ return null;
564
+ const node = entry.value;
565
+ const numbers = node.kind === 'seq' ? node.items.map((item) => (item.kind === 'scalar' ? item.value : null)) : null;
566
+ if (numbers && numbers.length > 0 && numbers.every((n) => typeof n === 'number' && n > 0 && n <= 100))
567
+ return numbers;
568
+ check.fail(node.line, `${name} must be a list of percentages, such as [50, 75, 90]`);
569
+ return null;
570
+ }
571
+ function readAccounts(entry, check) {
572
+ const accounts = {};
573
+ if (!entry)
574
+ return accounts;
575
+ if (entry.value.kind !== 'map') {
576
+ check.fail(entry.value.line, 'budgets.accounts must be a map');
577
+ return accounts;
578
+ }
579
+ for (const account of entry.value.entries) {
580
+ const fields = check.fields(account.value, `budgets.accounts.${account.key}`, ['kind', 'shared', 'reserve', 'floor', 'sources', 'check']);
581
+ const kind = check.oneOf(fields.get('kind'), `budgets.accounts.${account.key}.kind`, ['subscription', 'spend']);
582
+ if (!kind) {
583
+ check.fail(account.line, `budgets.accounts.${account.key} needs kind`);
584
+ continue;
585
+ }
586
+ const command = check.text(fields.get('check'), `budgets.accounts.${account.key}.check`) ?? null;
587
+ if (command && /\s/.test(command)) {
588
+ check.fail(fields.get('check')?.line ?? account.line, `budgets.accounts.${account.key}.check names one command, with no arguments; use a wrapper`);
589
+ }
590
+ const sources = sourcesOf(fields.get('sources'), account.key, kind, Boolean(command) && !/\s/.test(command ?? ''), check, account.line);
591
+ if (sources.includes('check') && !command)
592
+ check.fail(account.line, `budgets.accounts.${account.key} needs check`);
593
+ if (command && !/\s/.test(command) && !sources.includes('check')) {
594
+ check.fail(fields.get('check')?.line ?? account.line, `budgets.accounts.${account.key}.check needs sources to list check`);
595
+ }
596
+ const measured = kind === 'subscription'
597
+ ? check.measure(fields.get('reserve'), `budgets.accounts.${account.key}.reserve`, PERCENT, '20%')
598
+ : undefined;
599
+ const reserve = measured !== undefined && measured > 0 && measured <= 100 ? measured : null;
600
+ if (kind === 'subscription' && measured !== undefined && (measured <= 0 || measured > 100)) {
601
+ check.fail(fields.get('reserve')?.line ?? account.line, `budgets.accounts.${account.key}.reserve must be a percentage above 0 and at most 100, such as 20%`);
602
+ }
603
+ if (kind === 'subscription' && !fields.get('reserve'))
604
+ check.fail(account.line, `budgets.accounts.${account.key} needs reserve`);
605
+ if (kind === 'spend' && fields.get('reserve'))
606
+ check.fail(fields.get('reserve')?.line ?? account.line, 'a spend account has a floor, not a reserve');
607
+ const floor = kind === 'spend' ? money(fields.get('floor'), account.key, check) : null;
608
+ if (kind === 'spend' && !fields.get('floor'))
609
+ check.fail(account.line, `budgets.accounts.${account.key} needs floor`);
610
+ if (kind === 'subscription' && fields.get('floor'))
611
+ check.fail(fields.get('floor')?.line ?? account.line, 'a subscription has a reserve, not a floor');
612
+ accounts[account.key] = {
613
+ kind,
614
+ shared: check.flag(fields.get('shared'), `budgets.accounts.${account.key}.shared`),
615
+ reserve,
616
+ floor,
617
+ sources,
618
+ check: command,
619
+ };
620
+ }
621
+ return accounts;
622
+ }
623
+ function sourcesOf(entry, account, kind, hasCheck, check, line) {
624
+ if (!entry) {
625
+ if (hasCheck)
626
+ return ['check'];
627
+ if (kind === 'subscription')
628
+ return ['status_line'];
629
+ check.fail(line, `budgets.accounts.${account} needs check`);
630
+ return [];
631
+ }
632
+ const items = check.list(entry, `budgets.accounts.${account}.sources`);
633
+ if (items.length === 0) {
634
+ check.fail(entry.line, `budgets.accounts.${account} needs a source`);
635
+ return [];
636
+ }
637
+ const sources = [];
638
+ for (const item of items) {
639
+ if (item.value !== 'check' && item.value !== 'status_line') {
640
+ check.fail(item.line, `budgets.accounts.${account}.sources must be check or status_line`);
641
+ continue;
642
+ }
643
+ if (kind === 'spend' && item.value === 'status_line') {
644
+ check.fail(item.line, 'a spend account never reads a screen');
645
+ continue;
646
+ }
647
+ if (sources.includes(item.value))
648
+ check.fail(item.line, `budgets.accounts.${account}.sources lists ${item.value} twice`);
649
+ else
650
+ sources.push(item.value);
651
+ }
652
+ return sources;
653
+ }
654
+ function money(entry, account, check) {
655
+ if (!entry)
656
+ return null;
657
+ const raw = entry.value.kind === 'scalar' ? entry.value.raw : '';
658
+ const match = /^([0-9]+(?:\.[0-9]{1,4})?) ([A-Z]{3})$/.exec(raw);
659
+ if (!match || match[1] === undefined || match[2] === undefined) {
660
+ check.fail(entry.line, `budgets.accounts.${account}.floor is an amount and a currency, such as 5 USD`);
661
+ return null;
662
+ }
663
+ return { amount: Number(match[1]), currency: match[2] };
664
+ }
665
+ function readMachine(entry, check) {
666
+ const fields = check.fields(entry?.value, 'machine', [
667
+ 'load_start', 'load_max', 'memory_start', 'memory_min', 'disk_min', 'swap_free_min', 'swap_growth_max', 'swap_growth_window',
668
+ ]);
669
+ return {
670
+ loadStart: check.number(fields.get('load_start'), 'machine.load_start') ?? 3,
671
+ loadMax: check.number(fields.get('load_max'), 'machine.load_max') ?? 6,
672
+ memoryStart: check.measure(fields.get('memory_start'), 'machine.memory_start', PERCENT, '25%') ?? 25,
673
+ memoryMin: check.measure(fields.get('memory_min'), 'machine.memory_min', PERCENT, '15%') ?? 15,
674
+ diskMin: check.measure(fields.get('disk_min'), 'machine.disk_min', SIZE, '10GB') ?? 10e9,
675
+ swapFreeMin: check.measure(fields.get('swap_free_min'), 'machine.swap_free_min', SIZE, '2GB') ?? 2e9,
676
+ swapGrowthMax: check.measure(fields.get('swap_growth_max'), 'machine.swap_growth_max', SIZE, '1GB') ?? 1e9,
677
+ swapGrowthWindow: check.measure(fields.get('swap_growth_window'), 'machine.swap_growth_window', DURATION, '10m') ?? 600,
678
+ };
679
+ }
680
+ function readLimits(entry, check, declared) {
681
+ const fields = check.fields(entry?.value, 'limits', ['seats', 'temporary', 'vendors']);
682
+ const temporary = check.whole(fields.get('temporary'), 'limits.temporary', 0) ?? 2;
683
+ const vendors = {};
684
+ const vendorsEntry = fields.get('vendors');
685
+ if (vendorsEntry) {
686
+ const node = vendorsEntry.value;
687
+ if (node.kind !== 'map')
688
+ check.fail(node.line, 'limits.vendors must be a map: a vendor, then its ceiling');
689
+ else {
690
+ for (const vendor of node.entries) {
691
+ const ceiling = check.whole(vendor, `limits.vendors.${vendor.key}`, 0);
692
+ if (ceiling !== undefined)
693
+ vendors[vendor.key] = ceiling;
694
+ }
695
+ }
696
+ }
697
+ return {
698
+ seats: check.whole(fields.get('seats'), 'limits.seats', 1) ?? declared + temporary,
699
+ temporary,
700
+ vendors,
701
+ };
702
+ }