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,277 @@
1
+ // Edits the team file by the line. Comments, order and every other seat stay as written.
2
+ // Nothing here re-serialises the file.
3
+ function indent(line) {
4
+ let n = 0;
5
+ while (line[n] === ' ')
6
+ n++;
7
+ return n;
8
+ }
9
+ function scalar(raw) {
10
+ const value = raw.replace(/\s+#.*$/, '').trim();
11
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
12
+ return value.slice(1, -1);
13
+ }
14
+ return value;
15
+ }
16
+ function field(lines, name) {
17
+ for (const line of lines) {
18
+ const match = new RegExp(`^\\s*${name}:\\s*(.*)$`).exec(line);
19
+ if (!match)
20
+ continue;
21
+ return scalar(match[1] ?? '');
22
+ }
23
+ return null;
24
+ }
25
+ /** A seat's name, including one written on the entry's first line: `- name: helper`. */
26
+ function seatName(body) {
27
+ const inline = /^\s*-\s+name:\s*(.*)$/.exec(body[0] ?? '');
28
+ if (inline)
29
+ return scalar(inline[1] ?? '');
30
+ return field(body, 'name');
31
+ }
32
+ /** The seat entries under `seats:`, in order. A file with no seats section has none. */
33
+ export function seatBlocks(text) {
34
+ const lines = text.split('\n');
35
+ const header = lines.findIndex((line) => /^seats:\s*(#.*)?$/.test(line));
36
+ if (header < 0)
37
+ return [];
38
+ const key = indent(lines[header] ?? '');
39
+ const blocks = [];
40
+ let start = -1;
41
+ const flush = (end) => {
42
+ if (start < 0)
43
+ return;
44
+ const body = lines.slice(start, end);
45
+ const count = Number(field(body, 'count') ?? '1');
46
+ blocks.push({ start, end, name: seatName(body), count: Number.isInteger(count) && count > 0 ? count : 1 });
47
+ start = -1;
48
+ };
49
+ for (let i = header + 1; i < lines.length; i++) {
50
+ const line = lines[i] ?? '';
51
+ if (line.trim() === '' || /^\s*#/.test(line))
52
+ continue;
53
+ if (indent(line) <= key) {
54
+ flush(i);
55
+ break;
56
+ }
57
+ if (/^\s*-\s+/.test(line)) {
58
+ flush(i);
59
+ start = i;
60
+ }
61
+ }
62
+ if (start >= 0)
63
+ flush(lines.length);
64
+ return blocks;
65
+ }
66
+ function expanded(block) {
67
+ if (!block.name)
68
+ return [];
69
+ const names = [block.name];
70
+ for (let instance = 2; instance <= block.count; instance++)
71
+ names.push(`${block.name}-${instance}`);
72
+ return names;
73
+ }
74
+ /** Whether `name` is a seat entry, including one that `count` expands to. */
75
+ export function hasSeat(text, name) {
76
+ return seatBlocks(text).some((block) => expanded(block).includes(name));
77
+ }
78
+ function replaceLines(text, start, end, insert) {
79
+ const lines = text.split('\n');
80
+ lines.splice(start, end - start, ...insert);
81
+ return lines.join('\n');
82
+ }
83
+ function body(text, block) {
84
+ return text.split('\n').slice(block.start, block.end);
85
+ }
86
+ function setValue(line, value) {
87
+ const match = /^(\s*(?:-\s+)?[A-Za-z0-9_-]+:\s*)(.*)$/.exec(line);
88
+ if (!match)
89
+ return line;
90
+ const rest = match[2] ?? '';
91
+ const comment = /\s+#.*$/.exec(rest)?.[0] ?? '';
92
+ const raw = rest.slice(0, rest.length - comment.length).trim();
93
+ const quoted = raw.startsWith('"') ? `"${value}"` : raw.startsWith("'") ? `'${value}'` : value;
94
+ return `${match[1]}${quoted}${comment}`;
95
+ }
96
+ /** One `count` entry becomes one explicit seat per instance. Other lines are copied, not rebuilt. */
97
+ export function rewriteCount(text, declared) {
98
+ const block = seatBlocks(text).find((item) => item.name === declared && item.count > 1);
99
+ if (!block)
100
+ return null;
101
+ const lines = body(text, block);
102
+ const label = field(lines, 'label');
103
+ const copies = [];
104
+ for (let instance = 1; instance <= block.count; instance++) {
105
+ const suffix = instance === 1 ? '' : `-${instance}`;
106
+ copies.push(...lines.flatMap((line) => {
107
+ if (/^\s*count:/.test(line))
108
+ return [];
109
+ if (instance === 1)
110
+ return [line];
111
+ if (/^\s*(?:-\s+)?name:/.test(line))
112
+ return [setValue(line, `${declared}${suffix}`)];
113
+ if (label !== null && /^\s*label:/.test(line))
114
+ return [setValue(line, `${label}${suffix}`)];
115
+ return [line];
116
+ }));
117
+ }
118
+ return replaceLines(text, block.start, block.end, copies);
119
+ }
120
+ /** True when this seat's own entry, or the count entry it comes from, says stopped. */
121
+ export function seatIsStopped(text, name) {
122
+ const lines = text.split('\n');
123
+ const exact = seatBlocks(text).find((block) => block.name === name);
124
+ const block = exact ?? seatBlocks(text).find((item) => expanded(item).includes(name));
125
+ if (!block)
126
+ return false;
127
+ return lines.slice(block.start, block.end).some((line) => /^\s*stopped:\s*(true|yes)\b/.test(line));
128
+ }
129
+ /** Drops `stopped: true` from the entry whose name is `name`. A counted entry is split first. */
130
+ export function clearStopped(text, name) {
131
+ const counted = seatBlocks(text).find((block) => block.count > 1 && expanded(block).includes(name));
132
+ const rewritten = counted?.name ? rewriteCount(text, counted.name) ?? text : text;
133
+ const block = seatBlocks(rewritten).find((item) => item.name === name);
134
+ if (!block)
135
+ return rewritten;
136
+ const lines = body(rewritten, block).filter((line) => !/^\s*stopped:\s*(true|yes)\s*(#.*)?$/.test(line));
137
+ return replaceLines(rewritten, block.start, block.end, lines);
138
+ }
139
+ function prepared(text, name) {
140
+ const counted = seatBlocks(text).find((block) => block.count > 1 && expanded(block).includes(name));
141
+ const rewritten = counted?.name ? rewriteCount(text, counted.name) ?? text : text;
142
+ const block = seatBlocks(rewritten).find((item) => item.name === name);
143
+ return block ? { text: rewritten, block } : null;
144
+ }
145
+ /** Drops trailing comments and blank lines back into the file, ahead of the next entry. */
146
+ function fieldEnd(lines) {
147
+ let end = lines.length;
148
+ while (end > 0) {
149
+ const line = lines[end - 1] ?? '';
150
+ if (line.trim() !== '' && !/^\s*#/.test(line))
151
+ break;
152
+ end--;
153
+ }
154
+ return end;
155
+ }
156
+ /** Takes `name` out of the file. A counted entry is split first, so the other instances stay. */
157
+ export function takeOut(text, name) {
158
+ const ready = prepared(text, name);
159
+ if (!ready)
160
+ return text;
161
+ const lines = body(ready.text, ready.block);
162
+ return replaceLines(ready.text, ready.block.start, ready.block.start + fieldEnd(lines), []);
163
+ }
164
+ /** Leaves `name` in the file with `stopped: true`. A counted entry is split first. */
165
+ export function markStopped(text, name) {
166
+ const ready = prepared(text, name);
167
+ if (!ready)
168
+ return text;
169
+ const lines = body(ready.text, ready.block);
170
+ if (lines.some((line) => /^\s*stopped:\s*(true|yes)\b/.test(line)))
171
+ return ready.text;
172
+ const nested = lines.find((line) => /^\s+[A-Za-z0-9_-]+:/.test(line));
173
+ const pad = ' '.repeat(nested ? indent(nested) : indent(lines[0] ?? '') + 2);
174
+ lines.splice(fieldEnd(lines), 0, `${pad}stopped: true`);
175
+ return replaceLines(ready.text, ready.block.start, ready.block.end, lines);
176
+ }
177
+ /**
178
+ * Puts `name`'s entry back from `approved` when the current text lacks it.
179
+ * The entry is inserted after whichever approved neighbour is still present.
180
+ */
181
+ export function restoreSeat(current, approved, name) {
182
+ if (hasSeat(current, name))
183
+ return current;
184
+ const approvedBlocks = seatBlocks(approved);
185
+ const at = approvedBlocks.findIndex((block) => expanded(block).includes(name));
186
+ const source = approvedBlocks[at];
187
+ if (!source || at < 0)
188
+ return current;
189
+ const inserting = instanceLines(approved, source, name);
190
+ if (!inserting)
191
+ return current;
192
+ const lines = current.split('\n');
193
+ const header = lines.findIndex((line) => /^seats:\s*(#.*)?$/.test(line));
194
+ if (header < 0)
195
+ return current;
196
+ const currentBlocks = seatBlocks(current);
197
+ let insertAt = header + 1;
198
+ let placed = false;
199
+ const among = siblingSlot(currentBlocks, source, name);
200
+ if (among !== null) {
201
+ insertAt = among;
202
+ placed = true;
203
+ }
204
+ for (let i = at - 1; i >= 0 && !placed; i--) {
205
+ const neighbour = approvedBlocks[i];
206
+ const found = neighbour ? currentBlocks.find((block) => block.name === neighbour.name) : undefined;
207
+ if (found) {
208
+ insertAt = found.end;
209
+ placed = true;
210
+ }
211
+ }
212
+ for (let i = at + 1; i < approvedBlocks.length && !placed; i++) {
213
+ const neighbour = approvedBlocks[i];
214
+ const found = neighbour ? currentBlocks.find((block) => block.name === neighbour.name) : undefined;
215
+ if (found) {
216
+ insertAt = found.start;
217
+ placed = true;
218
+ }
219
+ }
220
+ if (!placed && currentBlocks.length > 0) {
221
+ const last = currentBlocks[currentBlocks.length - 1];
222
+ if (last)
223
+ insertAt = last.end;
224
+ }
225
+ lines.splice(insertAt, 0, ...inserting);
226
+ return lines.join('\n');
227
+ }
228
+ /** Where a restored instance sits among the copies of the same count entry that are still in the file. */
229
+ function siblingSlot(currentBlocks, source, name) {
230
+ if (source.count <= 1 || !source.name)
231
+ return null;
232
+ const mine = instanceNumber(source.name, name);
233
+ if (mine === null)
234
+ return null;
235
+ let lower = null;
236
+ let lowerN = 0;
237
+ let higher = null;
238
+ let higherN = Number.POSITIVE_INFINITY;
239
+ for (const block of currentBlocks) {
240
+ if (!block.name || !expanded(source).includes(block.name))
241
+ continue;
242
+ const n = instanceNumber(source.name, block.name);
243
+ if (n === null)
244
+ continue;
245
+ if (n < mine && n > lowerN) {
246
+ lower = block;
247
+ lowerN = n;
248
+ }
249
+ else if (n > mine && n < higherN) {
250
+ higher = block;
251
+ higherN = n;
252
+ }
253
+ }
254
+ if (lower)
255
+ return lower.end;
256
+ return higher ? higher.start : null;
257
+ }
258
+ function instanceNumber(declared, name) {
259
+ if (name === declared)
260
+ return 1;
261
+ const prefix = `${declared}-`;
262
+ if (!name.startsWith(prefix))
263
+ return null;
264
+ const rest = name.slice(prefix.length);
265
+ return /^\d+$/.test(rest) ? Number(rest) : null;
266
+ }
267
+ // A counted approved entry is one block for every instance. Restoring one of them inserts that
268
+ // instance alone; copying the whole `count` entry would repeat the declared name.
269
+ function instanceLines(approved, source, name) {
270
+ if (source.count <= 1 || !source.name)
271
+ return body(approved, source);
272
+ const rewritten = rewriteCount(approved, source.name);
273
+ if (!rewritten)
274
+ return null;
275
+ const instance = seatBlocks(rewritten).find((block) => block.name === name);
276
+ return instance ? body(rewritten, instance) : null;
277
+ }
@@ -0,0 +1,7 @@
1
+ import type { LoadResult, Problem, TeamFile } from './types.ts';
2
+ export declare const TEAM_FILE = ".agents/team.yaml";
3
+ export declare function findRoot(cwd: string): string | null;
4
+ export declare function loadTeamFile(cwd: string, options?: {
5
+ file?: string;
6
+ }): LoadResult;
7
+ export declare function placedProblems(team: TeamFile, root: string): Problem[];
@@ -0,0 +1,90 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
3
+ import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
4
+ import { fixedFolder } from "./paths.js";
5
+ import { validateTeamFile } from "./validate.js";
6
+ export const TEAM_FILE = '.agents/team.yaml';
7
+ // The project's root: the main checkout, found through git's common directory, so a command run
8
+ // in a subfolder or in a linked worktree reads the same `.agents/`.
9
+ export function findRoot(cwd) {
10
+ try {
11
+ const common = execFileSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
12
+ cwd,
13
+ encoding: 'utf8',
14
+ stdio: ['ignore', 'pipe', 'ignore'],
15
+ }).trim();
16
+ return common ? dirname(common) : null;
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ // Finds, reads and validates the team file. With `file`, the root is the folder that holds the
23
+ // file's `.agents/`, or the file's own folder when it sits elsewhere.
24
+ export function loadTeamFile(cwd, options = {}) {
25
+ let path;
26
+ let root;
27
+ if (options.file) {
28
+ path = isAbsolute(options.file) ? options.file : resolve(cwd, options.file);
29
+ const folder = dirname(path);
30
+ root = folder.endsWith('.agents') ? dirname(folder) : folder;
31
+ }
32
+ else {
33
+ const found = findRoot(cwd);
34
+ if (!found)
35
+ return { ok: false, errors: [{ line: 0, message: 'not inside a git repository: run team from a project, or pass --file' }] };
36
+ root = found;
37
+ path = join(root, TEAM_FILE);
38
+ }
39
+ if (!existsSync(path)) {
40
+ return {
41
+ ok: false,
42
+ path,
43
+ errors: [{
44
+ line: 0,
45
+ message: options.file
46
+ ? `no team file at ${options.file}`
47
+ : 'no team file here; it is private to each clone: run `team init`, or `team init --restore` to bring back the copy you last approved on this machine',
48
+ }],
49
+ };
50
+ }
51
+ // One read. Callers that store the text (approve) use this string, the one that was validated.
52
+ const text = readFileSync(path, 'utf8');
53
+ const result = validateTeamFile(text);
54
+ if (!result.ok)
55
+ return { ...result, path };
56
+ const errors = placedProblems(result.team, root);
57
+ return errors.length ? { ok: false, errors, path } : { ...result, root, path, text };
58
+ }
59
+ function real(path) {
60
+ try {
61
+ return realpathSync(path);
62
+ }
63
+ catch {
64
+ return path;
65
+ }
66
+ }
67
+ // What only shows once the root is known: a folder named by its own name can still be the
68
+ // project's parent, as "../../Code/*" is for a project at ~/Code/acme. Validation on the text
69
+ // alone can't see that; here every fixed folder is resolved, symlinks included.
70
+ export function placedProblems(team, root) {
71
+ const project = real(root);
72
+ const problems = [];
73
+ const holdsProject = (folder) => {
74
+ const full = real(resolve(project, folder));
75
+ return full !== project && project.startsWith(full.endsWith(sep) ? full : full + sep);
76
+ };
77
+ for (const pattern of team.trust) {
78
+ if (holdsProject(fixedFolder(pattern))) {
79
+ problems.push({ line: 0, message: `trust: "${pattern}" names the project's parent or a folder above it: it would trust every folder beside the project` });
80
+ }
81
+ }
82
+ const path = team.workspace.path;
83
+ if (path) {
84
+ const folder = path.split('/').slice(0, -1).join('/').replaceAll('{repo}', team.project) || '.';
85
+ if (holdsProject(folder)) {
86
+ problems.push({ line: 0, message: `workspace.path: "${path}" puts worktrees in the project's parent or a folder above it: give them a folder of their own` });
87
+ }
88
+ }
89
+ return problems;
90
+ }
@@ -0,0 +1,6 @@
1
+ export declare function normalize(path: string): string;
2
+ export declare function insideProject(path: string): boolean;
3
+ export declare function trustProblem(pattern: string): string | null;
4
+ export declare function fixedFolder(pattern: string): string;
5
+ export declare function protectedBy(path: string, checkouts: readonly string[]): string | null;
6
+ export declare function insideTrust(path: string, patterns: string[]): boolean;
@@ -0,0 +1,77 @@
1
+ // Paths in a team file are relative to the project's root, with "/" between segments. These
2
+ // checks are on the text alone; symlinks are resolved when trust is applied.
3
+ export function normalize(path) {
4
+ const out = [];
5
+ for (const segment of path.split('/')) {
6
+ if (!segment || segment === '.')
7
+ continue;
8
+ if (segment === '..' && out.length && out[out.length - 1] !== '..')
9
+ out.pop();
10
+ else
11
+ out.push(segment);
12
+ }
13
+ return out.join('/') || '.';
14
+ }
15
+ export function insideProject(path) {
16
+ const normal = normalize(path);
17
+ return normal !== '..' && !normal.startsWith('../');
18
+ }
19
+ // Why a trust pattern is refused, or null. A pattern is a folder inside the project, or one under
20
+ // a fixed folder that is neither the project's parent nor an ancestor; "*" matches one segment
21
+ // and only as the last one.
22
+ export function trustProblem(pattern) {
23
+ if (pattern.startsWith('/') || pattern.startsWith('~') || /^[A-Za-z]:[\\/]/.test(pattern)) {
24
+ return 'must be relative to the project';
25
+ }
26
+ const segments = normalize(pattern).split('/');
27
+ const last = segments.length - 1;
28
+ for (let i = 0; i < segments.length; i++) {
29
+ const segment = segments[i];
30
+ if (segment.includes('*') && (segment !== '*' || i !== last))
31
+ return 'takes "*" only as a whole last segment';
32
+ }
33
+ const fixed = segments[last] === '*' ? segments.slice(0, last) : segments;
34
+ if (fixed.length && fixed.every((segment) => segment === '..')) {
35
+ return segments[last] === '*'
36
+ ? 'would trust every folder beside the project or above it: name a fixed folder before "*"'
37
+ : 'is a parent of the project: trust a folder of the team\'s own';
38
+ }
39
+ return null;
40
+ }
41
+ // The folder a pattern keeps fixed: the pattern without its trailing "*".
42
+ export function fixedFolder(pattern) {
43
+ const segments = normalize(pattern).split('/');
44
+ if (segments[segments.length - 1] === '*')
45
+ segments.pop();
46
+ return segments.join('/') || '.';
47
+ }
48
+ // The protected checkout `path` is in, or null. A checkout named "." is the project root, so every
49
+ // path inside the project is inside it; every other entry names that folder and what lies under it.
50
+ export function protectedBy(path, checkouts) {
51
+ const target = normalize(path);
52
+ for (const checkout of checkouts) {
53
+ const folder = normalize(checkout);
54
+ if (folder === '.') {
55
+ if (insideProject(target))
56
+ return checkout;
57
+ continue;
58
+ }
59
+ if (target === folder || target.startsWith(`${folder}/`))
60
+ return checkout;
61
+ }
62
+ return null;
63
+ }
64
+ // True when `path` is a trusted folder or lies under one.
65
+ export function insideTrust(path, patterns) {
66
+ const target = normalize(path).split('/');
67
+ return patterns.some((pattern) => {
68
+ if (trustProblem(pattern))
69
+ return false;
70
+ const segments = normalize(pattern).split('/');
71
+ if (segments.length === 1 && segments[0] === '.')
72
+ return insideProject(path);
73
+ if (target.length < segments.length)
74
+ return false;
75
+ return segments.every((segment, i) => segment === '*' || segment === target[i]);
76
+ });
77
+ }
@@ -0,0 +1,6 @@
1
+ import type { YamlNode } from '../yaml.ts';
2
+ import type { Problem } from './types.ts';
3
+ export declare function findSecrets(root: YamlNode): {
4
+ refused: Problem[];
5
+ warned: Problem[];
6
+ };
@@ -0,0 +1,54 @@
1
+ // A team file never holds a credential. A value with a known key prefix, a private key or a URL
2
+ // with credentials refuses the file. A long random-looking string only warns: channel ids and
3
+ // slugs look like one.
4
+ const KEY_PREFIXES = [
5
+ 'sk-', 'ghp_', 'gho_', 'ghu_', 'ghs_', 'ghr_', 'github_pat_', 'glpat-', 'xoxb-', 'xoxp-', 'xoxa-', 'xoxs-',
6
+ 'AKIA', 'ASIA', 'AIza', 'lin_api_', 'npm_', 'shpat_', 'dckr_pat_',
7
+ ];
8
+ export function findSecrets(root) {
9
+ const refused = [];
10
+ const warned = [];
11
+ walk(root, (value, line) => {
12
+ for (const word of value.split(/[\s"'=,;]+/)) {
13
+ if (KEY_PREFIXES.some((prefix) => word.startsWith(prefix) && word.length >= prefix.length + 16)) {
14
+ refused.push({ line, message: 'a value shaped like a key or token: a team file never holds a credential' });
15
+ return;
16
+ }
17
+ }
18
+ if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(value)) {
19
+ refused.push({ line, message: 'a private key: a team file never holds a credential' });
20
+ }
21
+ else if (/[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:[^\s/@]+@/i.test(value)) {
22
+ refused.push({ line, message: 'a URL with credentials: a team file never holds a credential' });
23
+ }
24
+ else if (value.split(/\s+/).some(looksRandom)) {
25
+ warned.push({ line, message: 'a long random-looking value: check it is not a credential' });
26
+ }
27
+ });
28
+ return { refused, warned };
29
+ }
30
+ function walk(node, visit) {
31
+ if (node.kind === 'scalar') {
32
+ if (typeof node.value === 'string')
33
+ visit(node.value, node.line);
34
+ }
35
+ else if (node.kind === 'seq') {
36
+ for (const item of node.items)
37
+ walk(item, visit);
38
+ }
39
+ else {
40
+ for (const entry of node.entries)
41
+ walk(entry.value, visit);
42
+ }
43
+ }
44
+ function looksRandom(word) {
45
+ if (word.length < 32 || !/^[A-Za-z0-9+/_=-]+$/.test(word))
46
+ return false;
47
+ const counts = new Map();
48
+ for (const c of word)
49
+ counts.set(c, (counts.get(c) ?? 0) + 1);
50
+ let entropy = 0;
51
+ for (const count of counts.values())
52
+ entropy -= (count / word.length) * Math.log2(count / word.length);
53
+ return entropy > 4;
54
+ }
@@ -0,0 +1,13 @@
1
+ export declare const DEFAULT_COMMIT_TEMPLATE = "Agent: {display} \u00B7 {role}";
2
+ export declare const DEFAULT_PR_TEMPLATE = "**Agent:** {display} \u00B7 {role}";
3
+ export declare const DEFAULT_FORBIDDEN: string[];
4
+ export type Signer = {
5
+ display: string;
6
+ model: string;
7
+ version: string;
8
+ role: string;
9
+ };
10
+ export declare function renderSignature(template: string, seat: Signer): string;
11
+ export declare function templateProblem(template: string): string | null;
12
+ export declare function isTrailerTemplate(template: string): boolean;
13
+ export declare function hasVersionToken(display: string, version: string): boolean;
@@ -0,0 +1,36 @@
1
+ export const DEFAULT_COMMIT_TEMPLATE = 'Agent: {display} · {role}';
2
+ export const DEFAULT_PR_TEMPLATE = '**Agent:** {display} · {role}';
3
+ // Added to every file's `identity.forbidden`, never replaced by it.
4
+ export const DEFAULT_FORBIDDEN = ['^Claude-Session:', 'https?://claude\\.ai/code/session'];
5
+ export function renderSignature(template, seat) {
6
+ return template.replace(/\{(display|model|version|role)\}/g, (_, name) => seat[name]);
7
+ }
8
+ // What a template must hold to name a model, its version and a role.
9
+ export function templateProblem(template) {
10
+ if (!template.includes('{role}'))
11
+ return 'the template must contain {role}';
12
+ if (!template.includes('{display}') && !(template.includes('{model}') && template.includes('{version}'))) {
13
+ return 'the template must contain {display}, or both {model} and {version}';
14
+ }
15
+ const unknown = template.match(/\{[^}]*\}/g)?.find((p) => !/^\{(display|model|version|role)\}$/.test(p));
16
+ return unknown ? `unknown placeholder ${unknown}` : null;
17
+ }
18
+ // git reads a line as a trailer only when it is "<token>: <value>", the token without spaces.
19
+ export function isTrailerTemplate(template) {
20
+ const line = renderSignature(template, { display: 'Model 1', model: 'Model', version: '1', role: 'role' });
21
+ return /^[A-Za-z0-9-]+: \S/.test(line);
22
+ }
23
+ // The version as a token of the display: no letter, digit or dot directly before or after it.
24
+ export function hasVersionToken(display, version) {
25
+ let from = 0;
26
+ for (;;) {
27
+ const at = display.indexOf(version, from);
28
+ if (at < 0)
29
+ return false;
30
+ const before = display[at - 1] ?? '';
31
+ const after = display[at + version.length] ?? '';
32
+ if (!/[\p{L}\p{N}.]/u.test(before) && !/[\p{L}\p{N}.]/u.test(after))
33
+ return true;
34
+ from = at + 1;
35
+ }
36
+ }